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, type VectorSourceSpecification, } from "maplibre-gl"; import { useEffect, useMemo, useRef, useState } from "react"; import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; import type { ZonesGeoJson } from "../../entities/zone/api/useZones"; import type { ZoneId } from "../../entities/zone/model/meta"; import { ZONE_META } from "../../entities/zone/model/meta"; import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types"; import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer"; export type Map3DSettings = { showSeamark: boolean; showShips: boolean; showDensity: boolean; }; export type BaseMapId = "enhanced" | "legacy"; export type MapProjectionId = "mercator" | "globe"; type Props = { targets: AisTarget[]; zones: ZonesGeoJson | null; selectedMmsi: number | null; settings: Map3DSettings; baseMap: BaseMapId; projection: MapProjectionId; overlays: MapToggleState; onSelectMmsi: (mmsi: number | null) => void; onViewBboxChange?: (bbox: [number, number, number, number]) => void; legacyHits?: Map | null; pairLinks?: PairLink[]; fcLinks?: FcLink[]; fleetCircles?: FleetCircle[]; }; const SHIP_ICON_MAPPING = { ship: { x: 0, y: 0, width: 128, height: 128, anchorX: 64, anchorY: 64, mask: true, }, } as const; function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } function kickRepaint(map: maplibregl.Map | null) { if (!map) return; try { map.triggerRepaint(); } catch { // ignore } try { requestAnimationFrame(() => { try { map.triggerRepaint(); } catch { // ignore } }); requestAnimationFrame(() => { try { map.triggerRepaint(); } catch { // ignore } }); } catch { // ignore (e.g., non-browser env) } } function onMapStyleReady(map: maplibregl.Map, callback: () => void) { if (map.isStyleLoaded()) { callback(); return () => { // noop }; } let fired = false; const runOnce = () => { if (fired || !map.isStyleLoaded()) return; fired = true; callback(); try { map.off("style.load", runOnce); map.off("styledata", runOnce); map.off("idle", runOnce); } catch { // ignore } }; map.on("style.load", runOnce); map.on("styledata", runOnce); map.on("idle", runOnce); return () => { if (fired) return; fired = true; try { map.off("style.load", runOnce); map.off("styledata", runOnce); map.off("idle", runOnce); } catch { // ignore } }; } const DEG2RAD = Math.PI / 180; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); function rgbToHex(rgb: [number, number, number]) { const toHex = (v: number) => { const clamped = Math.max(0, Math.min(255, Math.round(v))); return clamped.toString(16).padStart(2, "0"); }; return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`; } function lightenColor(rgb: [number, number, number], ratio = 0.32) { const out = rgb.map((v) => Math.round(v + (255 - v) * ratio) as number) as [number, number, number]; return out; } function getGlobeShipColor({ selected, legacy, sog, }: { selected: boolean; legacy: string | null; sog: number | null; }) { if (selected) return "rgba(255,255,255,0.98)"; if (legacy) { const rgb = LEGACY_CODE_COLORS[legacy]; if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); } if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.75)"; if (sog >= 10) return "#3b82f6"; if (sog >= 1) return "#22c55e"; return "rgba(100,116,139,0.75)"; } const LEGACY_CODE_COLORS: Record = { PT: [30, 64, 175], // #1e40af "PT-S": [234, 88, 12], // #ea580c GN: [16, 185, 129], // #10b981 OT: [139, 92, 246], // #8b5cf6 PS: [239, 68, 68], // #ef4444 FC: [245, 158, 11], // #f59e0b }; const DEPTH_DISABLED_PARAMS = { // In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated. // For 2D overlays like zones/icons/halos we want stable painter's-order rendering // to avoid z-fighting flicker when layers overlap at (or near) the same z. depthCompare: "always", depthWriteEnabled: false, } as const; const GLOBE_OVERLAY_PARAMS = { // In globe mode we want depth-testing against the globe so features on the far side don't draw through. // Still disable depth writes so our overlays don't interfere with each other. depthCompare: "less-equal", depthWriteEnabled: false, } as const; function getMapTilerKey(): string | null { const k = import.meta.env.VITE_MAPTILER_KEY; if (typeof k !== "string") return null; const v = k.trim(); return v ? v : null; } function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) { const srcId = "seamark"; const layerId = "seamark"; if (!map.getSource(srcId)) { map.addSource(srcId, { type: "raster", tiles: ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"], tileSize: 256, attribution: "© OpenSeaMap contributors", }); } if (!map.getLayer(layerId)) { const layer: LayerSpecification = { id: layerId, type: "raster", source: srcId, paint: { "raster-opacity": 0.85 }, } as unknown as LayerSpecification; // By default, MapLibre adds new layers to the top. // For readability we want seamarks above bathymetry fill, but below bathymetry lines/labels. const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined; map.addLayer(layer, before); } } function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) { // NOTE: Vector-only bathymetry injection. // Raster/DEM hillshade was intentionally removed for now because it caused ocean flicker // and extra PNG tile traffic under globe projection in our setup. const oceanSourceId = "maptiler-ocean"; if (!style.sources) style.sources = {} as StyleSpecification["sources"]; if (!style.layers) style.layers = []; if (!style.sources[oceanSourceId]) { style.sources[oceanSourceId] = { type: "vector", url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`, } satisfies VectorSourceSpecification as unknown as StyleSpecification["sources"][string]; } const depth = ["to-number", ["get", "depth"]] as unknown as number[]; const depthLabel = ["concat", ["to-string", ["*", depth, -1]], "m"] as unknown as string[]; const bathyFillColor = [ "interpolate", ["linear"], depth, -11000, "#00040b", -8000, "#010610", -6000, "#020816", -4000, "#030c1c", -2000, "#041022", -1000, "#051529", -500, "#061a30", -200, "#071f36", -100, "#08263d", -50, "#092c44", -20, "#0a334b", 0, "#0b3a53", ] as const; const bathyFill: LayerSpecification = { id: "bathymetry-fill", type: "fill", source: oceanSourceId, "source-layer": "contour", // Very low zoom tiles can contain extremely complex polygons (coastline/detail), // which may exceed MapLibre's per-segment 16-bit vertex limit and render incorrectly. // We keep the fill starting at a more reasonable zoom. minzoom: 6, maxzoom: 12, paint: { // Dark-mode friendly palette (shallow = slightly brighter; deep = near-black). "fill-color": bathyFillColor, "fill-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.9, 6, 0.86, 10, 0.78], }, } as unknown as LayerSpecification; const bathyBandBorders: LayerSpecification = { id: "bathymetry-borders", type: "line", source: oceanSourceId, "source-layer": "contour", minzoom: 6, maxzoom: 14, paint: { "line-color": "rgba(255,255,255,0.06)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22], "line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 0.2], "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.2, 8, 0.35, 12, 0.6], }, } as unknown as LayerSpecification; const bathyLinesMinor: LayerSpecification = { id: "bathymetry-lines", type: "line", source: oceanSourceId, "source-layer": "contour_line", minzoom: 8, paint: { "line-color": [ "interpolate", ["linear"], depth, -11000, "rgba(255,255,255,0.04)", -6000, "rgba(255,255,255,0.05)", -2000, "rgba(255,255,255,0.07)", 0, "rgba(255,255,255,0.10)", ], "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.18, 10, 0.22, 12, 0.28], "line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 11, 0.3], "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.35, 10, 0.55, 12, 0.85], }, } as unknown as LayerSpecification; const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500]; const bathyMajorDepthFilter: unknown[] = [ "in", ["to-number", ["get", "depth"]], ["literal", majorDepths], ] as unknown[]; const bathyLinesMajor: LayerSpecification = { id: "bathymetry-lines-major", type: "line", source: oceanSourceId, "source-layer": "contour_line", minzoom: 8, maxzoom: 14, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.16)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34], "line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.4, 11, 0.2], "line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.6, 10, 0.95, 12, 1.3], }, } as unknown as LayerSpecification; const bathyBandBordersMajor: LayerSpecification = { id: "bathymetry-borders-major", type: "line", source: oceanSourceId, "source-layer": "contour", minzoom: 4, maxzoom: 14, filter: bathyMajorDepthFilter as unknown as unknown[], paint: { "line-color": "rgba(255,255,255,0.14)", "line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26], "line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.3, 10, 0.15], "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.35, 8, 0.55, 12, 0.85], }, } as unknown as LayerSpecification; const bathyLabels: LayerSpecification = { id: "bathymetry-labels", type: "symbol", source: oceanSourceId, "source-layer": "contour_line", minzoom: 10, filter: bathyMajorDepthFilter as unknown as unknown[], layout: { "symbol-placement": "line", "text-field": depthLabel, "text-font": ["Noto Sans Regular", "Open Sans Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 10, 10, 12, 12], "text-allow-overlap": false, "text-padding": 2, "text-rotation-alignment": "map", }, paint: { "text-color": "rgba(226,232,240,0.72)", "text-halo-color": "rgba(2,6,23,0.82)", "text-halo-width": 1.0, "text-halo-blur": 0.6, }, } as unknown as LayerSpecification; const landformLabels: LayerSpecification = { id: "bathymetry-landforms", type: "symbol", source: oceanSourceId, "source-layer": "landform", minzoom: 8, filter: ["has", "name"] as unknown as unknown[], layout: { "text-field": ["get", "name"] as unknown as unknown[], "text-font": ["Noto Sans Italic", "Noto Sans Regular", "Open Sans Italic", "Open Sans Regular"], "text-size": ["interpolate", ["linear"], ["zoom"], 8, 11, 10, 12, 12, 13], "text-allow-overlap": false, "text-anchor": "center", "text-offset": [0, 0.0], }, paint: { "text-color": "rgba(148,163,184,0.70)", "text-halo-color": "rgba(2,6,23,0.85)", "text-halo-width": 1.0, "text-halo-blur": 0.7, }, } as unknown as LayerSpecification; // Insert before the first symbol layer (keep labels on top), otherwise append. const layers = style.layers as LayerSpecification[]; const symbolIndex = layers.findIndex((l) => l.type === "symbol"); const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; const toInsert = [ bathyFill, bathyBandBorders, bathyBandBordersMajor, bathyLinesMinor, bathyLinesMajor, bathyLabels, landformLabels, ].filter( (l) => !layers.some((x) => x.id === l.id), ); if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert); } async function resolveInitialMapStyle(signal: AbortSignal): Promise { const key = getMapTilerKey(); if (!key) return "/map/styles/osm-seamark.json"; const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || "dataviz-dark").trim(); const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`; const res = await fetch(styleUrl, { signal, headers: { accept: "application/json" } }); if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`); const json = (await res.json()) as StyleSpecification; injectOceanBathymetryLayers(json, key); return json; } async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise { if (baseMap === "legacy") return "/map/styles/carto-dark.json"; return resolveInitialMapStyle(signal); } function getShipColor( t: AisTarget, selectedMmsi: number | null, legacyShipCode: string | null, ): [number, number, number, number] { if (selectedMmsi && t.mmsi === selectedMmsi) return [255, 255, 255, 255]; if (legacyShipCode) { const rgb = LEGACY_CODE_COLORS[legacyShipCode]; if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; return [245, 158, 11, 235]; } if (!isFiniteNumber(t.sog)) return [100, 116, 139, 160]; if (t.sog >= 10) return [59, 130, 246, 220]; if (t.sog >= 1) return [34, 197, 94, 210]; return [100, 116, 139, 160]; } type DashSeg = { from: [number, number]; to: [number, number]; suspicious: boolean }; function dashifyLine(from: [number, number], to: [number, number], suspicious: boolean): DashSeg[] { // Simple dashed effect: split into segments and render every other one. const segs: DashSeg[] = []; const steps = 14; for (let i = 0; i < steps; i++) { if (i % 2 === 1) continue; const a0 = i / steps; const a1 = (i + 1) / steps; const lon0 = from[0] + (to[0] - from[0]) * a0; const lat0 = from[1] + (to[1] - from[1]) * a0; const lon1 = from[0] + (to[0] - from[0]) * a1; const lat1 = from[1] + (to[1] - from[1]) * a1; segs.push({ from: [lon0, lat0], to: [lon1, lat1], suspicious }); } return segs; } function circleRingLngLat(center: [number, number], radiusMeters: number, steps = 72): [number, number][] { const [lon0, lat0] = center; const latRad = lat0 * DEG2RAD; const cosLat = Math.max(1e-6, Math.cos(latRad)); const r = Math.max(0, radiusMeters); const ring: [number, number][] = []; for (let i = 0; i <= steps; i++) { const a = (i / steps) * Math.PI * 2; const dy = r * Math.sin(a); const dx = r * Math.cos(a); const dLat = (dy / EARTH_RADIUS_M) / DEG2RAD; const dLon = (dx / (EARTH_RADIUS_M * cosLat)) / DEG2RAD; ring.push([lon0 + dLon, lat0 + dLat]); } return ring; } type PairRangeCircle = { center: [number, number]; // [lon, lat] radiusNm: number; warn: boolean; }; const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DECK_VIEW_ID = "mapbox"; export function Map3D({ targets, zones, selectedMmsi, settings, baseMap, projection, overlays, onSelectMmsi, onViewBboxChange, legacyHits, pairLinks, fcLinks, fleetCircles, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); const globeDeckLayerRef = useRef(null); const globeShipsEpochRef = useRef(-1); const showSeamarkRef = useRef(settings.showSeamark); const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const pulseMapSync = () => { setMapSyncEpoch((prev) => prev + 1); requestAnimationFrame(() => { kickRepaint(mapRef.current); setMapSyncEpoch((prev) => prev + 1); }); }; useEffect(() => { showSeamarkRef.current = settings.showSeamark; }, [settings.showSeamark]); useEffect(() => { baseMapRef.current = baseMap; }, [baseMap]); useEffect(() => { projectionRef.current = projection; }, [projection]); // Init MapLibre + Deck.gl (single WebGL context via MapboxOverlay) useEffect(() => { if (!containerRef.current || mapRef.current) return; let map: maplibregl.Map | null = null; let overlay: MapboxOverlay | 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") { overlay = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); map.addControl(overlay); 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. if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) { try { map!.addLayer(globeDeckLayerRef.current); } 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 (overlay) { overlay.finalize(); overlay = null; } overlayRef.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 = 6; const syncProjectionAndDeck = () => { if (cancelled) return; if (!map.isStyleLoaded()) { if (!cancelled && retries < maxRetries) { retries += 1; window.requestAnimationFrame(() => syncProjectionAndDeck()); } return; } const next = projection; try { map.setProjection({ type: next }); map.setRenderWorldCopies(next !== "globe"); } catch (e) { if (!cancelled && retries < maxRetries) { retries += 1; window.requestAnimationFrame(() => syncProjectionAndDeck()); return; } console.warn("Projection switch failed:", e); } const oldOverlay = overlayRef.current; if (projection === "globe" && oldOverlay) { // Globe mode uses custom MapLibre deck layers and should fully replace Mercator overlays. try { oldOverlay.finalize(); } catch { // ignore } overlayRef.current = null; } if (projection === "globe") { // Ensure any stale layer from old mode is dropped then re-added on this projection. if (globeDeckLayerRef.current) { try { if (map.getLayer(globeDeckLayerRef.current.id)) { map.removeLayer(globeDeckLayerRef.current.id); } } catch { // ignore } } if (!globeDeckLayerRef.current) { globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ id: "deck-globe", viewId: DECK_VIEW_ID, deckProps: { layers: [] }, }); } const layer = globeDeckLayerRef.current; if (layer && map.isStyleLoaded() && !map.getLayer(layer.id)) { try { map.addLayer(layer); } catch { // ignore } } } else { // Tear down globe custom layer (if present), restore MapboxOverlay interleaved. const globeLayer = globeDeckLayerRef.current; if (globeLayer && map.getLayer(globeLayer.id)) { try { globeLayer.requestFinalize(); map.removeLayer(globeLayer.id); } catch { // ignore } } if (!overlayRef.current) { try { const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); map.addControl(next); overlayRef.current = next; } catch (e) { console.warn("Deck overlay create failed:", e); } } } // 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. kickRepaint(map); try { map.resize(); } catch { // ignore } pulseMapSync(); }; if (map.isStyleLoaded()) syncProjectionAndDeck(); else { const stop = onMapStyleReady(map, syncProjectionAndDeck); return () => { cancelled = true; stop(); }; } return () => { cancelled = true; }; }, [projection]); // 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. // Some terrain/hillshade/extrusion effects look unstable under globe and can occlude Deck overlays. useEffect(() => { const map = mapRef.current; if (!map) return; const apply = () => { if (!map.isStyleLoaded()) return; const disableBathyHeavy = projection === "globe" && baseMap === "enhanced"; const visHeavy = disableBathyHeavy ? "none" : "visible"; const disableBaseMapSea = projection === "globe"; const seaVisibility = disableBaseMapSea ? "none" : "visible"; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; // Globe + our injected bathymetry fill polygons can exceed MapLibre's per-segment vertex limit // (65535), causing broken ocean rendering. Keep globe mode stable by disabling the heavy fill. const heavyIds = [ "bathymetry-fill", "bathymetry-borders", "bathymetry-borders-major", "bathymetry-extrusion", "bathymetry-hillshade", ]; for (const id of heavyIds) { try { if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", visHeavy); } catch { // ignore } } // Vector basemap water/raster layers can flicker on globe with dense symbols/fills in this stack. // Hide them only in globe mode and restore on return. try { for (const layer of map.getStyle().layers || []) { const id = String(layer.id ?? ""); 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 && !isRaster) 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 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 ensure = () => { // 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 } 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 firstSymbol = (style.layers || []).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; if (!map.getLayer(fillId)) { map.addLayer( { id: fillId, type: "fill", source: srcId, paint: { "fill-color": zoneColorExpr as never, "fill-opacity": 0.12, }, layout: { visibility }, } as unknown as LayerSpecification, before, ); } if (!map.getLayer(lineId)) { map.addLayer( { id: lineId, type: "line", source: srcId, paint: { "line-color": zoneColorExpr as never, "line-opacity": 0.85, "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], }, layout: { visibility }, } as unknown as LayerSpecification, before, ); } } catch (e) { console.warn("Zones layer setup failed:", e); } finally { kickRepaint(map); } }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [zones, overlays.zones, projection, 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 srcId = "ships-globe-src"; const haloId = "ships-globe-halo"; const outlineId = "ships-globe-outline"; const symbolId = "ships-globe"; 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 } kickRepaint(map); }; const ensureImage = () => { if (map.hasImage(imgId)) return; const size = 96; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) return; // Simple top-down ship silhouette, pointing north. ctx.clearRect(0, 0, size, size); ctx.fillStyle = "rgba(255,255,255,1)"; ctx.beginPath(); ctx.moveTo(size / 2, 6); ctx.lineTo(size / 2 - 14, 24); ctx.lineTo(size / 2 - 18, 58); ctx.lineTo(size / 2 - 10, 88); ctx.lineTo(size / 2 + 10, 88); ctx.lineTo(size / 2 + 18, 58); ctx.lineTo(size / 2 + 14, 24); ctx.closePath(); ctx.fill(); ctx.fillRect(size / 2 - 8, 34, 16, 18); const img = ctx.getImageData(0, 0, size, size); map.addImage(imgId, img, { pixelRatio: 2, sdf: true }); }; const ensure = () => { if (!map.isStyleLoaded()) return; if (projection !== "globe" || !settings.showShips) { remove(); return; } if (globeShipsEpochRef.current !== mapSyncEpoch) { remove(); globeShipsEpochRef.current = mapSyncEpoch; } try { ensureImage(); } catch (e) { console.warn("Ship icon image setup failed:", e); } const globeShipData = targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); const geojson: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: globeShipData.map((t) => { const legacy = legacyHits?.get(t.mmsi) ?? null; const cog = isFiniteNumber(t.cog) ? t.cog : 0; const cogNorm = ((cog % 360) + 360) % 360; 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 selectedScale = selected ? 1.08 : 1; 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", id: t.mmsi, geometry: { type: "Point", coordinates: [t.lon, t.lat] }, properties: { mmsi: t.mmsi, name: t.name || "", cog: cogNorm, sog: isFiniteNumber(t.sog) ? t.sog : 0, shipColor: getGlobeShipColor({ selected, legacy: legacy?.shipCode || null, sog: isFiniteNumber(t.sog) ? t.sog : null, }), iconSize3, iconSize7, iconSize10, iconSize14, sizeScale, selected: selected ? 1 : 0, permitted: !!legacy, 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 circleRadius = [ "interpolate", ["linear"], ["zoom"], 3, 4, 7, 6, 10, 8, 14, 11, ] as const; // Put ships at the top so they're always visible (especially important under globe projection). const before = undefined; if (!map.getLayer(haloId)) { try { map.addLayer( { id: haloId, type: "circle", source: srcId, layout: { visibility }, paint: { "circle-radius": circleRadius as never, "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "circle-opacity": 0.22, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("Ship halo layer add failed:", e); } } else { try { map.setLayoutProperty(haloId, "visibility", visibility); } catch { // ignore } } if (!map.getLayer(outlineId)) { try { map.addLayer( { id: outlineId, type: "circle", source: srcId, layout: { visibility }, paint: { "circle-radius": circleRadius as never, "circle-color": "rgba(0,0,0,0)", "circle-stroke-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "circle-stroke-width": [ "case", ["boolean", ["get", "permitted"], false], ["case", ["==", ["get", "selected"], 1], 2.5, 1.6], ["case", ["==", ["get", "selected"], 1], 2.0, 0.0], ] as unknown as number[], "circle-stroke-opacity": 0.8, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("Ship outline layer add failed:", e); } } else { try { map.setLayoutProperty(outlineId, "visibility", visibility); } catch { // ignore } } if (!map.getLayer(symbolId)) { try { map.addLayer( { id: symbolId, type: "symbol", source: srcId, layout: { visibility, "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", "cog"], 0], // Keep the icon on the sea surface. "icon-rotation-alignment": "map", "icon-pitch-alignment": "viewport", }, paint: { "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], "icon-halo-color": "rgba(15,23,42,0.25)", "icon-halo-width": 1, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("Ship symbol layer add failed:", e); } } else { try { map.setLayoutProperty(symbolId, "visibility", visibility); } catch { // ignore } } // Selection is now source-data driven (`selected` property), no per-feature state update needed. kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); return () => { stop(); }; }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); // 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)) { 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) { onSelectMmsi(bestMmsi); return; } } catch { // ignore } onSelectMmsi(null); }; map.on("click", onClick); return () => { try { map.off("click", onClick); } catch { // ignore } }; }, [projection, settings.showShips, onSelectMmsi, 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.removeLayer(layerId); } catch { // ignore } try { if (map.getSource(srcId)) map.removeSource(srcId); } catch { // ignore } }; const ensure = () => { 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, idx) => ({ type: "Feature", id: `${p.aMmsi}-${p.bMmsi}-${idx}`, geometry: { type: "LineString", coordinates: [p.from, p.to] }, properties: { 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 = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : 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", ["boolean", ["get", "warn"], false], "rgba(245,158,11,0.95)", "rgba(59,130,246,0.55)", ] as never, "line-width": ["case", ["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); } } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); remove(); }; }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); 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.removeLayer(layerId); } catch { // ignore } try { if (map.getSource(srcId)) map.removeSource(srcId); } catch { // ignore } }; const ensure = () => { 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)); if (segs.length === 0) { remove(); return; } const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: segs.map((s, idx) => ({ type: "Feature", id: `fc-${idx}`, geometry: { type: "LineString", coordinates: [s.from, s.to] }, properties: { suspicious: s.suspicious }, })), }; 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 = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : 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", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)", ] as never, "line-width": 1.3, "line-opacity": 0.9, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("FC lines layer add failed:", e); } } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); remove(); }; }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = "fleet-circles-ml-src"; const layerId = "fleet-circles-ml"; 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 (!map.isStyleLoaded()) return; if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) { remove(); return; } const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: (fleetCircles || []).map((c, idx) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: "Feature", id: `fleet-${c.ownerKey}-${idx}`, geometry: { type: "LineString", coordinates: ring }, properties: { count: c.count }, }; }), }; 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("Fleet circles source setup failed:", e); return; } const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : 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": "rgba(245,158,11,0.65)", "line-width": 1.1, "line-opacity": 0.85, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("Fleet circles layer add failed:", e); } } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); remove(); }; }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); 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.removeLayer(layerId); } catch { // ignore } try { if (map.getSource(srcId)) map.removeSource(srcId); } catch { // ignore } }; const ensure = () => { 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 }); } if (ranges.length === 0) { remove(); return; } const fc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: ranges.map((c, idx) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); return { type: "Feature", id: `pair-range-${idx}`, geometry: { type: "LineString", coordinates: ring }, properties: { warn: c.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 range source setup failed:", e); return; } const before = map.getLayer("ships-globe-halo") ? "ships-globe-halo" : 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", ["boolean", ["get", "warn"], false], "rgba(245,158,11,0.75)", "rgba(59,130,246,0.45)", ] as never, "line-width": 1.0, "line-opacity": 0.85, }, } as unknown as LayerSpecification, before, ); } catch (e) { console.warn("Pair range layer add failed:", e); } } kickRepaint(map); }; const stop = onMapStyleReady(map, ensure); ensure(); return () => { stop(); remove(); }; }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]); const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); }, [targets]); const legacyTargets = useMemo(() => { if (!legacyHits) return []; return shipData.filter((t) => legacyHits.has(t.mmsi)); }, [shipData, legacyHits]); const fcDashed = useMemo(() => { const segs: DashSeg[] = []; for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious)); return segs; }, [fcLinks]); const pairRanges = useMemo(() => { const out: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn }); } return out; }, [pairLinks]); // 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; map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); }, [selectedMmsi, shipData]); // Update Deck.gl layers useEffect(() => { const map = mapRef.current; if (!map) return; const deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current; if (!deckTarget) return; const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const layers = []; if (settings.showDensity && projection !== "globe") { layers.push( new HexagonLayer({ id: "density", data: shipData, pickable: true, extruded: true, radius: 2500, elevationScale: 35, coverage: 0.92, opacity: 0.35, getPosition: (d) => [d.lon, d.lat], }), ); } if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { layers.push( new ScatterplotLayer({ id: "fleet-circles", data: fleetCircles, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", getLineWidth: 1, getLineColor: () => [245, 158, 11, 140], getPosition: (d) => d.center, }), ); } if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) { layers.push( new ScatterplotLayer({ id: "pair-range", data: pairRanges, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: "pixels", getLineWidth: () => 1, getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]), getPosition: (d) => d.center, }), ); } if (overlays.pairLines && projection !== "globe" && (pairLinks?.length ?? 0) > 0) { layers.push( new LineLayer({ id: "pair-lines", data: pairLinks, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), getWidth: (d) => (d.warn ? 2.2 : 1.4), widthUnits: "pixels", }), ); } if (overlays.fcLines && projection !== "globe" && fcDashed.length > 0) { layers.push( new LineLayer({ id: "fc-lines", data: fcDashed, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), getWidth: () => 1.3, widthUnits: "pixels", }), ); } if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { layers.push( new ScatterplotLayer({ id: "legacy-halo", data: legacyTargets, pickable: false, billboard: false, // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. parameters: overlayParams, filled: false, stroked: true, radiusUnits: "pixels", getRadius: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 22 : 16), 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], updateTriggers: { getRadius: [selectedMmsi], getLineColor: [legacyHits], }, }), ); } if (settings.showShips && projection !== "globe") { layers.push( new IconLayer({ id: "ships", data: shipData, pickable: true, // Keep icons horizontal on the sea surface when view is pitched/rotated. billboard: false, parameters: overlayParams, iconAtlas: "/assets/ship.svg", iconMapping: SHIP_ICON_MAPPING, getIcon: () => "ship", getPosition: (d) => [d.lon, d.lat] as [number, number], getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0), sizeUnits: "pixels", getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22), getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null), alphaCutoff: 0.05, updateTriggers: { getSize: [selectedMmsi], getColor: [selectedMmsi, legacyHits], }, }), ); } const deckProps = { layers, getTooltip: projection === "globe" ? undefined : (info: PickingInfo) => { if (!info.object) return null; if (info.layer && info.layer.id === "density") { // eslint-disable-next-line @typescript-eslint/no-explicit-any const o: any = info.object; const n = Array.isArray(o?.points) ? o.points.length : 0; return { text: `AIS density: ${n}` }; } // zones // 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 name = (t.name || "").trim() || "(no name)"; const legacy = legacyHits?.get(t.mmsi); const legacyHtml = legacy ? `
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
` : ""; return { html: `
${name}
MMSI: ${t.mmsi} · ${t.vesselType || "Unknown"}
SOG: ${t.sog ?? "?"} kt · COG: ${t.cog ?? "?"}°
${t.status || ""}
${t.messageTimestamp || ""}
${legacyHtml}
`, }; } const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined; const label = p?.zoneName ?? p?.zoneLabel; if (label) return { text: label }; return null; }, onClick: projection === "globe" ? undefined : (info: PickingInfo) => { if (!info.object) { onSelectMmsi(null); return; } if (info.layer && info.layer.id === "density") return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj: any = info.object; if (typeof obj.mmsi === "number") { const t = obj as AisTarget; onSelectMmsi(t.mmsi); map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); } }, } as const; if (projection === "globe") globeDeckLayerRef.current?.setProps(deckProps); else overlayRef.current?.setProps(deckProps as unknown as never); }, [ projection, shipData, baseMap, zones, selectedMmsi, overlays.zones, settings.showShips, settings.showDensity, onSelectMmsi, legacyHits, legacyTargets, overlays.pairLines, overlays.pairRange, overlays.fcLines, overlays.fleetCircles, pairLinks, pairRanges, fcDashed, fleetCircles, mapSyncEpoch, ]); return
; }