import { useEffect, useMemo, useRef, type MutableRefObject } from 'react'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { type PickingInfo } from '@deck.gl/core'; import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; import { ScatterplotLayer } from '@deck.gl/layers'; import { ALARM_BADGE, type LegacyAlarmKind } from '../../../features/legacyDashboard/model/types'; import type { FcLink, FleetCircle, PairLink } from '../../../features/legacyDashboard/model/types'; import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; import type { DashSeg, Map3DSettings, MapProjectionId, PairRangeCircle } from '../types'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import { toSafeNumber } from '../lib/setUtils'; import { getShipTooltipHtml, getPairLinkTooltipHtml, getFcLinkTooltipHtml, getRangeTooltipHtml, getFleetCircleTooltipHtml, } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories'; // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). // Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts. const ENABLE_GLOBE_DECK_OVERLAYS = false; export function useDeckLayers( mapRef: MutableRefObject, overlayRef: MutableRefObject, globeDeckLayerRef: MutableRefObject, projectionBusyRef: MutableRefObject, opts: { projection: MapProjectionId; settings: Map3DSettings; trackReplayDeckLayers: unknown[]; shipLayerData: AisTarget[]; shipOverlayLayerData: AisTarget[]; shipData: AisTarget[]; legacyHits: Map | null | undefined; pairLinks: PairLink[] | undefined; fcLinks: FcLink[] | undefined; fcDashed: DashSeg[]; fleetCircles: FleetCircle[] | undefined; pairRanges: PairRangeCircle[]; pairLinksInteractive: PairLink[]; pairRangesInteractive: PairRangeCircle[]; fcLinesInteractive: DashSeg[]; fleetCirclesInteractive: FleetCircle[]; overlays: MapToggleState; shipByMmsi: Map; selectedMmsi: number | null; shipHighlightSet: Set; isHighlightedFleet: (ownerKey: string, vesselMmsis: number[]) => boolean; isHighlightedPair: (aMmsi: number, bMmsi: number) => boolean; isHighlightedMmsi: (mmsi: number) => boolean; clearDeckHoverPairs: () => void; clearDeckHoverMmsi: () => void; clearMapFleetHoverState: () => void; setDeckHoverPairs: (next: number[]) => void; setDeckHoverMmsi: (next: number[]) => void; setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; toFleetMmsiList: (value: unknown) => number[]; touchDeckHoverState: (isHover: boolean) => void; hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; onDeckSelectOrHighlight: (info: unknown, allowMultiSelect?: boolean) => void; onSelectMmsi: (mmsi: number | null) => void; onToggleHighlightMmsi?: (mmsi: number) => void; ensureMercatorOverlay: () => MapboxOverlay | null; alarmMmsiMap?: Map; }, ) { const { projection, settings, trackReplayDeckLayers, shipLayerData, shipOverlayLayerData, shipData, legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, shipByMmsi, selectedMmsi, shipHighlightSet, isHighlightedFleet, isHighlightedPair, isHighlightedMmsi, clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, ensureMercatorOverlay, alarmMmsiMap, } = opts; const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); }, [shipData, legacyHits]); const legacyTargetsOrdered = useMemo(() => { if (legacyTargets.length === 0) return legacyTargets; const layer = [...legacyTargets]; layer.sort((a, b) => a.mmsi - b.mmsi); return layer; }, [legacyTargets]); const legacyOverlayTargets = useMemo(() => { if (shipHighlightSet.size === 0) return []; return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi)); }, [legacyTargets, shipHighlightSet]); const alarmTargets = useMemo(() => { if (!alarmMmsiMap || alarmMmsiMap.size === 0) return []; return shipData.filter((t) => alarmMmsiMap.has(t.mmsi)); }, [shipData, alarmMmsiMap]); const mercatorLayersRef = useRef([]); const alarmRafRef = useRef(0); // Mercator Deck layers useEffect(() => { const map = mapRef.current; if (!map) return; if (projection !== 'mercator' || projectionBusyRef.current) { if (projection !== 'mercator') { try { if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never); } catch { // ignore } } return; } const deckTarget = ensureMercatorOverlay(); if (!deckTarget) return; const layers = buildMercatorDeckLayers({ shipLayerData, shipOverlayLayerData, legacyTargetsOrdered, legacyOverlayTargets, legacyHits, pairLinks, fcDashed, fleetCircles, pairRanges, pairLinksInteractive, pairRangesInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays, showDensity: settings.showDensity, showShips: settings.showShips, selectedMmsi, shipHighlightSet, touchDeckHoverState, setDeckHoverPairs, setDeckHoverMmsi, clearDeckHoverPairs, clearMapFleetHoverState, setMapFleetHoverState, toFleetMmsiList, hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, onDeckSelectOrHighlight, alarmTargets, alarmMmsiMap, alarmPulseRadius: 8, alarmPulseHoverRadius: 12, }); const normalizedBaseLayers = sanitizeDeckLayerList(layers); const normalizedTrackLayers = sanitizeDeckLayerList(trackReplayDeckLayers); const normalizedLayers = sanitizeDeckLayerList([...normalizedBaseLayers, ...normalizedTrackLayers]); mercatorLayersRef.current = normalizedLayers; const deckProps = { layers: normalizedLayers, getTooltip: (info: PickingInfo) => { if (!info.object) return null; if (info.layer && info.layer.id === 'density') { // eslint-disable-next-line @typescript-eslint/no-explicit-any const o: any = info.object; const n = Array.isArray(o?.points) ? o.points.length : 0; return { text: `AIS density: ${n}` }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj: any = info.object; if (typeof obj.mmsi === 'number') { return getShipTooltipHtml({ mmsi: obj.mmsi, targetByMmsi: shipByMmsi, legacyHits }); } if (info.layer && info.layer.id === 'pair-lines') { const aMmsi = toSafeNumber(obj.aMmsi); const bMmsi = toSafeNumber(obj.bMmsi); if (aMmsi == null || bMmsi == null) return null; return getPairLinkTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi }); } if (info.layer && info.layer.id === 'fc-lines') { const fcMmsi = toSafeNumber(obj.fcMmsi); const otherMmsi = toSafeNumber(obj.otherMmsi); if (fcMmsi == null || otherMmsi == null) return null; return getFcLinkTooltipHtml({ suspicious: !!obj.suspicious, distanceNm: toSafeNumber(obj.distanceNm), fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi }); } if (info.layer && info.layer.id === 'pair-range') { const aMmsi = toSafeNumber(obj.aMmsi); const bMmsi = toSafeNumber(obj.bMmsi); if (aMmsi == null || bMmsi == null) return null; return getRangeTooltipHtml({ warn: !!obj.warn, distanceNm: toSafeNumber(obj.distanceNm), aMmsi, bMmsi, legacyHits }); } if (info.layer && info.layer.id === 'fleet-circles') { return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ''), ownerLabel: String(obj.ownerKey ?? ''), count: Number(obj.count ?? 0) }); } return null; }, onClick: (info: PickingInfo) => { if (!info.object) { onSelectMmsi(null); return; } if (info.layer && info.layer.id === 'density') return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj: any = info.object; if (typeof obj.mmsi === 'number') { const t = obj as AisTarget; const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { onToggleHighlightMmsi?.(t.mmsi); return; } onSelectMmsi(t.mmsi); } }, }; try { deckTarget.setProps(deckProps as never); } catch (e) { console.error('Failed to apply base mercator deck props. Keeping previous layer set.', e); } }, [ ensureMercatorOverlay, projection, shipLayerData, shipByMmsi, pairRanges, pairLinks, fcDashed, fleetCircles, legacyTargetsOrdered, legacyHits, legacyOverlayTargets, shipOverlayLayerData, pairRangesInteractive, pairLinksInteractive, fcLinesInteractive, fleetCirclesInteractive, overlays.pairRange, overlays.pairLines, overlays.fcLines, overlays.fleetCircles, overlays.shipLabels, settings.showDensity, settings.showShips, trackReplayDeckLayers, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, setDeckHoverPairs, clearMapFleetHoverState, setDeckHoverMmsi, clearDeckHoverMmsi, toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, alarmTargets, alarmMmsiMap, ]); // Mercator alarm pulse breathing animation (rAF) useEffect(() => { if (projection !== 'mercator' || !alarmMmsiMap || alarmMmsiMap.size === 0 || alarmTargets.length === 0) { if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current); alarmRafRef.current = 0; return; } const animate = () => { // 프로젝션 전환 중에는 overlay에 접근하지 않음 — WebGL 자원 무효화 방지 if (projectionBusyRef.current) { alarmRafRef.current = requestAnimationFrame(animate); return; } const currentOverlay = overlayRef.current; if (!currentOverlay) { alarmRafRef.current = requestAnimationFrame(animate); return; } const t = (Math.sin(Date.now() / 1500 * Math.PI * 2) + 1) / 2; const normalR = 8 + t * 6; const hoverR = 12 + t * 6; const pulseLyr = new ScatterplotLayer({ id: 'alarm-pulse', data: alarmTargets, pickable: false, billboard: false, filled: true, stroked: false, radiusUnits: 'pixels', getRadius: (d) => { const isHover = (selectedMmsi != null && d.mmsi === selectedMmsi) || shipHighlightSet.has(d.mmsi); return isHover ? hoverR : normalR; }, getFillColor: (d) => { const kind = alarmMmsiMap.get(d.mmsi); return kind ? ALARM_BADGE[kind].rgba : [107, 114, 128, 90] as [number, number, number, number]; }, getPosition: (d) => [d.lon, d.lat] as [number, number], updateTriggers: { getRadius: [normalR, hoverR] }, }); const updated = mercatorLayersRef.current.map((l) => // eslint-disable-next-line @typescript-eslint/no-explicit-any (l as any)?.id === 'alarm-pulse' ? pulseLyr : l, ); try { currentOverlay.setProps({ layers: updated } as never); } catch { // ignore } alarmRafRef.current = requestAnimationFrame(animate); }; alarmRafRef.current = requestAnimationFrame(animate); return () => { if (alarmRafRef.current) cancelAnimationFrame(alarmRafRef.current); alarmRafRef.current = 0; }; }, [projection, alarmMmsiMap, alarmTargets, selectedMmsi, shipHighlightSet]); // Globe Deck overlay useEffect(() => { const map = mapRef.current; if (!map || projection !== 'globe' || projectionBusyRef.current) return; const deckTarget = globeDeckLayerRef.current; if (!deckTarget) return; if (!ENABLE_GLOBE_DECK_OVERLAYS) { try { deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never); } catch { // ignore } return; } const globeLayers = buildGlobeDeckLayers({ pairRanges, pairLinks, fcDashed, fleetCircles, legacyTargetsOrdered, legacyHits, overlays, showShips: settings.showShips, selectedMmsi, isHighlightedFleet, isHighlightedPair, isHighlightedMmsi, touchDeckHoverState, setDeckHoverPairs, setDeckHoverMmsi, clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, setMapFleetHoverState, toFleetMmsiList, }); const normalizedLayers = sanitizeDeckLayerList(globeLayers); const globeDeckProps = { layers: normalizedLayers, getTooltip: undefined, onClick: undefined }; try { deckTarget.setProps(globeDeckProps as never); } catch (e) { console.error('Failed to apply globe deck props. Falling back to empty deck layer set.', e); try { deckTarget.setProps({ ...globeDeckProps, layers: [] as unknown[] } as never); } catch { // Ignore secondary failure. } } }, [ projection, pairRanges, pairLinks, fcDashed, fleetCircles, legacyTargetsOrdered, overlays.pairRange, overlays.pairLines, overlays.fcLines, overlays.fleetCircles, settings.showShips, selectedMmsi, isHighlightedFleet, isHighlightedPair, clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, toFleetMmsiList, touchDeckHoverState, legacyHits, ]); }