import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; import type maplibregl from 'maplibre-gl'; import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; import type { FcLink, FleetCircle } from '../../../features/legacyDashboard/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { DashSeg, MapProjectionId } from '../types'; import { FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, FLEET_LINE_ML, FLEET_LINE_ML_HL, } from '../constants'; import { makeUniqueSorted } from '../lib/setUtils'; import { makeFcSegmentFeatureId, makeFleetCircleFeatureId, } from '../lib/featureIds'; import { makeMmsiAnyEndpointExpr, makeFleetOwnerMatchExpr, makeFleetMemberMatchExpr, } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { circleRingLngLat } from '../lib/geometry'; import { dashifyLine } from '../lib/dashifyLine'; // ── Overlay line width constants ── const FC_LINE_W_NORMAL = 2.2; const FC_LINE_W_HL = 3.2; const FLEET_LINE_W_NORMAL = 2.0; const FLEET_LINE_W_HL = 3.0; // ── Breathing animation constants ── const BREATHE_AMP = 2.0; const BREATHE_PERIOD_MS = 1200; /** Globe FC lines + fleet circles 오버레이 (stroke only — fill 제거) */ export function useGlobeFcFleetOverlay( mapRef: MutableRefObject, _projectionBusyRef: MutableRefObject, reorderGlobeFeatureLayers: () => void, opts: { overlays: MapToggleState; fcLinks: FcLink[] | undefined; fleetCircles: FleetCircle[] | undefined; projection: MapProjectionId; mapSyncEpoch: number; hoveredFleetMmsiList: number[]; hoveredFleetOwnerKeyList: string[]; hoveredPairMmsiList: number[]; }, ) { const { overlays, fcLinks, fleetCircles, projection, mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, } = opts; const breatheRafRef = useRef(0); // paint state ref — 데이터 effect에서 레이어 생성 직후 최신 paint state를 즉시 적용하기 위해 사용 const paintStateRef = useRef<() => void>(() => {}); // ── FC lines 데이터 effect ── // projectionBusy/isStyleLoaded 선행 가드 제거 — try/catch로 처리 // 실패 시 다음 AIS poll(mapSyncEpoch 변경)에서 자연스럽게 재시도 useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = 'fc-lines-ml-src'; const layerId = 'fc-lines-ml'; const ensure = () => { if (projection !== 'globe') { try { if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); } catch { /* ignore */ } return; } const segs: DashSeg[] = []; for (const l of fcLinks || []) { segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); } if (segs.length === 0) { try { if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); } catch { /* ignore */ } return; } const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: segs.map((s, idx) => ({ type: 'Feature', id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), geometry: { type: 'LineString', coordinates: [s.from, s.to] }, properties: { type: 'fc', suspicious: s.suspicious, distanceNm: s.distanceNm, fcMmsi: s.fromMmsi ?? -1, otherMmsi: s.toMmsi ?? -1, }, })), }; 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': FC_LINE_NORMAL_ML, 'line-width': FC_LINE_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, fcLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); // ── Fleet circles 데이터 effect (stroke only — fill 제거) ── useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = 'fleet-circles-ml-src'; const layerId = 'fleet-circles-ml'; const ensure = () => { if (projection !== 'globe' || (fleetCircles?.length ?? 0) === 0) { try { if (map.getLayer(layerId)) map.setPaintProperty(layerId, 'line-opacity', 0); } catch { /* ignore */ } return; } const circles = fleetCircles || []; const fcLine: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: circles.map((c) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: 'Feature', id: makeFleetCircleFeatureId(c.ownerKey), geometry: { type: 'LineString', coordinates: ring }, properties: { type: 'fleet', ownerKey: c.ownerKey, ownerLabel: c.ownerLabel, count: c.count, vesselMmsis: c.vesselMmsis, }, }; }), }; try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; if (existing) existing.setData(fcLine); else map.addSource(srcId, { type: 'geojson', data: fcLine } 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': FLEET_LINE_ML, 'line-width': FLEET_LINE_W_NORMAL, 'line-opacity': 0, }, } as unknown as LayerSpecification, undefined, ); } catch { return; // 다음 poll에서 재시도 } reorderGlobeFeatureLayers(); } paintStateRef.current(); kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); }; }, [projection, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]); // ── FC + Fleet paint state update (가시성 + 하이라이트 통합) ── // eslint-disable-next-line react-hooks/preserve-manual-memoization const updateFcFleetPaintStates = useCallback(() => { if (projection !== 'globe') return; const map = mapRef.current; if (!map) return; const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 ? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList) : false; const fleetOwnerMatchExpr = hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; const fleetMemberExpr = hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; const fleetHighlightExpr = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) : false; // ── FC lines ── const fcVisible = overlays.fcLines; // ── Fleet circles ── const fleetVisible = overlays.fleetCircles; try { if (map.getLayer('fc-lines-ml')) { map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0); if (fcVisible) { map.setPaintProperty( 'fc-lines-ml', 'line-color', fcEndpointHighlightExpr !== false ? ['case', fcEndpointHighlightExpr, ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL], ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never : ['case', ['boolean', ['get', 'suspicious'], false], FC_LINE_SUSPICIOUS_ML, FC_LINE_NORMAL_ML] as never, ); map.setPaintProperty( 'fc-lines-ml', 'line-width', fcEndpointHighlightExpr !== false ? ['case', fcEndpointHighlightExpr, FC_LINE_W_HL, FC_LINE_W_NORMAL] as never : FC_LINE_W_NORMAL, ); } } } catch { // ignore } try { if (map.getLayer('fleet-circles-ml')) { map.setPaintProperty('fleet-circles-ml', 'line-opacity', fleetVisible ? 0.85 : 0); if (fleetVisible) { map.setPaintProperty('fleet-circles-ml', 'line-color', fleetHighlightExpr !== false ? ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never : FLEET_LINE_ML, ); map.setPaintProperty('fleet-circles-ml', 'line-width', fleetHighlightExpr !== false ? ['case', fleetHighlightExpr, FLEET_LINE_W_HL, FLEET_LINE_W_NORMAL] as never : FLEET_LINE_W_NORMAL, ); } } } catch { // ignore } kickRepaint(map); }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, overlays.fcLines, overlays.fleetCircles]); // paintStateRef를 최신 콜백으로 유지 useEffect(() => { paintStateRef.current = updateFcFleetPaintStates; }, [updateFcFleetPaintStates]); // paint state 동기화 useEffect(() => { updateFcFleetPaintStates(); }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, overlays.fcLines, overlays.fleetCircles, updateFcFleetPaintStates, fcLinks, fleetCircles]); // ── Breathing animation ── useEffect(() => { const map = mapRef.current; const hasFleetHover = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; const hasFcHover = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0; if (!map || (!hasFleetHover && !hasFcHover) || projection !== 'globe') { if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current); breatheRafRef.current = 0; return; } const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 ? makeMmsiAnyEndpointExpr('fcMmsi', 'otherMmsi', fleetAwarePairMmsiList) : false; const fleetOwnerMatchExpr = hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; const fleetMemberExpr = hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; const fleetHighlightExpr = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 ? (['any', fleetOwnerMatchExpr, fleetMemberExpr] as never) : false; 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('fc-lines-ml') && fcEndpointHighlightExpr !== false) { const hlW = FC_LINE_W_HL + t * BREATHE_AMP; map.setPaintProperty('fc-lines-ml', 'line-width', ['case', fcEndpointHighlightExpr, hlW, FC_LINE_W_NORMAL] as never); } if (map.getLayer('fleet-circles-ml') && fleetHighlightExpr !== false) { const hlW = FLEET_LINE_W_HL + t * BREATHE_AMP; map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, hlW, FLEET_LINE_W_NORMAL] as never); } } catch { // ignore } breatheRafRef.current = requestAnimationFrame(animate); }; breatheRafRef.current = requestAnimationFrame(animate); return () => { if (breatheRafRef.current) cancelAnimationFrame(breatheRafRef.current); breatheRafRef.current = 0; }; }, [hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection]); }