diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 1064450..1d519c8 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -1,120 +1,32 @@ -import { HexagonLayer } from "@deck.gl/aggregation-layers"; -import { IconLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers"; -import { MapboxOverlay } from "@deck.gl/mapbox"; -import { type PickingInfo } from "@deck.gl/core"; -import maplibregl, { - type GeoJSONSource, - type GeoJSONSourceSpecification, - type LayerSpecification, - type StyleSpecification, -} from "maplibre-gl"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AisTarget } from "../../entities/aisTarget/model/types"; -import type { ZoneId } from "../../entities/zone/model/meta"; -import { ZONE_META } from "../../entities/zone/model/meta"; -import type { FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; -import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; -import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; -import type { BaseMapId, Map3DProps, MapProjectionId } from "./types"; -import type { DashSeg, PairRangeCircle } from "./types"; -import { - SHIP_ICON_MAPPING, - ANCHORED_SHIP_ICON_ID, - DEG2RAD, - GLOBE_ICON_HEADING_OFFSET_DEG, - FLAT_SHIP_ICON_SIZE, - FLAT_SHIP_ICON_SIZE_SELECTED, - FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, - FLAT_LEGACY_HALO_RADIUS, - FLAT_LEGACY_HALO_RADIUS_SELECTED, - FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, - EMPTY_MMSI_SET, - DECK_VIEW_ID, - DEPTH_DISABLED_PARAMS, - GLOBE_OVERLAY_PARAMS, - LEGACY_CODE_COLORS, - PAIR_RANGE_NORMAL_DECK, - PAIR_RANGE_WARN_DECK, - PAIR_LINE_NORMAL_DECK, - PAIR_LINE_WARN_DECK, - FC_LINE_NORMAL_DECK, - FC_LINE_SUSPICIOUS_DECK, - FLEET_RANGE_LINE_DECK, - FLEET_RANGE_FILL_DECK, - PAIR_RANGE_NORMAL_DECK_HL, - PAIR_RANGE_WARN_DECK_HL, - PAIR_LINE_NORMAL_DECK_HL, - PAIR_LINE_WARN_DECK_HL, - FC_LINE_NORMAL_DECK_HL, - FC_LINE_SUSPICIOUS_DECK_HL, - FLEET_RANGE_LINE_DECK_HL, - FLEET_RANGE_FILL_DECK_HL, - PAIR_LINE_NORMAL_ML, - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML_HL, - PAIR_LINE_WARN_ML_HL, - PAIR_RANGE_NORMAL_ML, - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML_HL, - PAIR_RANGE_WARN_ML_HL, - FC_LINE_NORMAL_ML, - FC_LINE_SUSPICIOUS_ML, - FC_LINE_NORMAL_ML_HL, - FC_LINE_SUSPICIOUS_ML_HL, - FLEET_FILL_ML, - FLEET_FILL_ML_HL, - FLEET_LINE_ML, - FLEET_LINE_ML_HL, -} from "./constants"; +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, - toSafeNumber, toIntMmsi, makeUniqueSorted, equalNumberArrays, -} from "./lib/setUtils"; -import { getZoneIdFromProps, getZoneDisplayNameFromProps } from "./lib/zoneUtils"; -import { - makePairLinkFeatureId, - makeFcSegmentFeatureId, - makeFleetCircleFeatureId, -} from "./lib/featureIds"; -import { - makeMmsiPairHighlightExpr, - makeMmsiAnyEndpointExpr, - makeFleetOwnerMatchExpr, - makeFleetMemberMatchExpr, - GLOBE_SHIP_CIRCLE_RADIUS_EXPR, -} from "./lib/mlExpressions"; -import { - toValidBearingDeg, - isAnchoredShip, - getDisplayHeading, - lightenColor, - getGlobeBaseShipColor, - getShipColor, -} from "./lib/shipUtils"; -import { - getShipTooltipHtml, - getPairLinkTooltipHtml, - getFcLinkTooltipHtml, - getRangeTooltipHtml, - getFleetCircleTooltipHtml, -} from "./lib/tooltips"; -import { - buildFallbackGlobeAnchoredShipIcon, - ensureFallbackShipImage, -} from "./lib/globeShipIcon"; -import { kickRepaint, onMapStyleReady, extractProjectionType, getLayerId, sanitizeDeckLayerList } from "./lib/mapCore"; -import { destinationPointLngLat, circleRingLngLat, clampNumber } from "./lib/geometry"; -import { dashifyLine } from "./lib/dashifyLine"; -import { ensureSeamarkOverlay } from "./layers/seamark"; -import { applyBathymetryZoomProfile, resolveMapStyle } from "./layers/bathymetry"; -import { useHoverState } from "./hooks/useHoverState"; +} 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'; -export type { Map3DSettings, BaseMapId, MapProjectionId } from "./types"; +export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; type Props = Map3DProps; @@ -155,24 +67,22 @@ export function Map3D({ 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 globeShipsEpochRef = useRef(-1); - const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); - const globeShipIconLoadingRef = useRef(false); const projectionBusyRef = useRef(false); - const projectionBusyTokenRef = useRef(0); - const projectionBusyTimerRef = useRef | null>(null); - const projectionPrevRef = useRef(projection); - const bathyZoomProfileKeyRef = useRef(""); - const mapTooltipRef = useRef(null); const deckHoverRafRef = useRef(null); const deckHoverHasHitRef = useRef(false); + + useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]); + useEffect(() => { projectionRef.current = projection; }, [projection]); + + // ── Hover state ────────────────────────────────────────────────────── const { setHoveredDeckMmsiSet, setHoveredDeckPairMmsiSet, @@ -187,50 +97,13 @@ export function Map3D({ hoveredMmsiSet, hoveredFleetMmsiSet, hoveredPairMmsiSet, hoveredFleetOwnerKey, highlightedMmsiSet, }); + const fleetFocusId = fleetFocus?.id; const fleetFocusLon = fleetFocus?.center?.[0]; const fleetFocusLat = fleetFocus?.center?.[1]; const fleetFocusZoom = fleetFocus?.zoom; - const reorderGlobeFeatureLayers = useCallback(() => { - const map = mapRef.current; - if (!map || projectionRef.current !== "globe") return; - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - const ordering = [ - "zones-fill", - "zones-line", - "zones-label", - "predict-vectors-outline", - "predict-vectors", - "predict-vectors-hl-outline", - "predict-vectors-hl", - "ships-globe-halo", - "ships-globe-outline", - "ships-globe", - "ships-globe-label", - "ships-globe-hover-halo", - "ships-globe-hover-outline", - "ships-globe-hover", - "pair-lines-ml", - "fc-lines-ml", - "pair-range-ml", - "fleet-circles-ml-fill", - "fleet-circles-ml", - ]; - - for (const layerId of ordering) { - try { - if (map.getLayer(layerId)) map.moveLayer(layerId); - } catch { - // ignore - } - } - - kickRepaint(map); - }, []); - + // ── Highlight memos ────────────────────────────────────────────────── const effectiveHoveredPairMmsiSet = useMemo( () => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef), [hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef], @@ -258,7 +131,7 @@ export function Map3D({ ], ); const highlightedMmsiSetForShips = useMemo( - () => (projection === "globe" ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), + () => (projection === 'globe' ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), [projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined], ); const hoveredShipSignature = useMemo( @@ -274,6 +147,7 @@ export function Map3D({ 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]); @@ -285,16 +159,13 @@ export function Map3D({ const baseHighlightedMmsiSet = useMemo(() => { const out = new Set(); if (selectedMmsi != null) out.add(selectedMmsi); - externalHighlightedSetRef.forEach((value) => { - out.add(value); - }); + 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 && @@ -302,7 +173,6 @@ export function Map3D({ effectiveHoveredPairMmsiSet.has(bMmsi), [effectiveHoveredPairMmsiSet], ); - const isHighlightedFleet = useCallback( (ownerKey: string, vesselMmsis: number[]) => { if (hoveredFleetOwnerKeys.has(ownerKey)) return true; @@ -311,6 +181,7 @@ export function Map3D({ [hoveredFleetOwnerKeys, isHighlightedMmsi], ); + // ── Ship data memos ────────────────────────────────────────────────── const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); }, [targets]); @@ -334,7 +205,7 @@ export function Map3D({ const shipHoverOverlaySet = useMemo( () => - projection === "globe" + projection === 'globe' ? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet) : shipHighlightSet, [projection, highlightedMmsiSetCombined, shipHighlightSet], @@ -343,16 +214,12 @@ export function Map3D({ 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 => { + (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null): boolean => { if (!ev) return false; return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); }, @@ -392,22 +259,23 @@ export function Map3D({ [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 globeHoverShipSignatureRef = useRef(""); + const mapFleetHoverStateRef = useRef<{ ownerKey: string | null; vesselMmsis: number[] }>({ + ownerKey: null, + vesselMmsis: [], + }); const clearMapFleetHoverState = useCallback(() => { mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; @@ -481,6 +349,7 @@ export function Map3D({ [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], ); + // hover RAF cleanup useEffect(() => { return () => { if (deckHoverRafRef.current != null) { @@ -491,6 +360,7 @@ export function Map3D({ }; }, []); + // sync external fleet hover state useEffect(() => { mapFleetHoverStateRef.current = { ownerKey: hoveredFleetOwnerKey, @@ -498,3051 +368,7 @@ export function Map3D({ }; }, [hoveredFleetOwnerKey, hoveredFleetMmsiSet]); - const clearProjectionBusyTimer = useCallback(() => { - if (projectionBusyTimerRef.current == null) return; - clearTimeout(projectionBusyTimerRef.current); - projectionBusyTimerRef.current = null; - }, []); - - const endProjectionLoading = useCallback(() => { - if (!projectionBusyRef.current) return; - projectionBusyRef.current = false; - clearProjectionBusyTimer(); - if (onProjectionLoadingChange) { - onProjectionLoadingChange(false); - } - // Many layer "ensure" functions bail out while projectionBusyRef is true. - // Trigger a sync pulse when loading ends so globe/mercator layers appear immediately - // without requiring a user toggle (e.g., industry filter). - setMapSyncEpoch((prev) => prev + 1); - kickRepaint(mapRef.current); - }, [clearProjectionBusyTimer, onProjectionLoadingChange]); - - const setProjectionLoading = useCallback( - (loading: boolean) => { - if (projectionBusyRef.current === loading) return; - if (!loading) { - endProjectionLoading(); - return; - } - - clearProjectionBusyTimer(); - projectionBusyRef.current = true; - const token = ++projectionBusyTokenRef.current; - if (onProjectionLoadingChange) { - onProjectionLoadingChange(true); - } - - projectionBusyTimerRef.current = setTimeout(() => { - if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; - console.debug("Projection loading fallback timeout reached."); - endProjectionLoading(); - }, 4000); - }, - [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], - ); - - const pulseMapSync = () => { - setMapSyncEpoch((prev) => prev + 1); - requestAnimationFrame(() => { - kickRepaint(mapRef.current); - setMapSyncEpoch((prev) => prev + 1); - }); - }; - - useEffect(() => { - return () => { - clearProjectionBusyTimer(); - endProjectionLoading(); - }; - }, [clearProjectionBusyTimer, endProjectionLoading]); - - useEffect(() => { - showSeamarkRef.current = settings.showSeamark; - }, [settings.showSeamark]); - - useEffect(() => { - baseMapRef.current = baseMap; - }, [baseMap]); - - useEffect(() => { - projectionRef.current = projection; - }, [projection]); - - const removeLayerIfExists = useCallback((map: maplibregl.Map, layerId: string | null | undefined) => { - if (!layerId) return; - try { - if (map.getLayer(layerId)) { - map.removeLayer(layerId); - } - } catch { - // ignore - } - }, []); - - const removeSourceIfExists = useCallback((map: maplibregl.Map, sourceId: string) => { - try { - if (map.getSource(sourceId)) { - map.removeSource(sourceId); - } - } catch { - // ignore - } - }, []); - - const ensureMercatorOverlay = 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 clearGlobeNativeLayers = useCallback(() => { - const map = mapRef.current; - if (!map) return; - - const layerIds = [ - "ships-globe-halo", - "ships-globe-outline", - "ships-globe", - "ships-globe-label", - "ships-globe-hover-halo", - "ships-globe-hover-outline", - "ships-globe-hover", - "pair-lines-ml", - "fc-lines-ml", - "fleet-circles-ml-fill", - "fleet-circles-ml", - "pair-range-ml", - "deck-globe", - ]; - - for (const id of layerIds) { - removeLayerIfExists(map, id); - } - - const sourceIds = [ - "ships-globe-src", - "ships-globe-hover-src", - "pair-lines-ml-src", - "fc-lines-ml-src", - "fleet-circles-ml-src", - "fleet-circles-ml-fill-src", - "pair-range-ml-src", - ]; - for (const id of sourceIds) { - removeSourceIfExists(map, id); - } - }, [removeLayerIfExists, removeSourceIfExists]); - - // Init MapLibre + Deck.gl (single WebGL context via MapboxOverlay) - useEffect(() => { - if (!containerRef.current || mapRef.current) return; - - let map: maplibregl.Map | null = null; - let cancelled = false; - const controller = new AbortController(); - - (async () => { - let style: string | StyleSpecification = "/map/styles/osm-seamark.json"; - try { - style = await resolveMapStyle(baseMapRef.current, controller.signal); - } catch (e) { - // Don't block the app if MapTiler isn't configured yet. - // This is expected in early dev environments without `VITE_MAPTILER_KEY`. - console.warn("Map style init failed, falling back to local raster style:", e); - style = "/map/styles/osm-seamark.json"; - } - if (cancelled || !containerRef.current) return; - - map = new maplibregl.Map({ - container: containerRef.current, - style, - center: [126.5, 34.2], - zoom: 7, - pitch: 45, - bearing: 0, - maxPitch: 85, - dragRotate: true, - pitchWithRotate: true, - touchPitch: true, - scrollZoom: { around: "center" }, - }); - - map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), "top-left"); - map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-left"); - - mapRef.current = map; - - // Initial Deck integration: - // - 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") { - const overlay = ensureMercatorOverlay(); - if (!overlay) return; - overlayRef.current = overlay; - } else { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: "deck-globe", - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } - - function applyProjection() { - if (!map) return; - const next = projectionRef.current; - if (next === "mercator") return; - try { - map.setProjection({ type: next }); - // Globe mode renders a single world; copies can look odd and aren't needed for KR region. - map.setRenderWorldCopies(next !== "globe"); - } catch (e) { - console.warn("Projection apply failed:", e); - } - } - - // Ensure the seamark raster overlay exists even when using MapTiler vector styles. - onMapStyleReady(map, () => { - applyProjection(); - // Globe deck layer lives inside the style and must be re-added after any style swap. - const deckLayer = globeDeckLayerRef.current; - if (projectionRef.current === "globe" && deckLayer && !map!.getLayer(deckLayer.id)) { - try { - map!.addLayer(deckLayer); - } catch { - // ignore - } - } - if (!showSeamarkRef.current) return; - try { - ensureSeamarkOverlay(map!, "bathymetry-lines"); - } catch { - // ignore (style not ready / already has it) - } - }); - - // Send initial bbox and update on move end (useful for lists / debug) - const emitBbox = () => { - const cb = onViewBboxChange; - if (!cb || !map) return; - const b = map.getBounds(); - cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]); - }; - map.on("load", emitBbox); - map.on("moveend", emitBbox); - - function applySeamarkOpacity() { - if (!map) return; - const opacity = settings.showSeamark ? 0.85 : 0; - try { - map.setPaintProperty("seamark", "raster-opacity", opacity); - } catch { - // style not ready yet - } - } - - map.once("load", () => { - if (showSeamarkRef.current) { - try { - ensureSeamarkOverlay(map!, "bathymetry-lines"); - } catch { - // ignore - } - applySeamarkOpacity(); - } - }); - })(); - - return () => { - cancelled = true; - controller.abort(); - - // If we are unmounting, ensure the globe Deck instance is finalized (style reload would keep it alive). - try { - globeDeckLayerRef.current?.requestFinalize(); - } catch { - // ignore - } - - if (map) { - map.remove(); - map = null; - } - if (overlayRef.current) { - overlayRef.current.finalize(); - overlayRef.current = null; - } - if (overlayInteractionRef.current) { - overlayInteractionRef.current.finalize(); - overlayInteractionRef.current = null; - } - globeDeckLayerRef.current = null; - mapRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Projection toggle (mercator <-> globe) - useEffect(() => { - const map = mapRef.current; - if (!map) return; - let cancelled = false; - let retries = 0; - const maxRetries = 18; - const isTransition = projectionPrevRef.current !== projection; - projectionPrevRef.current = projection; - let settleScheduled = false; - let settleCleanup: (() => void) | null = null; - - const startProjectionSettle = () => { - if (!isTransition || settleScheduled) return; - settleScheduled = true; - - const finalize = () => { - if (!cancelled && isTransition) setProjectionLoading(false); - }; - - const finalizeSoon = () => { - if (cancelled || !isTransition || projectionBusyRef.current === false) return; - if (!map.isStyleLoaded()) { - requestAnimationFrame(finalizeSoon); - return; - } - requestAnimationFrame(finalize); - }; - - const onIdle = () => finalizeSoon(); - try { - map.on("idle", onIdle); - const styleReadyCleanup = onMapStyleReady(map, finalizeSoon); - settleCleanup = () => { - map.off("idle", onIdle); - styleReadyCleanup(); - }; - } catch { - requestAnimationFrame(finalize); - settleCleanup = null; - } - - finalizeSoon(); - }; - - if (isTransition) setProjectionLoading(true); - - 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 = () => { - const current = globeDeckLayerRef.current; - if (!current) return; - removeLayerIfExists(map, current.id); - try { - current.requestFinalize(); - } catch { - // ignore - } - globeDeckLayerRef.current = null; - }; - - const syncProjectionAndDeck = () => { - if (cancelled) return; - if (!isTransition) { - return; - } - - if (!map.isStyleLoaded()) { - if (!cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - } - return; - } - - const next = projection; - const currentProjection = extractProjectionType(map); - const shouldSwitchProjection = currentProjection !== next; - - if (projection === "globe") { - disposeMercatorOverlays(); - clearGlobeNativeLayers(); - } else { - disposeGlobeDeckLayer(); - clearGlobeNativeLayers(); - } - - try { - if (shouldSwitchProjection) { - map.setProjection({ type: next }); - } - map.setRenderWorldCopies(next !== "globe"); - if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } - } catch (e) { - if (!cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } - if (isTransition) setProjectionLoading(false); - console.warn("Projection switch failed:", e); - } - - if (projection === "globe") { - // Start with a clean globe Deck layer state to avoid partially torn-down renders. - disposeGlobeDeckLayer(); - - if (!globeDeckLayerRef.current) { - globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ - id: "deck-globe", - viewId: DECK_VIEW_ID, - deckProps: { layers: [] }, - }); - } - - const layer = globeDeckLayerRef.current; - const layerId = layer?.id; - if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { - try { - map.addLayer(layer); - } catch { - // ignore - } - if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { - retries += 1; - window.requestAnimationFrame(() => syncProjectionAndDeck()); - return; - } - } - } else { - // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. - disposeGlobeDeckLayer(); - - ensureMercatorOverlay(); - } - - // MapLibre may not schedule a frame immediately after projection swaps if the map is idle. - // Kick a few repaints so overlay sources (ships/zones) appear instantly. - reorderGlobeFeatureLayers(); - kickRepaint(map); - try { - map.resize(); - } catch { - // ignore - } - if (isTransition) { - startProjectionSettle(); - } - pulseMapSync(); - }; - - if (!isTransition) return; - - if (map.isStyleLoaded()) syncProjectionAndDeck(); - else { - const stop = onMapStyleReady(map, syncProjectionAndDeck); - return () => { - cancelled = true; - if (settleCleanup) settleCleanup(); - stop(); - if (isTransition) setProjectionLoading(false); - }; - } - - return () => { - cancelled = true; - if (settleCleanup) settleCleanup(); - if (isTransition) setProjectionLoading(false); - }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]); - - // Base map toggle - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - let cancelled = false; - const controller = new AbortController(); - let stop: (() => void) | null = null; - - (async () => { - try { - const style = await resolveMapStyle(baseMap, controller.signal); - if (cancelled) return; - // Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and - // to ensure a clean rebuild when switching between very different styles. - map.setStyle(style, { diff: false }); - stop = onMapStyleReady(map, () => { - kickRepaint(map); - requestAnimationFrame(() => kickRepaint(map)); - pulseMapSync(); - }); - } catch (e) { - if (cancelled) return; - console.warn("Base map switch failed:", e); - } - })(); - - return () => { - cancelled = true; - controller.abort(); - stop?.(); - }; - }, [baseMap]); - - // Globe rendering + bathymetry tuning. - // Under globe projection, low-zoom bathymetry polygons can exceed MapLibre's per-segment 16-bit vertex - // limit (65535) due to projection subdivision. Keep globe stable by gating heavy bathymetry fills/borders - // to higher zoom levels rather than toggling them on every frame. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const apply = () => { - if (!map.isStyleLoaded()) return; - const seaVisibility = "visible" as const; - const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; - - // Apply zoom gating for heavy bathymetry layers once per (baseMap, projection) combination. - // This avoids repeatedly mutating layer zoom ranges on hover/mapSyncEpoch pulses. - const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`; - if (bathyZoomProfileKeyRef.current !== nextProfileKey) { - applyBathymetryZoomProfile(map, baseMap, projection); - bathyZoomProfileKeyRef.current = nextProfileKey; - kickRepaint(map); - } - - // Vector basemap water layers can be tuned per-style. Keep visible by default, - // only toggling layers that match an explicit water/sea signature. - try { - const style = map.getStyle(); - const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; - for (const layer of styleLayers) { - const id = getLayerId(layer); - if (!id) continue; - const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); - const source = String((layer as { source?: unknown }).source ?? "").toLowerCase(); - const type = String((layer as { type?: unknown }).type ?? "").toLowerCase(); - const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); - const isRaster = type === "raster"; - if (!isSea) continue; - if (!map.getLayer(id)) continue; - if (isRaster && id === "seamark") continue; - try { - map.setLayoutProperty(id, "visibility", seaVisibility); - } catch { - // ignore - } - } - } catch { - // ignore - } - }; - - const stop = onMapStyleReady(map, apply); - return () => { - stop(); - }; - }, [projection, baseMap, mapSyncEpoch]); - - // seamark toggle - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (settings.showSeamark) { - try { - ensureSeamarkOverlay(map, "bathymetry-lines"); - map.setPaintProperty("seamark", "raster-opacity", 0.85); - } catch { - // ignore until style is ready - } - return; - } - - // If seamark is off, remove the layer+source to avoid unnecessary network tile requests. - try { - if (map.getLayer("seamark")) map.removeLayer("seamark"); - } catch { - // ignore - } - try { - if (map.getSource("seamark")) map.removeSource("seamark"); - } catch { - // ignore - } - }, [settings.showSeamark]); - - // Zones (MapLibre-native GeoJSON layers; works in both mercator + globe) - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "zones-src"; - const fillId = "zones-fill"; - const lineId = "zones-line"; - const labelId = "zones-label"; - - const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]]; - for (const k of Object.keys(ZONE_META) as ZoneId[]) { - zoneColorExpr.push(k, ZONE_META[k].color); - } - zoneColorExpr.push("#3B82F6"); - const zoneLabelExpr: unknown[] = ["match", ["to-string", ["coalesce", ["get", "zoneId"], ""]]]; - for (const k of Object.keys(ZONE_META) as ZoneId[]) { - zoneLabelExpr.push(k, ZONE_META[k].name); - } - zoneLabelExpr.push(["coalesce", ["get", "zoneName"], ["get", "zoneLabel"], ["get", "NAME"], "수역"]); - - const ensure = () => { - if (projectionBusyRef.current) return; - // Always update visibility if the layers exist. - const visibility = overlays.zones ? "visible" : "none"; - try { - if (map.getLayer(fillId)) map.setLayoutProperty(fillId, "visibility", visibility); - } catch { - // ignore - } - try { - if (map.getLayer(lineId)) map.setLayoutProperty(lineId, "visibility", visibility); - } catch { - // ignore - } - try { - if (map.getLayer(labelId)) map.setLayoutProperty(labelId, "visibility", visibility); - } catch { - // ignore - } - - if (!zones) return; - if (!map.isStyleLoaded()) return; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) { - existing.setData(zones); - } else { - map.addSource(srcId, { type: "geojson", data: zones } as GeoJSONSourceSpecification); - } - - // Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled. - const style = map.getStyle(); - const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; - const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === "symbol") as - | { id?: string } - | undefined; - const before = map.getLayer("deck-globe") - ? "deck-globe" - : map.getLayer("ships") - ? "ships" - : map.getLayer("seamark") - ? "seamark" - : firstSymbol?.id; - - const zoneMatchExpr = - hoveredZoneId !== null - ? (["==", ["to-string", ["coalesce", ["get", "zoneId"], ""]], hoveredZoneId] as unknown[]) - : false; - const zoneLineWidthExpr = hoveredZoneId - ? ([ - "interpolate", - ["linear"], - ["zoom"], - 4, - ["case", zoneMatchExpr, 1.6, 0.8], - 10, - ["case", zoneMatchExpr, 2.0, 1.4], - 14, - ["case", zoneMatchExpr, 2.8, 2.1], - ] as unknown as never) - : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never); - - if (map.getLayer(fillId)) { - try { - map.setPaintProperty( - fillId, - "fill-opacity", - hoveredZoneId ? (["case", zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12, - ); - } catch { - // ignore - } - } - - if (map.getLayer(lineId)) { - try { - map.setPaintProperty( - lineId, - "line-color", - hoveredZoneId - ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) - : (zoneColorExpr as never), - ); - } catch { - // ignore - } - try { - map.setPaintProperty(lineId, "line-opacity", hoveredZoneId ? (["case", zoneMatchExpr, 1, 0.85] as never) : 0.85); - } catch { - // ignore - } - try { - map.setPaintProperty(lineId, "line-width", zoneLineWidthExpr); - } catch { - // ignore - } - } - - if (!map.getLayer(fillId)) { - map.addLayer( - { - id: fillId, - type: "fill", - source: srcId, - paint: { - "fill-color": zoneColorExpr as never, - "fill-opacity": hoveredZoneId - ? ([ - "case", - zoneMatchExpr, - 0.24, - 0.1, - ] as unknown as number) - : 0.12, - }, - layout: { visibility }, - } as unknown as LayerSpecification, - before, - ); - } - - if (!map.getLayer(lineId)) { - map.addLayer( - { - id: lineId, - type: "line", - source: srcId, - paint: { - "line-color": hoveredZoneId - ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) - : (zoneColorExpr as never), - "line-opacity": hoveredZoneId - ? (["case", zoneMatchExpr, 1, 0.85] as never) - : 0.85, - "line-width": zoneLineWidthExpr, - }, - layout: { visibility }, - } as unknown as LayerSpecification, - before, - ); - } - - if (!map.getLayer(labelId)) { - map.addLayer( - { - id: labelId, - type: "symbol", - source: srcId, - layout: { - visibility, - "symbol-placement": "point", - "text-field": zoneLabelExpr as never, - "text-size": 11, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - "text-anchor": "top", - "text-offset": [0, 0.35], - "text-allow-overlap": false, - "text-ignore-placement": false, - }, - paint: { - "text-color": "#dbeafe", - "text-halo-color": "rgba(2,6,23,0.85)", - "text-halo-width": 1.2, - "text-halo-blur": 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } - } catch (e) { - console.warn("Zones layer setup failed:", e); - } finally { - reorderGlobeFeatureLayers(); - kickRepaint(map); - } - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); - - // Prediction vectors: MapLibre-native GeoJSON line layer so it stays stable in both mercator + globe. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "predict-vectors-src"; - const outlineId = "predict-vectors-outline"; - const lineId = "predict-vectors"; - const hlOutlineId = "predict-vectors-hl-outline"; - const hlId = "predict-vectors-hl"; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - const visibility = overlays.predictVectors ? "visible" : "none"; - - const horizonMinutes = 15; - const horizonSeconds = horizonMinutes * 60; - const metersPerSecondPerKnot = 0.514444; - - const features: GeoJSON.Feature[] = []; - if (overlays.predictVectors && settings.showShips && shipData.length > 0) { - for (const t of shipData) { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const isTarget = !!legacy; - const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; - const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); - if (!isTarget && !isSelected && !isPinnedHighlight) continue; - - const sog = isFiniteNumber(t.sog) ? t.sog : null; - const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading); - if (sog == null || bearing == null) continue; - if (sog < 0.2) continue; - - const distM = sog * metersPerSecondPerKnot * horizonSeconds; - if (!Number.isFinite(distM) || distM <= 0) continue; - - const to = destinationPointLngLat([t.lon, t.lat], bearing, distM); - - const baseRgb = isTarget - ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving - : OTHER_AIS_SPEED_RGB.moving; - const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62); - const alpha = isTarget ? 0.72 : 0.52; - const alphaHl = isTarget ? 0.92 : 0.84; - const hl = isSelected || isPinnedHighlight ? 1 : 0; - - features.push({ - type: "Feature", - id: `pred-${t.mmsi}`, - geometry: { type: "LineString", coordinates: [[t.lon, t.lat], to] }, - properties: { - mmsi: t.mmsi, - minutes: horizonMinutes, - sog, - cog: bearing, - target: isTarget ? 1 : 0, - hl, - color: rgbaCss(rgb, alpha), - colorHl: rgbaCss(rgb, alphaHl), - }, - }); - } - } - - const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features }; - - 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("Prediction vector source setup failed:", e); - return; - } - - const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter: unknown[]) => { - if (!map.getLayer(id)) { - try { - map.addLayer( - { - id, - type: "line", - source: srcId, - filter: filter as never, - layout: { - visibility, - "line-cap": "round", - "line-join": "round", - }, - paint, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn("Prediction vector layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(id, "visibility", visibility); - map.setFilter(id, filter as never); - if (paint && typeof paint === "object") { - for (const [key, value] of Object.entries(paint)) { - map.setPaintProperty(id, key as never, value as never); - } - } - } catch { - // ignore - } - } - }; - - const baseFilter = ["==", ["to-number", ["get", "hl"], 0], 0] as unknown as unknown[]; - const hlFilter = ["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[]; - - // Outline (halo) for readability over bathymetry + seamark textures. - ensureLayer( - outlineId, - { - "line-color": "rgba(2,6,23,0.86)", - "line-width": 4.8, - "line-opacity": 1, - "line-blur": 0.2, - "line-dasharray": [1.2, 1.8] as never, - } as never, - baseFilter, - ); - ensureLayer( - lineId, - { - "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.62)"] as never, - "line-width": 2.4, - "line-opacity": 1, - "line-dasharray": [1.2, 1.8] as never, - } as never, - baseFilter, - ); - ensureLayer( - hlOutlineId, - { - "line-color": "rgba(2,6,23,0.92)", - "line-width": 6.4, - "line-opacity": 1, - "line-blur": 0.25, - "line-dasharray": [1.2, 1.8] as never, - } as never, - hlFilter, - ); - ensureLayer( - hlId, - { - "line-color": ["coalesce", ["get", "colorHl"], ["get", "color"], "rgba(241,245,249,0.92)"] as never, - "line-width": 3.6, - "line-opacity": 1, - "line-dasharray": [1.2, 1.8] as never, - } as never, - hlFilter, - ); - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - overlays.predictVectors, - settings.showShips, - shipData, - legacyHits, - selectedMmsi, - externalHighlightedSetRef, - projection, - baseMap, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Ship name labels in mercator: MapLibre-native symbol layer so collision/placement is handled automatically. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "ship-labels-src"; - const layerId = "ship-labels"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.removeLayer(layerId); - } catch { - // ignore - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== "mercator" || !settings.showShips) { - remove(); - return; - } - - const visibility = overlays.shipLabels ? "visible" : "none"; - - const features: GeoJSON.Feature[] = []; - for (const t of shipData) { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const isTarget = !!legacy; - const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; - const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); - if (!isTarget && !isSelected && !isPinnedHighlight) continue; - - const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || "").trim(); - if (!labelName) continue; - - features.push({ - type: "Feature", - id: `ship-label-${t.mmsi}`, - geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - labelName, - selected: isSelected ? 1 : 0, - highlighted: isPinnedHighlight ? 1 : 0, - permitted: isTarget ? 1 : 0, - }, - }); - } - - const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features }; - - 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("Ship label source setup failed:", e); - return; - } - - const filter = ["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""] as unknown as unknown[]; - - if (!map.getLayer(layerId)) { - try { - map.addLayer( - { - id: layerId, - type: "symbol", - source: srcId, - minzoom: 7, - filter: filter as never, - layout: { - visibility, - "symbol-placement": "point", - "text-field": ["get", "labelName"] as never, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - "text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never, - "text-anchor": "top", - "text-offset": [0, 1.1], - "text-padding": 2, - "text-allow-overlap": false, - "text-ignore-placement": false, - }, - paint: { - "text-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - "rgba(226,232,240,0.92)", - ] as never, - "text-halo-color": "rgba(2,6,23,0.85)", - "text-halo-width": 1.2, - "text-halo-blur": 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn("Ship label layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", visibility); - } catch { - // ignore - } - } - - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - overlays.shipLabels, - shipData, - legacyHits, - selectedMmsi, - externalHighlightedSetRef, - baseMap, - mapSyncEpoch, - ]); - - // Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface. - // Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const imgId = "ship-globe-icon"; - const anchoredImgId = ANCHORED_SHIP_ICON_ID; - const srcId = "ships-globe-src"; - const haloId = "ships-globe-halo"; - const outlineId = "ships-globe-outline"; - const symbolId = "ships-globe"; - const labelId = "ships-globe-label"; - - const remove = () => { - for (const id of [labelId, symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ""; - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const ensureImage = () => { - ensureFallbackShipImage(map, imgId); - ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); - if (globeShipIconLoadingRef.current) return; - if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; - - const addFallbackImage = () => { - ensureFallbackShipImage(map, imgId); - ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); - kickRepaint(map); - }; - - let fallbackTimer: ReturnType | null = null; - try { - globeShipIconLoadingRef.current = true; - fallbackTimer = window.setTimeout(() => { - addFallbackImage(); - }, 80); - void map - .loadImage("/assets/ship.svg") - .then((response) => { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - - const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; - if (!loadedImage) { - addFallbackImage(); - return; - } - - try { - if (map.hasImage(imgId)) { - try { - map.removeImage(imgId); - } catch { - // ignore - } - } - if (map.hasImage(anchoredImgId)) { - try { - map.removeImage(anchoredImgId); - } catch { - // ignore - } - } - map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); - map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true }); - kickRepaint(map); - } catch (e) { - console.warn("Ship icon image add failed:", e); - } - }) - .catch(() => { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - addFallbackImage(); - }); - } catch (e) { - globeShipIconLoadingRef.current = false; - if (fallbackTimer != null) { - clearTimeout(fallbackTimer); - fallbackTimer = null; - } - try { - addFallbackImage(); - } catch (fallbackError) { - console.warn("Ship icon image setup failed:", e, fallbackError); - } - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== "globe" || !settings.showShips) { - remove(); - return; - } - - if (globeShipsEpochRef.current !== mapSyncEpoch) { - globeShipsEpochRef.current = mapSyncEpoch; - } - - try { - ensureImage(); - } catch (e) { - console.warn("Ship icon image setup failed:", e); - } - - const globeShipData = shipData; - const geojson: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: globeShipData.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const labelName = - legacy?.shipNameCn || - legacy?.shipNameRoman || - t.name || - ""; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); - const isAnchored = isAnchoredShip({ - sog: t.sog, - cog: t.cog, - heading: t.heading, - }); - const shipHeading = isAnchored ? 0 : heading; - const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const highlighted = isBaseHighlightedMmsi(t.mmsi); - const selectedScale = selected ? 1.08 : 1; - const highlightScale = highlighted ? 1.06 : 1; - const iconScale = selected ? selectedScale : highlightScale; - const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); - const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); - const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); - const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); - return { - type: "Feature", - ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), - geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || "", - labelName, - cog: shipHeading, - heading: shipHeading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - isAnchored: isAnchored ? 1 : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), - iconSize3: iconSize3 * iconScale, - iconSize7: iconSize7 * iconScale, - iconSize10: iconSize10 * iconScale, - iconSize14: iconSize14 * iconScale, - sizeScale, - selected: selected ? 1 : 0, - highlighted: highlighted ? 1 : 0, - permitted: legacy ? 1 : 0, - code: legacy?.shipCode || "", - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(geojson); - else map.addSource(srcId, { type: "geojson", data: geojson } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Ship source setup failed:", e); - return; - } - - const visibility = settings.showShips ? "visible" : "none"; - - const before = undefined; - - if (!map.getLayer(haloId)) { - try { - map.addLayer( - { - id: haloId, - type: "circle", - source: srcId, - layout: { - visibility, - "circle-sort-key": [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 120, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 115, - ["==", ["get", "permitted"], 1], - 110, - ["==", ["get", "selected"], 1], - 60, - ["==", ["get", "highlighted"], 1], - 55, - 20, - ] as never, - }, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "circle-opacity": [ - "case", - ["==", ["get", "selected"], 1], - 0.38, - ["==", ["get", "highlighted"], 1], - 0.34, - 0.16, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship halo layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(haloId, "visibility", visibility); - map.setLayoutProperty( - haloId, - "circle-sort-key", - [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 120, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 115, - ["==", ["get", "permitted"], 1], - 110, - ["==", ["get", "selected"], 1], - 60, - ["==", ["get", "highlighted"], 1], - 55, - 20, - ] as never, - ); - map.setPaintProperty( - haloId, - "circle-color", - [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,1)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,1)", - ["coalesce", ["get", "shipColor"], "#64748b"], - ] as never, - ); - map.setPaintProperty(haloId, "circle-opacity", [ - "case", - ["==", ["get", "selected"], 1], - 0.38, - ["==", ["get", "highlighted"], 1], - 0.34, - 0.16, - ] as never); - map.setPaintProperty(haloId, "circle-radius", GLOBE_SHIP_CIRCLE_RADIUS_EXPR); - } catch { - // ignore - } - } - - if (!map.getLayer(outlineId)) { - try { - map.addLayer( - { - id: outlineId, - type: "circle", - source: srcId, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - ["coalesce", ["get", "shipColor"], "#64748b"], - ] as never, - "circle-stroke-width": [ - "case", - ["==", ["get", "selected"], 1], - 3.4, - ["==", ["get", "highlighted"], 1], - 2.7, - ["==", ["get", "permitted"], 1], - 1.8, - 0.0, - ] as never, - "circle-stroke-opacity": 0.85, - }, - layout: { - visibility, - "circle-sort-key": [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 130, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 125, - ["==", ["get", "permitted"], 1], - 120, - ["==", ["get", "selected"], 1], - 70, - ["==", ["get", "highlighted"], 1], - 65, - 30, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship outline layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(outlineId, "visibility", visibility); - map.setLayoutProperty( - outlineId, - "circle-sort-key", - [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 130, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 125, - ["==", ["get", "permitted"], 1], - 120, - ["==", ["get", "selected"], 1], - 70, - ["==", ["get", "highlighted"], 1], - 65, - 30, - ] as never, - ); - map.setPaintProperty( - outlineId, - "circle-stroke-color", - [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - ["coalesce", ["get", "shipColor"], "#64748b"], - ] as never, - ); - map.setPaintProperty( - outlineId, - "circle-stroke-width", - [ - "case", - ["==", ["get", "selected"], 1], - 3.4, - ["==", ["get", "highlighted"], 1], - 2.7, - ["==", ["get", "permitted"], 1], - 1.8, - 0.0, - ] as never, - ); - } catch { - // ignore - } - } - - if (!map.getLayer(symbolId)) { - try { - map.addLayer( - { - id: symbolId, - type: "symbol", - source: srcId, - layout: { - visibility, - "symbol-sort-key": [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 140, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 135, - ["==", ["get", "permitted"], 1], - 130, - ["==", ["get", "selected"], 1], - 80, - ["==", ["get", "highlighted"], 1], - 75, - 45, - ] as never, - "icon-image": [ - "case", - ["==", ["to-number", ["get", "isAnchored"], 0], 1], - anchoredImgId, - imgId, - ] as never, - "icon-size": [ - "interpolate", - ["linear"], - ["zoom"], - 3, - ["to-number", ["get", "iconSize3"], 0.35], - 7, - ["to-number", ["get", "iconSize7"], 0.45], - 10, - ["to-number", ["get", "iconSize10"], 0.56], - 14, - ["to-number", ["get", "iconSize14"], 0.72], - ] as unknown as number[], - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-anchor": "center", - "icon-rotate": [ - "case", - ["==", ["to-number", ["get", "isAnchored"], 0], 1], - 0, - ["to-number", ["get", "heading"], 0], - ] as never, - // Keep the icon on the sea surface. - "icon-rotation-alignment": "map", - "icon-pitch-alignment": "map", - }, - paint: { - "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "icon-opacity": [ - "case", - ["==", ["get", "permitted"], 1], - 1, - ["==", ["get", "selected"], 1], - 0.86, - ["==", ["get", "highlighted"], 1], - 0.82, - 0.66, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship symbol layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(symbolId, "visibility", visibility); - map.setLayoutProperty( - symbolId, - "symbol-sort-key", - [ - "case", - ["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]], - 140, - ["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]], - 135, - ["==", ["get", "permitted"], 1], - 130, - ["==", ["get", "selected"], 1], - 80, - ["==", ["get", "highlighted"], 1], - 75, - 45, - ] as never, - ); - map.setPaintProperty( - symbolId, - "icon-opacity", - [ - "case", - ["==", ["get", "permitted"], 1], - 1, - ["==", ["get", "selected"], 1], - 0.86, - ["==", ["get", "highlighted"], 1], - 0.82, - 0.66, - ] as never, - ); - } catch { - // ignore - } - } - - // Optional ship name labels (toggle). Keep labels readable and avoid clutter. - const labelVisibility = overlays.shipLabels ? "visible" : "none"; - const labelFilter = [ - "all", - ["!=", ["to-string", ["coalesce", ["get", "labelName"], ""]], ""], - [ - "any", - ["==", ["get", "permitted"], 1], - ["==", ["get", "selected"], 1], - ["==", ["get", "highlighted"], 1], - ], - ] as unknown as unknown[]; - - if (!map.getLayer(labelId)) { - try { - map.addLayer( - { - id: labelId, - type: "symbol", - source: srcId, - minzoom: 7, - filter: labelFilter as never, - layout: { - visibility: labelVisibility, - "symbol-placement": "point", - "text-field": ["get", "labelName"] as never, - "text-font": ["Noto Sans Regular", "Open Sans Regular"], - "text-size": ["interpolate", ["linear"], ["zoom"], 7, 10, 10, 11, 12, 12, 14, 13] as never, - "text-anchor": "top", - "text-offset": [0, 1.1], - "text-padding": 2, - "text-allow-overlap": false, - "text-ignore-placement": false, - }, - paint: { - "text-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - ["==", ["get", "highlighted"], 1], - "rgba(245,158,11,0.95)", - "rgba(226,232,240,0.92)", - ] as never, - "text-halo-color": "rgba(2,6,23,0.85)", - "text-halo-width": 1.2, - "text-halo-blur": 0.8, - }, - } as unknown as LayerSpecification, - undefined, - ); - } catch (e) { - console.warn("Ship label layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(labelId, "visibility", labelVisibility); - map.setFilter(labelId, labelFilter as never); - map.setLayoutProperty(labelId, "text-field", ["get", "labelName"] as never); - } catch { - // ignore - } - } - - // Selection and highlight are now source-data driven. - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - overlays.shipLabels, - shipData, - legacyHits, - selectedMmsi, - isBaseHighlightedMmsi, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const imgId = "ship-globe-icon"; - const srcId = "ships-globe-hover-src"; - const haloId = "ships-globe-hover-halo"; - const outlineId = "ships-globe-hover-outline"; - const symbolId = "ships-globe-hover"; - - const remove = () => { - for (const id of [symbolId, outlineId, haloId]) { - try { - if (map.getLayer(id)) map.removeLayer(id); - } catch { - // ignore - } - } - try { - if (map.getSource(srcId)) map.removeSource(srcId); - } catch { - // ignore - } - globeHoverShipSignatureRef.current = ""; - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - - if (projection !== "globe" || !settings.showShips || shipHoverOverlaySet.size === 0) { - remove(); - return; - } - - if (globeShipsEpochRef.current !== mapSyncEpoch) { - globeShipsEpochRef.current = mapSyncEpoch; - } - - ensureFallbackShipImage(map, imgId); - if (!map.hasImage(imgId)) { - return; - } - - const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); - if (hovered.length === 0) { - remove(); - return; - } - const hoverSignature = hovered - .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) - .join("|"); - const hasHoverSource = map.getSource(srcId) != null; - const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); - if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { - return; - } - globeHoverShipSignatureRef.current = hoverSignature; - const needReorder = !hasHoverSource || !hasHoverLayers; - - const hoverGeojson: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: hovered.map((t) => { - const legacy = legacyHits?.get(t.mmsi) ?? null; - const heading = getDisplayHeading({ - cog: t.cog, - heading: t.heading, - offset: GLOBE_ICON_HEADING_OFFSET_DEG, - }); - const hull = clampNumber( - (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, - 50, - 420, - ); - const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); - const selected = t.mmsi === selectedMmsi; - const scale = selected ? 1.16 : 1.1; - return { - type: "Feature", - ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), - geometry: { type: "Point", coordinates: [t.lon, t.lat] }, - properties: { - mmsi: t.mmsi, - name: t.name || "", - cog: heading, - heading, - sog: isFiniteNumber(t.sog) ? t.sog : 0, - shipColor: getGlobeBaseShipColor({ - legacy: legacy?.shipCode || null, - sog: isFiniteNumber(t.sog) ? t.sog : null, - }), - iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), - iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), - iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), - iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), - selected: selected ? 1 : 0, - permitted: legacy ? 1 : 0, - }, - }; - }), - }; - - try { - const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(hoverGeojson); - else map.addSource(srcId, { type: "geojson", data: hoverGeojson } as GeoJSONSourceSpecification); - } catch (e) { - console.warn("Ship hover source setup failed:", e); - return; - } - - const before = undefined; - - if (!map.getLayer(haloId)) { - try { - map.addLayer( - { - id: haloId, - type: "circle", - source: srcId, - layout: { - visibility: "visible", - "circle-sort-key": [ - "case", - ["==", ["get", "selected"], 1], - 120, - ["==", ["get", "permitted"], 1], - 115, - 110, - ] as never, - }, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,1)", - "rgba(245,158,11,1)", - ] as never, - "circle-opacity": 0.42, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship hover halo layer add failed:", e); - } - } else { - map.setLayoutProperty(haloId, "visibility", "visible"); - } - - if (!map.getLayer(outlineId)) { - try { - map.addLayer( - { - id: outlineId, - type: "circle", - source: srcId, - paint: { - "circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": [ - "case", - ["==", ["get", "selected"], 1], - "rgba(14,234,255,0.95)", - "rgba(245,158,11,0.95)", - ] as never, - "circle-stroke-width": [ - "case", - ["==", ["get", "selected"], 1], - 3.8, - 2.2, - ] as never, - "circle-stroke-opacity": 0.9, - }, - layout: { - visibility: "visible", - "circle-sort-key": [ - "case", - ["==", ["get", "selected"], 1], - 121, - ["==", ["get", "permitted"], 1], - 116, - 111, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship hover outline layer add failed:", e); - } - } else { - map.setLayoutProperty(outlineId, "visibility", "visible"); - } - - if (!map.getLayer(symbolId)) { - try { - map.addLayer( - { - id: symbolId, - type: "symbol", - source: srcId, - layout: { - visibility: "visible", - "symbol-sort-key": [ - "case", - ["==", ["get", "selected"], 1], - 122, - ["==", ["get", "permitted"], 1], - 117, - 112, - ] as never, - "icon-image": imgId, - "icon-size": [ - "interpolate", - ["linear"], - ["zoom"], - 3, - ["to-number", ["get", "iconSize3"], 0.35], - 7, - ["to-number", ["get", "iconSize7"], 0.45], - 10, - ["to-number", ["get", "iconSize10"], 0.56], - 14, - ["to-number", ["get", "iconSize14"], 0.72], - ] as unknown as number[], - "icon-allow-overlap": true, - "icon-ignore-placement": true, - "icon-anchor": "center", - "icon-rotate": ["to-number", ["get", "heading"], 0], - // Keep the icon on the sea surface. - "icon-rotation-alignment": "map", - "icon-pitch-alignment": "map", - }, - paint: { - "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "icon-opacity": 1, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Ship hover symbol layer add failed:", e); - } - } else { - map.setLayoutProperty(symbolId, "visibility", "visible"); - } - - if (needReorder) { - reorderGlobeFeatureLayers(); - } - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - return () => { - stop(); - }; - }, [ - projection, - settings.showShips, - shipLayerData, - legacyHits, - shipHoverOverlaySet, - selectedMmsi, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - // Globe ship click selection (MapLibre-native ships layer) - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (projection !== "globe" || !settings.showShips) return; - - const symbolId = "ships-globe"; - const haloId = "ships-globe-halo"; - const outlineId = "ships-globe-outline"; - const clickedRadiusDeg2 = Math.pow(0.08, 2); - - const onClick = (e: maplibregl.MapMouseEvent) => { - try { - const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); - let feats: unknown[] = []; - if (layerIds.length > 0) { - try { - feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; - } catch { - feats = []; - } - } - const f = feats?.[0]; - const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< - string, - unknown - >; - const mmsi = Number(props.mmsi); - if (Number.isFinite(mmsi)) { - if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { - onToggleHighlightMmsi?.(mmsi); - return; - } - onSelectMmsi(mmsi); - return; - } - - const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; - const cosLat = Math.cos(clicked.lat * DEG2RAD); - let bestMmsi: number | null = null; - let bestD2 = Number.POSITIVE_INFINITY; - for (const t of targets) { - if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; - const dLon = (clicked.lon - t.lon) * cosLat; - const dLat = clicked.lat - t.lat; - const d2 = dLon * dLon + dLat * dLat; - if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { - bestD2 = d2; - bestMmsi = t.mmsi; - } - } - if (bestMmsi != null) { - if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { - onToggleHighlightMmsi?.(bestMmsi); - return; - } - onSelectMmsi(bestMmsi); - return; - } - } catch { - // ignore - } - onSelectMmsi(null); - }; - - map.on("click", onClick); - return () => { - try { - map.off("click", onClick); - } catch { - // ignore - } - }; - }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); - - - // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. - // Deck custom layers are more fragile under globe projection; MapLibre-native rendering stays aligned like zones. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "pair-lines-ml-src"; - const layerId = "pair-lines-ml"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: (pairLinks || []).map((p) => ({ - type: "Feature", - id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), - geometry: { type: "LineString", coordinates: [p.from, p.to] }, - properties: { - type: "pair", - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - warn: p.warn, - }, - })), - }; - - 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("Pair lines source setup failed:", e); - return; - } - - const before = undefined; - - 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", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML, - ] as never, - "line-width": [ - "case", - ["==", ["get", "highlighted"], 1], - 2.8, - ["boolean", ["get", "warn"], false], - 2.2, - 1.4, - ] as never, - "line-opacity": 0.9, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Pair lines layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.pairLines, - pairLinks, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "fc-lines-ml-src"; - const layerId = "fc-lines-ml"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.fcLines) { - 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; - } - - const before = undefined; - - 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, - before, - ); - } catch (e) { - console.warn("FC lines layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fcLines, - fcLinks, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - 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 = () => { - try { - if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, "visibility", "none"); - } catch { - // ignore - } - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { - remove(); - return; - } - - const fcLine: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: (fleetCircles || []).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, - // Kept for backward compatibility with existing paint expressions. - // Actual hover-state highlighting is now handled in - // updateGlobeOverlayPaintStates. - highlighted: 0, - }, - }; - }), - }; - - const fcFill: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: (fleetCircles || []).map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: "Feature", - id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, - geometry: { type: "Polygon", coordinates: [ring] }, - properties: { - type: "fleet-fill", - ownerKey: c.ownerKey, - ownerLabel: c.ownerLabel, - count: c.count, - vesselMmsis: c.vesselMmsis, - // Kept for backward compatibility with existing paint expressions. - // Actual hover-state highlighting is now handled in - // updateGlobeOverlayPaintStates. - highlighted: 0, - }, - }; - }), - }; - - 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 source setup failed:", e); - return; - } - - const before = undefined; - - if (!map.getLayer(fillLayerId)) { - try { - map.addLayer( - { - id: fillLayerId, - type: "fill", - source: fillSrcId, - layout: { visibility: "visible" }, - paint: { - "fill-color": [ - "case", - ["==", ["get", "highlighted"], 1], - FLEET_FILL_ML_HL, - FLEET_FILL_ML, - ] as never, - "fill-opacity": ["case", ["==", ["get", "highlighted"], 1], 0.7, 0.36] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Fleet circles fill layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(fillLayerId, "visibility", "visible"); - } catch { - // ignore - } - } - - 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, - before, - ); - } catch (e) { - console.warn("Fleet circles layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - reorderGlobeFeatureLayers(); - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.fleetCircles, - fleetCircles, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const srcId = "pair-range-ml-src"; - const layerId = "pair-range-ml"; - - const remove = () => { - try { - if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none"); - } catch { - // ignore - } - }; - - const ensure = () => { - if (projectionBusyRef.current) return; - if (!map.isStyleLoaded()) return; - if (projection !== "globe" || !overlays.pairRange) { - remove(); - return; - } - - const ranges: PairRangeCircle[] = []; - for (const p of pairLinks || []) { - const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; - ranges.push({ - center, - radiusNm: Math.max(0.05, p.distanceNm / 2), - warn: p.warn, - aMmsi: p.aMmsi, - bMmsi: p.bMmsi, - distanceNm: p.distanceNm, - }); - } - if (ranges.length === 0) { - remove(); - return; - } - - const fc: GeoJSON.FeatureCollection = { - type: "FeatureCollection", - features: ranges.map((c) => { - const ring = circleRingLngLat(c.center, c.radiusNm * 1852); - return { - type: "Feature", - id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), - geometry: { type: "LineString", coordinates: ring }, - properties: { - type: "pair-range", - warn: c.warn, - aMmsi: c.aMmsi, - bMmsi: c.bMmsi, - distanceNm: c.distanceNm, - // Kept for backward compatibility with existing paint expressions. - // Actual hover-state highlighting is now handled in - // updateGlobeOverlayPaintStates. - highlighted: 0, - }, - }; - }), - }; - - 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("Pair range source setup failed:", e); - return; - } - - const before = undefined; - - 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", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML, - ] as never, - "line-width": ["case", ["==", ["get", "highlighted"], 1], 1.6, 1.0] as never, - "line-opacity": 0.85, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn("Pair range layer add failed:", e); - } - } else { - try { - map.setLayoutProperty(layerId, "visibility", "visible"); - } catch { - // ignore - } - } - - kickRepaint(map); - }; - - const stop = onMapStyleReady(map, ensure); - ensure(); - return () => { - stop(); - }; - }, [ - projection, - overlays.pairRange, - pairLinks, - mapSyncEpoch, - reorderGlobeFeatureLayers, - ]); - - const updateGlobeOverlayPaintStates = useCallback(() => { - if (projection !== "globe" || projectionBusyRef.current) return; - - const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - - const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); - - const pairHighlightExpr = hoveredPairMmsiList.length >= 2 - ? makeMmsiPairHighlightExpr("aMmsi", "bMmsi", hoveredPairMmsiList) - : false; - - 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("pair-lines-ml")) { - map.setPaintProperty( - "pair-lines-ml", - "line-color", - [ - "case", - pairHighlightExpr, - ["case", ["boolean", ["get", "warn"], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_LINE_WARN_ML, - PAIR_LINE_NORMAL_ML, - ] as never, - ); - map.setPaintProperty( - "pair-lines-ml", - "line-width", - ["case", pairHighlightExpr, 2.8, ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, - ); - } - } catch { - // ignore - } - - 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("pair-range-ml")) { - map.setPaintProperty( - "pair-range-ml", - "line-color", - [ - "case", - pairHighlightExpr, - ["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], - ["boolean", ["get", "warn"], false], - PAIR_RANGE_WARN_ML, - PAIR_RANGE_NORMAL_ML, - ] as never, - ); - map.setPaintProperty( - "pair-range-ml", - "line-width", - ["case", pairHighlightExpr, 1.6, 1.0] as never, - ); - } - } catch { - // ignore - } - - try { - if (map.getLayer("fleet-circles-ml-fill")) { - map.setPaintProperty( - "fleet-circles-ml-fill", - "fill-color", - [ - "case", - fleetHighlightExpr, - FLEET_FILL_ML_HL, - FLEET_FILL_ML, - ] as never, - ); - map.setPaintProperty( - "fleet-circles-ml-fill", - "fill-opacity", - ["case", fleetHighlightExpr, 0.7, 0.28] as never, - ); - } - 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, updateGlobeOverlayPaintStates); - updateGlobeOverlayPaintStates(); - return () => { - stop(); - }; - }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); - - const clearGlobeTooltip = useCallback(() => { - if (!mapTooltipRef.current) return; - try { - mapTooltipRef.current.remove(); - } catch { - // ignore - } - mapTooltipRef.current = null; - }, []); - - const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => { - const map = mapRef.current; - if (!map || !map.isStyleLoaded()) return; - if (!mapTooltipRef.current) { - mapTooltipRef.current = new maplibregl.Popup({ - closeButton: false, - closeOnClick: false, - maxWidth: "360px", - className: "maplibre-tooltip-popup", - }); - } - - const container = document.createElement("div"); - container.className = "maplibre-tooltip-popup__content"; - container.innerHTML = tooltipHtml; - - mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map); - }, []); - - const buildGlobeFeatureTooltip = useCallback( - (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { - if (!feature) return null; - const props = feature.properties || {}; - const layerId = feature.layer?.id; - - const maybeMmsi = toIntMmsi(props.mmsi); - if (maybeMmsi != null && maybeMmsi > 0) { - return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits }); - } - - if (layerId === "pair-lines-ml") { - const warn = props.warn === true; - const aMmsi = toIntMmsi(props.aMmsi); - const bMmsi = toIntMmsi(props.bMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getPairLinkTooltipHtml({ - warn, - distanceNm: toSafeNumber(props.distanceNm), - aMmsi, - bMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - - if (layerId === "fc-lines-ml") { - const fcMmsi = toIntMmsi(props.fcMmsi); - const otherMmsi = toIntMmsi(props.otherMmsi); - if (fcMmsi == null || otherMmsi == null) return null; - return getFcLinkTooltipHtml({ - suspicious: props.suspicious === true, - distanceNm: toSafeNumber(props.distanceNm), - fcMmsi, - otherMmsi, - legacyHits, - targetByMmsi: shipByMmsi, - }); - } - - if (layerId === "pair-range-ml") { - const aMmsi = toIntMmsi(props.aMmsi); - const bMmsi = toIntMmsi(props.bMmsi); - if (aMmsi == null || bMmsi == null) return null; - return getRangeTooltipHtml({ - warn: props.warn === true, - distanceNm: toSafeNumber(props.distanceNm), - aMmsi, - bMmsi, - legacyHits, - }); - } - - if (layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill") { - return getFleetCircleTooltipHtml({ - ownerKey: String(props.ownerKey ?? ""), - ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ""), - count: Number(props.count ?? 0), - }); - } - - const zoneLabel = getZoneDisplayNameFromProps(props); - if (zoneLabel) { - return { html: `
${zoneLabel}
` }; - } - - return null; - }, - [legacyHits, shipByMmsi], - ); - - useEffect(() => { - const map = mapRef.current; - if (!map) return; - - const clearDeckGlobeHoverState = () => { - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - clearMapFleetHoverState(); - }; - - const resetGlobeHoverStates = () => { - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - clearMapFleetHoverState(); - }; - - const normalizeMmsiList = (value: unknown): number[] => { - if (!Array.isArray(value)) return []; - const out: number[] = []; - for (const n of value) { - const m = toIntMmsi(n); - if (m != null) out.push(m); - } - return out; - }; - - const onMouseMove = (e: maplibregl.MapMouseEvent) => { - if (projection !== "globe") { - clearGlobeTooltip(); - resetGlobeHoverStates(); - return; - } - if (projectionBusyRef.current) { - resetGlobeHoverStates(); - clearGlobeTooltip(); - return; - } - if (!map.isStyleLoaded()) { - clearDeckGlobeHoverState(); - clearGlobeTooltip(); - return; - } - - let candidateLayerIds: string[] = []; - try { - candidateLayerIds = [ - "ships-globe", - "ships-globe-halo", - "ships-globe-outline", - "pair-lines-ml", - "fc-lines-ml", - "fleet-circles-ml", - "fleet-circles-ml-fill", - "pair-range-ml", - "zones-fill", - "zones-line", - "zones-label", - ].filter((id) => map.getLayer(id)); - } catch { - candidateLayerIds = []; - } - - if (candidateLayerIds.length === 0) { - resetGlobeHoverStates(); - clearGlobeTooltip(); - return; - } - - let rendered: Array<{ properties?: Record | null; layer?: { id?: string } }> = []; - try { - rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{ - properties?: Record | null; - layer?: { id?: string }; - }>; - } catch { - rendered = []; - } - - const priority = [ - "ships-globe", - "ships-globe-halo", - "ships-globe-outline", - "pair-lines-ml", - "fc-lines-ml", - "pair-range-ml", - "fleet-circles-ml-fill", - "fleet-circles-ml", - "zones-fill", - "zones-line", - "zones-label", - ]; - - const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as - | { properties?: Record | null; layer?: { id?: string } } - | undefined; - - if (!first) { - resetGlobeHoverStates(); - clearGlobeTooltip(); - return; - } - - const layerId = first.layer?.id; - const props = first.properties || {}; - const isShipLayer = layerId === "ships-globe" || layerId === "ships-globe-halo" || layerId === "ships-globe-outline"; - const isPairLayer = layerId === "pair-lines-ml" || layerId === "pair-range-ml"; - const isFcLayer = layerId === "fc-lines-ml"; - const isFleetLayer = layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill"; - const isZoneLayer = layerId === "zones-fill" || layerId === "zones-line" || layerId === "zones-label"; - - if (isShipLayer) { - const mmsi = toIntMmsi(props.mmsi); - setDeckHoverMmsi(mmsi == null ? [] : [mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isPairLayer) { - const aMmsi = toIntMmsi(props.aMmsi); - const bMmsi = toIntMmsi(props.bMmsi); - setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); - clearDeckHoverMmsi(); - clearMapFleetHoverState(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isFcLayer) { - const from = toIntMmsi(props.fcMmsi); - const to = toIntMmsi(props.otherMmsi); - const fromTo = [from, to].filter((v): v is number => v != null); - setDeckHoverPairs(fromTo); - setDeckHoverMmsi(fromTo); - clearMapFleetHoverState(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isFleetLayer) { - const ownerKey = String(props.ownerKey ?? ""); - const list = normalizeMmsiList(props.vesselMmsis); - setMapFleetHoverState(ownerKey || null, list); - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - setHoveredZoneId((prev) => (prev === null ? prev : null)); - } else if (isZoneLayer) { - clearMapFleetHoverState(); - clearDeckHoverMmsi(); - clearDeckHoverPairs(); - const zoneId = getZoneIdFromProps(props); - setHoveredZoneId(zoneId || null); - } else { - resetGlobeHoverStates(); - } - - const tooltip = buildGlobeFeatureTooltip(first); - if (!tooltip) { - if (!isZoneLayer) { - resetGlobeHoverStates(); - } - clearGlobeTooltip(); - return; - } - - const content = tooltip?.html ?? ""; - if (content) { - setGlobeTooltip(e.lngLat, content); - return; - } - clearGlobeTooltip(); - }; - - const onMouseOut = () => { - resetGlobeHoverStates(); - clearGlobeTooltip(); - }; - - map.on("mousemove", onMouseMove); - map.on("mouseout", onMouseOut); - - return () => { - map.off("mousemove", onMouseMove); - map.off("mouseout", onMouseOut); - clearGlobeTooltip(); - }; - }, [ - projection, - buildGlobeFeatureTooltip, - clearGlobeTooltip, - clearMapFleetHoverState, - clearDeckHoverPairs, - clearDeckHoverMmsi, - setDeckHoverPairs, - setDeckHoverMmsi, - setMapFleetHoverState, - setGlobeTooltip, - ]); - - 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]); - + // ── Overlay data memos ─────────────────────────────────────────────── const fcDashed = useMemo(() => { const segs: DashSeg[] = []; for (const l of fcLinks || []) { @@ -3591,7 +417,7 @@ export function Map3D({ (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); - }, [fcDashed, hoveredShipSignature, overlays.fcLines, highlightedMmsiSetCombined]); + }, [fcDashed, overlays.fcLines, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; @@ -3600,959 +426,84 @@ export function Map3D({ return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); - // 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; - } + // ── Hook orchestration ─────────────────────────────────────────────── + const { ensureMercatorOverlay, clearGlobeNativeLayers, pulseMapSync } = useMapInit( + containerRef, mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, + baseMapRef, projectionRef, + { baseMap, projection, showSeamark: settings.showSeamark, onViewBboxChange, setMapSyncEpoch }, + ); - const deckTarget = ensureMercatorOverlay(); - if (!deckTarget) return; + const reorderGlobeFeatureLayers = useProjectionToggle( + mapRef, overlayRef, overlayInteractionRef, globeDeckLayerRef, projectionBusyRef, + { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch }, + ); - const layers: unknown[] = []; - const overlayParams = DEPTH_DISABLED_PARAMS; - const clearDeckHover = () => { - touchDeckHoverState(false); - }; - const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); - const shipOtherData: AisTarget[] = []; - const shipTargetData: AisTarget[] = []; - for (const t of shipLayerData) { - if (isTargetShip(t.mmsi)) shipTargetData.push(t); - else shipOtherData.push(t); - } - const shipOverlayOtherData: AisTarget[] = []; - const shipOverlayTargetData: AisTarget[] = []; - for (const t of shipOverlayLayerData) { - if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); - else shipOverlayOtherData.push(t); - } + useBaseMapToggle( + mapRef, + { baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync }, + ); - 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], - }), - ); - } + useZonesLayer( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch }, + ); - 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 ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), - 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 }); - }, - }), - ); - } + usePredictionVectors( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { + overlays, settings, shipData, legacyHits, selectedMmsi, + externalHighlightedSetRef, projection, baseMap, mapSyncEpoch, + }, + ); - 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 ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), - 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 }); - }, - }), - ); - } + useGlobeShips( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { + projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, + shipOverlayLayerData, shipLayerData, shipByMmsi, mapSyncEpoch, + onSelectMmsi, onToggleHighlightMmsi, targets, overlays, + legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, + }, + ); - 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 ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), - 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 }); - }, - }), - ); - } + useGlobeOverlays( + mapRef, projectionBusyRef, reorderGlobeFeatureLayers, + { + overlays, pairLinks, fcLinks, fleetCircles, projection, + mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, + }, + ); - 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: () => FLEET_RANGE_LINE_DECK, - 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 }); - }, - }), - ); - } + useGlobeInteraction( + mapRef, projectionBusyRef, + { + projection, settings, overlays, targets, shipData, shipByMmsi, selectedMmsi, + hoveredZoneId, legacyHits, + clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, + setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId, + }, + ); - 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: () => FLEET_RANGE_FILL_DECK, - getPosition: (d) => d.center, - }), - ); - } + 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, + }, + ); - if (settings.showShips) { - // Always render non-target ships below target ships. - const shipOnHover = (info: PickingInfo) => { - if (!info.object) { - clearDeckHover(); - return; - } - touchDeckHoverState(true); - const obj = info.object as AisTarget; - setDeckHoverMmsi([obj.mmsi]); - clearDeckHoverPairs(); - clearMapFleetHoverState(); - }; - const shipOnClick = (info: PickingInfo) => { - if (!info.object) { - onSelectMmsi(null); - return; - } - onDeckSelectOrHighlight( - { - mmsi: (info.object as AisTarget).mmsi, - srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, - }, - true, - ); - }; + useFlyTo( + mapRef, projectionRef, + { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom }, + ); - if (shipOtherData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-other", - data: shipOtherData, - 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, null, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - - // Hover/selection overlay for non-target ships stays below all target ships. - if (shipOverlayOtherData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-overlay-other", - data: shipOverlayOtherData, - 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) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), - alphaCutoff: 0.05, - }), - ); - } - - // Target ship halos and icons render above non-target ships. - if (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 (shipTargetData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-target", - data: shipTargetData, - 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, EMPTY_MMSI_SET), - onHover: shipOnHover, - onClick: shipOnClick, - alphaCutoff: 0.05, - }), - ); - } - } - - // Interaction overlays (hover/selection highlights) are appended so they always render above base layers. - if (overlays.pairRange && pairRangesInteractive.length > 0) { - layers.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 ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), - getPosition: (d) => d.center, - }), - ); - } - - if (overlays.pairLines && pairLinksInteractive.length > 0) { - layers.push( - new LineLayer({ - id: "pair-lines-overlay", - data: pairLinksInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), - getWidth: () => 2.6, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fcLines && fcLinesInteractive.length > 0) { - layers.push( - new LineLayer({ - id: "fc-lines-overlay", - data: fcLinesInteractive, - pickable: false, - parameters: overlayParams, - getSourcePosition: (d) => d.from, - getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), - getWidth: () => 1.9, - widthUnits: "pixels", - }), - ); - } - - if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { - layers.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: () => FLEET_RANGE_FILL_DECK_HL, - }), - ); - layers.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: () => FLEET_RANGE_LINE_DECK_HL, - getPosition: (d) => d.center, - }), - ); - } - - if (settings.showShips && legacyOverlayTargets.length > 0) { - layers.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 && shipOverlayTargetData.length > 0) { - layers.push( - new IconLayer({ - id: "ships-overlay-target", - data: shipOverlayTargetData, - 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) => { - if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; - if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; - return 0; - }, - getColor: (d) => { - if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; - return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); - }, - }), - ); - } - - 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); - const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; - if (projectionRef.current === "globe") { - map.flyTo(clickOpts); - } else { - map.easeTo(clickOpts); - } - } - }, - }; - - 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. - } - } - }, [ - ensureMercatorOverlay, - projection, - overlayRef, - projectionBusyRef, - shipLayerData, - shipByMmsi, - pairRanges, - pairLinks, - fcDashed, - fleetCircles, - legacyTargetsOrdered, - legacyHits, - legacyOverlayTargets, - shipOverlayLayerData, - pairRangesInteractive, - pairLinksInteractive, - fcLinesInteractive, - fleetCirclesInteractive, - overlays.pairRange, - overlays.pairLines, - overlays.fcLines, - overlays.fleetCircles, - settings.showDensity, - settings.showShips, - onDeckSelectOrHighlight, - onSelectMmsi, - onToggleHighlightMmsi, - setDeckHoverPairs, - clearMapFleetHoverState, - setDeckHoverMmsi, - clearDeckHoverMmsi, - toFleetMmsiList, - touchDeckHoverState, - hasAuxiliarySelectModifier, - ]); - - // 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) => { - const hl = isHighlightedPair(d.aMmsi, d.bMmsi); - if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; - return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; - }, - 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) => { - const hl = isHighlightedPair(d.aMmsi, d.bMmsi); - if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; - return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; - }, - 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)); - if (isHighlighted) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; - return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; - }, - 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) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), - 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) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), - 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; - return FLAT_LEGACY_HALO_RADIUS; - }, - lineWidthUnits: "pixels", - getLineWidth: (d) => { - return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 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, 200]; - return [rgb[0], rgb[1], rgb[2], 200]; - }, - getPosition: (d) => [d.lon, d.lat] as [number, number], - }), - ); - } - - 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, - overlays.pairRange, - overlays.pairLines, - overlays.fcLines, - overlays.fleetCircles, - settings.showShips, - selectedMmsi, - isHighlightedFleet, - isHighlightedPair, - clearDeckHoverPairs, - clearDeckHoverMmsi, - clearMapFleetHoverState, - setDeckHoverPairs, - setDeckHoverMmsi, - setMapFleetHoverState, - toFleetMmsiList, - touchDeckHoverState, - legacyHits, - ]); - - // When the selected MMSI changes due to external UI (e.g., list click), fly to it. - useEffect(() => { - const map = mapRef.current; - if (!map) return; - if (!selectedMmsi) return; - const t = shipData.find((x) => x.mmsi === selectedMmsi); - if (!t) return; - const opts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; - if (projectionRef.current === "globe") { - map.flyTo(opts); - } else { - map.easeTo(opts); - } - }, [selectedMmsi, shipData]); - - useEffect(() => { - const map = mapRef.current; - if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat)) - return; - const lon = fleetFocusLon; - const lat = fleetFocusLat; - const zoom = fleetFocusZoom ?? 10; - - const apply = () => { - const opts = { center: [lon, lat] as [number, number], zoom, duration: 700 }; - if (projectionRef.current === "globe") { - map.flyTo(opts); - } else { - map.easeTo(opts); - } - }; - - if (map.isStyleLoaded()) { - apply(); - return; - } - - const stop = onMapStyleReady(map, apply); - return () => { - stop(); - }; - }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); - - return
; + return
; } diff --git a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts new file mode 100644 index 0000000..9844603 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts @@ -0,0 +1,132 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { BaseMapId, MapProjectionId } from '../types'; +import { kickRepaint, onMapStyleReady, getLayerId } from '../lib/mapCore'; +import { ensureSeamarkOverlay } from '../layers/seamark'; +import { applyBathymetryZoomProfile, resolveMapStyle } from '../layers/bathymetry'; + +export function useBaseMapToggle( + mapRef: MutableRefObject, + opts: { + baseMap: BaseMapId; + projection: MapProjectionId; + showSeamark: boolean; + mapSyncEpoch: number; + pulseMapSync: () => void; + }, +) { + const { baseMap, projection, showSeamark, mapSyncEpoch, pulseMapSync } = opts; + + const showSeamarkRef = useRef(showSeamark); + const bathyZoomProfileKeyRef = useRef(''); + + useEffect(() => { + showSeamarkRef.current = showSeamark; + }, [showSeamark]); + + // Base map style toggle + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + let cancelled = false; + const controller = new AbortController(); + let stop: (() => void) | null = null; + + (async () => { + try { + const style = await resolveMapStyle(baseMap, controller.signal); + if (cancelled) return; + map.setStyle(style, { diff: false }); + stop = onMapStyleReady(map, () => { + kickRepaint(map); + requestAnimationFrame(() => kickRepaint(map)); + pulseMapSync(); + }); + } catch (e) { + if (cancelled) return; + console.warn('Base map switch failed:', e); + } + })(); + + return () => { + cancelled = true; + controller.abort(); + stop?.(); + }; + }, [baseMap]); + + // Bathymetry zoom profile + water layer visibility + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const apply = () => { + if (!map.isStyleLoaded()) return; + const seaVisibility = 'visible' as const; + const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; + + const nextProfileKey = `bathyZoomV1|${baseMap}|${projection}`; + if (bathyZoomProfileKeyRef.current !== nextProfileKey) { + applyBathymetryZoomProfile(map, baseMap, projection); + bathyZoomProfileKeyRef.current = nextProfileKey; + kickRepaint(map); + } + + try { + const style = map.getStyle(); + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + for (const layer of styleLayers) { + const id = getLayerId(layer); + if (!id) continue; + const sourceLayer = String((layer as Record)['source-layer'] ?? '').toLowerCase(); + const source = String((layer as { source?: unknown }).source ?? '').toLowerCase(); + const type = String((layer as { type?: unknown }).type ?? '').toLowerCase(); + const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source); + const isRaster = type === 'raster'; + if (!isSea) continue; + if (!map.getLayer(id)) continue; + if (isRaster && id === 'seamark') continue; + try { + map.setLayoutProperty(id, 'visibility', seaVisibility); + } catch { + // ignore + } + } + } catch { + // ignore + } + }; + + const stop = onMapStyleReady(map, apply); + return () => { + stop(); + }; + }, [projection, baseMap, mapSyncEpoch]); + + // Seamark toggle + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (showSeamark) { + try { + ensureSeamarkOverlay(map, 'bathymetry-lines'); + map.setPaintProperty('seamark', 'raster-opacity', 0.85); + } catch { + // ignore until style is ready + } + return; + } + + try { + if (map.getLayer('seamark')) map.removeLayer('seamark'); + } catch { + // ignore + } + try { + if (map.getSource('seamark')) map.removeSource('seamark'); + } catch { + // ignore + } + }, [showSeamark]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts new file mode 100644 index 0000000..b22a6c4 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -0,0 +1,665 @@ +import { useEffect, useMemo, type MutableRefObject } from 'react'; +import { HexagonLayer } from '@deck.gl/aggregation-layers'; +import { IconLayer, LineLayer, ScatterplotLayer } from '@deck.gl/layers'; +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 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 { + SHIP_ICON_MAPPING, + FLAT_SHIP_ICON_SIZE, + FLAT_SHIP_ICON_SIZE_SELECTED, + FLAT_SHIP_ICON_SIZE_HIGHLIGHTED, + FLAT_LEGACY_HALO_RADIUS, + FLAT_LEGACY_HALO_RADIUS_SELECTED, + FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED, + EMPTY_MMSI_SET, + DEPTH_DISABLED_PARAMS, + GLOBE_OVERLAY_PARAMS, + LEGACY_CODE_COLORS, + PAIR_RANGE_NORMAL_DECK, + PAIR_RANGE_WARN_DECK, + PAIR_LINE_NORMAL_DECK, + PAIR_LINE_WARN_DECK, + FC_LINE_NORMAL_DECK, + FC_LINE_SUSPICIOUS_DECK, + FLEET_RANGE_LINE_DECK, + FLEET_RANGE_FILL_DECK, + PAIR_RANGE_NORMAL_DECK_HL, + PAIR_RANGE_WARN_DECK_HL, + PAIR_LINE_NORMAL_DECK_HL, + PAIR_LINE_WARN_DECK_HL, + FC_LINE_NORMAL_DECK_HL, + FC_LINE_SUSPICIOUS_DECK_HL, + FLEET_RANGE_LINE_DECK_HL, + FLEET_RANGE_FILL_DECK_HL, +} from '../constants'; +import { toSafeNumber } from '../lib/setUtils'; +import { getDisplayHeading, getShipColor } from '../lib/shipUtils'; +import { + getShipTooltipHtml, + getPairLinkTooltipHtml, + getFcLinkTooltipHtml, + getRangeTooltipHtml, + getFleetCircleTooltipHtml, +} from '../lib/tooltips'; +import { sanitizeDeckLayerList } from '../lib/mapCore'; + +export function useDeckLayers( + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + globeDeckLayerRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + 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; + projectionRef: MutableRefObject; + }, +) { + const { + projection, settings, 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, projectionRef, + } = 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]); + + // 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: unknown[] = []; + const overlayParams = DEPTH_DISABLED_PARAMS; + const clearDeckHover = () => { + touchDeckHoverState(false); + }; + const isTargetShip = (mmsi: number) => (legacyHits ? legacyHits.has(mmsi) : false); + const shipOtherData: AisTarget[] = []; + const shipTargetData: AisTarget[] = []; + for (const t of shipLayerData) { + if (isTargetShip(t.mmsi)) shipTargetData.push(t); + else shipOtherData.push(t); + } + const shipOverlayOtherData: AisTarget[] = []; + const shipOverlayTargetData: AisTarget[] = []; + for (const t of shipOverlayLayerData) { + if (isTargetShip(t.mmsi)) shipOverlayTargetData.push(t); + else shipOverlayOtherData.push(t); + } + + 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 ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK), + 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 ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK), + 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 ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK), + 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: () => FLEET_RANGE_LINE_DECK, + 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 }); + }, + }), + ); + 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: () => FLEET_RANGE_FILL_DECK, + getPosition: (d) => d.center, + }), + ); + } + + if (settings.showShips) { + const shipOnHover = (info: PickingInfo) => { + if (!info.object) { clearDeckHover(); return; } + touchDeckHoverState(true); + const obj = info.object as AisTarget; + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + }; + const shipOnClick = (info: PickingInfo) => { + if (!info.object) { onSelectMmsi(null); return; } + onDeckSelectOrHighlight( + { + mmsi: (info.object as AisTarget).mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, + true, + ); + }; + + if (shipOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-other', + data: shipOtherData, + 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, null, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + + if (shipOverlayOtherData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-overlay-other', + data: shipOverlayOtherData, + 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) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => getShipColor(d, selectedMmsi, null, shipHighlightSet), + alphaCutoff: 0.05, + }), + ); + } + + if (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 (shipTargetData.length > 0) { + layers.push( + new IconLayer({ + id: 'ships-target', + data: shipTargetData, + 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, EMPTY_MMSI_SET), + onHover: shipOnHover, + onClick: shipOnClick, + alphaCutoff: 0.05, + }), + ); + } + } + + if (overlays.pairRange && pairRangesInteractive.length > 0) { + layers.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 ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); + } + if (overlays.pairLines && pairLinksInteractive.length > 0) { + layers.push(new LineLayer({ id: 'pair-lines-overlay', data: pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 2.6, widthUnits: 'pixels' })); + } + if (overlays.fcLines && fcLinesInteractive.length > 0) { + layers.push(new LineLayer({ id: 'fc-lines-overlay', data: fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 1.9, widthUnits: 'pixels' })); + } + if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) { + layers.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: () => FLEET_RANGE_FILL_DECK_HL })); + layers.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: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); + } + + if (settings.showShips && legacyOverlayTargets.length > 0) { + layers.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.filter((t) => legacyHits?.has(t.mmsi)).length > 0) { + const shipOverlayTargetData2 = shipOverlayLayerData.filter((t) => legacyHits?.has(t.mmsi)); + layers.push(new IconLayer({ id: 'ships-overlay-target', data: shipOverlayTargetData2, 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) => { if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; return 0; }, getColor: (d) => { if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet); } })); + } + + 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); + const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; + if (projectionRef.current === 'globe') { + map.flyTo(clickOpts); + } else { + map.easeTo(clickOpts); + } + } + }, + }; + + 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. + } + } + }, [ + ensureMercatorOverlay, + projection, + shipLayerData, + shipByMmsi, + pairRanges, + pairLinks, + fcDashed, + fleetCircles, + legacyTargetsOrdered, + legacyHits, + legacyOverlayTargets, + shipOverlayLayerData, + pairRangesInteractive, + pairLinksInteractive, + fcLinesInteractive, + fleetCirclesInteractive, + overlays.pairRange, + overlays.pairLines, + overlays.fcLines, + overlays.fleetCircles, + settings.showDensity, + settings.showShips, + onDeckSelectOrHighlight, + onSelectMmsi, + onToggleHighlightMmsi, + setDeckHoverPairs, + clearMapFleetHoverState, + setDeckHoverMmsi, + clearDeckHoverMmsi, + toFleetMmsiList, + touchDeckHoverState, + hasAuxiliarySelectModifier, + ]); + + // Globe Deck overlay + 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) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL; return d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK; }, 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) => { const hl = isHighlightedPair(d.aMmsi, d.bMmsi); if (hl) return d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL; return d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK; }, 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 ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); if (ih) return d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL; return d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK; }, getWidth: (d) => { const ih = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); return ih ? 1.9 : 1.3; }, widthUnits: 'pixels', onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } touchDeckHoverState(true); const obj = info.object as DashSeg; if (obj.fromMmsi == null || obj.toMmsi == null) { clearDeckHoverPairs(); clearDeckHoverMmsi(); return; } setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]); setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]); 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) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK), 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) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK), 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; return FLAT_LEGACY_HALO_RADIUS; }, lineWidthUnits: 'pixels', getLineWidth: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 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, 200]; return [rgb[0], rgb[1], rgb[2], 200]; }, getPosition: (d) => [d.lon, d.lat] as [number, number] })); + } + + 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, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useFlyTo.ts b/apps/web/src/widgets/map3d/hooks/useFlyTo.ts new file mode 100644 index 0000000..d0c65cb --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useFlyTo.ts @@ -0,0 +1,61 @@ +import { useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import { onMapStyleReady } from '../lib/mapCore'; +import type { MapProjectionId } from '../types'; + +export function useFlyTo( + mapRef: MutableRefObject, + projectionRef: MutableRefObject, + opts: { + selectedMmsi: number | null; + shipData: { mmsi: number; lon: number; lat: number }[]; + fleetFocusId: string | number | undefined; + fleetFocusLon: number | undefined; + fleetFocusLat: number | undefined; + fleetFocusZoom: number | undefined; + }, +) { + const { selectedMmsi, shipData, fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom } = opts; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (!selectedMmsi) return; + const t = shipData.find((x) => x.mmsi === selectedMmsi); + if (!t) return; + const flyOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 }; + if (projectionRef.current === 'globe') { + map.flyTo(flyOpts); + } else { + map.easeTo(flyOpts); + } + }, [selectedMmsi, shipData]); + + useEffect(() => { + const map = mapRef.current; + if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat)) + return; + const lon = fleetFocusLon; + const lat = fleetFocusLat; + const zoom = fleetFocusZoom ?? 10; + + const apply = () => { + const flyOpts = { center: [lon, lat] as [number, number], zoom, duration: 700 }; + if (projectionRef.current === 'globe') { + map.flyTo(flyOpts); + } else { + map.easeTo(flyOpts); + } + }; + + if (map.isStyleLoaded()) { + apply(); + return; + } + + const stop = onMapStyleReady(map, apply); + return () => { + stop(); + }; + }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts new file mode 100644 index 0000000..96892ae --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeInteraction.ts @@ -0,0 +1,318 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; +import maplibregl from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { toIntMmsi, toSafeNumber } from '../lib/setUtils'; +import { + getShipTooltipHtml, + getPairLinkTooltipHtml, + getFcLinkTooltipHtml, + getRangeTooltipHtml, + getFleetCircleTooltipHtml, +} from '../lib/tooltips'; +import { getZoneIdFromProps, getZoneDisplayNameFromProps } from '../lib/zoneUtils'; + +export function useGlobeInteraction( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + overlays: MapToggleState; + targets: AisTarget[]; + shipData: AisTarget[]; + shipByMmsi: Map; + selectedMmsi: number | null; + hoveredZoneId: string | null; + legacyHits: Map | null | undefined; + clearDeckHoverPairs: () => void; + clearDeckHoverMmsi: () => void; + clearMapFleetHoverState: () => void; + setDeckHoverPairs: (next: number[]) => void; + setDeckHoverMmsi: (next: number[]) => void; + setMapFleetHoverState: (ownerKey: string | null, vesselMmsis: number[]) => void; + setHoveredZoneId: (updater: (prev: string | null) => string | null) => void; + }, +) { + const { + projection, legacyHits, shipByMmsi, + clearDeckHoverPairs, clearDeckHoverMmsi, clearMapFleetHoverState, + setDeckHoverPairs, setDeckHoverMmsi, setMapFleetHoverState, setHoveredZoneId, + } = opts; + + const mapTooltipRef = useRef(null); + + const clearGlobeTooltip = useCallback(() => { + if (!mapTooltipRef.current) return; + try { + mapTooltipRef.current.remove(); + } catch { + // ignore + } + mapTooltipRef.current = null; + }, []); + + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + if (!mapTooltipRef.current) { + mapTooltipRef.current = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + maxWidth: '360px', + className: 'maplibre-tooltip-popup', + }); + } + + const container = document.createElement('div'); + container.className = 'maplibre-tooltip-popup__content'; + container.innerHTML = tooltipHtml; + + mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map); + }, []); + + const buildGlobeFeatureTooltip = useCallback( + (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { + if (!feature) return null; + const props = feature.properties || {}; + const layerId = feature.layer?.id; + + const maybeMmsi = toIntMmsi(props.mmsi); + if (maybeMmsi != null && maybeMmsi > 0) { + return getShipTooltipHtml({ mmsi: maybeMmsi, targetByMmsi: shipByMmsi, legacyHits }); + } + + if (layerId === 'pair-lines-ml') { + const warn = props.warn === true; + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getPairLinkTooltipHtml({ + warn, distanceNm: toSafeNumber(props.distanceNm), + aMmsi, bMmsi, legacyHits, targetByMmsi: shipByMmsi, + }); + } + + if (layerId === 'fc-lines-ml') { + const fcMmsi = toIntMmsi(props.fcMmsi); + const otherMmsi = toIntMmsi(props.otherMmsi); + if (fcMmsi == null || otherMmsi == null) return null; + return getFcLinkTooltipHtml({ + suspicious: props.suspicious === true, + distanceNm: toSafeNumber(props.distanceNm), + fcMmsi, otherMmsi, legacyHits, targetByMmsi: shipByMmsi, + }); + } + + if (layerId === 'pair-range-ml') { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + if (aMmsi == null || bMmsi == null) return null; + return getRangeTooltipHtml({ + warn: props.warn === true, + distanceNm: toSafeNumber(props.distanceNm), + aMmsi, bMmsi, legacyHits, + }); + } + + if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') { + return getFleetCircleTooltipHtml({ + ownerKey: String(props.ownerKey ?? ''), + ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''), + count: Number(props.count ?? 0), + }); + } + + const zoneLabel = getZoneDisplayNameFromProps(props); + if (zoneLabel) { + return { html: `
${zoneLabel}
` }; + } + + return null; + }, + [legacyHits, shipByMmsi], + ); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const clearDeckGlobeHoverState = () => { + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const resetGlobeHoverStates = () => { + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const normalizeMmsiList = (value: unknown): number[] => { + if (!Array.isArray(value)) return []; + const out: number[] = []; + for (const n of value) { + const m = toIntMmsi(n); + if (m != null) out.push(m); + } + return out; + }; + + const onMouseMove = (e: maplibregl.MapMouseEvent) => { + if (projection !== 'globe') { + clearGlobeTooltip(); + resetGlobeHoverStates(); + return; + } + if (projectionBusyRef.current) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } + if (!map.isStyleLoaded()) { + clearDeckGlobeHoverState(); + clearGlobeTooltip(); + return; + } + + let candidateLayerIds: string[] = []; + try { + candidateLayerIds = [ + 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'pair-lines-ml', 'fc-lines-ml', + 'fleet-circles-ml', 'fleet-circles-ml-fill', + 'pair-range-ml', + 'zones-fill', 'zones-line', 'zones-label', + ].filter((id) => map.getLayer(id)); + } catch { + candidateLayerIds = []; + } + + if (candidateLayerIds.length === 0) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } + + let rendered: Array<{ properties?: Record | null; layer?: { id?: string } }> = []; + try { + rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{ + properties?: Record | null; + layer?: { id?: string }; + }>; + } catch { + rendered = []; + } + + const priority = [ + 'ships-globe', 'ships-globe-halo', 'ships-globe-outline', + 'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml', + 'fleet-circles-ml-fill', 'fleet-circles-ml', + 'zones-fill', 'zones-line', 'zones-label', + ]; + + const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as + | { properties?: Record | null; layer?: { id?: string } } + | undefined; + + if (!first) { + resetGlobeHoverStates(); + clearGlobeTooltip(); + return; + } + + const layerId = first.layer?.id; + const props = first.properties || {}; + const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline'; + const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml'; + const isFcLayer = layerId === 'fc-lines-ml'; + const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill'; + const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label'; + + if (isShipLayer) { + const mmsi = toIntMmsi(props.mmsi); + setDeckHoverMmsi(mmsi == null ? [] : [mmsi]); + clearDeckHoverPairs(); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isPairLayer) { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); + clearDeckHoverMmsi(); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isFcLayer) { + const from = toIntMmsi(props.fcMmsi); + const to = toIntMmsi(props.otherMmsi); + const fromTo = [from, to].filter((v): v is number => v != null); + setDeckHoverPairs(fromTo); + setDeckHoverMmsi(fromTo); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isFleetLayer) { + const ownerKey = String(props.ownerKey ?? ''); + const list = normalizeMmsiList(props.vesselMmsis); + setMapFleetHoverState(ownerKey || null, list); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isZoneLayer) { + clearMapFleetHoverState(); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); + const zoneId = getZoneIdFromProps(props); + setHoveredZoneId(() => zoneId || null); + } else { + resetGlobeHoverStates(); + } + + const tooltip = buildGlobeFeatureTooltip(first); + if (!tooltip) { + if (!isZoneLayer) { + resetGlobeHoverStates(); + } + clearGlobeTooltip(); + return; + } + + const content = tooltip?.html ?? ''; + if (content) { + setGlobeTooltip(e.lngLat, content); + return; + } + clearGlobeTooltip(); + }; + + const onMouseOut = () => { + resetGlobeHoverStates(); + clearGlobeTooltip(); + }; + + map.on('mousemove', onMouseMove); + map.on('mouseout', onMouseOut); + + return () => { + map.off('mousemove', onMouseMove); + map.off('mouseout', onMouseOut); + clearGlobeTooltip(); + }; + }, [ + projection, + buildGlobeFeatureTooltip, + clearGlobeTooltip, + clearMapFleetHoverState, + clearDeckHoverPairs, + clearDeckHoverMmsi, + setDeckHoverPairs, + setDeckHoverMmsi, + setMapFleetHoverState, + setGlobeTooltip, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts new file mode 100644 index 0000000..dad2779 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeOverlays.ts @@ -0,0 +1,618 @@ +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, PairLink } from '../../../features/legacyDashboard/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { DashSeg, MapProjectionId, PairRangeCircle } from '../types'; +import { + PAIR_LINE_NORMAL_ML, PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML_HL, PAIR_LINE_WARN_ML_HL, + PAIR_RANGE_NORMAL_ML, PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL, + FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML, + FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL, + FLEET_FILL_ML, FLEET_FILL_ML_HL, + FLEET_LINE_ML, FLEET_LINE_ML_HL, +} from '../constants'; +import { makeUniqueSorted } from '../lib/setUtils'; +import { + makePairLinkFeatureId, + makeFcSegmentFeatureId, + makeFleetCircleFeatureId, +} from '../lib/featureIds'; +import { + makeMmsiPairHighlightExpr, + makeMmsiAnyEndpointExpr, + makeFleetOwnerMatchExpr, + makeFleetMemberMatchExpr, +} from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { circleRingLngLat } from '../lib/geometry'; +import { dashifyLine } from '../lib/dashifyLine'; + +export function useGlobeOverlays( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + pairLinks: PairLink[] | undefined; + fcLinks: FcLink[] | undefined; + fleetCircles: FleetCircle[] | undefined; + projection: MapProjectionId; + mapSyncEpoch: number; + hoveredFleetMmsiList: number[]; + hoveredFleetOwnerKeyList: string[]; + hoveredPairMmsiList: number[]; + }, +) { + const { + overlays, pairLinks, fcLinks, fleetCircles, projection, mapSyncEpoch, + hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, + } = opts; + + // Pair lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-lines-ml-src'; + const layerId = 'pair-lines-ml'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.pairLines || (pairLinks?.length ?? 0) === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: (pairLinks || []).map((p) => ({ + type: 'Feature', + id: makePairLinkFeatureId(p.aMmsi, p.bMmsi), + geometry: { type: 'LineString', coordinates: [p.from, p.to] }, + properties: { + type: 'pair', + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, + })), + }; + + 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('Pair 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', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_LINE_WARN_ML, + PAIR_LINE_NORMAL_ML, + ] as never, + 'line-width': [ + 'case', + ['==', ['get', 'highlighted'], 1], 2.8, + ['boolean', ['get', 'warn'], false], 2.2, + 1.4, + ] as never, + 'line-opacity': 0.9, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair lines layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // FC lines + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'fc-lines-ml-src'; + const layerId = 'fc-lines-ml'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.fcLines) { + 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 { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.fcLines, fcLinks, 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 = () => { + try { + if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none'); + } catch { + // ignore + } + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { + remove(); + return; + } + + const fcLine: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: (fleetCircles || []).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: (fleetCircles || []).map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + type: 'fleet-fill', + ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, + count: c.count, + vesselMmsis: c.vesselMmsis, + highlighted: 0, + }, + }; + }), + }; + + 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 source setup failed:', e); + return; + } + + if (!map.getLayer(fillLayerId)) { + try { + map.addLayer( + { + id: fillLayerId, + type: 'fill', + source: fillSrcId, + layout: { visibility: 'visible' }, + paint: { + 'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never, + 'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Fleet circles fill layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(fillLayerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + 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 { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Pair range + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'pair-range-ml-src'; + const layerId = 'pair-range-ml'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none'); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + if (projection !== 'globe' || !overlays.pairRange) { + remove(); + return; + } + + const ranges: PairRangeCircle[] = []; + for (const p of pairLinks || []) { + const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; + ranges.push({ + center, + radiusNm: Math.max(0.05, p.distanceNm / 2), + warn: p.warn, + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + }); + } + if (ranges.length === 0) { + remove(); + return; + } + + const fc: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: ranges.map((c) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: 'Feature', + id: makePairLinkFeatureId(c.aMmsi, c.bMmsi), + geometry: { type: 'LineString', coordinates: ring }, + properties: { + type: 'pair-range', + warn: c.warn, + aMmsi: c.aMmsi, + bMmsi: c.bMmsi, + distanceNm: c.distanceNm, + highlighted: 0, + }, + }; + }), + }; + + 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('Pair range 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', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], + ['boolean', ['get', 'warn'], false], + PAIR_RANGE_WARN_ML, + PAIR_RANGE_NORMAL_ML, + ] as never, + 'line-width': ['case', ['==', ['get', 'highlighted'], 1], 1.6, 1.0] as never, + 'line-opacity': 0.85, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Pair range layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', 'visible'); + } catch { + // ignore + } + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + ensure(); + return () => { + stop(); + }; + }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, reorderGlobeFeatureLayers]); + + // Paint state updates for hover highlights + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const updateGlobeOverlayPaintStates = useCallback(() => { + if (projection !== 'globe' || projectionBusyRef.current) return; + + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + + const pairHighlightExpr = hoveredPairMmsiList.length >= 2 + ? makeMmsiPairHighlightExpr('aMmsi', 'bMmsi', hoveredPairMmsiList) + : false; + + 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('pair-lines-ml')) { + map.setPaintProperty( + 'pair-lines-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML_HL, PAIR_LINE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_LINE_WARN_ML, PAIR_LINE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-lines-ml', 'line-width', + ['case', pairHighlightExpr, 2.8, ['boolean', ['get', 'warn'], false], 2.2, 1.4] as never, + ); + } + } catch { + // ignore + } + + 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('pair-range-ml')) { + map.setPaintProperty( + 'pair-range-ml', 'line-color', + ['case', pairHighlightExpr, ['case', ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL], ['boolean', ['get', 'warn'], false], PAIR_RANGE_WARN_ML, PAIR_RANGE_NORMAL_ML] as never, + ); + map.setPaintProperty( + 'pair-range-ml', 'line-width', + ['case', pairHighlightExpr, 1.6, 1.0] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer('fleet-circles-ml-fill')) { + map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never); + map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never); + } + 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, updateGlobeOverlayPaintStates); + updateGlobeOverlayPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts new file mode 100644 index 0000000..525cb65 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShips.ts @@ -0,0 +1,1029 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { + ANCHORED_SHIP_ICON_ID, + GLOBE_ICON_HEADING_OFFSET_DEG, + DEG2RAD, +} from '../constants'; +import { isFiniteNumber } from '../lib/setUtils'; +import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { + isAnchoredShip, + getDisplayHeading, + getGlobeBaseShipColor, +} from '../lib/shipUtils'; +import { + buildFallbackGlobeAnchoredShipIcon, + ensureFallbackShipImage, +} from '../lib/globeShipIcon'; +import { clampNumber } from '../lib/geometry'; + +export function useGlobeShips( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + projection: MapProjectionId; + settings: Map3DSettings; + shipData: AisTarget[]; + shipHighlightSet: Set; + shipHoverOverlaySet: Set; + shipOverlayLayerData: AisTarget[]; + shipLayerData: AisTarget[]; + shipByMmsi: Map; + mapSyncEpoch: number; + onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; + targets: AisTarget[]; + overlays: MapToggleState; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + isBaseHighlightedMmsi: (mmsi: number) => boolean; + hasAuxiliarySelectModifier: (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null) => boolean; + }, +) { + const { + projection, settings, shipData, shipHighlightSet, shipHoverOverlaySet, + shipLayerData, mapSyncEpoch, onSelectMmsi, onToggleHighlightMmsi, targets, + overlays, legacyHits, selectedMmsi, isBaseHighlightedMmsi, hasAuxiliarySelectModifier, + } = opts; + + const globeShipsEpochRef = useRef(-1); + const globeShipIconLoadingRef = useRef(false); + const globeHoverShipSignatureRef = useRef(''); + + // Ship name labels in mercator + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'ship-labels-src'; + const layerId = 'ship-labels'; + + const remove = () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + } catch { + // ignore + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'mercator' || !settings.showShips) { + remove(); + return; + } + + const visibility = overlays.shipLabels ? 'visible' : 'none'; + + const features: GeoJSON.Feature[] = []; + for (const t of shipData) { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const isTarget = !!legacy; + const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; + const isPinnedHighlight = shipHighlightSet.has(t.mmsi); + if (!isTarget && !isSelected && !isPinnedHighlight) continue; + + const labelName = (legacy?.shipNameCn || legacy?.shipNameRoman || t.name || '').trim(); + if (!labelName) continue; + + features.push({ + type: 'Feature', + id: `ship-label-${t.mmsi}`, + geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + labelName, + selected: isSelected ? 1 : 0, + highlighted: isPinnedHighlight ? 1 : 0, + permitted: isTarget ? 1 : 0, + }, + }); + } + + const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }; + + 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('Ship label source setup failed:', e); + return; + } + + const filter = ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''] as unknown as unknown[]; + + if (!map.getLayer(layerId)) { + try { + map.addLayer( + { + id: layerId, + type: 'symbol', + source: srcId, + minzoom: 7, + filter: filter as never, + layout: { + visibility, + 'symbol-placement': 'point', + 'text-field': ['get', 'labelName'] as never, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, + 'text-anchor': 'top', + 'text-offset': [0, 1.1], + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': [ + 'case', + ['==', ['get', 'selected'], 1], + 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], + 'rgba(245,158,11,0.95)', + 'rgba(226,232,240,0.92)', + ] as never, + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Ship label layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(layerId, 'visibility', visibility); + } catch { + // ignore + } + } + + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + overlays.shipLabels, + shipData, + legacyHits, + selectedMmsi, + shipHighlightSet, + mapSyncEpoch, + ]); + + // Ships in globe mode + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = 'ship-globe-icon'; + const anchoredImgId = ANCHORED_SHIP_ICON_ID; + const srcId = 'ships-globe-src'; + const haloId = 'ships-globe-halo'; + const outlineId = 'ships-globe-outline'; + const symbolId = 'ships-globe'; + const labelId = 'ships-globe-label'; + + const remove = () => { + for (const id of [labelId, symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + globeHoverShipSignatureRef.current = ''; + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const ensureImage = () => { + ensureFallbackShipImage(map, imgId); + ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); + if (globeShipIconLoadingRef.current) return; + if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return; + + const addFallbackImage = () => { + ensureFallbackShipImage(map, imgId); + ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon); + kickRepaint(map); + }; + + let fallbackTimer: ReturnType | null = null; + try { + globeShipIconLoadingRef.current = true; + fallbackTimer = window.setTimeout(() => { + addFallbackImage(); + }, 80); + void map + .loadImage('/assets/ship.svg') + .then((response) => { + globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + + const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; + if (!loadedImage) { + addFallbackImage(); + return; + } + + try { + if (map.hasImage(imgId)) { + try { + map.removeImage(imgId); + } catch { + // ignore + } + } + if (map.hasImage(anchoredImgId)) { + try { + map.removeImage(anchoredImgId); + } catch { + // ignore + } + } + map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true }); + map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true }); + kickRepaint(map); + } catch (e) { + console.warn('Ship icon image add failed:', e); + } + }) + .catch(() => { + globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + addFallbackImage(); + }); + } catch (e) { + globeShipIconLoadingRef.current = false; + if (fallbackTimer != null) { + clearTimeout(fallbackTimer); + fallbackTimer = null; + } + try { + addFallbackImage(); + } catch (fallbackError) { + console.warn('Ship icon image setup failed:', e, fallbackError); + } + } + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'globe' || !settings.showShips) { + remove(); + return; + } + + if (globeShipsEpochRef.current !== mapSyncEpoch) { + globeShipsEpochRef.current = mapSyncEpoch; + } + + try { + ensureImage(); + } catch (e) { + console.warn('Ship icon image setup failed:', e); + } + + const globeShipData = shipData; + const geojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: globeShipData.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const labelName = + legacy?.shipNameCn || + legacy?.shipNameRoman || + t.name || + ''; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const isAnchored = isAnchoredShip({ + sog: t.sog, + cog: t.cog, + heading: t.heading, + }); + const shipHeading = isAnchored ? 0 : heading; + const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const highlighted = isBaseHighlightedMmsi(t.mmsi); + const selectedScale = selected ? 1.08 : 1; + const highlightScale = highlighted ? 1.06 : 1; + const iconScale = selected ? selectedScale : highlightScale; + const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); + const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); + const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); + const iconSize14 = clampNumber(0.72 * sizeScale * selectedScale, 0.45, 2.1); + return { + type: 'Feature', + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || '', + labelName, + cog: shipHeading, + heading: shipHeading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + isAnchored: isAnchored ? 1 : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: iconSize3 * iconScale, + iconSize7: iconSize7 * iconScale, + iconSize10: iconSize10 * iconScale, + iconSize14: iconSize14 * iconScale, + sizeScale, + selected: selected ? 1 : 0, + highlighted: highlighted ? 1 : 0, + permitted: legacy ? 1 : 0, + code: legacy?.shipCode || '', + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(geojson); + else map.addSource(srcId, { type: 'geojson', data: geojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship source setup failed:', e); + return; + } + + const visibility = settings.showShips ? 'visible' : 'none'; + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: 'circle', + source: srcId, + layout: { + visibility, + 'circle-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, + ['==', ['get', 'permitted'], 1], 110, + ['==', ['get', 'selected'], 1], 60, + ['==', ['get', 'highlighted'], 1], 55, + 20, + ] as never, + }, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'circle-opacity': [ + 'case', + ['==', ['get', 'selected'], 1], 0.38, + ['==', ['get', 'highlighted'], 1], 0.34, + 0.16, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship halo layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(haloId, 'visibility', visibility); + map.setLayoutProperty(haloId, 'circle-sort-key', [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115, + ['==', ['get', 'permitted'], 1], 110, + ['==', ['get', 'selected'], 1], 60, + ['==', ['get', 'highlighted'], 1], 55, + 20, + ] as never); + map.setPaintProperty(haloId, 'circle-color', [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)', + ['coalesce', ['get', 'shipColor'], '#64748b'], + ] as never); + map.setPaintProperty(haloId, 'circle-opacity', [ + 'case', + ['==', ['get', 'selected'], 1], 0.38, + ['==', ['get', 'highlighted'], 1], 0.34, + 0.16, + ] as never); + map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR); + } catch { + // ignore + } + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: 'circle', + source: srcId, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + ['coalesce', ['get', 'shipColor'], '#64748b'], + ] as never, + 'circle-stroke-width': [ + 'case', + ['==', ['get', 'selected'], 1], 3.4, + ['==', ['get', 'highlighted'], 1], 2.7, + ['==', ['get', 'permitted'], 1], 1.8, + 0.0, + ] as never, + 'circle-stroke-opacity': 0.85, + }, + layout: { + visibility, + 'circle-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, + ['==', ['get', 'permitted'], 1], 120, + ['==', ['get', 'selected'], 1], 70, + ['==', ['get', 'highlighted'], 1], 65, + 30, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship outline layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(outlineId, 'visibility', visibility); + map.setLayoutProperty(outlineId, 'circle-sort-key', [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125, + ['==', ['get', 'permitted'], 1], 120, + ['==', ['get', 'selected'], 1], 70, + ['==', ['get', 'highlighted'], 1], 65, + 30, + ] as never); + map.setPaintProperty(outlineId, 'circle-stroke-color', [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + ['coalesce', ['get', 'shipColor'], '#64748b'], + ] as never); + map.setPaintProperty(outlineId, 'circle-stroke-width', [ + 'case', + ['==', ['get', 'selected'], 1], 3.4, + ['==', ['get', 'highlighted'], 1], 2.7, + ['==', ['get', 'permitted'], 1], 1.8, + 0.0, + ] as never); + } catch { + // ignore + } + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: 'symbol', + source: srcId, + layout: { + visibility, + 'symbol-sort-key': [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, + ['==', ['get', 'permitted'], 1], 130, + ['==', ['get', 'selected'], 1], 80, + ['==', ['get', 'highlighted'], 1], 75, + 45, + ] as never, + 'icon-image': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], + anchoredImgId, + imgId, + ] as never, + 'icon-size': [ + 'interpolate', ['linear'], ['zoom'], + 3, ['to-number', ['get', 'iconSize3'], 0.35], + 7, ['to-number', ['get', 'iconSize7'], 0.45], + 10, ['to-number', ['get', 'iconSize10'], 0.56], + 14, ['to-number', ['get', 'iconSize14'], 0.72], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': [ + 'case', + ['==', ['to-number', ['get', 'isAnchored'], 0], 1], 0, + ['to-number', ['get', 'heading'], 0], + ] as never, + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': [ + 'case', + ['==', ['get', 'permitted'], 1], 1, + ['==', ['get', 'selected'], 1], 0.86, + ['==', ['get', 'highlighted'], 1], 0.82, + 0.66, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship symbol layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(symbolId, 'visibility', visibility); + map.setLayoutProperty(symbolId, 'symbol-sort-key', [ + 'case', + ['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140, + ['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135, + ['==', ['get', 'permitted'], 1], 130, + ['==', ['get', 'selected'], 1], 80, + ['==', ['get', 'highlighted'], 1], 75, + 45, + ] as never); + map.setPaintProperty(symbolId, 'icon-opacity', [ + 'case', + ['==', ['get', 'permitted'], 1], 1, + ['==', ['get', 'selected'], 1], 0.86, + ['==', ['get', 'highlighted'], 1], 0.82, + 0.66, + ] as never); + } catch { + // ignore + } + } + + const labelVisibility = overlays.shipLabels ? 'visible' : 'none'; + const labelFilter = [ + 'all', + ['!=', ['to-string', ['coalesce', ['get', 'labelName'], '']], ''], + [ + 'any', + ['==', ['get', 'permitted'], 1], + ['==', ['get', 'selected'], 1], + ['==', ['get', 'highlighted'], 1], + ], + ] as unknown as unknown[]; + + if (!map.getLayer(labelId)) { + try { + map.addLayer( + { + id: labelId, + type: 'symbol', + source: srcId, + minzoom: 7, + filter: labelFilter as never, + layout: { + visibility: labelVisibility, + 'symbol-placement': 'point', + 'text-field': ['get', 'labelName'] as never, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, + 'text-anchor': 'top', + 'text-offset': [0, 1.1], + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + 'rgba(226,232,240,0.92)', + ] as never, + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Ship label layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(labelId, 'visibility', labelVisibility); + map.setFilter(labelId, labelFilter as never); + map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never); + } catch { + // ignore + } + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + overlays.shipLabels, + shipData, + legacyHits, + selectedMmsi, + isBaseHighlightedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Globe hover overlay ships + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const imgId = 'ship-globe-icon'; + const srcId = 'ships-globe-hover-src'; + const haloId = 'ships-globe-hover-halo'; + const outlineId = 'ships-globe-hover-outline'; + const symbolId = 'ships-globe-hover'; + + const remove = () => { + for (const id of [symbolId, outlineId, haloId]) { + try { + if (map.getLayer(id)) map.removeLayer(id); + } catch { + // ignore + } + } + try { + if (map.getSource(srcId)) map.removeSource(srcId); + } catch { + // ignore + } + globeHoverShipSignatureRef.current = ''; + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + if (projection !== 'globe' || !settings.showShips || shipHoverOverlaySet.size === 0) { + remove(); + return; + } + + if (globeShipsEpochRef.current !== mapSyncEpoch) { + globeShipsEpochRef.current = mapSyncEpoch; + } + + ensureFallbackShipImage(map, imgId); + if (!map.hasImage(imgId)) { + return; + } + + const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi)); + if (hovered.length === 0) { + remove(); + return; + } + const hoverSignature = hovered + .map((t) => `${t.mmsi}:${t.lon.toFixed(6)}:${t.lat.toFixed(6)}:${t.heading ?? 0}`) + .join('|'); + const hasHoverSource = map.getSource(srcId) != null; + const hasHoverLayers = [symbolId, outlineId, haloId].every((id) => map.getLayer(id)); + if (hoverSignature === globeHoverShipSignatureRef.current && hasHoverSource && hasHoverLayers) { + return; + } + globeHoverShipSignatureRef.current = hoverSignature; + const needReorder = !hasHoverSource || !hasHoverLayers; + + const hoverGeojson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: hovered.map((t) => { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const heading = getDisplayHeading({ + cog: t.cog, + heading: t.heading, + offset: GLOBE_ICON_HEADING_OFFSET_DEG, + }); + const hull = clampNumber( + (isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, + 50, + 420, + ); + const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); + const selected = t.mmsi === selectedMmsi; + const scale = selected ? 1.16 : 1.1; + return { + type: 'Feature', + ...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}), + geometry: { type: 'Point', coordinates: [t.lon, t.lat] }, + properties: { + mmsi: t.mmsi, + name: t.name || '', + cog: heading, + heading, + sog: isFiniteNumber(t.sog) ? t.sog : 0, + shipColor: getGlobeBaseShipColor({ + legacy: legacy?.shipCode || null, + sog: isFiniteNumber(t.sog) ? t.sog : null, + }), + iconSize3: clampNumber(0.35 * sizeScale * scale, 0.25, 1.45), + iconSize7: clampNumber(0.45 * sizeScale * scale, 0.3, 1.7), + iconSize10: clampNumber(0.56 * sizeScale * scale, 0.35, 2.0), + iconSize14: clampNumber(0.72 * sizeScale * scale, 0.45, 2.4), + selected: selected ? 1 : 0, + permitted: legacy ? 1 : 0, + }, + }; + }), + }; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) existing.setData(hoverGeojson); + else map.addSource(srcId, { type: 'geojson', data: hoverGeojson } as GeoJSONSourceSpecification); + } catch (e) { + console.warn('Ship hover source setup failed:', e); + return; + } + + const before = undefined; + + if (!map.getLayer(haloId)) { + try { + map.addLayer( + { + id: haloId, + type: 'circle', + source: srcId, + layout: { + visibility: 'visible', + 'circle-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 120, + ['==', ['get', 'permitted'], 1], 115, + 110, + ] as never, + }, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)', + 'rgba(245,158,11,1)', + ] as never, + 'circle-opacity': 0.42, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover halo layer add failed:', e); + } + } else { + map.setLayoutProperty(haloId, 'visibility', 'visible'); + } + + if (!map.getLayer(outlineId)) { + try { + map.addLayer( + { + id: outlineId, + type: 'circle', + source: srcId, + paint: { + 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, + 'circle-color': 'rgba(0,0,0,0)', + 'circle-stroke-color': [ + 'case', + ['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)', + 'rgba(245,158,11,0.95)', + ] as never, + 'circle-stroke-width': [ + 'case', + ['==', ['get', 'selected'], 1], 3.8, + 2.2, + ] as never, + 'circle-stroke-opacity': 0.9, + }, + layout: { + visibility: 'visible', + 'circle-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 121, + ['==', ['get', 'permitted'], 1], 116, + 111, + ] as never, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover outline layer add failed:', e); + } + } else { + map.setLayoutProperty(outlineId, 'visibility', 'visible'); + } + + if (!map.getLayer(symbolId)) { + try { + map.addLayer( + { + id: symbolId, + type: 'symbol', + source: srcId, + layout: { + visibility: 'visible', + 'symbol-sort-key': [ + 'case', + ['==', ['get', 'selected'], 1], 122, + ['==', ['get', 'permitted'], 1], 117, + 112, + ] as never, + 'icon-image': imgId, + 'icon-size': [ + 'interpolate', ['linear'], ['zoom'], + 3, ['to-number', ['get', 'iconSize3'], 0.35], + 7, ['to-number', ['get', 'iconSize7'], 0.45], + 10, ['to-number', ['get', 'iconSize10'], 0.56], + 14, ['to-number', ['get', 'iconSize14'], 0.72], + ] as unknown as number[], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'icon-anchor': 'center', + 'icon-rotate': ['to-number', ['get', 'heading'], 0], + 'icon-rotation-alignment': 'map', + 'icon-pitch-alignment': 'map', + }, + paint: { + 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, + 'icon-opacity': 1, + }, + } as unknown as LayerSpecification, + before, + ); + } catch (e) { + console.warn('Ship hover symbol layer add failed:', e); + } + } else { + map.setLayoutProperty(symbolId, 'visibility', 'visible'); + } + + if (needReorder) { + reorderGlobeFeatureLayers(); + } + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + projection, + settings.showShips, + shipLayerData, + legacyHits, + shipHoverOverlaySet, + selectedMmsi, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); + + // Globe ship click selection + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (projection !== 'globe' || !settings.showShips) return; + + const symbolId = 'ships-globe'; + const haloId = 'ships-globe-halo'; + const outlineId = 'ships-globe-outline'; + const clickedRadiusDeg2 = Math.pow(0.08, 2); + + const onClick = (e: maplibregl.MapMouseEvent) => { + try { + const layerIds = [symbolId, haloId, outlineId].filter((id) => map.getLayer(id)); + let feats: unknown[] = []; + if (layerIds.length > 0) { + try { + feats = map.queryRenderedFeatures(e.point, { layers: layerIds }) as unknown[]; + } catch { + feats = []; + } + } + const f = feats?.[0]; + const props = ((f as { properties?: Record } | undefined)?.properties || {}) as Record< + string, + unknown + >; + const mmsi = Number(props.mmsi); + if (Number.isFinite(mmsi)) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(mmsi); + return; + } + onSelectMmsi(mmsi); + return; + } + + const clicked = { lat: e.lngLat.lat, lon: e.lngLat.lng }; + const cosLat = Math.cos(clicked.lat * DEG2RAD); + let bestMmsi: number | null = null; + let bestD2 = Number.POSITIVE_INFINITY; + for (const t of targets) { + if (!isFiniteNumber(t.lat) || !isFiniteNumber(t.lon)) continue; + const dLon = (clicked.lon - t.lon) * cosLat; + const dLat = clicked.lat - t.lat; + const d2 = dLon * dLon + dLat * dLat; + if (d2 <= clickedRadiusDeg2 && d2 < bestD2) { + bestD2 = d2; + bestMmsi = t.mmsi; + } + } + if (bestMmsi != null) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(bestMmsi); + return; + } + onSelectMmsi(bestMmsi); + return; + } + } catch { + // ignore + } + onSelectMmsi(null); + }; + + map.on('click', onClick); + return () => { + try { + map.off('click', onClick); + } catch { + // ignore + } + }; + }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts new file mode 100644 index 0000000..26fb2de --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, type SetStateAction } from 'react'; +import maplibregl, { type StyleSpecification } from 'maplibre-gl'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; +import type { BaseMapId, MapProjectionId } from '../types'; +import { DECK_VIEW_ID } from '../constants'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { ensureSeamarkOverlay } from '../layers/seamark'; +import { resolveMapStyle } from '../layers/bathymetry'; +import { clearGlobeNativeLayers } from '../lib/layerHelpers'; + +export function useMapInit( + containerRef: MutableRefObject, + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + overlayInteractionRef: MutableRefObject, + globeDeckLayerRef: MutableRefObject, + baseMapRef: MutableRefObject, + projectionRef: MutableRefObject, + opts: { + baseMap: BaseMapId; + projection: MapProjectionId; + showSeamark: boolean; + onViewBboxChange?: (bbox: [number, number, number, number]) => void; + setMapSyncEpoch: Dispatch>; + }, +) { + const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts; + const showSeamarkRef = useRef(showSeamark); + useEffect(() => { + showSeamarkRef.current = showSeamark; + }, [showSeamark]); + + const ensureMercatorOverlay = 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 clearGlobeNativeLayersCb = useCallback(() => { + const map = mapRef.current; + if (!map) return; + clearGlobeNativeLayers(map); + }, []); + + const pulseMapSync = useCallback(() => { + setMapSyncEpoch((prev) => prev + 1); + requestAnimationFrame(() => { + kickRepaint(mapRef.current); + setMapSyncEpoch((prev) => prev + 1); + }); + }, [setMapSyncEpoch]); + + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + let map: maplibregl.Map | null = null; + let cancelled = false; + const controller = new AbortController(); + + (async () => { + let style: string | StyleSpecification = '/map/styles/osm-seamark.json'; + try { + style = await resolveMapStyle(baseMapRef.current, controller.signal); + } catch (e) { + console.warn('Map style init failed, falling back to local raster style:', e); + style = '/map/styles/osm-seamark.json'; + } + if (cancelled || !containerRef.current) return; + + map = new maplibregl.Map({ + container: containerRef.current, + style, + center: [126.5, 34.2], + zoom: 7, + pitch: 45, + bearing: 0, + maxPitch: 85, + dragRotate: true, + pitchWithRotate: true, + touchPitch: true, + scrollZoom: { around: 'center' }, + }); + + map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); + + mapRef.current = map; + + if (projectionRef.current === 'mercator') { + const overlay = ensureMercatorOverlay(); + if (!overlay) return; + overlayRef.current = overlay; + } else { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: 'deck-globe', + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } + + function applyProjection() { + if (!map) return; + const next = projectionRef.current; + if (next === 'mercator') return; + try { + map.setProjection({ type: next }); + map.setRenderWorldCopies(next !== 'globe'); + } catch (e) { + console.warn('Projection apply failed:', e); + } + } + + onMapStyleReady(map, () => { + applyProjection(); + const deckLayer = globeDeckLayerRef.current; + if (projectionRef.current === 'globe' && deckLayer && !map!.getLayer(deckLayer.id)) { + try { + map!.addLayer(deckLayer); + } catch { + // ignore + } + } + if (!showSeamarkRef.current) return; + try { + ensureSeamarkOverlay(map!, 'bathymetry-lines'); + } catch { + // ignore + } + }); + + const emitBbox = () => { + const cb = onViewBboxChange; + if (!cb || !map) return; + const b = map.getBounds(); + cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]); + }; + map.on('load', emitBbox); + map.on('moveend', emitBbox); + + map.once('load', () => { + if (showSeamarkRef.current) { + try { + ensureSeamarkOverlay(map!, 'bathymetry-lines'); + } catch { + // ignore + } + try { + const opacity = showSeamarkRef.current ? 0.85 : 0; + map!.setPaintProperty('seamark', 'raster-opacity', opacity); + } catch { + // ignore + } + } + }); + })(); + + return () => { + cancelled = true; + controller.abort(); + + try { + globeDeckLayerRef.current?.requestFinalize(); + } catch { + // ignore + } + + if (map) { + map.remove(); + map = null; + } + if (overlayRef.current) { + overlayRef.current.finalize(); + overlayRef.current = null; + } + if (overlayInteractionRef.current) { + overlayInteractionRef.current.finalize(); + overlayInteractionRef.current = null; + } + globeDeckLayerRef.current = null; + mapRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { ensureMercatorOverlay, clearGlobeNativeLayers: clearGlobeNativeLayersCb, pulseMapSync }; +} diff --git a/apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts b/apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts new file mode 100644 index 0000000..70a057f --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/usePredictionVectors.ts @@ -0,0 +1,210 @@ +import { useEffect, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, GeoJSONSourceSpecification, LayerSpecification } from 'maplibre-gl'; +import type { AisTarget } from '../../../entities/aisTarget/model/types'; +import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { Map3DSettings, MapProjectionId } from '../types'; +import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from '../../../shared/lib/map/palette'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; +import { destinationPointLngLat } from '../lib/geometry'; +import { isFiniteNumber } from '../lib/setUtils'; +import { toValidBearingDeg, lightenColor } from '../lib/shipUtils'; + +export function usePredictionVectors( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + overlays: MapToggleState; + settings: Map3DSettings; + shipData: AisTarget[]; + legacyHits: Map | null | undefined; + selectedMmsi: number | null; + externalHighlightedSetRef: Set; + projection: MapProjectionId; + baseMap: string; + mapSyncEpoch: number; + }, +) { + const { overlays, settings, shipData, legacyHits, selectedMmsi, externalHighlightedSetRef, projection, baseMap, mapSyncEpoch } = opts; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'predict-vectors-src'; + const outlineId = 'predict-vectors-outline'; + const lineId = 'predict-vectors'; + const hlOutlineId = 'predict-vectors-hl-outline'; + const hlId = 'predict-vectors-hl'; + + const ensure = () => { + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + const visibility = overlays.predictVectors ? 'visible' : 'none'; + + const horizonMinutes = 15; + const horizonSeconds = horizonMinutes * 60; + const metersPerSecondPerKnot = 0.514444; + + const features: GeoJSON.Feature[] = []; + if (overlays.predictVectors && settings.showShips && shipData.length > 0) { + for (const t of shipData) { + const legacy = legacyHits?.get(t.mmsi) ?? null; + const isTarget = !!legacy; + const isSelected = selectedMmsi != null && t.mmsi === selectedMmsi; + const isPinnedHighlight = externalHighlightedSetRef.has(t.mmsi); + if (!isTarget && !isSelected && !isPinnedHighlight) continue; + + const sog = isFiniteNumber(t.sog) ? t.sog : null; + const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading); + if (sog == null || bearing == null) continue; + if (sog < 0.2) continue; + + const distM = sog * metersPerSecondPerKnot * horizonSeconds; + if (!Number.isFinite(distM) || distM <= 0) continue; + + const to = destinationPointLngLat([t.lon, t.lat], bearing, distM); + + const baseRgb = isTarget + ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ''] ?? OTHER_AIS_SPEED_RGB.moving + : OTHER_AIS_SPEED_RGB.moving; + const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62); + const alpha = isTarget ? 0.72 : 0.52; + const alphaHl = isTarget ? 0.92 : 0.84; + const hl = isSelected || isPinnedHighlight ? 1 : 0; + + features.push({ + type: 'Feature', + id: `pred-${t.mmsi}`, + geometry: { type: 'LineString', coordinates: [[t.lon, t.lat], to] }, + properties: { + mmsi: t.mmsi, + minutes: horizonMinutes, + sog, + cog: bearing, + target: isTarget ? 1 : 0, + hl, + color: rgbaCss(rgb, alpha), + colorHl: rgbaCss(rgb, alphaHl), + }, + }); + } + } + + const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }; + + 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('Prediction vector source setup failed:', e); + return; + } + + const ensureLayer = (id: string, paint: LayerSpecification['paint'], filter: unknown[]) => { + if (!map.getLayer(id)) { + try { + map.addLayer( + { + id, + type: 'line', + source: srcId, + filter: filter as never, + layout: { + visibility, + 'line-cap': 'round', + 'line-join': 'round', + }, + paint, + } as unknown as LayerSpecification, + undefined, + ); + } catch (e) { + console.warn('Prediction vector layer add failed:', e); + } + } else { + try { + map.setLayoutProperty(id, 'visibility', visibility); + map.setFilter(id, filter as never); + if (paint && typeof paint === 'object') { + for (const [key, value] of Object.entries(paint)) { + map.setPaintProperty(id, key as never, value as never); + } + } + } catch { + // ignore + } + } + }; + + const baseFilter = ['==', ['to-number', ['get', 'hl'], 0], 0] as unknown as unknown[]; + const hlFilter = ['==', ['to-number', ['get', 'hl'], 0], 1] as unknown as unknown[]; + + ensureLayer( + outlineId, + { + 'line-color': 'rgba(2,6,23,0.86)', + 'line-width': 4.8, + 'line-opacity': 1, + 'line-blur': 0.2, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + baseFilter, + ); + ensureLayer( + lineId, + { + 'line-color': ['coalesce', ['get', 'color'], 'rgba(226,232,240,0.62)'] as never, + 'line-width': 2.4, + 'line-opacity': 1, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + baseFilter, + ); + ensureLayer( + hlOutlineId, + { + 'line-color': 'rgba(2,6,23,0.92)', + 'line-width': 6.4, + 'line-opacity': 1, + 'line-blur': 0.25, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + hlFilter, + ); + ensureLayer( + hlId, + { + 'line-color': ['coalesce', ['get', 'colorHl'], ['get', 'color'], 'rgba(241,245,249,0.92)'] as never, + 'line-width': 3.6, + 'line-opacity': 1, + 'line-dasharray': [1.2, 1.8] as never, + } as never, + hlFilter, + ); + + reorderGlobeFeatureLayers(); + kickRepaint(map); + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [ + overlays.predictVectors, + settings.showShips, + shipData, + legacyHits, + selectedMmsi, + externalHighlightedSetRef, + projection, + baseMap, + mapSyncEpoch, + reorderGlobeFeatureLayers, + ]); +} diff --git a/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts new file mode 100644 index 0000000..ec1e0ce --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; +import type { MapProjectionId } from '../types'; +import { DECK_VIEW_ID } from '../constants'; +import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore'; +import { removeLayerIfExists } from '../lib/layerHelpers'; + +export function useProjectionToggle( + mapRef: MutableRefObject, + overlayRef: MutableRefObject, + overlayInteractionRef: MutableRefObject, + globeDeckLayerRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + opts: { + projection: MapProjectionId; + clearGlobeNativeLayers: () => void; + ensureMercatorOverlay: () => MapboxOverlay | null; + onProjectionLoadingChange?: (loading: boolean) => void; + pulseMapSync: () => void; + setMapSyncEpoch: (updater: (prev: number) => number) => void; + }, +): () => void { + const { projection, clearGlobeNativeLayers, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts; + + const projectionBusyTokenRef = useRef(0); + const projectionBusyTimerRef = useRef | null>(null); + const projectionPrevRef = useRef(projection); + const projectionRef = useRef(projection); + + useEffect(() => { + projectionRef.current = projection; + }, [projection]); + + const clearProjectionBusyTimer = useCallback(() => { + if (projectionBusyTimerRef.current == null) return; + clearTimeout(projectionBusyTimerRef.current); + projectionBusyTimerRef.current = null; + }, []); + + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const endProjectionLoading = useCallback(() => { + if (!projectionBusyRef.current) return; + projectionBusyRef.current = false; + clearProjectionBusyTimer(); + if (onProjectionLoadingChange) { + onProjectionLoadingChange(false); + } + setMapSyncEpoch((prev) => prev + 1); + kickRepaint(mapRef.current); + }, [clearProjectionBusyTimer, onProjectionLoadingChange, setMapSyncEpoch]); + + const setProjectionLoading = useCallback( + // eslint-disable-next-line react-hooks/preserve-manual-memoization + (loading: boolean) => { + if (projectionBusyRef.current === loading) return; + if (!loading) { + endProjectionLoading(); + return; + } + + clearProjectionBusyTimer(); + projectionBusyRef.current = true; + const token = ++projectionBusyTokenRef.current; + if (onProjectionLoadingChange) { + onProjectionLoadingChange(true); + } + + projectionBusyTimerRef.current = setTimeout(() => { + if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return; + console.debug('Projection loading fallback timeout reached.'); + endProjectionLoading(); + }, 4000); + }, + [clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange], + ); + + useEffect(() => { + return () => { + clearProjectionBusyTimer(); + endProjectionLoading(); + }; + }, [clearProjectionBusyTimer, endProjectionLoading]); + + // eslint-disable-next-line react-hooks/preserve-manual-memoization + const reorderGlobeFeatureLayers = useCallback(() => { + const map = mapRef.current; + if (!map || projectionRef.current !== 'globe') return; + if (projectionBusyRef.current) return; + if (!map.isStyleLoaded()) return; + + const ordering = [ + 'zones-fill', + 'zones-line', + 'zones-label', + 'predict-vectors-outline', + 'predict-vectors', + 'predict-vectors-hl-outline', + 'predict-vectors-hl', + 'ships-globe-halo', + 'ships-globe-outline', + 'ships-globe', + 'ships-globe-label', + 'ships-globe-hover-halo', + 'ships-globe-hover-outline', + 'ships-globe-hover', + 'pair-lines-ml', + 'fc-lines-ml', + 'pair-range-ml', + 'fleet-circles-ml-fill', + 'fleet-circles-ml', + ]; + + for (const layerId of ordering) { + try { + if (map.getLayer(layerId)) map.moveLayer(layerId); + } catch { + // ignore + } + } + + kickRepaint(map); + }, []); + + // Projection toggle (mercator <-> globe) + useEffect(() => { + const map = mapRef.current; + if (!map) return; + let cancelled = false; + let retries = 0; + const maxRetries = 18; + const isTransition = projectionPrevRef.current !== projection; + projectionPrevRef.current = projection; + let settleScheduled = false; + let settleCleanup: (() => void) | null = null; + + const startProjectionSettle = () => { + if (!isTransition || settleScheduled) return; + settleScheduled = true; + + const finalize = () => { + if (!cancelled && isTransition) setProjectionLoading(false); + }; + + const finalizeSoon = () => { + if (cancelled || !isTransition || projectionBusyRef.current === false) return; + if (!map.isStyleLoaded()) { + requestAnimationFrame(finalizeSoon); + return; + } + requestAnimationFrame(finalize); + }; + + const onIdle = () => finalizeSoon(); + try { + map.on('idle', onIdle); + const styleReadyCleanup = onMapStyleReady(map, finalizeSoon); + settleCleanup = () => { + map.off('idle', onIdle); + styleReadyCleanup(); + }; + } catch { + requestAnimationFrame(finalize); + settleCleanup = null; + } + + finalizeSoon(); + }; + + if (isTransition) setProjectionLoading(true); + + 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 = () => { + const current = globeDeckLayerRef.current; + if (!current) return; + removeLayerIfExists(map, current.id); + try { + current.requestFinalize(); + } catch { + // ignore + } + globeDeckLayerRef.current = null; + }; + + const syncProjectionAndDeck = () => { + if (cancelled) return; + if (!isTransition) { + return; + } + + if (!map.isStyleLoaded()) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + } + return; + } + + const next = projection; + const currentProjection = extractProjectionType(map); + const shouldSwitchProjection = currentProjection !== next; + + if (projection === 'globe') { + disposeMercatorOverlays(); + clearGlobeNativeLayers(); + } else { + disposeGlobeDeckLayer(); + clearGlobeNativeLayers(); + } + + try { + if (shouldSwitchProjection) { + map.setProjection({ type: next }); + } + map.setRenderWorldCopies(next !== 'globe'); + if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } + } catch (e) { + if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } + if (isTransition) setProjectionLoading(false); + console.warn('Projection switch failed:', e); + } + + if (projection === 'globe') { + disposeGlobeDeckLayer(); + + if (!globeDeckLayerRef.current) { + globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ + id: 'deck-globe', + viewId: DECK_VIEW_ID, + deckProps: { layers: [] }, + }); + } + + const layer = globeDeckLayerRef.current; + const layerId = layer?.id; + if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) { + try { + map.addLayer(layer); + } catch { + // ignore + } + if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } + } + } else { + disposeGlobeDeckLayer(); + ensureMercatorOverlay(); + } + + reorderGlobeFeatureLayers(); + kickRepaint(map); + try { + map.resize(); + } catch { + // ignore + } + if (isTransition) { + startProjectionSettle(); + } + pulseMapSync(); + }; + + if (!isTransition) return; + + if (map.isStyleLoaded()) syncProjectionAndDeck(); + else { + const stop = onMapStyleReady(map, syncProjectionAndDeck); + return () => { + cancelled = true; + if (settleCleanup) settleCleanup(); + stop(); + if (isTransition) setProjectionLoading(false); + }; + } + + return () => { + cancelled = true; + if (settleCleanup) settleCleanup(); + if (isTransition) setProjectionLoading(false); + }; + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]); + + return reorderGlobeFeatureLayers; +} diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts new file mode 100644 index 0000000..e1f19fd --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -0,0 +1,230 @@ +import { useEffect, type MutableRefObject } from 'react'; +import maplibregl, { + type GeoJSONSource, + type GeoJSONSourceSpecification, + type LayerSpecification, +} from 'maplibre-gl'; +import type { ZoneId } from '../../../entities/zone/model/meta'; +import { ZONE_META } from '../../../entities/zone/model/meta'; +import type { ZonesGeoJson } from '../../../entities/zone/api/useZones'; +import type { MapToggleState } from '../../../features/mapToggles/MapToggles'; +import type { BaseMapId, MapProjectionId } from '../types'; +import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; + +export function useZonesLayer( + mapRef: MutableRefObject, + projectionBusyRef: MutableRefObject, + reorderGlobeFeatureLayers: () => void, + opts: { + zones: ZonesGeoJson | null; + overlays: MapToggleState; + projection: MapProjectionId; + baseMap: BaseMapId; + hoveredZoneId: string | null; + mapSyncEpoch: number; + }, +) { + const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts; + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const srcId = 'zones-src'; + const fillId = 'zones-fill'; + const lineId = 'zones-line'; + const labelId = 'zones-label'; + + const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneColorExpr.push(k, ZONE_META[k].color); + } + zoneColorExpr.push('#3B82F6'); + const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]]; + for (const k of Object.keys(ZONE_META) as ZoneId[]) { + zoneLabelExpr.push(k, ZONE_META[k].name); + } + zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']); + + const ensure = () => { + if (projectionBusyRef.current) return; + const visibility = overlays.zones ? 'visible' : 'none'; + try { + if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility); + } catch { + // ignore + } + try { + if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility); + } catch { + // ignore + } + try { + if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility); + } catch { + // ignore + } + + if (!zones) return; + if (!map.isStyleLoaded()) return; + + try { + const existing = map.getSource(srcId) as GeoJSONSource | undefined; + if (existing) { + existing.setData(zones); + } else { + map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification); + } + + const style = map.getStyle(); + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as + | { id?: string } + | undefined; + const before = map.getLayer('deck-globe') + ? 'deck-globe' + : map.getLayer('ships') + ? 'ships' + : map.getLayer('seamark') + ? 'seamark' + : firstSymbol?.id; + + const zoneMatchExpr = + hoveredZoneId !== null + ? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[]) + : false; + const zoneLineWidthExpr = hoveredZoneId + ? ([ + 'interpolate', + ['linear'], + ['zoom'], + 4, + ['case', zoneMatchExpr, 1.6, 0.8], + 10, + ['case', zoneMatchExpr, 2.0, 1.4], + 14, + ['case', zoneMatchExpr, 2.8, 2.1], + ] as unknown as never) + : (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never); + + if (map.getLayer(fillId)) { + try { + map.setPaintProperty( + fillId, + 'fill-opacity', + hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12, + ); + } catch { + // ignore + } + } + + if (map.getLayer(lineId)) { + try { + map.setPaintProperty( + lineId, + 'line-color', + hoveredZoneId + ? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never) + : (zoneColorExpr as never), + ); + } catch { + // ignore + } + try { + map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85); + } catch { + // ignore + } + try { + map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr); + } catch { + // ignore + } + } + + if (!map.getLayer(fillId)) { + map.addLayer( + { + id: fillId, + type: 'fill', + source: srcId, + paint: { + 'fill-color': zoneColorExpr as never, + 'fill-opacity': hoveredZoneId + ? ([ + 'case', + zoneMatchExpr, + 0.24, + 0.1, + ] as unknown as number) + : 0.12, + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + + if (!map.getLayer(lineId)) { + map.addLayer( + { + id: lineId, + type: 'line', + source: srcId, + paint: { + 'line-color': hoveredZoneId + ? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never) + : (zoneColorExpr as never), + 'line-opacity': hoveredZoneId + ? (['case', zoneMatchExpr, 1, 0.85] as never) + : 0.85, + 'line-width': zoneLineWidthExpr, + }, + layout: { visibility }, + } as unknown as LayerSpecification, + before, + ); + } + + if (!map.getLayer(labelId)) { + map.addLayer( + { + id: labelId, + type: 'symbol', + source: srcId, + layout: { + visibility, + 'symbol-placement': 'point', + 'text-field': zoneLabelExpr as never, + 'text-size': 11, + 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-anchor': 'top', + 'text-offset': [0, 0.35], + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': '#dbeafe', + 'text-halo-color': 'rgba(2,6,23,0.85)', + 'text-halo-width': 1.2, + 'text-halo-blur': 0.8, + }, + } as unknown as LayerSpecification, + undefined, + ); + } + } catch (e) { + console.warn('Zones layer setup failed:', e); + } finally { + reorderGlobeFeatureLayers(); + kickRepaint(map); + } + }; + + const stop = onMapStyleReady(map, ensure); + return () => { + stop(); + }; + }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]); +} diff --git a/apps/web/src/widgets/map3d/lib/layerHelpers.ts b/apps/web/src/widgets/map3d/lib/layerHelpers.ts index 7eecc45..ff62d63 100644 --- a/apps/web/src/widgets/map3d/lib/layerHelpers.ts +++ b/apps/web/src/widgets/map3d/lib/layerHelpers.ts @@ -3,6 +3,62 @@ import maplibregl, { type LayerSpecification, } from 'maplibre-gl'; +export function removeLayerIfExists(map: maplibregl.Map, layerId: string | null | undefined) { + if (!layerId) return; + try { + if (map.getLayer(layerId)) { + map.removeLayer(layerId); + } + } catch { + // ignore + } +} + +export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) { + try { + if (map.getSource(sourceId)) { + map.removeSource(sourceId); + } + } catch { + // ignore + } +} + +const GLOBE_NATIVE_LAYER_IDS = [ + 'ships-globe-halo', + 'ships-globe-outline', + 'ships-globe', + 'ships-globe-label', + 'ships-globe-hover-halo', + 'ships-globe-hover-outline', + 'ships-globe-hover', + 'pair-lines-ml', + 'fc-lines-ml', + 'fleet-circles-ml-fill', + 'fleet-circles-ml', + 'pair-range-ml', + 'deck-globe', +]; + +const GLOBE_NATIVE_SOURCE_IDS = [ + 'ships-globe-src', + 'ships-globe-hover-src', + 'pair-lines-ml-src', + 'fc-lines-ml-src', + 'fleet-circles-ml-src', + 'fleet-circles-ml-fill-src', + 'pair-range-ml-src', +]; + +export function clearGlobeNativeLayers(map: maplibregl.Map) { + for (const id of GLOBE_NATIVE_LAYER_IDS) { + removeLayerIfExists(map, id); + } + for (const id of GLOBE_NATIVE_SOURCE_IDS) { + removeSourceIfExists(map, id); + } +} + export function ensureGeoJsonSource( map: maplibregl.Map, sourceId: string,