import { MapboxOverlay } from '@deck.gl/mapbox'; import maplibregl from 'maplibre-gl'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AisTarget } from '../../entities/aisTarget/model/types'; import { MaplibreDeckCustomLayer } from './MaplibreDeckCustomLayer'; import type { BaseMapId, Map3DProps, MapProjectionId } from './types'; import type { DashSeg, PairRangeCircle } from './types'; import { mergeNumberSets, makeSetSignature, isFiniteNumber, toIntMmsi, makeUniqueSorted, equalNumberArrays, } from './lib/setUtils'; import { dashifyLine } from './lib/dashifyLine'; import { useHoverState } from './hooks/useHoverState'; import { useMapInit } from './hooks/useMapInit'; import { useProjectionToggle } from './hooks/useProjectionToggle'; import { useBaseMapToggle } from './hooks/useBaseMapToggle'; import { useFlyTo } from './hooks/useFlyTo'; import { useZonesLayer } from './hooks/useZonesLayer'; import { usePredictionVectors } from './hooks/usePredictionVectors'; import { useGlobeShips } from './hooks/useGlobeShips'; import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; import { useSubcablesLayer } from './hooks/useSubcablesLayer'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; type Props = Map3DProps; export function Map3D({ targets, zones, selectedMmsi, hoveredMmsiSet = [], hoveredFleetMmsiSet = [], hoveredPairMmsiSet = [], hoveredFleetOwnerKey = null, highlightedMmsiSet = [], settings, baseMap, projection, overlays, onSelectMmsi, onToggleHighlightMmsi, onViewBboxChange, legacyHits, pairLinks, fcLinks, fleetCircles, onProjectionLoadingChange, fleetFocus, onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover, subcableGeo = null, hoveredCableId = null, onHoverCable, onClickCable, }: Props) { void onHoverFleet; void onClearFleetHover; void onHoverMmsi; void onClearMmsiHover; void onHoverPair; void onClearPairHover; // ── Shared refs ────────────────────────────────────────────────────── const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); const overlayInteractionRef = useRef(null); const globeDeckLayerRef = useRef(null); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); const projectionBusyRef = useRef(false); const deckHoverRafRef = useRef(null); const deckHoverHasHitRef = useRef(false); useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]); useEffect(() => { projectionRef.current = projection; }, [projection]); // ── Hover state ────────────────────────────────────────────────────── const { setHoveredDeckMmsiSet, setHoveredDeckPairMmsiSet, setHoveredDeckFleetOwnerKey, setHoveredDeckFleetMmsiSet, hoveredZoneId, setHoveredZoneId, hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef, externalHighlightedSetRef, hoveredDeckMmsiSetRef, hoveredDeckPairMmsiSetRef, hoveredDeckFleetMmsiSetRef, hoveredFleetOwnerKeys, } = useHoverState({ hoveredMmsiSet, hoveredFleetMmsiSet, hoveredPairMmsiSet, hoveredFleetOwnerKey, highlightedMmsiSet, }); const fleetFocusId = fleetFocus?.id; const fleetFocusLon = fleetFocus?.center?.[0]; const fleetFocusLat = fleetFocus?.center?.[1]; const fleetFocusZoom = fleetFocus?.zoom; // ── Highlight memos ────────────────────────────────────────────────── const effectiveHoveredPairMmsiSet = useMemo( () => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef), [hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef], ); const effectiveHoveredFleetMmsiSet = useMemo( () => mergeNumberSets(hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef), [hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef], ); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const highlightedMmsiSetCombined = useMemo( () => mergeNumberSets( hoveredMmsiSetRef, hoveredDeckMmsiSetRef, externalHighlightedSetRef, effectiveHoveredFleetMmsiSet, effectiveHoveredPairMmsiSet, ), [ hoveredMmsiSetRef, hoveredDeckMmsiSetRef, externalHighlightedSetRef, effectiveHoveredFleetMmsiSet, effectiveHoveredPairMmsiSet, ], ); const highlightedMmsiSetForShips = useMemo( () => (projection === 'globe' ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), [projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined], ); const hoveredShipSignature = useMemo( () => `${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature( hoveredDeckMmsiSetRef, )}|${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${makeSetSignature(effectiveHoveredPairMmsiSet)}`, [ hoveredMmsiSetRef, externalHighlightedSetRef, hoveredDeckMmsiSetRef, effectiveHoveredFleetMmsiSet, effectiveHoveredPairMmsiSet, ], ); void hoveredShipSignature; const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]); const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]); const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]); const isHighlightedMmsi = useCallback( (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), [highlightedMmsiSetCombined], ); const baseHighlightedMmsiSet = useMemo(() => { const out = new Set(); if (selectedMmsi != null) out.add(selectedMmsi); externalHighlightedSetRef.forEach((value) => { out.add(value); }); return out; }, [selectedMmsi, externalHighlightedSetRef]); const isBaseHighlightedMmsi = useCallback( (mmsi: number) => baseHighlightedMmsiSet.has(mmsi), [baseHighlightedMmsiSet], ); const isHighlightedPair = useCallback( (aMmsi: number, bMmsi: number) => effectiveHoveredPairMmsiSet.size === 2 && effectiveHoveredPairMmsiSet.has(aMmsi) && effectiveHoveredPairMmsiSet.has(bMmsi), [effectiveHoveredPairMmsiSet], ); const isHighlightedFleet = useCallback( (ownerKey: string, vesselMmsis: number[]) => { if (hoveredFleetOwnerKeys.has(ownerKey)) return true; return vesselMmsis.some((x) => isHighlightedMmsi(x)); }, [hoveredFleetOwnerKeys, isHighlightedMmsi], ); // ── Ship data memos ────────────────────────────────────────────────── const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); }, [targets]); const shipByMmsi = useMemo(() => { const byMmsi = new Map(); for (const t of shipData) byMmsi.set(t.mmsi, t); return byMmsi; }, [shipData]); const shipLayerData = useMemo(() => { if (shipData.length === 0) return shipData; return [...shipData]; }, [shipData]); const shipHighlightSet = useMemo(() => { const out = new Set(highlightedMmsiSetForShips); if (selectedMmsi) out.add(selectedMmsi); return out; }, [highlightedMmsiSetForShips, selectedMmsi]); const shipHoverOverlaySet = useMemo( () => projection === 'globe' ? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet) : shipHighlightSet, [projection, highlightedMmsiSetCombined, shipHighlightSet], ); const shipOverlayLayerData = useMemo(() => { if (shipLayerData.length === 0) return []; if (shipHighlightSet.size === 0) return []; return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); }, [shipHighlightSet, shipLayerData]); // ── Deck hover management ──────────────────────────────────────────── const hasAuxiliarySelectModifier = useCallback( (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => { if (!ev) return false; return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); }, [], ); const toFleetMmsiList = useCallback((value: unknown) => { if (!Array.isArray(value)) return []; const out: number[] = []; for (const item of value) { const v = toIntMmsi(item); if (v != null) out.push(v); } return out; }, []); const onDeckSelectOrHighlight = useCallback( (info: unknown, allowMultiSelect = false) => { const obj = info as { mmsi?: unknown; srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null; }; const mmsi = toIntMmsi(obj.mmsi); if (mmsi == null) return; const evt = obj.srcEvent ?? null; const isAux = hasAuxiliarySelectModifier(evt); if (onToggleHighlightMmsi && isAux) { onToggleHighlightMmsi(mmsi); return; } if (!allowMultiSelect && selectedMmsi === mmsi) { onSelectMmsi(null); return; } onSelectMmsi(mmsi); }, [hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi], ); // eslint-disable-next-line react-hooks/preserve-manual-memoization const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { const normalized = makeUniqueSorted(next); setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); }, []); // eslint-disable-next-line react-hooks/preserve-manual-memoization const setHoveredDeckFleetOwner = useCallback((ownerKey: string | null) => { setHoveredDeckFleetOwnerKey((prev) => (prev === ownerKey ? prev : ownerKey)); }, []); const mapDeckMmsiHoverRef = useRef([]); const mapDeckPairHoverRef = useRef([]); const mapFleetHoverStateRef = useRef<{ ownerKey: string | null; vesselMmsis: number[] }>({ ownerKey: null, vesselMmsis: [], }); const clearMapFleetHoverState = useCallback(() => { mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; setHoveredDeckFleetOwner(null); setHoveredDeckFleetMmsis([]); }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); const clearDeckHoverPairs = useCallback(() => { mapDeckPairHoverRef.current = []; setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); }, [setHoveredDeckPairMmsiSet]); const clearDeckHoverMmsi = useCallback(() => { mapDeckMmsiHoverRef.current = []; setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); }, [setHoveredDeckMmsiSet]); const scheduleDeckHoverResolve = useCallback(() => { if (deckHoverRafRef.current != null) return; deckHoverRafRef.current = window.requestAnimationFrame(() => { deckHoverRafRef.current = null; if (!deckHoverHasHitRef.current) { clearDeckHoverMmsi(); clearDeckHoverPairs(); clearMapFleetHoverState(); } deckHoverHasHitRef.current = false; }); }, [clearDeckHoverMmsi, clearDeckHoverPairs, clearMapFleetHoverState]); const touchDeckHoverState = useCallback( (isHover: boolean) => { if (isHover) deckHoverHasHitRef.current = true; scheduleDeckHoverResolve(); }, [scheduleDeckHoverResolve], ); const setDeckHoverMmsi = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); touchDeckHoverState(normalized.length > 0); setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckMmsiHoverRef.current = normalized; }, [setHoveredDeckMmsiSet, touchDeckHoverState], ); const setDeckHoverPairs = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); touchDeckHoverState(normalized.length > 0); setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckPairHoverRef.current = normalized; }, [setHoveredDeckPairMmsiSet, touchDeckHoverState], ); const setMapFleetHoverState = useCallback( (ownerKey: string | null, vesselMmsis: number[]) => { const normalized = makeUniqueSorted(vesselMmsis); const prev = mapFleetHoverStateRef.current; if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) { return; } touchDeckHoverState(!!ownerKey || normalized.length > 0); setHoveredDeckFleetOwner(ownerKey); setHoveredDeckFleetMmsis(normalized); mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], ); // hover RAF cleanup useEffect(() => { return () => { if (deckHoverRafRef.current != null) { window.cancelAnimationFrame(deckHoverRafRef.current); deckHoverRafRef.current = null; } deckHoverHasHitRef.current = false; }; }, []); // sync external fleet hover state useEffect(() => { mapFleetHoverStateRef.current = { ownerKey: hoveredFleetOwnerKey, vesselMmsis: hoveredFleetMmsiSet, }; }, [hoveredFleetOwnerKey, hoveredFleetMmsiSet]); // ── Overlay data memos ─────────────────────────────────────────────── const fcDashed = useMemo(() => { const segs: DashSeg[] = []; for (const l of fcLinks || []) { segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); } return segs; }, [fcLinks]); const pairRanges = useMemo(() => { const out: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn, aMmsi: p.aMmsi, bMmsi: p.bMmsi, distanceNm: p.distanceNm, }); } return out; }, [pairLinks]); const pairLinksInteractive = useMemo(() => { if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return []; if (hoveredPairMmsiSetRef.size < 2) return []; const links = pairLinks || []; return links.filter((link) => hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi), ); }, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]); const pairRangesInteractive = useMemo(() => { if (!overlays.pairRange || pairRanges.length === 0) return []; if (hoveredPairMmsiSetRef.size < 2) return []; return pairRanges.filter((range) => hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi), ); }, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]); const fcLinesInteractive = useMemo(() => { if (!overlays.fcLines || fcDashed.length === 0) return []; if (highlightedMmsiSetCombined.size === 0) return []; return fcDashed.filter( (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); }, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return []; const circles = fleetCircles || []; return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); // ── Hook orchestration ─────────────────────────────────────────────── const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit( containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, baseMapRef, projectionRef, { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch }, ); const reorderGlobeFeatureLayers = useProjectionToggle( mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, ); useBaseMapToggle( mapRef, { baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync }, ); useZonesLayer( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch }, ); usePredictionVectors( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch, }, ); useGlobeShips( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, }, ); useGlobeOverlays( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, }, ); useGlobeInteraction( mapRef, projectionBusyRef, { projection, settings, overlays, targets, shipData, shipByMmsi, selectedMmsi, hoveredZoneId, legacyHits, clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId, }, ); useDeckLayers( mapRef, overlayRef, globeDeckLayerRef, projectionBusyRef, { projection, settings, shipLayerData, shipOverlayLayerData, shipData, legacyHits, pairLinks, fcLinks, 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, projectionRef, }, ); // eslint-disable-next-line @typescript-eslint/no-unused-vars const noopCable = useCallback((_: string | null) => {}, []); useSubcablesLayer( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { subcableGeo: subcableGeo ?? null, overlays, projection, mapSyncEpoch, hoveredCableId: hoveredCableId ?? null, onHoverCable: onHoverCable ?? noopCable, onClickCable: onClickCable ?? noopCable, }, ); useFlyTo( mapRef, projectionRef, { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, ); return
; }