From 54d33a8670ee7c029120ac3b3bd3af13babc70b3 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:28:04 +0900 Subject: [PATCH] fix(map3d): restore mercator static/overlay split and stabilize globe deck rendering --- apps/web/src/widgets/map3d/Map3D.tsx | 1713 ++++++++++++++++---------- 1 file changed, 1069 insertions(+), 644 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 87ee2b0..2050554 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1082,6 +1082,7 @@ export function Map3D({ const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); + const overlayInteractionRef = useRef(null); const globeDeckLayerRef = useRef(null); const globeShipsEpochRef = useRef(-1); const showSeamarkRef = useRef(settings.showSeamark); @@ -1187,11 +1188,6 @@ export function Map3D({ effectiveHoveredPairMmsiSet, ], ); - const hoveredFleetSignature = useMemo( - () => `${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${[...hoveredFleetOwnerKeys].sort().join(",")}`, - [effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys], - ); - const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]); const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]); const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]); const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]); @@ -1236,6 +1232,39 @@ export function Map3D({ 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], + ); + const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { const normalized = makeUniqueSorted(next); setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); @@ -1464,20 +1493,30 @@ export function Map3D({ } }, []); - const ensureMercatorOverlay = useCallback(() => { + const ensureMercatorOverlays = useCallback(() => { const map = mapRef.current; if (!map) return null; - if (overlayRef.current) return overlayRef.current; - try { - const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(next); - overlayRef.current = next; - return next; - } catch (e) { - console.warn("Deck overlay create failed:", e); - return null; - } + const ensureLayer = (ref: { current: MapboxOverlay | null }) => { + if (ref.current) return ref.current; + try { + const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); + map.addControl(next); + ref.current = next; + return next; + } catch (e) { + console.warn("Deck overlay create failed:", e); + return null; + } + }; + + const base = ensureLayer(overlayRef); + if (!base) return null; + + const interaction = ensureLayer(overlayInteractionRef); + if (!interaction) return null; + + return { base, interaction }; }, []); const clearGlobeNativeLayers = useCallback(() => { @@ -1518,7 +1557,6 @@ export function Map3D({ if (!containerRef.current || mapRef.current) return; let map: maplibregl.Map | null = null; - let overlay: MapboxOverlay | null = null; let cancelled = false; const controller = new AbortController(); @@ -1557,9 +1595,9 @@ export function Map3D({ // - mercator: MapboxOverlay interleaved (fast, feature-rich) // - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned) if (projectionRef.current === "mercator") { - overlay = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); - map.addControl(overlay); - overlayRef.current = overlay; + const overlays = ensureMercatorOverlays(); + if (!overlays) return; + overlayRef.current = overlays.base; } else { globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ id: "deck-globe", @@ -1648,12 +1686,14 @@ export function Map3D({ map.remove(); map = null; } - if (overlay) { - overlay.finalize(); - overlay = null; + if (overlayRef.current) { + overlayRef.current.finalize(); + overlayRef.current = null; + } + if (overlayInteractionRef.current) { + overlayInteractionRef.current.finalize(); + overlayInteractionRef.current = null; } - - overlayRef.current = null; globeDeckLayerRef.current = null; mapRef.current = null; }; @@ -1707,25 +1747,33 @@ export function Map3D({ if (isTransition) setProjectionLoading(true); - const disposeMercatorOverlay = () => { - const current = overlayRef.current; - if (!current) return; - try { - current.setProps({ layers: [] } as never); - } catch { - // ignore - } - try { - map.removeControl(current as never); - } catch { - // ignore - } - try { - current.finalize(); - } catch { - // ignore - } - overlayRef.current = null; + const disposeMercatorOverlays = () => { + const disposeOne = (target: MapboxOverlay | null, toNull: "base" | "interaction") => { + if (!target) return; + try { + target.setProps({ layers: [] } as never); + } catch { + // ignore + } + try { + map.removeControl(target as never); + } catch { + // ignore + } + try { + target.finalize(); + } catch { + // ignore + } + if (toNull === "base") { + overlayRef.current = null; + } else { + overlayInteractionRef.current = null; + } + }; + + disposeOne(overlayRef.current, "base"); + disposeOne(overlayInteractionRef.current, "interaction"); }; const disposeGlobeDeckLayer = () => { @@ -1759,7 +1807,7 @@ export function Map3D({ const shouldSwitchProjection = currentProjection !== next; if (projection === "globe") { - disposeMercatorOverlay(); + disposeMercatorOverlays(); clearGlobeNativeLayers(); } else { disposeGlobeDeckLayer(); @@ -1816,7 +1864,7 @@ export function Map3D({ // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. disposeGlobeDeckLayer(); - ensureMercatorOverlay(); + ensureMercatorOverlays(); } // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. @@ -1852,7 +1900,7 @@ export function Map3D({ if (settleCleanup) settleCleanup(); if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlays, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); // Base map toggle useEffect(() => { @@ -3321,15 +3369,21 @@ export function Map3D({ const shipLayerData = useMemo(() => { if (shipData.length === 0) return shipData; const layer = [...shipData]; - layer.sort((a, b) => { - const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; - const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; - if (aPriority !== bPriority) return aPriority - bPriority; - return a.mmsi - b.mmsi; - }); + layer.sort((a, b) => a.mmsi - b.mmsi); return layer; }, [shipData, isHighlightedMmsi, selectedMmsi]); + const shipHighlightSet = useMemo(() => { + const out = new Set(highlightedMmsiSetCombined); + if (selectedMmsi) out.add(selectedMmsi); + return out; + }, [highlightedMmsiSetCombined, selectedMmsi]); + + const shipOverlayLayerData = useMemo(() => { + if (shipHighlightSet.size === 0) return []; + return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); + }, [shipLayerData, shipHighlightSet]); + const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; try { @@ -3631,14 +3685,14 @@ export function Map3D({ const legacyTargetsOrdered = useMemo(() => { if (legacyTargets.length === 0) return legacyTargets; const layer = [...legacyTargets]; - layer.sort((a, b) => { - const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; - const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; - if (aPriority !== bPriority) return aPriority - bPriority; - return a.mmsi - b.mmsi; - }); + layer.sort((a, b) => a.mmsi - b.mmsi); return layer; - }, [legacyTargets, isHighlightedMmsi, selectedMmsi]); + }, [legacyTargets]); + + const legacyOverlayTargets = useMemo(() => { + if (shipHighlightSet.size === 0) return []; + return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi)); + }, [legacyTargets, shipHighlightSet]); const fcDashed = useMemo(() => { const segs: DashSeg[] = []; @@ -3664,6 +3718,961 @@ export function Map3D({ 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 (shipHighlightSet.size === 0) return []; + return fcDashed.filter( + (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : shipHighlightSet.has(mmsi))), + ); + }, [fcDashed, hoveredShipSignature, overlays.fcLines, shipHighlightSet]); + + const fleetCirclesInteractive = useMemo(() => { + if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; + if (hoveredFleetOwnerKeys.size === 0 && shipHighlightSet.size === 0) return []; + const circles = fleetCircles || []; + return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); + }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, shipHighlightSet]); + + // Static deck layers for mercator (positions + base states). Interaction overlays are handled separately. + 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 refs = ensureMercatorOverlays(); + const deckTarget = refs?.base; + if (!deckTarget) return; + + const layers: unknown[] = []; + const overlayParams = DEPTH_DISABLED_PARAMS; + const clearDeckHover = () => { + touchDeckHoverState(false); + }; + + if (settings.showDensity) { + layers.push( + new HexagonLayer({ + id: "density", + data: shipLayerData, + pickable: true, + extruded: true, + radius: 2500, + elevationScale: 35, + coverage: 0.92, + opacity: 0.35, + getPosition: (d) => [d.lon, d.lat], + }), + ); + } + + if (overlays.pairRange && pairRanges.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "pair-range", + data: pairRanges, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: "pixels", + getLineWidth: () => 1, + getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const p = info.object as PairRangeCircle; + setDeckHoverPairs([p.aMmsi, p.bMmsi]); + setDeckHoverMmsi([p.aMmsi, p.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + const obj = info.object as PairRangeCircle; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + layers.push( + new LineLayer({ + id: "pair-lines", + data: pairLinks, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), + getWidth: (d) => (d.warn ? 2.2 : 1.4), + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as PairLink; + setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as PairLink; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + }), + ); + } + + if (overlays.fcLines && fcDashed.length > 0) { + layers.push( + new LineLayer({ + id: "fc-lines", + data: fcDashed, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), + getWidth: () => 1.3, + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) { + clearDeckHover(); + return; + } + setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); + setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) return; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.fromMmsi); + onToggleHighlightMmsi?.(obj.toMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); + }, + }), + ); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles", + data: fleetCircles, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: "pixels", + getLineWidth: () => 1.1, + getLineColor: () => [245, 158, 11, 140], + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + setMapFleetHoverState(obj.ownerKey || null, list); + setDeckHoverMmsi(list); + clearDeckHoverPairs(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); + return; + } + const first = list[0]; + if (first != null) onDeckSelectOrHighlight({ mmsi: first }); + }, + }), + ); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles-fill", + data: fleetCircles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => [245, 158, 11, 6], + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips && legacyTargetsOrdered.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "legacy-halo", + data: legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: () => FLAT_LEGACY_HALO_RADIUS, + lineWidthUnits: "pixels", + getLineWidth: () => 2, + getLineColor: (d) => { + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (settings.showShips) { + layers.push( + new IconLayer({ + id: "ships", + data: shipLayerData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: () => FLAT_SHIP_ICON_SIZE, + getColor: (d) => + getShipColor( + d, + null, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + new Set(), + ), + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + onDeckSelectOrHighlight({ + mmsi: info.object.mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, true); + }, + alphaCutoff: 0.05, + }), + ); + } + + const normalizedLayers = sanitizeDeckLayerList(layers); + 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); + map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); + } + }, + }; + + try { + deckTarget.setProps(deckProps as never); + } catch (e) { + console.error("Failed to apply base mercator deck props. Falling back to empty layer set.", e); + try { + deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never); + } catch { + // Ignore secondary failure. + } + } + }, [ + ensureMercatorOverlays, + projection, + overlayRef, + projectionBusyRef, + shipLayerData, + shipByMmsi, + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + legacyHits, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showDensity, + settings.showShips, + onDeckSelectOrHighlight, + onSelectMmsi, + onToggleHighlightMmsi, + setDeckHoverPairs, + clearMapFleetHoverState, + setDeckHoverMmsi, + clearDeckHoverMmsi, + toFleetMmsiList, + touchDeckHoverState, + hasAuxiliarySelectModifier, + ]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projectionBusyRef.current) return; + + if (projection !== "mercator") { + try { + if (overlayInteractionRef.current) overlayInteractionRef.current.setProps({ layers: [] } as never); + } catch { + // ignore + } + return; + } + + const refs = ensureMercatorOverlays(); + const deckTarget = refs?.interaction; + if (!deckTarget) return; + + const overlayParams = DEPTH_DISABLED_PARAMS; + const overlayLayers: unknown[] = []; + + if (overlays.pairRange && pairRangesInteractive.length > 0) { + overlayLayers.push( + new ScatterplotLayer({ + id: "pair-range-overlay", + data: pairRangesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: "pixels", + getLineWidth: () => 2.2, + getLineColor: (d) => (d.warn ? [245, 158, 11, 220] : [245, 158, 11, 170]), + getPosition: (d) => d.center, + }), + ); + } + + if (overlays.pairLines && pairLinksInteractive.length > 0) { + overlayLayers.push( + new LineLayer({ + id: "pair-lines-overlay", + data: pairLinksInteractive, + pickable: false, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: () => [245, 158, 11, 245], + getWidth: () => 2.6, + widthUnits: "pixels", + }), + ); + } + + if (overlays.fcLines && fcLinesInteractive.length > 0) { + overlayLayers.push( + new LineLayer({ + id: "fc-lines-overlay", + data: fcLinesInteractive, + pickable: false, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: () => [245, 158, 11, 230], + getWidth: () => 1.9, + widthUnits: "pixels", + }), + ); + } + + if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { + overlayLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-overlay-fill", + data: fleetCirclesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: () => [245, 158, 11, 42], + }), + ); + overlayLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-overlay", + data: fleetCirclesInteractive, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: "pixels", + getLineWidth: () => 1.8, + getLineColor: () => [245, 158, 11, 220], + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips && legacyOverlayTargets.length > 0) { + overlayLayers.push( + new ScatterplotLayer({ + id: "legacy-halo-overlay", + data: legacyOverlayTargets, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + }, + lineWidthUnits: "pixels", + getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2.2), + getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 210]; + return [rgb[0], rgb[1], rgb[2], 210]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (settings.showShips && shipOverlayLayerData.length > 0) { + overlayLayers.push( + new IconLayer({ + id: "ships-overlay", + data: shipOverlayLayerData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE_HIGHLIGHTED), + getColor: (d) => + getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + shipHighlightSet, + ), + }), + ); + } + + const normalizedLayers = sanitizeDeckLayerList(overlayLayers); + const overlayDeckProps = { + layers: normalizedLayers, + getTooltip: undefined, + onClick: undefined, + }; + + try { + deckTarget.setProps(overlayDeckProps as never); + } catch (e) { + console.error("Failed to apply interaction mercator deck props. Falling back to empty layer set.", e); + try { + deckTarget.setProps({ ...overlayDeckProps, layers: [] as unknown[] } as never); + } catch { + // Ignore secondary failure. + } + } + }, [ + ensureMercatorOverlays, + projection, + projectionBusyRef, + shipOverlayLayerData, + legacyOverlayTargets, + pairRangesInteractive, + pairLinksInteractive, + fcLinesInteractive, + fleetCirclesInteractive, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showShips, + selectedMmsi, + shipHighlightSet, + legacyHits, + ]); + + // Globe deck (3D) layer updates. Keep rendering logic deterministic and avoid per-frame churn. + useEffect(() => { + const map = mapRef.current; + if (!map || projection !== "globe" || projectionBusyRef.current) return; + const deckTarget = globeDeckLayerRef.current; + if (!deckTarget) return; + + const overlayParams = GLOBE_OVERLAY_PARAMS; + const globeLayers: unknown[] = []; + + if (overlays.pairRange && pairRanges.length > 0) { + globeLayers.push( + new ScatterplotLayer({ + id: "pair-range-globe", + data: pairRanges, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + radiusMinPixels: 10, + lineWidthUnits: "pixels", + getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), + getLineColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 220] : [59, 130, 246, 110]), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + touchDeckHoverState(true); + const p = info.object as PairRangeCircle; + setDeckHoverPairs([p.aMmsi, p.bMmsi]); + setDeckHoverMmsi([p.aMmsi, p.bMmsi]); + clearMapFleetHoverState(); + }, + }), + ); + } + + if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) { + const links = pairLinks || []; + globeLayers.push( + new LineLayer({ + id: "pair-lines-globe", + data: links, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? [245, 158, 11, 245] : [59, 130, 246, 85]), + getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + touchDeckHoverState(true); + const obj = info.object as PairLink; + setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); + clearMapFleetHoverState(); + }, + }), + ); + } + + if (overlays.fcLines && fcDashed.length > 0) { + globeLayers.push( + new LineLayer({ + id: "fc-lines-globe", + data: fcDashed, + pickable: true, + parameters: overlayParams, + getSourcePosition: (d) => d.from, + getTargetPosition: (d) => d.to, + getColor: (d) => { + const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); + return isHighlighted ? [245, 158, 11, 230] : [217, 119, 6, 200]; + }, + getWidth: (d) => { + const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); + return isHighlighted ? 1.9 : 1.3; + }, + widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + touchDeckHoverState(true); + const obj = info.object as DashSeg; + const aMmsi = obj.fromMmsi; + const bMmsi = obj.toMmsi; + if (aMmsi == null || bMmsi == null) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + return; + } + setDeckHoverPairs([aMmsi, bMmsi]); + setDeckHoverMmsi([aMmsi, bMmsi]); + clearMapFleetHoverState(); + }, + }), + ); + } + + if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) { + const circles = fleetCircles || []; + globeLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-globe", + data: circles, + pickable: true, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + lineWidthUnits: "pixels", + getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), + getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 220] : [245, 158, 11, 140]), + getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + clearMapFleetHoverState(); + return; + } + touchDeckHoverState(true); + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + setMapFleetHoverState(obj.ownerKey || null, list); + setDeckHoverMmsi(list); + clearDeckHoverPairs(); + }, + }), + ); + globeLayers.push( + new ScatterplotLayer({ + id: "fleet-circles-fill-globe", + data: circles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? [245, 158, 11, 42] : [245, 158, 11, 6]), + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips && legacyTargetsOrdered.length > 0) { + globeLayers.push( + new ScatterplotLayer({ + id: "legacy-halo-globe", + data: legacyTargetsOrdered, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + return FLAT_LEGACY_HALO_RADIUS; + }, + lineWidthUnits: "pixels", + getLineWidth: (d) => { + const isHighlighted = isHighlightedMmsi(d.mmsi); + return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : isHighlighted ? 2.2 : 2; + }, + getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; + if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; + const l = legacyHits?.get(d.mmsi); + const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; + if (!rgb) return [245, 158, 11, 200]; + return [rgb[0], rgb[1], rgb[2], 200]; + }, + getPosition: (d) => [d.lon, d.lat] as [number, number], + }), + ); + } + + if (settings.showShips) { + globeLayers.push( + new IconLayer({ + id: "ships-globe", + data: shipLayerData, + pickable: true, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return FLAT_SHIP_ICON_SIZE; + }, + getColor: (d) => + getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, highlightedMmsiSetCombined), + onHover: (info) => { + if (!info.object) { + clearDeckHoverPairs(); + clearDeckHoverMmsi(); + clearMapFleetHoverState(); + return; + } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }, + alphaCutoff: 0.05, + }), + ); + } + + 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, + projectionBusyRef, + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + shipLayerData, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showShips, + selectedMmsi, + isHighlightedMmsi, + isHighlightedFleet, + isHighlightedPair, + clearDeckHoverPairs, + clearDeckHoverMmsi, + clearMapFleetHoverState, + setDeckHoverPairs, + setDeckHoverMmsi, + setMapFleetHoverState, + toFleetMmsiList, + touchDeckHoverState, + legacyHits, + highlightedMmsiSetCombined, + ]); + // When the selected MMSI changes due to external UI (e.g., list click), fly to it. useEffect(() => { const map = mapRef.current; @@ -3701,589 +4710,5 @@ export function Map3D({ }; }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); - // Update Deck.gl layers - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projectionBusyRef.current) return; - - let deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; - - if (projection === "mercator") { - if (!deckTarget) deckTarget = ensureMercatorOverlay(); - if (!deckTarget) return; - try { - deckTarget.setProps({ layers: [] } as never); - } catch { - // ignore - } - } else if (!deckTarget && projection === "globe") { - return; - } - if (!deckTarget) return; - - const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; - const layers = []; - const clearDeckHover = () => { - touchDeckHoverState(false); - }; - - const toFleetMmsiList = (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 = (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); - }; - - if (settings.showDensity && projection !== "globe") { - layers.push( - new HexagonLayer({ - id: "density", - data: shipLayerData, - pickable: true, - extruded: true, - radius: 2500, - elevationScale: 35, - coverage: 0.92, - opacity: 0.35, - getPosition: (d) => [d.lon, d.lat], - }), - ); - } - - if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "pair-range", - data: pairRanges, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - radiusMinPixels: 10, - lineWidthUnits: "pixels", - getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), - getLineColor: (d) => { - if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 220]; - return d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]; - }, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const p = info.object as PairRangeCircle; - const aMmsi = p.aMmsi; - const bMmsi = p.bMmsi; - setDeckHoverPairs([aMmsi, bMmsi]); - setDeckHoverMmsi([aMmsi, bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - const obj = info.object as PairRangeCircle; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - updateTriggers: { - getLineWidth: [hoveredPairSignature], - getLineColor: [hoveredPairSignature], - }, - }), - ); - } - - if (overlays.pairLines && projection !== "globe" && (pairLinks?.length ?? 0) > 0) { - layers.push( - new LineLayer({ - id: "pair-lines", - data: pairLinks, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => { - if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 245]; - return d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]; - }, - getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as PairLink; - setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); - setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as PairLink; - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.aMmsi); - onToggleHighlightMmsi?.(obj.bMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); - }, - updateTriggers: { - getColor: [hoveredPairSignature], - getWidth: [hoveredPairSignature], - }, - }), - ); - } - - if (overlays.fcLines && projection !== "globe" && fcDashed.length > 0) { - layers.push( - new LineLayer({ - id: "fc-lines", - data: fcDashed, - pickable: true, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => { - const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - if (isHighlighted) return [245, 158, 11, 230]; - return d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]; - }, - getWidth: (d) => { - const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); - return isHighlighted ? 1.9 : 1.3; - }, - widthUnits: "pixels", - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as DashSeg; - const aMmsi = obj.fromMmsi; - const bMmsi = obj.toMmsi; - if (aMmsi == null || bMmsi == null) { - clearDeckHover(); - return; - } - setDeckHoverPairs([aMmsi, bMmsi]); - setDeckHoverMmsi([aMmsi, bMmsi]); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as DashSeg; - if (obj.fromMmsi == null || obj.toMmsi == null) { - return; - } - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - onToggleHighlightMmsi?.(obj.fromMmsi); - onToggleHighlightMmsi?.(obj.toMmsi); - return; - } - onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); - }, - updateTriggers: { - getColor: [hoveredShipSignature], - getWidth: [hoveredShipSignature], - }, - }), - ); - } - - if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: "fleet-circles", - data: fleetCircles, - pickable: true, - billboard: false, - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - lineWidthUnits: "pixels", - getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), - getLineColor: (d) => { - const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); - return isHighlighted ? [245, 158, 11, 220] : [245, 158, 11, 140]; - }, - getPosition: (d) => d.center, - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - setMapFleetHoverState(obj.ownerKey || null, list); - setDeckHoverMmsi(list); - clearDeckHoverPairs(); - }, - onClick: (info) => { - if (!info.object) return; - const obj = info.object as FleetCircle; - const list = toFleetMmsiList(obj.vesselMmsis); - const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; - if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { - for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); - return; - } - const first = list[0]; - if (first != null) { - onDeckSelectOrHighlight({ mmsi: first }); - } - }, - updateTriggers: { - getLineWidth: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], - getLineColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], - }, - }), - ); - } - - if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { - layers.push( - new ScatterplotLayer({ - id: "fleet-circles-fill", - data: fleetCircles, - pickable: false, - billboard: false, - parameters: overlayParams, - filled: true, - stroked: false, - radiusUnits: "meters", - getRadius: (d) => d.radiusNm * 1852, - getFillColor: (d) => { - const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); - return isHighlighted ? [245, 158, 11, 42] : [245, 158, 11, 6]; - }, - getPosition: (d) => d.center, - updateTriggers: { - getFillColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], - }, - }), - ); - } - - if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargetsOrdered, - pickable: false, - billboard: false, - // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; - if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; - return FLAT_LEGACY_HALO_RADIUS; - }, - lineWidthUnits: "pixels", - getLineWidth: (d) => { - const isHighlighted = isHighlightedMmsi(d.mmsi); - return selectedMmsi && d.mmsi === selectedMmsi - ? 2.5 - : isHighlighted - ? 2.2 - : 2; - }, - getLineColor: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; - if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; - const l = legacyHits?.get(d.mmsi); - const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null; - if (!rgb) return [245, 158, 11, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - updateTriggers: { - getRadius: [selectedMmsi, hoveredShipSignature], - getLineColor: [selectedMmsi, legacyHits, hoveredShipSignature], - }, - }), - ); - } - - if (settings.showShips && projection !== "globe") { - layers.push( - new IconLayer({ - id: "ships", - data: shipLayerData, - pickable: true, - // Keep icons horizontal on the sea surface when view is pitched/rotated. - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => [d.lon, d.lat] as [number, number], - getAngle: (d) => - getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => { - if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (isHighlightedMmsi(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return FLAT_SHIP_ICON_SIZE; - }, - getColor: (d) => - getShipColor( - d, - selectedMmsi, - legacyHits?.get(d.mmsi)?.shipCode ?? null, - highlightedMmsiSetCombined, - ), - onHover: (info) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }, - onClick: (info) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - onDeckSelectOrHighlight({ - mmsi: info.object.mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, true); - }, - alphaCutoff: 0.05, - updateTriggers: { - getSize: [selectedMmsi, hoveredShipSignature], - getColor: [selectedMmsi, legacyHits, hoveredShipSignature], - }, - }), - ); - } - - const normalizedLayers = sanitizeDeckLayerList(layers); - - const deckProps = { - layers: normalizedLayers, - getTooltip: - projection === "globe" - ? undefined - : (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) ?? toSafeNumber(obj.fromMmsi); - const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); - 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) ?? toSafeNumber(obj.fromMmsi); - const otherMmsi = toSafeNumber(obj.otherMmsi) ?? toSafeNumber(obj.toMmsi); - 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) ?? toSafeNumber(obj.fromMmsi); - const bMmsi = toSafeNumber(obj.bMmsi) ?? toSafeNumber(obj.toMmsi); - 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.ownerLabel ?? obj.ownerKey ?? ""), - count: Number(obj.count ?? 0), - }); - } - - const p = obj.properties as Record | undefined; - const label = getZoneDisplayNameFromProps(p); - if (label) return { text: label }; - return null; - }, - onClick: - projection === "globe" - ? undefined - : (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); - map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); - } - }, - } as const; - - const safeDeckProps = { ...deckProps, layers: normalizedLayers }; - const fallbackDeckProps = { ...safeDeckProps, layers: [] as unknown[] }; - const applyDeckProps = () => { - if (projection === "globe") { - const target = globeDeckLayerRef.current; - if (!target) return; - try { - target.setProps(safeDeckProps as never); - } catch (e) { - console.error("Failed to apply deck props on globe overlay. Falling back to empty deck layer set.", e); - try { - target.setProps(fallbackDeckProps as never); - } catch { - // Ignore secondary failure; rendering will recover on next update. - } - } - return; - } - - const target = overlayRef.current; - if (!target) return; - try { - target.setProps(safeDeckProps as unknown as never); - } catch (e) { - console.error("Failed to apply deck props on mercator overlay. Falling back to empty deck layer set.", e); - try { - target.setProps(fallbackDeckProps as unknown as never); - } catch { - // Ignore secondary failure. - } - } - }; - - applyDeckProps(); - }, [ - projection, - shipLayerData, - legacyTargetsOrdered, - baseMap, - zones, - selectedMmsi, - overlays.zones, - settings.showShips, - settings.showDensity, - onSelectMmsi, - legacyHits, - legacyTargets, - overlays.pairLines, - overlays.pairRange, - overlays.fcLines, - overlays.fleetCircles, - pairLinks, - pairRanges, - fcDashed, - fleetCircles, - shipByMmsi, - mapSyncEpoch, - hoveredShipSignature, - hoveredFleetSignature, - hoveredPairSignature, - hoveredFleetOwnerKey, - highlightedMmsiSetCombined, - onToggleHighlightMmsi, - isHighlightedMmsi, - isHighlightedFleet, - isHighlightedPair, - setDeckHoverMmsi, - clearDeckHoverMmsi, - setDeckHoverPairs, - clearDeckHoverPairs, - clearMapFleetHoverState, - setMapFleetHoverState, - touchDeckHoverState, - ensureMercatorOverlay, - ]); - return
; }