import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { PairLink } from '../../../features/legacyDashboard/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { MapProjectionId, PairRangeCircle } from '../types'; import { PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, } from '../constants'; import { makePairLinkFeatureId } from '../lib/featureIds'; import { makeMmsiPairHighlightExpr } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { circleRingLngLat } from '../lib/geometry'; // ── Overlay line width constants ── const PAIR_LINE_W_NORMAL = 2.5; const PAIR_LINE_W_WARN = 3.5; const PAIR_LINE_W_HL = 4.5; const PAIR_RANGE_W_NORMAL = 1.8; const PAIR_RANGE_W_HL = 2.8; // ── Breathing animation constants ── const BREATHE_AMP = 2.0; const BREATHE_PERIOD_MS = 1200; /** Globe pair lines + pair range 오버레이 */ export function useGlobePairOverlay( mapRef: MutableRefObject, _projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { overlays: MapToggleState; pairLinks: PairLink[] | undefined; projection: MapProjectionId; mapSyncEpoch: number; hoveredPairMmsiList: number[]; }, ) { const { overlays, pairLinks, projection, mapSyncEpoch, hoveredPairMmsiList } = opts; const breatheRafRef = useRef(0); // paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용 const paintStateRef = useRef<() => void>(() => {}); // ── Pair lines 데이터 effect ── // projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리 // 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도 useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = 'pair-lines-ml-src'; const layerId = 'pair-lines-ml'; const ensure = () => { if (projection !== 'globe') { try { if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); } catch { /* ignore */ } return; } if ((pairLinks?.length ?? 0) === 0) { try { if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); } catch { /* ignore */ } return; } const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: (pairLinks || []).map((p) => ({ type: 'Feature', id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), geometry: { type: 'LineString', coordinates: [p.from, p.to] }, properties: { type: 'pair', aMmsi: p.aMmsi, bMmsi: p.bMmsi, distanceNm: p.distanceNm, warn: p.warn, }, })), }; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(fc); else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); } catch { return; // 다음 poll에서 재시도 } const needReorder = !map.getLayer(layerId); if (needReorder) { try { map.addLayer( { id: layerId, type: 'line', source: srcId, layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, paint: { 'line-color': PAIR_LINE_NORMAL_ML, 'line-width': PAIR_LINE_W_NORMAL, 'line-opacity': 0, }, } as unknown as LayerSpecification, undefined, ); } catch { return; // 다음 poll에서 재시도 } reorderGlobeFeatureLayers(); } // 즉시 올바른 paint state 적용 — 타이밍 간극으로 opacity:0 고착 방지 paintStateRef.current(); kickRepaint(map); }; // 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장 const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); }; }, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); // ── Pair range 데이터 effect ── useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = 'pair-range-ml-src'; const layerId = 'pair-range-ml'; const ensure = () => { if (projection !== 'globe') { try { if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); } catch { /* ignore */ } return; } const ranges: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; ranges.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn, aMmsi: p.aMmsi, bMmsi: p.bMmsi, distanceNm: p.distanceNm, }); } if (ranges.length === 0) { try { if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); } catch { /* ignore */ } return; } const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: ranges.map((c) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: 'Feature', id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), geometry: { type: 'LineString', coordinates: ring }, properties: { type: 'pair-range', warn: c.warn, aMmsi: c.aMmsi, bMmsi: c.bMmsi, distanceNm: c.distanceNm, }, }; }), }; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(fc); else map.addSource(srcId, { type: 'geojson', data: fc } as GeoJSONSourceSpecification); } catch { return; // 다음 poll에서 재시도 } const needReorder = !map.getLayer(layerId); if (needReorder) { try { map.addLayer( { id: layerId, type: 'line', source: srcId, layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible', }, paint: { 'line-color': PAIR_RANGE_NORMAL_ML, 'line-width': PAIR_RANGE_W_NORMAL, 'line-opacity': 0, }, } as unknown as LayerSpecification, undefined, ); } catch { return; // 다음 poll에서 재시도 } reorderGlobeFeatureLayers(); } paintStateRef.current(); kickRepaint(map); }; // 초기 style 로드 대기 — 이후에는 AIS poll 사이클이 재시도 보장 const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); }; }, [projection, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); // ── Pair paint state update (가시성 + 하이라이트 통합) ── // setLayoutProperty(visibility) 대신 setPaintProperty(line-opacity)로 가시성 제어 // → style._changed 미트리거 → alarm badge symbol placement 재계산 방지 // eslint-disable-next-line react-hooks/preserve-manual-memoization const updatePairPaintStates = useCallback(() => { if (projection !== 'globe') return; const map = mapRef.current; if (!map) return; const active = hoveredPairMmsiList.length >= 2; const pairHighlightExpr = active ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) : false; // ── Pair lines: 가시성 + 하이라이트 ── const pairLinesVisible = overlays.pairLines || active; try { if (map.getLayer('pair-lines-ml')) { map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0); if (pairLinesVisible) { map.setPaintProperty( 'pair-lines-ml', 'line-color', pairHighlightExpr !== false ? ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never : ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, ); map.setPaintProperty( 'pair-lines-ml', 'line-width', pairHighlightExpr !== false ? ['case', pairHighlightExpr, PAIR_LINE_W_HL, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never : ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never, ); } } } catch { // ignore } // ── Pair range: 가시성 + 하이라이트 ── const pairRangeVisible = overlays.pairRange || active; try { if (map.getLayer('pair-range-ml')) { map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0); if (pairRangeVisible) { map.setPaintProperty( 'pair-range-ml', 'line-color', pairHighlightExpr !== false ? ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never : ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, ); map.setPaintProperty( 'pair-range-ml', 'line-width', pairHighlightExpr !== false ? ['case', pairHighlightExpr, PAIR_RANGE_W_HL, PAIR_RANGE_W_NORMAL] as never : PAIR_RANGE_W_NORMAL, ); } } } catch { // ignore } kickRepaint(map); }, [projection, hoveredPairMmsiList, overlays.pairLines, overlays.pairRange]); // paintStateRef를 최신 콜백으로 유지 — useEffect 내에서만 ref 업데이트 (react-hooks/refs 준수) useEffect(() => { paintStateRef.current = updatePairPaintStates; }, [updatePairPaintStates]); // paint state 동기화: 호버/토글/epoch 변경 시 즉시 반영 useEffect(() => { updatePairPaintStates(); }, [mapSyncEpoch, hoveredPairMmsiList, projection, overlays.pairLines, overlays.pairRange, updatePairPaintStates, pairLinks]); // ── Breathing animation for highlighted pair overlays ── useEffect(() => { const map = mapRef.current; if (!map || hoveredPairMmsiList.length < 2 || projection !== 'globe') { if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current); breatheRafRef.current = 0; return; } const pairHighlightExpr = makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList); const animate = () => { if (!map.isStyleLoaded()) { breatheRafRef.current = requestAnimationFrame(animate); return; } const t = (Math.sin(Date.now() / BREATHE_PERIOD_MS * Math.PI * 2) + 1) / 2; try { if (map.getLayer('pair-lines-ml')) { const hlW = PAIR_LINE_W_HL + t * BREATHE_AMP; map.setPaintProperty('pair-lines-ml', 'line-width', ['case', pairHighlightExpr, hlW, ['boolean', ['get', 'warn'], false], PAIR_LINE_W_WARN, PAIR_LINE_W_NORMAL] as never); } if (map.getLayer('pair-range-ml')) { const hlW = PAIR_RANGE_W_HL + t * BREATHE_AMP; map.setPaintProperty('pair-range-ml', 'line-width', ['case', pairHighlightExpr, hlW, PAIR_RANGE_W_NORMAL] as never); } } catch { // ignore } breatheRafRef.current = requestAnimationFrame(animate); }; breatheRafRef.current = requestAnimationFrame(animate); return () => { if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current); breatheRafRef.current = 0; }; }, [hoveredPairMmsiList, projection]); }