gc-wing/apps/web/src/widgets/map3d/hooks/useVesselTrackLayer.ts
htlee 86a0b2276f fix(web): vessel-track 안정화 반영
- 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>
2026-02-16 22:43:08 +09:00

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]);
}