- Mercator/Globe track-replay 레이어 충돌 및 setProps 레이스 해결 - track DTO 좌표/시간 정규화 + stale query 응답 무시 - 조회 직후 표시 안정화 및 기본 100x 자동재생 적용 - Global Track Replay 패널 초기 위치 조정 + 헤더 드래그 지원 - liveRenderer batch rendering + trackReplay store 기반 구조 반영 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
/**
|
|
* useVesselTrackLayer — 항적(Track) 렌더링 hook
|
|
*
|
|
* Mercator: TripsLayer 애니메이션 + ScatterplotLayer 포인트
|
|
* Globe: MapLibre 네이티브 line + circle + symbol(arrow)
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react';
|
|
import maplibregl from 'maplibre-gl';
|
|
import { TripsLayer } from '@deck.gl/geo-layers';
|
|
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
|
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
import type { ActiveTrack, NormalizedTrip, TrackPoint } from '../../../entities/vesselTrack/model/types';
|
|
import {
|
|
normalizeTrip,
|
|
buildTrackLineGeoJson,
|
|
buildTrackPointsGeoJson,
|
|
getTrackTimeRange,
|
|
} from '../../../entities/vesselTrack/lib/buildTrackGeoJson';
|
|
import { getTrackLineTooltipHtml, getTrackPointTooltipHtml } from '../lib/tooltips';
|
|
import { useNativeMapLayers, type NativeLayerSpec, type NativeSourceConfig } from './useNativeMapLayers';
|
|
import type { MapProjectionId } from '../types';
|
|
|
|
/* ── Constants ──────────────────────────────────────────────────────── */
|
|
const TRACK_COLOR: [number, number, number] = [0, 224, 255]; // cyan
|
|
const TRACK_COLOR_CSS = `rgb(${TRACK_COLOR.join(',')})`;
|
|
|
|
// Globe 네이티브 레이어/소스 ID
|
|
const LINE_SRC = 'vessel-track-line-src';
|
|
const PTS_SRC = 'vessel-track-pts-src';
|
|
const LINE_ID = 'vessel-track-line';
|
|
const ARROW_ID = 'vessel-track-arrow';
|
|
const HITAREA_ID = 'vessel-track-line-hitarea';
|
|
const PTS_ID = 'vessel-track-pts';
|
|
const PTS_HL_ID = 'vessel-track-pts-highlight';
|
|
|
|
// Mercator Deck.gl 레이어 ID
|
|
const DECK_PATH_ID = 'vessel-track-path';
|
|
const DECK_TRIPS_ID = 'vessel-track-trips';
|
|
const DECK_POINTS_ID = 'vessel-track-deck-pts';
|
|
|
|
/* ── Globe 네이티브 레이어 스펙 ────────────────────────────────────── */
|
|
const GLOBE_LAYERS: NativeLayerSpec[] = [
|
|
{
|
|
id: LINE_ID,
|
|
type: 'line',
|
|
sourceId: LINE_SRC,
|
|
paint: {
|
|
'line-color': TRACK_COLOR_CSS,
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 4],
|
|
'line-opacity': 0.8,
|
|
},
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
},
|
|
{
|
|
id: HITAREA_ID,
|
|
type: 'line',
|
|
sourceId: LINE_SRC,
|
|
paint: { 'line-color': 'rgba(0,0,0,0)', 'line-width': 14, 'line-opacity': 0 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
},
|
|
{
|
|
id: ARROW_ID,
|
|
type: 'symbol',
|
|
sourceId: LINE_SRC,
|
|
paint: {
|
|
'text-color': TRACK_COLOR_CSS,
|
|
'text-opacity': 0.7,
|
|
},
|
|
layout: {
|
|
'symbol-placement': 'line',
|
|
'text-field': '▶',
|
|
'text-size': 10,
|
|
'symbol-spacing': 80,
|
|
'text-rotation-alignment': 'map',
|
|
'text-allow-overlap': true,
|
|
'text-ignore-placement': true,
|
|
},
|
|
},
|
|
{
|
|
id: PTS_ID,
|
|
type: 'circle',
|
|
sourceId: PTS_SRC,
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 2, 6, 3, 10, 5],
|
|
'circle-color': TRACK_COLOR_CSS,
|
|
'circle-stroke-width': 1,
|
|
'circle-stroke-color': 'rgba(0,0,0,0.5)',
|
|
'circle-opacity': 0.85,
|
|
},
|
|
},
|
|
{
|
|
id: PTS_HL_ID,
|
|
type: 'circle',
|
|
sourceId: PTS_SRC,
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 6, 6, 8, 10, 12],
|
|
'circle-color': '#ffffff',
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-color': TRACK_COLOR_CSS,
|
|
'circle-opacity': 0,
|
|
},
|
|
filter: ['==', ['get', 'index'], -1],
|
|
},
|
|
];
|
|
|
|
/* ── Animation speed: 전체 궤적을 ~20초에 재생 ────────────────────── */
|
|
const ANIM_CYCLE_SEC = 20;
|
|
|
|
/* ── Hook ──────────────────────────────────────────────────────────── */
|
|
/** @deprecated trackReplay store 엔진으로 이관 완료. 유지보수 호환 용도로만 남겨둔다. */
|
|
export function useVesselTrackLayer(
|
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
reorderGlobeFeatureLayers: () => void,
|
|
opts: {
|
|
activeTrack: ActiveTrack | null;
|
|
projection: MapProjectionId;
|
|
mapSyncEpoch: number;
|
|
},
|
|
) {
|
|
const { activeTrack, projection, mapSyncEpoch } = opts;
|
|
|
|
/* ── 정규화 데이터 ── */
|
|
const normalizedTrip = useMemo<NormalizedTrip | null>(() => {
|
|
if (!activeTrack || activeTrack.points.length < 2) return null;
|
|
return normalizeTrip(activeTrack, TRACK_COLOR);
|
|
}, [activeTrack]);
|
|
|
|
const timeRange = useMemo(() => {
|
|
if (!normalizedTrip) return null;
|
|
return getTrackTimeRange(normalizedTrip);
|
|
}, [normalizedTrip]);
|
|
|
|
/* ── Globe 네이티브 GeoJSON ── */
|
|
const lineGeoJson = useMemo(() => {
|
|
if (!activeTrack || activeTrack.points.length < 2) return null;
|
|
return buildTrackLineGeoJson(activeTrack);
|
|
}, [activeTrack]);
|
|
|
|
const pointsGeoJson = useMemo(() => {
|
|
if (!activeTrack || activeTrack.points.length === 0) return null;
|
|
return buildTrackPointsGeoJson(activeTrack);
|
|
}, [activeTrack]);
|
|
|
|
/* ── Globe 네이티브 레이어 (useNativeMapLayers) ── */
|
|
const globeSources = useMemo<NativeSourceConfig[]>(() => [
|
|
{ id: LINE_SRC, data: lineGeoJson, options: { lineMetrics: true } },
|
|
{ id: PTS_SRC, data: pointsGeoJson },
|
|
], [lineGeoJson, pointsGeoJson]);
|
|
|
|
const isGlobeVisible = projection === 'globe' && activeTrack != null && activeTrack.points.length >= 2;
|
|
|
|
useNativeMapLayers(
|
|
mapRef,
|
|
projectionBusyRef,
|
|
reorderGlobeFeatureLayers,
|
|
{
|
|
sources: globeSources,
|
|
layers: GLOBE_LAYERS,
|
|
visible: isGlobeVisible,
|
|
beforeLayer: ['zones-fill', 'zones-line'],
|
|
},
|
|
[lineGeoJson, pointsGeoJson, isGlobeVisible, projection, mapSyncEpoch],
|
|
);
|
|
|
|
/* ── Globe 호버 툴팁 ── */
|
|
const tooltipRef = useRef<maplibregl.Popup | null>(null);
|
|
|
|
const clearTooltip = useCallback(() => {
|
|
try { tooltipRef.current?.remove(); } catch { /* ignore */ }
|
|
tooltipRef.current = null;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || projection !== 'globe' || !activeTrack) {
|
|
clearTooltip();
|
|
return;
|
|
}
|
|
|
|
const onMove = (e: maplibregl.MapMouseEvent) => {
|
|
if (projectionBusyRef.current || !map.isStyleLoaded()) {
|
|
clearTooltip();
|
|
return;
|
|
}
|
|
|
|
const layers = [PTS_ID, HITAREA_ID].filter((id) => map.getLayer(id));
|
|
if (layers.length === 0) { clearTooltip(); return; }
|
|
|
|
let features: maplibregl.MapGeoJSONFeature[] = [];
|
|
try {
|
|
features = map.queryRenderedFeatures(e.point, { layers });
|
|
} catch { /* ignore */ }
|
|
|
|
if (features.length === 0) {
|
|
clearTooltip();
|
|
// 하이라이트 리셋
|
|
try {
|
|
if (map.getLayer(PTS_HL_ID)) {
|
|
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never);
|
|
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0);
|
|
}
|
|
} catch { /* ignore */ }
|
|
return;
|
|
}
|
|
|
|
const feat = features[0];
|
|
const props = feat.properties || {};
|
|
const layerId = feat.layer?.id;
|
|
let tooltipHtml = '';
|
|
|
|
if (layerId === PTS_ID && props.index != null) {
|
|
tooltipHtml = getTrackPointTooltipHtml({
|
|
name: String(props.name ?? ''),
|
|
sog: Number(props.sog),
|
|
cog: Number(props.cog),
|
|
heading: Number(props.heading),
|
|
status: String(props.status ?? ''),
|
|
messageTimestamp: String(props.messageTimestamp ?? ''),
|
|
}).html;
|
|
// 하이라이트
|
|
try {
|
|
if (map.getLayer(PTS_HL_ID)) {
|
|
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], Number(props.index)] as never);
|
|
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0.8);
|
|
}
|
|
} catch { /* ignore */ }
|
|
} else if (layerId === HITAREA_ID) {
|
|
tooltipHtml = getTrackLineTooltipHtml({
|
|
name: String(props.name ?? ''),
|
|
pointCount: Number(props.pointCount ?? 0),
|
|
minutes: Number(props.minutes ?? 0),
|
|
totalDistanceNm: Number(props.totalDistanceNm ?? 0),
|
|
}).html;
|
|
}
|
|
|
|
if (!tooltipHtml) { clearTooltip(); return; }
|
|
|
|
if (!tooltipRef.current) {
|
|
tooltipRef.current = new maplibregl.Popup({
|
|
closeButton: false, closeOnClick: false,
|
|
maxWidth: '360px', className: 'maplibre-tooltip-popup',
|
|
});
|
|
}
|
|
const container = document.createElement('div');
|
|
container.className = 'maplibre-tooltip-popup__content';
|
|
container.innerHTML = tooltipHtml;
|
|
tooltipRef.current.setLngLat(e.lngLat).setDOMContent(container).addTo(map);
|
|
};
|
|
|
|
const onOut = () => {
|
|
clearTooltip();
|
|
try {
|
|
if (map.getLayer(PTS_HL_ID)) {
|
|
map.setFilter(PTS_HL_ID, ['==', ['get', 'index'], -1] as never);
|
|
map.setPaintProperty(PTS_HL_ID, 'circle-opacity', 0);
|
|
}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
map.on('mousemove', onMove);
|
|
map.on('mouseout', onOut);
|
|
return () => {
|
|
map.off('mousemove', onMove);
|
|
map.off('mouseout', onOut);
|
|
clearTooltip();
|
|
};
|
|
}, [projection, activeTrack, clearTooltip]);
|
|
|
|
/* ── Mercator: 정적 레이어 1회 생성 + rAF 애니메이션 (React state 미사용) ── */
|
|
const animRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const overlay = overlayRef.current;
|
|
if (!overlay || projection !== 'mercator') {
|
|
cancelAnimationFrame(animRef.current);
|
|
return;
|
|
}
|
|
|
|
const isTrackLayer = (id?: string) =>
|
|
id === DECK_PATH_ID || id === DECK_TRIPS_ID || id === DECK_POINTS_ID;
|
|
|
|
if (!normalizedTrip || !activeTrack || activeTrack.points.length < 2 || !timeRange || timeRange.durationSec === 0) {
|
|
cancelAnimationFrame(animRef.current);
|
|
try {
|
|
const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? [];
|
|
const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id));
|
|
if (filtered.length !== (existing as unknown[]).length) {
|
|
overlay.setProps({ layers: filtered } as never);
|
|
}
|
|
} catch { /* ignore */ }
|
|
return;
|
|
}
|
|
|
|
// 정적 레이어: activeTrack 변경 시 1회만 생성, rAF 루프에서 재사용
|
|
const pathLayer = new PathLayer<NormalizedTrip>({
|
|
id: DECK_PATH_ID,
|
|
data: [normalizedTrip],
|
|
getPath: (d) => d.path,
|
|
getColor: [...TRACK_COLOR, 90] as [number, number, number, number],
|
|
getWidth: 2,
|
|
widthMinPixels: 2,
|
|
widthUnits: 'pixels' as const,
|
|
capRounded: true,
|
|
jointRounded: true,
|
|
pickable: false,
|
|
});
|
|
|
|
const sorted = [...activeTrack.points].sort(
|
|
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
|
);
|
|
|
|
const pointsLayer = new ScatterplotLayer<TrackPoint>({
|
|
id: DECK_POINTS_ID,
|
|
data: sorted,
|
|
getPosition: (d) => [d.lon, d.lat],
|
|
getRadius: 4,
|
|
radiusUnits: 'pixels' as const,
|
|
getFillColor: TRACK_COLOR,
|
|
getLineColor: [0, 0, 0, 128],
|
|
lineWidthMinPixels: 1,
|
|
stroked: true,
|
|
pickable: true,
|
|
});
|
|
|
|
// rAF 루프: TripsLayer만 매 프레임 갱신 (React 재렌더링 없음)
|
|
const { minTime, maxTime, durationSec } = timeRange;
|
|
const speed = durationSec / ANIM_CYCLE_SEC;
|
|
let current = minTime;
|
|
|
|
const loop = () => {
|
|
current += speed / 60;
|
|
if (current > maxTime) current = minTime;
|
|
|
|
const tripsLayer = new TripsLayer({
|
|
id: DECK_TRIPS_ID,
|
|
data: [normalizedTrip],
|
|
getPath: (d: NormalizedTrip) => d.path,
|
|
getTimestamps: (d: NormalizedTrip) => d.timestamps,
|
|
getColor: (d: NormalizedTrip) => d.color,
|
|
currentTime: current,
|
|
trailLength: durationSec * 0.15,
|
|
fadeTrail: true,
|
|
widthMinPixels: 4,
|
|
capRounded: true,
|
|
jointRounded: true,
|
|
pickable: false,
|
|
});
|
|
|
|
try {
|
|
const existing = (overlay as unknown as { props?: { layers?: unknown[] } }).props?.layers ?? [];
|
|
const filtered = (existing as { id?: string }[]).filter((l) => !isTrackLayer(l.id));
|
|
overlay.setProps({ layers: [...filtered, pathLayer, tripsLayer, pointsLayer] } as never);
|
|
} catch { /* ignore */ }
|
|
|
|
animRef.current = requestAnimationFrame(loop);
|
|
};
|
|
|
|
animRef.current = requestAnimationFrame(loop);
|
|
return () => cancelAnimationFrame(animRef.current);
|
|
}, [projection, normalizedTrip, activeTrack, timeRange]);
|
|
|
|
/* ── 항적 조회 시 자동 fitBounds ── */
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || !activeTrack || activeTrack.points.length < 2) return;
|
|
if (projectionBusyRef.current) return;
|
|
|
|
let minLon = Infinity;
|
|
let minLat = Infinity;
|
|
let maxLon = -Infinity;
|
|
let maxLat = -Infinity;
|
|
for (const pt of activeTrack.points) {
|
|
if (pt.lon < minLon) minLon = pt.lon;
|
|
if (pt.lat < minLat) minLat = pt.lat;
|
|
if (pt.lon > maxLon) maxLon = pt.lon;
|
|
if (pt.lat > maxLat) maxLat = pt.lat;
|
|
}
|
|
|
|
const fitOpts = { padding: 80, duration: 1000, maxZoom: 14 };
|
|
const apply = () => {
|
|
try {
|
|
map.fitBounds([[minLon, minLat], [maxLon, maxLat]], fitOpts);
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
if (map.isStyleLoaded()) {
|
|
apply();
|
|
} else {
|
|
const onLoad = () => { apply(); map.off('styledata', onLoad); };
|
|
map.on('styledata', onLoad);
|
|
return () => { map.off('styledata', onLoad); };
|
|
}
|
|
}, [activeTrack]);
|
|
}
|