import { useCallback, useEffect, 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_FILL_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 { guardedSetVisibility } from '../lib/layerHelpers'; import { dashifyLine } from '../lib/dashifyLine'; /** Globe FC lines + fleet circles 오버레이 */ 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; // FC lines useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = 'fc-lines-ml-src'; const layerId = 'fc-lines-ml'; const remove = () => { guardedSetVisibility(map, layerId, 'none'); }; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); const fcHoverActive = fleetAwarePairMmsiList.length > 0; if (projection !== 'globe' || (!overlays.fcLines && !fcHoverActive)) { remove(); 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) { remove(); 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 (e) { console.warn('FC lines source setup failed:', e); return; } if (!map.getLayer(layerId)) { try { map.addLayer( { id: layerId, type: 'line', source: srcId, layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, paint: { 'line-color': [ 'case', ['==', ['get', 'highlighted'], 1], ['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, 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2.0, 1.3] as never, 'line-opacity': 0.9, }, } as unknown as LayerSpecification, undefined, ); } catch (e) { console.warn('FC lines layer add failed:', e); } } else { guardedSetVisibility(map, layerId, 'visible'); } reorderGlobeFeatureLayers(); kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); }; }, [ projection, overlays.fcLines, fcLinks, hoveredPairMmsiList, hoveredFleetMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers, ]); // Fleet circles useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = 'fleet-circles-ml-src'; const fillSrcId = 'fleet-circles-ml-fill-src'; const layerId = 'fleet-circles-ml'; const fillLayerId = 'fleet-circles-ml-fill'; const remove = () => { guardedSetVisibility(map, layerId, 'none'); guardedSetVisibility(map, fillLayerId, 'none'); }; const ensure = () => { if (projectionBusyRef.current) return; if (!map.isStyleLoaded()) return; const fleetHoverActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; if (projection !== 'globe' || (!overlays.fleetCircles && !fleetHoverActive) || (fleetCircles?.length ?? 0) === 0) { remove(); return; } const circles = fleetCircles || []; const isHighlightedFleet = (ownerKey: string, vesselMmsis: number[]) => hoveredFleetOwnerKeyList.includes(ownerKey) || (hoveredFleetMmsiList.length > 0 && vesselMmsis.some((mmsi) => hoveredFleetMmsiList.includes(mmsi))); 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, highlighted: 0, }, }; }), }; const fcFill: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: circles .filter((c) => isHighlightedFleet(c.ownerKey, c.vesselMmsis)) .map((c) => ({ type: 'Feature', id: makeFleetCircleFeatureId(`${c.ownerKey}-fill`), geometry: { type: 'Polygon', coordinates: [circleRingLngLat(c.center, c.radiusNm * 1852, 24)], }, properties: { ownerKey: c.ownerKey, }, })), }; 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 (e) { console.warn('Fleet circles source setup failed:', e); return; } try { const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; if (existingFill) existingFill.setData(fcFill); else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification); } catch (e) { console.warn('Fleet circles fill source setup failed:', e); } if (!map.getLayer(layerId)) { try { map.addLayer( { id: layerId, type: 'line', source: srcId, layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'visible' }, paint: { 'line-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never, 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 2, 1.1] as never, 'line-opacity': 0.85, }, } as unknown as LayerSpecification, undefined, ); } catch (e) { console.warn('Fleet circles layer add failed:', e); } } else { guardedSetVisibility(map, layerId, 'visible'); } if (!map.getLayer(fillLayerId)) { try { map.addLayer( { id: fillLayerId, type: 'fill', source: fillSrcId, layout: { visibility: fcFill.features.length > 0 ? 'visible' : 'none' }, paint: { 'fill-color': FLEET_FILL_ML_HL, }, } as unknown as LayerSpecification, undefined, ); } catch (e) { console.warn('Fleet circles fill layer add failed:', e); } } else { guardedSetVisibility(map, fillLayerId, fcFill.features.length > 0 ? 'visible' : 'none'); } reorderGlobeFeatureLayers(); kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); }; }, [ projection, overlays.fleetCircles, fleetCircles, hoveredFleetOwnerKeyList, hoveredFleetMmsiList, mapSyncEpoch, reorderGlobeFeatureLayers, ]); // FC + Fleet paint state updates // eslint-disable-next-line react-hooks/preserve-manual-memoization const updateFcFleetPaintStates = useCallback(() => { if (projection !== 'globe' || projectionBusyRef.current) return; const map = mapRef.current; if (!map || !map.isStyleLoaded()) 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; try { if (map.getLayer('fc-lines-ml')) { map.setPaintProperty( 'fc-lines-ml', 'line-color', ['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, ); map.setPaintProperty( 'fc-lines-ml', 'line-width', ['case', fcEndpointHighlightExpr, 2.0, 1.3] as never, ); } } catch { // ignore } try { if (map.getLayer('fleet-circles-ml')) { map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never); map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never); } } catch { // ignore } }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); useEffect(() => { const map = mapRef.current; if (!map) return; const stop = onMapStyleReady(map, updateFcFleetPaintStates); updateFcFleetPaintStates(); return () => { stop(); }; }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateFcFleetPaintStates]); }