/** * 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, overlayRef: MutableRefObject, projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { activeTrack: ActiveTrack | null; projection: MapProjectionId; mapSyncEpoch: number; }, ) { const { activeTrack, projection, mapSyncEpoch } = opts; /* ── 정규화 데이터 ── */ const normalizedTrip = useMemo(() => { 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(() => [ { 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(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({ 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({ 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]); }