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 { useCallback, 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 { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, OVERLAY_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette"; 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; hoveredMmsiSet?: number[]; hoveredFleetMmsiSet?: number[]; hoveredPairMmsiSet?: number[]; hoveredFleetOwnerKey?: string | null; highlightedMmsiSet?: number[]; settings: Map3DSettings; baseMap: BaseMapId; projection: MapProjectionId; overlays: MapToggleState; onSelectMmsi: (mmsi: number | null) => void; onToggleHighlightMmsi?: (mmsi: number) => void; onViewBboxChange?: (bbox: [number, number, number, number]) => void; legacyHits?: Map | null; pairLinks?: PairLink[]; fcLinks?: FcLink[]; fleetCircles?: FleetCircle[]; onProjectionLoadingChange?: (loading: boolean) => void; fleetFocus?: { id: string | number; center: [number, number]; zoom?: number; }; onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; onClearFleetHover?: () => void; onHoverMmsi?: (mmsiList: number[]) => void; onClearMmsiHover?: () => void; onHoverPair?: (mmsiList: number[]) => void; onClearPairHover?: () => void; }; function toNumberSet(values: number[] | undefined | null) { const out = new Set(); if (!values) return out; for (const value of values) { if (Number.isFinite(value)) { out.add(value); } } return out; } function mergeNumberSets(...sets: Set[]) { const out = new Set(); for (const s of sets) { for (const v of s) { out.add(v); } } return out; } function makeSetSignature(values: Set) { return Array.from(values).sort((a, b) => a - b).join(","); } function toTextValue(value: unknown): string { if (value == null) return ""; return String(value).trim(); } function getZoneIdFromProps(props: Record | null | undefined): string { const safeProps = props || {}; const candidates = [ "zoneId", "zone_id", "zoneIdNo", "zoneKey", "zoneCode", "ZONE_ID", "ZONECODE", "id", ]; for (const key of candidates) { const value = toTextValue(safeProps[key]); if (value) return value; } return ""; } function getZoneDisplayNameFromProps(props: Record | null | undefined): string { const safeProps = props || {}; const nameCandidates = ["zoneName", "zoneLabel", "NAME", "name", "ZONE_NM", "label"]; for (const key of nameCandidates) { const name = toTextValue(safeProps[key]); if (name) return name; } const zoneId = getZoneIdFromProps(safeProps); if (!zoneId) return "수역"; return ZONE_META[(zoneId as ZoneId)]?.name || `수역 ${zoneId}`; } function makeOrderedPairKey(a: number, b: number) { const left = Math.trunc(Math.min(a, b)); const right = Math.trunc(Math.max(a, b)); return `${left}-${right}`; } function makePairLinkFeatureId(a: number, b: number, suffix?: string) { const pair = makeOrderedPairKey(a, b); return suffix ? `pair-${pair}-${suffix}` : `pair-${pair}`; } function makeFcSegmentFeatureId(a: number, b: number, segmentIndex: number) { const pair = makeOrderedPairKey(a, b); return `fc-${pair}-${segmentIndex}`; } function makeFleetCircleFeatureId(ownerKey: string) { return `fleet-${ownerKey}`; } function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) { if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) { return false; } const inA = ["in", ["to-number", ["get", aField]], ["literal", hoveredMmsiList]] as unknown[]; const inB = ["in", ["to-number", ["get", bField]], ["literal", hoveredMmsiList]] as unknown[]; return ["all", inA, inB] as unknown[]; } function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) { if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) { return false; } const literal = ["literal", hoveredMmsiList] as unknown[]; return [ "any", ["in", ["to-number", ["get", aField]], literal], ["in", ["to-number", ["get", bField]], literal], ] as unknown[]; } function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) { if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) { return false; } const expr = ["match", ["to-string", ["coalesce", ["get", "ownerKey"], ""]]] as unknown[]; for (const ownerKey of hoveredOwnerKeys) { expr.push(String(ownerKey), true); } expr.push(false); return expr; } function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) { return false; } const clauses = hoveredFleetMmsiList.map((mmsi) => ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], ["literal", []]]] as unknown[], ); return ["any", ...clauses] as unknown[]; } const SHIP_ICON_MAPPING = { ship: { x: 0, y: 0, width: 128, height: 128, anchorX: 64, anchorY: 64, mask: true, }, } as const; const ANCHOR_SPEED_THRESHOLD_KN = 1; const ANCHORED_SHIP_ICON_ID = "ship-globe-anchored-icon"; function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } function isAnchoredShip({ sog, cog, heading, }: { sog: number | null | undefined; cog: number | null | undefined; heading: number | null | undefined; }): boolean { if (!isFiniteNumber(sog)) return true; if (sog <= ANCHOR_SPEED_THRESHOLD_KN) return true; return toValidBearingDeg(cog) == null && toValidBearingDeg(heading) == null; } 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 | null, callback: () => void) { if (!map) { return () => { // noop }; } if (map.isStyleLoaded()) { callback(); return () => { // noop }; } let fired = false; const runOnce = () => { if (!map || 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 { if (!map) return; map.off("style.load", runOnce); map.off("styledata", runOnce); map.off("idle", runOnce); } catch { // ignore } }; } function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined { const projection = map.getProjection?.(); if (!projection || typeof projection !== "object") return undefined; const rawType = (projection as { type?: unknown; name?: unknown }).type ?? (projection as { type?: unknown; name?: unknown }).name; if (rawType === "globe") return "globe"; if (rawType === "mercator") return "mercator"; return undefined; } const DEG2RAD = Math.PI / 180; const RAD2DEG = 180 / Math.PI; // ship.svg's native "up" direction is north (0deg), // so map icon rotation can use COG directly. const GLOBE_ICON_HEADING_OFFSET_DEG = 0; const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); function getLayerId(value: unknown): string | null { if (!value || typeof value !== "object") return null; const candidate = (value as { id?: unknown }).id; return typeof candidate === "string" ? candidate : null; } function wrapLonDeg(lon: number) { // Normalize longitude into [-180, 180). const v = ((lon + 180) % 360 + 360) % 360; return v - 180; } function destinationPointLngLat( from: [number, number], // [lon, lat] bearingDeg: number, distanceMeters: number, ): [number, number] { const [lonDeg, latDeg] = from; const lat1 = latDeg * DEG2RAD; const lon1 = lonDeg * DEG2RAD; const brng = bearingDeg * DEG2RAD; const dr = Math.max(0, distanceMeters) / EARTH_RADIUS_M; if (!Number.isFinite(dr) || dr === 0) return [lonDeg, latDeg]; const sinLat1 = Math.sin(lat1); const cosLat1 = Math.cos(lat1); const sinDr = Math.sin(dr); const cosDr = Math.cos(dr); const lat2 = Math.asin(sinLat1 * cosDr + cosLat1 * sinDr * Math.cos(brng)); const lon2 = lon1 + Math.atan2( Math.sin(brng) * sinDr * cosLat1, cosDr - sinLat1 * Math.sin(lat2), ); const outLon = wrapLonDeg(lon2 * RAD2DEG); const outLat = clampNumber(lat2 * RAD2DEG, -85.0, 85.0); return [outLon, outLat]; } function sanitizeDeckLayerList(value: unknown): unknown[] { if (!Array.isArray(value)) return []; const seen = new Set(); const out: unknown[] = []; let dropped = 0; for (const layer of value) { const layerId = getLayerId(layer); if (!layerId) { dropped += 1; continue; } if (seen.has(layerId)) { dropped += 1; continue; } seen.add(layerId); out.push(layer); } if (dropped > 0 && import.meta.env.DEV) { console.warn(`Sanitized deck layer list: dropped ${dropped} invalid/duplicate entries`); } return out; } function normalizeAngleDeg(value: number, offset = 0): number { const v = value + offset; return ((v % 360) + 360) % 360; } function toValidBearingDeg(value: unknown): number | null { if (typeof value !== "number" || !Number.isFinite(value)) return null; // AIS heading uses 511 as "not available". Some feeds may also use 360 as "not available". if (value === 511) return null; if (value < 0) return null; if (value >= 360) return null; return value; } function getDisplayHeading({ cog, heading, offset = 0, }: { cog: number | null | undefined; heading: number | null | undefined; offset?: number; }) { // Use COG (0=N, 90=E...) as the primary bearing so ship icons + prediction vectors stay aligned. const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0; return normalizeAngleDeg(raw, offset); } function toSafeNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; return null; } function toIntMmsi(value: unknown): number | null { const n = toSafeNumber(value); if (n == null) return null; return Math.trunc(n); } function formatNm(value: number | null | undefined) { if (!isFiniteNumber(value)) return "-"; return `${value.toFixed(2)} NM`; } function getLegacyTag(legacyHits: Map | null | undefined, mmsi: number) { const legacy = legacyHits?.get(mmsi); if (!legacy) return null; return `${legacy.permitNo} (${legacy.shipCode})`; } function getTargetName(mmsi: number, targetByMmsi: Map, legacyHits: Map | null | undefined) { const legacy = legacyHits?.get(mmsi); const target = targetByMmsi.get(mmsi); return ( (target?.name || "").trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` ); } function getShipTooltipHtml({ mmsi, targetByMmsi, legacyHits, }: { mmsi: number; targetByMmsi: Map; legacyHits: Map | null | undefined; }) { const legacy = legacyHits?.get(mmsi); const t = targetByMmsi.get(mmsi); const name = getTargetName(mmsi, targetByMmsi, legacyHits); const sog = isFiniteNumber(t?.sog) ? t.sog : null; const cog = isFiniteNumber(t?.cog) ? t.cog : null; const msg = t?.messageTimestamp ?? null; const vesselType = t?.vesselType || ""; const legacyHtml = legacy ? `
CN Permit · ${legacy.shipCode} · ${legacy.permitNo}
유효범위: ${legacy.workSeaArea || "-"}
` : ""; return { html: `
${name}
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ""}
SOG: ${sog ?? "?"} kt · COG: ${cog ?? "?"}°
${msg ? `
${msg}
` : ""} ${legacyHtml}
`, }; } function getPairLinkTooltipHtml({ warn, distanceNm, aMmsi, bMmsi, legacyHits, targetByMmsi, }: { warn: boolean; distanceNm: number | null | undefined; aMmsi: number; bMmsi: number; legacyHits: Map | null | undefined; targetByMmsi: Map; }) { const d = formatNm(distanceNm); const a = getTargetName(aMmsi, targetByMmsi, legacyHits); const b = getTargetName(bMmsi, targetByMmsi, legacyHits); const aTag = getLegacyTag(legacyHits, aMmsi); const bTag = getLegacyTag(legacyHits, bMmsi); return { html: `
쌍 연결
${aTag ?? `MMSI ${aMmsi}`}
↔ ${bTag ?? `MMSI ${bMmsi}`}
거리: ${d} · 상태: ${warn ? "주의" : "정상"}
${a} / ${b}
`, }; } function getFcLinkTooltipHtml({ suspicious, distanceNm, fcMmsi, otherMmsi, legacyHits, targetByMmsi, }: { suspicious: boolean; distanceNm: number | null | undefined; fcMmsi: number; otherMmsi: number; legacyHits: Map | null | undefined; targetByMmsi: Map; }) { const d = formatNm(distanceNm); const a = getTargetName(fcMmsi, targetByMmsi, legacyHits); const b = getTargetName(otherMmsi, targetByMmsi, legacyHits); const aTag = getLegacyTag(legacyHits, fcMmsi); const bTag = getLegacyTag(legacyHits, otherMmsi); return { html: `
환적 연결
${aTag ?? `MMSI ${fcMmsi}`}
→ ${bTag ?? `MMSI ${otherMmsi}`}
거리: ${d} · 상태: ${suspicious ? "의심" : "일반"}
${a} / ${b}
`, }; } function getRangeTooltipHtml({ warn, distanceNm, aMmsi, bMmsi, legacyHits, }: { warn: boolean; distanceNm: number | null | undefined; aMmsi: number; bMmsi: number; legacyHits: Map | null | undefined; }) { const d = formatNm(distanceNm); const aTag = getLegacyTag(legacyHits, aMmsi); const bTag = getLegacyTag(legacyHits, bMmsi); const radiusNm = toSafeNumber(distanceNm); return { html: `
쌍 연결범위
${aTag ?? `MMSI ${aMmsi}`}
↔ ${bTag ?? `MMSI ${bMmsi}`}
범위: ${d} · 반경: ${formatNm(radiusNm == null ? null : radiusNm / 2)} · 상태: ${warn ? "주의" : "정상"}
`, }; } function getFleetCircleTooltipHtml({ ownerKey, ownerLabel, count, }: { ownerKey: string; ownerLabel?: string; count: number; }) { const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey; return { html: `
선단 범위
소유주: ${displayOwner || "-"}
선박 수: ${count}
`, }; } 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 getGlobeBaseShipColor({ legacy, sog, }: { legacy: string | null; sog: number | null; }) { if (legacy) { const rgb = LEGACY_CODE_COLORS[legacy]; if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); } // Non-target AIS should be visible but muted so target vessels stand out. // Encode speed mostly via brightness (not hue) to avoid clashing with target category colors. if (!isFiniteNumber(sog)) return "rgba(100,116,139,0.55)"; if (sog >= 10) return "rgba(148,163,184,0.78)"; if (sog >= 1) return "rgba(100,116,139,0.74)"; return "rgba(71,85,105,0.68)"; } const LEGACY_CODE_COLORS = LEGACY_CODE_COLORS_RGB; const OVERLAY_PAIR_NORMAL_RGB = OVERLAY_RGB.pairNormal; const OVERLAY_PAIR_WARN_RGB = OVERLAY_RGB.pairWarn; const OVERLAY_FC_TRANSFER_RGB = OVERLAY_RGB.fcTransfer; const OVERLAY_FLEET_RANGE_RGB = OVERLAY_RGB.fleetRange; const OVERLAY_SUSPICIOUS_RGB = OVERLAY_RGB.suspicious; // Deck.gl color constants (avoid per-object allocations inside accessors). const PAIR_RANGE_NORMAL_DECK: [number, number, number, number] = [ OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 110, ]; const PAIR_RANGE_WARN_DECK: [number, number, number, number] = [ OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 170, ]; const PAIR_LINE_NORMAL_DECK: [number, number, number, number] = [ OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 85, ]; const PAIR_LINE_WARN_DECK: [number, number, number, number] = [ OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 220, ]; const FC_LINE_NORMAL_DECK: [number, number, number, number] = [ OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 200, ]; const FC_LINE_SUSPICIOUS_DECK: [number, number, number, number] = [ OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 220, ]; const FLEET_RANGE_LINE_DECK: [number, number, number, number] = [ OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 140, ]; const FLEET_RANGE_FILL_DECK: [number, number, number, number] = [ OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 6, ]; const PAIR_RANGE_NORMAL_DECK_HL: [number, number, number, number] = [ OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 200, ]; const PAIR_RANGE_WARN_DECK_HL: [number, number, number, number] = [ OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 240, ]; const PAIR_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ OVERLAY_PAIR_NORMAL_RGB[0], OVERLAY_PAIR_NORMAL_RGB[1], OVERLAY_PAIR_NORMAL_RGB[2], 245, ]; const PAIR_LINE_WARN_DECK_HL: [number, number, number, number] = [ OVERLAY_PAIR_WARN_RGB[0], OVERLAY_PAIR_WARN_RGB[1], OVERLAY_PAIR_WARN_RGB[2], 245, ]; const FC_LINE_NORMAL_DECK_HL: [number, number, number, number] = [ OVERLAY_FC_TRANSFER_RGB[0], OVERLAY_FC_TRANSFER_RGB[1], OVERLAY_FC_TRANSFER_RGB[2], 235, ]; const FC_LINE_SUSPICIOUS_DECK_HL: [number, number, number, number] = [ OVERLAY_SUSPICIOUS_RGB[0], OVERLAY_SUSPICIOUS_RGB[1], OVERLAY_SUSPICIOUS_RGB[2], 245, ]; const FLEET_RANGE_LINE_DECK_HL: [number, number, number, number] = [ OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 220, ]; const FLEET_RANGE_FILL_DECK_HL: [number, number, number, number] = [ OVERLAY_FLEET_RANGE_RGB[0], OVERLAY_FLEET_RANGE_RGB[1], OVERLAY_FLEET_RANGE_RGB[2], 42, ]; // MapLibre overlay colors. const PAIR_LINE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.55); const PAIR_LINE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.95); const PAIR_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.95); const PAIR_LINE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.98); const PAIR_RANGE_NORMAL_ML = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.45); const PAIR_RANGE_WARN_ML = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.75); const PAIR_RANGE_NORMAL_ML_HL = rgbaCss(OVERLAY_PAIR_NORMAL_RGB, 0.92); const PAIR_RANGE_WARN_ML_HL = rgbaCss(OVERLAY_PAIR_WARN_RGB, 0.92); const FC_LINE_NORMAL_ML = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.92); const FC_LINE_SUSPICIOUS_ML = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.95); const FC_LINE_NORMAL_ML_HL = rgbaCss(OVERLAY_FC_TRANSFER_RGB, 0.98); const FC_LINE_SUSPICIOUS_ML_HL = rgbaCss(OVERLAY_SUSPICIOUS_RGB, 0.98); const FLEET_FILL_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.02); const FLEET_FILL_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.16); const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65); const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95); 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 FLAT_SHIP_ICON_SIZE = 19; const FLAT_SHIP_ICON_SIZE_SELECTED = 28; const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; const FLAT_LEGACY_HALO_RADIUS = 14; const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; const EMPTY_MMSI_SET = new Set(); 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 makeGlobeCircleRadiusExpr() { const base3 = 4; const base7 = 6; const base10 = 8; const base14 = 11; return [ "interpolate", ["linear"], ["zoom"], 3, ["case", ["==", ["get", "selected"], 1], 4.6, ["==", ["get", "highlighted"], 1], 4.2, base3], 7, ["case", ["==", ["get", "selected"], 1], 6.8, ["==", ["get", "highlighted"], 1], 6.2, base7], 10, ["case", ["==", ["get", "selected"], 1], 9.0, ["==", ["get", "highlighted"], 1], 8.2, base10], 14, ["case", ["==", ["get", "selected"], 1], 11.8, ["==", ["get", "highlighted"], 1], 10.8, base14], ]; } const GLOBE_SHIP_CIRCLE_RADIUS_EXPR = makeGlobeCircleRadiusExpr() as never; function buildFallbackGlobeShipIcon() { const size = 96; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) return null; 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); return ctx.getImageData(0, 0, size, size); } function buildFallbackGlobeAnchoredShipIcon() { const baseImage = buildFallbackGlobeShipIcon(); if (!baseImage) return null; const size = baseImage.width; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) return null; ctx.putImageData(baseImage, 0, 0); // Add a small anchor glyph below the ship body for anchored-state distinction. ctx.strokeStyle = "rgba(248,250,252,1)"; ctx.lineWidth = 5; ctx.lineCap = "round"; ctx.beginPath(); const cx = size / 2; ctx.moveTo(cx - 18, 76); ctx.lineTo(cx + 18, 76); ctx.moveTo(cx, 66); ctx.lineTo(cx, 82); ctx.moveTo(cx, 82); ctx.arc(cx, 82, 7, 0, Math.PI * 2); ctx.moveTo(cx, 82); ctx.lineTo(cx, 88); ctx.moveTo(cx - 9, 88); ctx.lineTo(cx + 9, 88); ctx.stroke(); return ctx.getImageData(0, 0, size, size); } function ensureFallbackShipImage( map: maplibregl.Map, imageId: string, fallbackBuilder: () => ImageData | null = buildFallbackGlobeShipIcon, ) { if (!map || map.hasImage(imageId)) return; const image = fallbackBuilder(); if (!image) return; try { map.addImage(imageId, image, { pixelRatio: 2, sdf: true }); } catch { // ignore } } 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, // Source maxzoom is 12, but we allow overzoom so the bathymetry doesn't disappear when zooming in. maxzoom: 24, 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: 24, 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: 24, 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: 24, 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"], // Make depth labels more legible on both mercator + globe. "text-size": ["interpolate", ["linear"], ["zoom"], 10, 12, 12, 14, 14, 15], "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 = Array.isArray(style.layers) ? (style.layers as unknown as LayerSpecification[]) : []; if (!Array.isArray(style.layers)) { style.layers = layers as unknown as StyleSpecification["layers"]; } const symbolIndex = layers.findIndex((l) => (l as { type?: unknown } | null)?.type === "symbol"); const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length; const existingIds = new Set(); for (const layer of layers) { const id = getLayerId(layer); if (id) existingIds.add(id); } const toInsert = [ bathyFill, bathyBandBorders, bathyBandBordersMajor, bathyLinesMinor, bathyLinesMajor, bathyLabels, landformLabels, ].filter((l) => !existingIds.has(l.id)); if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert); } type BathyZoomRange = { id: string; mercator: [number, number]; globe: [number, number]; }; const BATHY_ZOOM_RANGES: BathyZoomRange[] = [ // MapTiler Ocean tiles maxzoom=12; beyond that we overzoom the z12 geometry. // Keep rendering at high zoom so the sea doesn't revert to the basemap's flat water color. { id: "bathymetry-fill", mercator: [6, 24], globe: [8, 24] }, { id: "bathymetry-borders", mercator: [6, 24], globe: [8, 24] }, { id: "bathymetry-borders-major", mercator: [4, 24], globe: [8, 24] }, ]; function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMapId, projection: MapProjectionId) { if (!map || !map.isStyleLoaded()) return; if (baseMap !== "enhanced") return; const isGlobe = projection === "globe"; for (const range of BATHY_ZOOM_RANGES) { if (!map.getLayer(range.id)) continue; const [minzoom, maxzoom] = isGlobe ? range.globe : range.mercator; try { // Safety: ensure heavy layers aren't stuck hidden from a previous session. map.setLayoutProperty(range.id, "visibility", "visible"); } catch { // ignore } try { map.setLayerZoomRange(range.id, minzoom, maxzoom); } catch { // ignore } } } 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, highlightedMmsis: Set, ): [number, number, number, number] { if (selectedMmsi && t.mmsi === selectedMmsi) { return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255]; } if (highlightedMmsis.has(t.mmsi)) { return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235]; } if (legacyShipCode) { const rgb = LEGACY_CODE_COLORS[legacyShipCode]; if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; return [245, 158, 11, 235]; } // Non-target AIS: muted gray scale (avoid clashing with target category colors like PT/GN/etc). if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130]; if (t.sog >= 10) return [148, 163, 184, 185]; // slate-400 if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; // slate-500 return [71, 85, 105, 165]; // slate-600 } type DashSeg = { from: [number, number]; to: [number, number]; suspicious: boolean; distanceNm?: number; fromMmsi?: number; toMmsi?: number; }; function dashifyLine( from: [number, number], to: [number, number], suspicious: boolean, distanceNm?: number, fromMmsi?: number, toMmsi?: number, ): 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, distanceNm, fromMmsi, toMmsi, }); } 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; aMmsi: number; bMmsi: number; distanceNm: number; }; const toNumberArray = (values: unknown): number[] => { if (values == null) return []; if (Array.isArray(values)) { return values as unknown as number[]; } if (typeof values === "number" && Number.isFinite(values)) { return [values]; } if (typeof values === "string") { const value = toSafeNumber(Number(values)); return value == null ? [] : [value]; } if (typeof values === "object") { if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === "function") { try { return Array.from(values as Iterable) as number[]; } catch { return []; } } } return []; }; const makeUniqueSorted = (values: unknown) => { const maybeArray = toNumberArray(values); const normalized = Array.isArray(maybeArray) ? maybeArray : []; const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value)))); unique.sort((a, b) => a - b); return unique; }; const equalNumberArrays = (a: number[], b: number[]) => { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false; } return true; }; const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DECK_VIEW_ID = "mapbox"; export function Map3D({ targets, zones, selectedMmsi, hoveredMmsiSet = [], hoveredFleetMmsiSet = [], hoveredPairMmsiSet = [], hoveredFleetOwnerKey = null, highlightedMmsiSet = [], settings, baseMap, projection, overlays, onSelectMmsi, onToggleHighlightMmsi, onViewBboxChange, legacyHits, pairLinks, fcLinks, fleetCircles, onProjectionLoadingChange, fleetFocus, onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover, }: Props) { void onHoverFleet; void onClearFleetHover; void onHoverMmsi; void onClearMmsiHover; void onHoverPair; void onClearPairHover; 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); const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState([]); const [hoveredZoneId, setHoveredZoneId] = useState(null); const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]); const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]); const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]); const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]); const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]); const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]); const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]); const hoveredFleetOwnerKeys = useMemo(() => { const keys = new Set(); if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey); if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); return keys; }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); 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; 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); }, []); const effectiveHoveredPairMmsiSet = useMemo( () => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef), [hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef], ); const effectiveHoveredFleetMmsiSet = useMemo( () => mergeNumberSets(hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef), [hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef], ); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); const highlightedMmsiSetCombined = useMemo( () => mergeNumberSets( hoveredMmsiSetRef, hoveredDeckMmsiSetRef, externalHighlightedSetRef, effectiveHoveredFleetMmsiSet, effectiveHoveredPairMmsiSet, ), [ hoveredMmsiSetRef, hoveredDeckMmsiSetRef, externalHighlightedSetRef, effectiveHoveredFleetMmsiSet, effectiveHoveredPairMmsiSet, ], ); const highlightedMmsiSetForShips = useMemo( () => (projection === "globe" ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined), [projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined], ); const hoveredShipSignature = useMemo( () => `${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature( hoveredDeckMmsiSetRef, )}|${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${makeSetSignature(effectiveHoveredPairMmsiSet)}`, [ hoveredMmsiSetRef, externalHighlightedSetRef, hoveredDeckMmsiSetRef, effectiveHoveredFleetMmsiSet, effectiveHoveredPairMmsiSet, ], ); const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]); const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]); const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]); const isHighlightedMmsi = useCallback( (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), [highlightedMmsiSetCombined], ); const baseHighlightedMmsiSet = useMemo(() => { const out = new Set(); if (selectedMmsi != null) out.add(selectedMmsi); externalHighlightedSetRef.forEach((value) => { out.add(value); }); return out; }, [selectedMmsi, externalHighlightedSetRef]); const isBaseHighlightedMmsi = useCallback( (mmsi: number) => baseHighlightedMmsiSet.has(mmsi), [baseHighlightedMmsiSet], ); const isHighlightedPair = useCallback( (aMmsi: number, bMmsi: number) => effectiveHoveredPairMmsiSet.size === 2 && effectiveHoveredPairMmsiSet.has(aMmsi) && effectiveHoveredPairMmsiSet.has(bMmsi), [effectiveHoveredPairMmsiSet], ); const isHighlightedFleet = useCallback( (ownerKey: string, vesselMmsis: number[]) => { if (hoveredFleetOwnerKeys.has(ownerKey)) return true; return vesselMmsis.some((x) => isHighlightedMmsi(x)); }, [hoveredFleetOwnerKeys, isHighlightedMmsi], ); const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); }, [targets]); const shipByMmsi = useMemo(() => { const byMmsi = new Map(); for (const t of shipData) byMmsi.set(t.mmsi, t); return byMmsi; }, [shipData]); const shipLayerData = useMemo(() => { if (shipData.length === 0) return shipData; return [...shipData]; }, [shipData]); const shipHighlightSet = useMemo(() => { const out = new Set(highlightedMmsiSetForShips); if (selectedMmsi) out.add(selectedMmsi); return out; }, [highlightedMmsiSetForShips, selectedMmsi]); const shipHoverOverlaySet = useMemo( () => projection === "globe" ? mergeNumberSets(highlightedMmsiSetCombined, shipHighlightSet) : shipHighlightSet, [projection, highlightedMmsiSetCombined, shipHighlightSet], ); const shipOverlayLayerData = useMemo(() => { if (shipLayerData.length === 0) return []; if (shipHighlightSet.size === 0) return []; return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); }, [shipHighlightSet, shipLayerData]); const hasAuxiliarySelectModifier = useCallback( (ev?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; } | null): boolean => { if (!ev) return false; return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); }, [], ); const toFleetMmsiList = useCallback((value: unknown) => { if (!Array.isArray(value)) return []; const out: number[] = []; for (const item of value) { const v = toIntMmsi(item); if (v != null) out.push(v); } return out; }, []); const onDeckSelectOrHighlight = useCallback( (info: unknown, allowMultiSelect = false) => { const obj = info as { mmsi?: unknown; srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null; }; const mmsi = toIntMmsi(obj.mmsi); if (mmsi == null) return; const evt = obj.srcEvent ?? null; const isAux = hasAuxiliarySelectModifier(evt); if (onToggleHighlightMmsi && isAux) { onToggleHighlightMmsi(mmsi); return; } if (!allowMultiSelect && selectedMmsi === mmsi) { onSelectMmsi(null); return; } onSelectMmsi(mmsi); }, [hasAuxiliarySelectModifier, onSelectMmsi, onToggleHighlightMmsi, selectedMmsi], ); const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { const normalized = makeUniqueSorted(next); setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); }, []); 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 clearMapFleetHoverState = useCallback(() => { mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; setHoveredDeckFleetOwner(null); setHoveredDeckFleetMmsis([]); }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); const clearDeckHoverPairs = useCallback(() => { mapDeckPairHoverRef.current = []; setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); }, [setHoveredDeckPairMmsiSet]); const clearDeckHoverMmsi = useCallback(() => { mapDeckMmsiHoverRef.current = []; setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); }, [setHoveredDeckMmsiSet]); const scheduleDeckHoverResolve = useCallback(() => { if (deckHoverRafRef.current != null) return; deckHoverRafRef.current = window.requestAnimationFrame(() => { deckHoverRafRef.current = null; if (!deckHoverHasHitRef.current) { clearDeckHoverMmsi(); clearDeckHoverPairs(); clearMapFleetHoverState(); } deckHoverHasHitRef.current = false; }); }, [clearDeckHoverMmsi, clearDeckHoverPairs, clearMapFleetHoverState]); const touchDeckHoverState = useCallback( (isHover: boolean) => { if (isHover) deckHoverHasHitRef.current = true; scheduleDeckHoverResolve(); }, [scheduleDeckHoverResolve], ); const setDeckHoverMmsi = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); touchDeckHoverState(normalized.length > 0); setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckMmsiHoverRef.current = normalized; }, [setHoveredDeckMmsiSet, touchDeckHoverState], ); const setDeckHoverPairs = useCallback( (next: number[]) => { const normalized = makeUniqueSorted(next); touchDeckHoverState(normalized.length > 0); setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); mapDeckPairHoverRef.current = normalized; }, [setHoveredDeckPairMmsiSet, touchDeckHoverState], ); const setMapFleetHoverState = useCallback( (ownerKey: string | null, vesselMmsis: number[]) => { const normalized = makeUniqueSorted(vesselMmsis); const prev = mapFleetHoverStateRef.current; if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) { return; } touchDeckHoverState(!!ownerKey || normalized.length > 0); setHoveredDeckFleetOwner(ownerKey); setHoveredDeckFleetMmsis(normalized); mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], ); useEffect(() => { return () => { if (deckHoverRafRef.current != null) { window.cancelAnimationFrame(deckHoverRafRef.current); deckHoverRafRef.current = null; } deckHoverHasHitRef.current = false; }; }, []); useEffect(() => { mapFleetHoverStateRef.current = { ownerKey: hoveredFleetOwnerKey, vesselMmsis: hoveredFleetMmsiSet, }; }, [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); requestAnimationFrame(() => { kickRepaint(mapRef.current); setMapSyncEpoch((prev) => prev + 1); }); }, [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(() => 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) { remove(); 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, 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); } 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) { remove(); 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, }, }; }), }; 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]); const fcDashed = useMemo(() => { const segs: DashSeg[] = []; for (const l of fcLinks || []) { segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi)); } return segs; }, [fcLinks]); const pairRanges = useMemo(() => { const out: PairRangeCircle[] = []; for (const p of pairLinks || []) { const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2]; out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn, aMmsi: p.aMmsi, bMmsi: p.bMmsi, distanceNm: p.distanceNm, }); } return out; }, [pairLinks]); const pairLinksInteractive = useMemo(() => { if (!overlays.pairLines || (pairLinks?.length ?? 0) === 0) return []; if (hoveredPairMmsiSetRef.size < 2) return []; const links = pairLinks || []; return links.filter((link) => hoveredPairMmsiSetRef.has(link.aMmsi) && hoveredPairMmsiSetRef.has(link.bMmsi), ); }, [pairLinks, hoveredPairMmsiSetRef, overlays.pairLines]); const pairRangesInteractive = useMemo(() => { if (!overlays.pairRange || pairRanges.length === 0) return []; if (hoveredPairMmsiSetRef.size < 2) return []; return pairRanges.filter((range) => hoveredPairMmsiSetRef.has(range.aMmsi) && hoveredPairMmsiSetRef.has(range.bMmsi), ); }, [pairRanges, hoveredPairMmsiSetRef, overlays.pairRange]); const fcLinesInteractive = useMemo(() => { if (!overlays.fcLines || fcDashed.length === 0) return []; if (highlightedMmsiSetCombined.size === 0) return []; return fcDashed.filter( (line) => [line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))), ); }, [fcDashed, hoveredShipSignature, overlays.fcLines, highlightedMmsiSetCombined]); const fleetCirclesInteractive = useMemo(() => { if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return []; if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return []; const circles = fleetCircles || []; return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis)); }, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]); // Static deck layers for mercator (positions + base states). Interaction overlays are handled separately. useEffect(() => { const map = mapRef.current; if (!map) return; if (projection !== "mercator" || projectionBusyRef.current) { if (projection !== "mercator") { try { if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never); } catch { // ignore } } return; } const 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 }); }, }), ); } 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, }), ); } 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, ); }; 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); map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); } }, }; try { deckTarget.setProps(deckProps as never); } catch (e) { console.error("Failed to apply base mercator deck props. Falling back to empty layer set.", e); try { deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never); } catch { // Ignore secondary failure. } } }, [ 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; map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); }, [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 = () => { map.easeTo({ center: [lon, lat], zoom, duration: 700, }); }; if (map.isStyleLoaded()) { apply(); return; } const stop = onMapStyleReady(map, apply); return () => { stop(); }; }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); return
; }