2026-02-15 11:22:38 +09:00
|
|
|
import { HexagonLayer } from "@deck.gl/aggregation-layers";
|
2026-02-15 12:11:39 +09:00
|
|
|
import { IconLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers";
|
2026-02-15 11:22:38 +09:00
|
|
|
import { MapboxOverlay } from "@deck.gl/mapbox";
|
2026-02-15 12:11:39 +09:00
|
|
|
import { type PickingInfo } from "@deck.gl/core";
|
2026-02-15 11:22:38 +09:00
|
|
|
import maplibregl, {
|
2026-02-15 12:11:39 +09:00
|
|
|
type GeoJSONSource,
|
|
|
|
|
type GeoJSONSourceSpecification,
|
2026-02-15 11:22:38 +09:00
|
|
|
type LayerSpecification,
|
|
|
|
|
type RasterDEMSourceSpecification,
|
|
|
|
|
type StyleSpecification,
|
|
|
|
|
type VectorSourceSpecification,
|
|
|
|
|
} from "maplibre-gl";
|
|
|
|
|
import { useEffect, useMemo, useRef } 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";
|
2026-02-15 12:11:39 +09:00
|
|
|
import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer";
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
export type Map3DSettings = {
|
|
|
|
|
showSeamark: boolean;
|
|
|
|
|
showShips: boolean;
|
|
|
|
|
showDensity: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BaseMapId = "enhanced" | "legacy";
|
|
|
|
|
export type MapProjectionId = "mercator" | "globe";
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
targets: AisTarget[];
|
|
|
|
|
zones: ZonesGeoJson | null;
|
|
|
|
|
selectedMmsi: number | null;
|
|
|
|
|
settings: Map3DSettings;
|
|
|
|
|
baseMap: BaseMapId;
|
|
|
|
|
projection: MapProjectionId;
|
|
|
|
|
overlays: MapToggleState;
|
|
|
|
|
onSelectMmsi: (mmsi: number | null) => void;
|
|
|
|
|
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
|
|
|
|
|
legacyHits?: Map<number, LegacyVesselInfo> | null;
|
|
|
|
|
pairLinks?: PairLink[];
|
|
|
|
|
fcLinks?: FcLink[];
|
|
|
|
|
fleetCircles?: FleetCircle[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SHIP_ICON_MAPPING = {
|
|
|
|
|
ship: {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: 128,
|
|
|
|
|
height: 128,
|
|
|
|
|
anchorX: 64,
|
|
|
|
|
anchorY: 64,
|
|
|
|
|
mask: true,
|
|
|
|
|
},
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
function isFiniteNumber(x: unknown): x is number {
|
|
|
|
|
return typeof x === "number" && Number.isFinite(x);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius`
|
|
|
|
|
const DEG2RAD = Math.PI / 180;
|
|
|
|
|
|
|
|
|
|
function lngLatToUnitSphere(lon: number, lat: number, altitudeMeters = 0): [number, number, number] {
|
|
|
|
|
const lambda = lon * DEG2RAD;
|
|
|
|
|
const phi = lat * DEG2RAD;
|
|
|
|
|
const cosPhi = Math.cos(phi);
|
|
|
|
|
const s = 1 + altitudeMeters / EARTH_RADIUS_M;
|
|
|
|
|
// MapLibre globe space: x = east, y = north, z = lon=0 at equator.
|
|
|
|
|
return [Math.sin(lambda) * cosPhi * s, Math.sin(phi) * s, Math.cos(lambda) * cosPhi * s];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
|
|
|
|
|
PT: [30, 64, 175], // #1e40af
|
|
|
|
|
"PT-S": [234, 88, 12], // #ea580c
|
|
|
|
|
GN: [16, 185, 129], // #10b981
|
|
|
|
|
OT: [139, 92, 246], // #8b5cf6
|
|
|
|
|
PS: [239, 68, 68], // #ef4444
|
|
|
|
|
FC: [245, 158, 11], // #f59e0b
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEPTH_DISABLED_PARAMS = {
|
|
|
|
|
// In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated.
|
|
|
|
|
// For 2D overlays like zones/icons/halos we want stable painter's-order rendering
|
|
|
|
|
// to avoid z-fighting flicker when layers overlap at (or near) the same z.
|
|
|
|
|
depthCompare: "always",
|
|
|
|
|
depthWriteEnabled: false,
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
const GLOBE_OVERLAY_PARAMS = {
|
|
|
|
|
// In globe mode we want depth-testing against the globe so features on the far side don't draw through.
|
|
|
|
|
// Still disable depth writes so our overlays don't interfere with each other.
|
|
|
|
|
depthCompare: "less-equal",
|
|
|
|
|
depthWriteEnabled: false,
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
function getMapTilerKey(): string | null {
|
|
|
|
|
const k = import.meta.env.VITE_MAPTILER_KEY;
|
|
|
|
|
if (typeof k !== "string") return null;
|
|
|
|
|
const v = k.trim();
|
|
|
|
|
return v ? v : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) {
|
|
|
|
|
const srcId = "seamark";
|
|
|
|
|
const layerId = "seamark";
|
|
|
|
|
|
|
|
|
|
if (!map.getSource(srcId)) {
|
|
|
|
|
map.addSource(srcId, {
|
|
|
|
|
type: "raster",
|
|
|
|
|
tiles: ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"],
|
|
|
|
|
tileSize: 256,
|
|
|
|
|
attribution: "© OpenSeaMap contributors",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(layerId)) {
|
|
|
|
|
const layer: LayerSpecification = {
|
|
|
|
|
id: layerId,
|
|
|
|
|
type: "raster",
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: { "raster-opacity": 0.85 },
|
|
|
|
|
} as unknown as LayerSpecification;
|
|
|
|
|
|
|
|
|
|
// By default, MapLibre adds new layers to the top.
|
|
|
|
|
// For readability we want seamarks above bathymetry fill, but below bathymetry lines/labels.
|
|
|
|
|
const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined;
|
|
|
|
|
map.addLayer(layer, before);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
|
|
|
|
const oceanSourceId = "maptiler-ocean";
|
|
|
|
|
const terrainSourceId = "maptiler-terrain";
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!style.sources[terrainSourceId]) {
|
|
|
|
|
style.sources[terrainSourceId] = {
|
|
|
|
|
type: "raster-dem",
|
|
|
|
|
url: `https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
|
|
|
|
|
tileSize: 512,
|
|
|
|
|
encoding: "mapbox",
|
|
|
|
|
} satisfies RasterDEMSourceSpecification 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 bathyHillshade: LayerSpecification = {
|
|
|
|
|
id: "bathymetry-hillshade",
|
|
|
|
|
type: "hillshade",
|
|
|
|
|
source: terrainSourceId,
|
|
|
|
|
paint: {
|
|
|
|
|
"hillshade-illumination-anchor": "viewport",
|
|
|
|
|
"hillshade-illumination-direction": 315,
|
|
|
|
|
"hillshade-illumination-altitude": 45,
|
|
|
|
|
"hillshade-exaggeration": ["interpolate", ["linear"], ["zoom"], 0, 0.15, 6, 0.25, 10, 0.32],
|
|
|
|
|
// Dark-mode tuned shading. Alpha is baked into the colors.
|
|
|
|
|
"hillshade-shadow-color": "rgba(0,0,0,0.45)",
|
|
|
|
|
"hillshade-highlight-color": "rgba(255,255,255,0.18)",
|
|
|
|
|
"hillshade-accent-color": "rgba(255,255,255,0.06)",
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification;
|
|
|
|
|
|
|
|
|
|
const bathyFill: LayerSpecification = {
|
|
|
|
|
id: "bathymetry-fill",
|
|
|
|
|
type: "fill",
|
|
|
|
|
source: oceanSourceId,
|
|
|
|
|
"source-layer": "contour",
|
|
|
|
|
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 bathyExtrusion: LayerSpecification = {
|
|
|
|
|
id: "bathymetry-extrusion",
|
|
|
|
|
type: "fill-extrusion",
|
|
|
|
|
source: oceanSourceId,
|
|
|
|
|
"source-layer": "contour",
|
|
|
|
|
minzoom: 6,
|
|
|
|
|
paint: {
|
|
|
|
|
"fill-extrusion-color": bathyFillColor,
|
|
|
|
|
// MapLibre fill-extrusion cannot go below 0m, so we exaggerate the "relative seabed height"
|
|
|
|
|
// (shallow areas higher, deep areas lower) to create a stepped relief.
|
|
|
|
|
"fill-extrusion-base": 0,
|
|
|
|
|
// NOTE: `zoom` can only appear as the input to a top-level `step`/`interpolate`.
|
|
|
|
|
"fill-extrusion-height": [
|
|
|
|
|
"interpolate",
|
|
|
|
|
["linear"],
|
|
|
|
|
["zoom"],
|
|
|
|
|
6,
|
|
|
|
|
["*", ["+", depth, 12000], 0.002], // depth is negative; -> range [0..12000]
|
|
|
|
|
10,
|
|
|
|
|
["*", ["+", depth, 12000], 0.01],
|
|
|
|
|
],
|
|
|
|
|
"fill-extrusion-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0.0, 7, 0.25, 10, 0.55],
|
|
|
|
|
"fill-extrusion-vertical-gradient": true,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification;
|
|
|
|
|
|
|
|
|
|
const bathyBandBorders: LayerSpecification = {
|
|
|
|
|
id: "bathymetry-borders",
|
|
|
|
|
type: "line",
|
|
|
|
|
source: oceanSourceId,
|
|
|
|
|
"source-layer": "contour",
|
|
|
|
|
minzoom: 4,
|
|
|
|
|
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 bathyLinesMajor: LayerSpecification = {
|
|
|
|
|
id: "bathymetry-lines-major",
|
|
|
|
|
type: "line",
|
|
|
|
|
source: oceanSourceId,
|
|
|
|
|
"source-layer": "contour_line",
|
|
|
|
|
minzoom: 8,
|
|
|
|
|
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
|
|
|
|
filter: ["in", "depth", ...majorDepths] 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,
|
|
|
|
|
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
|
|
|
|
filter: ["in", "depth", ...majorDepths] 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,
|
|
|
|
|
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
|
|
|
|
|
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
|
|
|
|
|
layout: {
|
|
|
|
|
"symbol-placement": "line",
|
|
|
|
|
"text-field": depthLabel,
|
|
|
|
|
"text-font": ["Noto Sans Regular", "Open Sans Regular"],
|
|
|
|
|
"text-size": ["interpolate", ["linear"], ["zoom"], 10, 10, 12, 12],
|
|
|
|
|
"text-allow-overlap": false,
|
|
|
|
|
"text-padding": 2,
|
|
|
|
|
"text-rotation-alignment": "map",
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
"text-color": "rgba(226,232,240,0.72)",
|
|
|
|
|
"text-halo-color": "rgba(2,6,23,0.82)",
|
|
|
|
|
"text-halo-width": 1.0,
|
|
|
|
|
"text-halo-blur": 0.6,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification;
|
|
|
|
|
|
|
|
|
|
const landformLabels: LayerSpecification = {
|
|
|
|
|
id: "bathymetry-landforms",
|
|
|
|
|
type: "symbol",
|
|
|
|
|
source: oceanSourceId,
|
|
|
|
|
"source-layer": "landform",
|
|
|
|
|
minzoom: 8,
|
|
|
|
|
filter: ["has", "name"] as unknown as unknown[],
|
|
|
|
|
layout: {
|
|
|
|
|
"text-field": ["get", "name"] as unknown as unknown[],
|
|
|
|
|
"text-font": ["Noto Sans Italic", "Noto Sans Regular", "Open Sans Italic", "Open Sans Regular"],
|
|
|
|
|
"text-size": ["interpolate", ["linear"], ["zoom"], 8, 11, 10, 12, 12, 13],
|
|
|
|
|
"text-allow-overlap": false,
|
|
|
|
|
"text-anchor": "center",
|
|
|
|
|
"text-offset": [0, 0.0],
|
|
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
"text-color": "rgba(148,163,184,0.70)",
|
|
|
|
|
"text-halo-color": "rgba(2,6,23,0.85)",
|
|
|
|
|
"text-halo-width": 1.0,
|
|
|
|
|
"text-halo-blur": 0.7,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification;
|
|
|
|
|
|
|
|
|
|
// Insert before the first symbol layer (keep labels on top), otherwise append.
|
|
|
|
|
const layers = style.layers as LayerSpecification[];
|
|
|
|
|
const symbolIndex = layers.findIndex((l) => l.type === "symbol");
|
|
|
|
|
const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length;
|
|
|
|
|
|
|
|
|
|
const toInsert = [
|
|
|
|
|
bathyFill,
|
|
|
|
|
bathyHillshade,
|
|
|
|
|
bathyExtrusion,
|
|
|
|
|
bathyBandBorders,
|
|
|
|
|
bathyBandBordersMajor,
|
|
|
|
|
bathyLinesMinor,
|
|
|
|
|
bathyLinesMajor,
|
|
|
|
|
bathyLabels,
|
|
|
|
|
landformLabels,
|
|
|
|
|
].filter(
|
|
|
|
|
(l) => !layers.some((x) => x.id === l.id),
|
|
|
|
|
);
|
|
|
|
|
if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
|
|
|
|
|
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<string | StyleSpecification> {
|
|
|
|
|
if (baseMap === "legacy") return "/map/styles/carto-dark.json";
|
|
|
|
|
return resolveInitialMapStyle(signal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getShipColor(
|
|
|
|
|
t: AisTarget,
|
|
|
|
|
selectedMmsi: number | null,
|
|
|
|
|
legacyShipCode: string | null,
|
|
|
|
|
): [number, number, number, number] {
|
|
|
|
|
if (selectedMmsi && t.mmsi === selectedMmsi) return [255, 255, 255, 255];
|
|
|
|
|
if (legacyShipCode) {
|
|
|
|
|
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
|
|
|
|
|
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
|
|
|
|
|
return [245, 158, 11, 235];
|
|
|
|
|
}
|
|
|
|
|
if (!isFiniteNumber(t.sog)) return [100, 116, 139, 160];
|
|
|
|
|
if (t.sog >= 10) return [59, 130, 246, 220];
|
|
|
|
|
if (t.sog >= 1) return [34, 197, 94, 210];
|
|
|
|
|
return [100, 116, 139, 160];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DashSeg = { from: [number, number]; to: [number, number]; suspicious: boolean };
|
|
|
|
|
|
|
|
|
|
function dashifyLine(from: [number, number], to: [number, number], suspicious: boolean): DashSeg[] {
|
|
|
|
|
// Simple dashed effect: split into segments and render every other one.
|
|
|
|
|
const segs: DashSeg[] = [];
|
|
|
|
|
const steps = 14;
|
|
|
|
|
for (let i = 0; i < steps; i++) {
|
|
|
|
|
if (i % 2 === 1) continue;
|
|
|
|
|
const a0 = i / steps;
|
|
|
|
|
const a1 = (i + 1) / steps;
|
|
|
|
|
const lon0 = from[0] + (to[0] - from[0]) * a0;
|
|
|
|
|
const lat0 = from[1] + (to[1] - from[1]) * a0;
|
|
|
|
|
const lon1 = from[0] + (to[0] - from[0]) * a1;
|
|
|
|
|
const lat1 = from[1] + (to[1] - from[1]) * a1;
|
|
|
|
|
segs.push({ from: [lon0, lat0], to: [lon1, lat1], suspicious });
|
|
|
|
|
}
|
|
|
|
|
return segs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PairRangeCircle = {
|
|
|
|
|
center: [number, number]; // [lon, lat]
|
|
|
|
|
radiusNm: number;
|
|
|
|
|
warn: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DECK_VIEW_ID = "mapbox";
|
|
|
|
|
|
|
|
|
|
export function Map3D({
|
|
|
|
|
targets,
|
|
|
|
|
zones,
|
|
|
|
|
selectedMmsi,
|
|
|
|
|
settings,
|
|
|
|
|
baseMap,
|
|
|
|
|
projection,
|
|
|
|
|
overlays,
|
|
|
|
|
onSelectMmsi,
|
|
|
|
|
onViewBboxChange,
|
|
|
|
|
legacyHits,
|
|
|
|
|
pairLinks,
|
|
|
|
|
fcLinks,
|
|
|
|
|
fleetCircles,
|
|
|
|
|
}: Props) {
|
|
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
|
|
|
|
const overlayRef = useRef<MapboxOverlay | null>(null);
|
2026-02-15 12:11:39 +09:00
|
|
|
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
|
2026-02-15 11:22:38 +09:00
|
|
|
const showSeamarkRef = useRef(settings.showSeamark);
|
|
|
|
|
const baseMapRef = useRef<BaseMapId>(baseMap);
|
|
|
|
|
const projectionRef = useRef<MapProjectionId>(projection);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
showSeamarkRef.current = settings.showSeamark;
|
|
|
|
|
}, [settings.showSeamark]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
baseMapRef.current = baseMap;
|
|
|
|
|
}, [baseMap]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
projectionRef.current = projection;
|
|
|
|
|
}, [projection]);
|
|
|
|
|
|
|
|
|
|
// Init MapLibre + Deck.gl (single WebGL context via MapboxOverlay)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!containerRef.current || mapRef.current) return;
|
|
|
|
|
|
|
|
|
|
let map: maplibregl.Map | null = null;
|
|
|
|
|
let overlay: MapboxOverlay | null = null;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
let style: string | StyleSpecification = "/map/styles/osm-seamark.json";
|
|
|
|
|
try {
|
|
|
|
|
style = await resolveMapStyle(baseMapRef.current, controller.signal);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Don't block the app if MapTiler isn't configured yet.
|
|
|
|
|
// This is expected in early dev environments without `VITE_MAPTILER_KEY`.
|
|
|
|
|
console.warn("Map style init failed, falling back to local raster style:", e);
|
|
|
|
|
style = "/map/styles/osm-seamark.json";
|
|
|
|
|
}
|
|
|
|
|
if (cancelled || !containerRef.current) return;
|
|
|
|
|
|
|
|
|
|
map = new maplibregl.Map({
|
|
|
|
|
container: containerRef.current,
|
|
|
|
|
style,
|
|
|
|
|
center: [126.5, 34.2],
|
|
|
|
|
zoom: 7,
|
|
|
|
|
pitch: 45,
|
|
|
|
|
bearing: 0,
|
|
|
|
|
maxPitch: 85,
|
|
|
|
|
dragRotate: true,
|
|
|
|
|
pitchWithRotate: true,
|
|
|
|
|
touchPitch: true,
|
|
|
|
|
scrollZoom: { around: "center" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), "top-left");
|
|
|
|
|
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-left");
|
|
|
|
|
|
|
|
|
|
mapRef.current = map;
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
// Initial Deck integration:
|
|
|
|
|
// - mercator: MapboxOverlay interleaved (fast, feature-rich)
|
|
|
|
|
// - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned)
|
|
|
|
|
if (projectionRef.current === "mercator") {
|
|
|
|
|
overlay = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never);
|
|
|
|
|
map.addControl(overlay);
|
|
|
|
|
overlayRef.current = overlay;
|
|
|
|
|
} else {
|
|
|
|
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
|
|
|
|
id: "deck-globe",
|
|
|
|
|
viewId: DECK_VIEW_ID,
|
|
|
|
|
deckProps: { layers: [] },
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
map.on("style.load", () => {
|
|
|
|
|
applyProjection();
|
2026-02-15 12:11:39 +09:00
|
|
|
// Globe deck layer lives inside the style and must be re-added after any style swap.
|
|
|
|
|
if (projectionRef.current === "globe" && globeDeckLayerRef.current && !map!.getLayer(globeDeckLayerRef.current.id)) {
|
|
|
|
|
try {
|
|
|
|
|
map!.addLayer(globeDeckLayerRef.current);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
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 (map) {
|
|
|
|
|
map.remove();
|
|
|
|
|
map = null;
|
|
|
|
|
}
|
|
|
|
|
if (overlay) {
|
|
|
|
|
overlay.finalize();
|
|
|
|
|
overlay = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
overlayRef.current = null;
|
2026-02-15 12:11:39 +09:00
|
|
|
globeDeckLayerRef.current = null;
|
2026-02-15 11:22:38 +09:00
|
|
|
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;
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
const syncProjectionAndDeck = () => {
|
2026-02-15 11:22:38 +09:00
|
|
|
if (cancelled) return;
|
|
|
|
|
try {
|
|
|
|
|
map.setProjection({ type: projection });
|
|
|
|
|
map.setRenderWorldCopies(projection !== "globe");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Projection switch failed:", e);
|
|
|
|
|
}
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
if (projection === "globe") {
|
|
|
|
|
// Tear down MapboxOverlay (mercator) and use a MapLibre custom layer that renders Deck
|
|
|
|
|
// with MapLibre's globe MVP matrix. This avoids the Deck <-> MapLibre globe mismatch.
|
|
|
|
|
const old = overlayRef.current;
|
|
|
|
|
if (old) {
|
|
|
|
|
try {
|
|
|
|
|
old.finalize();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
overlayRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!globeDeckLayerRef.current) {
|
|
|
|
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
|
|
|
|
id: "deck-globe",
|
|
|
|
|
viewId: DECK_VIEW_ID,
|
|
|
|
|
deckProps: { layers: [] },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const layer = globeDeckLayerRef.current;
|
|
|
|
|
if (layer && map.isStyleLoaded() && !map.getLayer(layer.id)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(layer);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Tear down globe custom layer (if present), restore MapboxOverlay interleaved.
|
|
|
|
|
const globeLayer = globeDeckLayerRef.current;
|
|
|
|
|
if (globeLayer && map.getLayer(globeLayer.id)) {
|
|
|
|
|
try {
|
|
|
|
|
map.removeLayer(globeLayer.id);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!overlayRef.current) {
|
|
|
|
|
try {
|
|
|
|
|
const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never);
|
|
|
|
|
map.addControl(next);
|
|
|
|
|
overlayRef.current = next;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Deck overlay create failed:", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
};
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
|
|
|
|
else map.once("style.load", syncProjectionAndDeck);
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
try {
|
2026-02-15 12:11:39 +09:00
|
|
|
map.off("style.load", syncProjectionAndDeck);
|
2026-02-15 11:22:38 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [projection]);
|
|
|
|
|
|
|
|
|
|
// Base map toggle
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
|
|
|
|
(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 });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
console.warn("Base map switch failed:", e);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
controller.abort();
|
|
|
|
|
};
|
|
|
|
|
}, [baseMap]);
|
|
|
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
// Zones (MapLibre-native GeoJSON layers; works in both mercator + globe)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const srcId = "zones-src";
|
|
|
|
|
const fillId = "zones-fill";
|
|
|
|
|
const lineId = "zones-line";
|
|
|
|
|
|
|
|
|
|
const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]];
|
|
|
|
|
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
|
|
|
|
zoneColorExpr.push(k, ZONE_META[k].color);
|
|
|
|
|
}
|
|
|
|
|
zoneColorExpr.push("#3B82F6");
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
|
|
|
|
// Always update visibility if the layers exist.
|
|
|
|
|
const visibility = overlays.zones ? "visible" : "none";
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, "visibility", visibility);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, "visibility", visibility);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!zones) return;
|
|
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
|
|
|
|
if (existing) {
|
|
|
|
|
existing.setData(zones);
|
|
|
|
|
} else {
|
|
|
|
|
map.addSource(srcId, { type: "geojson", data: zones } as GeoJSONSourceSpecification);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled.
|
|
|
|
|
const style = map.getStyle();
|
|
|
|
|
const firstSymbol = (style.layers || []).find((l) => (l as { type?: string } | undefined)?.type === "symbol") as
|
|
|
|
|
| { id?: string }
|
|
|
|
|
| undefined;
|
|
|
|
|
const before = map.getLayer("deck-globe")
|
|
|
|
|
? "deck-globe"
|
|
|
|
|
: map.getLayer("ships")
|
|
|
|
|
? "ships"
|
|
|
|
|
: map.getLayer("seamark")
|
|
|
|
|
? "seamark"
|
|
|
|
|
: firstSymbol?.id;
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(fillId)) {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: fillId,
|
|
|
|
|
type: "fill",
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: {
|
|
|
|
|
"fill-color": zoneColorExpr as never,
|
|
|
|
|
"fill-opacity": 0.12,
|
|
|
|
|
},
|
|
|
|
|
layout: { visibility },
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(lineId)) {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: lineId,
|
|
|
|
|
type: "line",
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: {
|
|
|
|
|
"line-color": zoneColorExpr as never,
|
|
|
|
|
"line-opacity": 0.85,
|
|
|
|
|
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1],
|
|
|
|
|
},
|
|
|
|
|
layout: { visibility },
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Zones layer setup failed:", e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (map.isStyleLoaded()) ensure();
|
|
|
|
|
map.on("style.load", ensure);
|
|
|
|
|
return () => {
|
|
|
|
|
try {
|
|
|
|
|
map.off("style.load", ensure);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [zones, overlays.zones]);
|
|
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
const shipData = useMemo(() => {
|
|
|
|
|
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
|
|
|
|
|
}, [targets]);
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
const globePosByMmsi = useMemo(() => {
|
|
|
|
|
if (projection !== "globe") return null;
|
|
|
|
|
const m = new Map<number, [number, number, number]>();
|
|
|
|
|
for (const t of shipData) {
|
|
|
|
|
// Slightly above the sea surface to keep the icon readable and avoid depth-fighting.
|
|
|
|
|
m.set(t.mmsi, lngLatToUnitSphere(t.lon, t.lat, 12));
|
|
|
|
|
}
|
|
|
|
|
return m;
|
|
|
|
|
}, [projection, shipData]);
|
|
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
const legacyTargets = useMemo(() => {
|
|
|
|
|
if (!legacyHits) return [];
|
|
|
|
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
|
|
|
|
}, [shipData, legacyHits]);
|
|
|
|
|
|
|
|
|
|
const fcDashed = useMemo(() => {
|
|
|
|
|
const segs: DashSeg[] = [];
|
|
|
|
|
for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious));
|
|
|
|
|
return segs;
|
|
|
|
|
}, [fcLinks]);
|
|
|
|
|
|
|
|
|
|
const pairRanges = useMemo(() => {
|
|
|
|
|
const out: PairRangeCircle[] = [];
|
|
|
|
|
for (const p of pairLinks || []) {
|
|
|
|
|
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
|
|
|
|
|
out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn });
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}, [pairLinks]);
|
|
|
|
|
|
|
|
|
|
// When the selected MMSI changes due to external UI (e.g., list click), fly to it.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
if (!selectedMmsi) return;
|
|
|
|
|
const t = shipData.find((x) => x.mmsi === selectedMmsi);
|
|
|
|
|
if (!t) return;
|
|
|
|
|
map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 });
|
|
|
|
|
}, [selectedMmsi, shipData]);
|
|
|
|
|
|
|
|
|
|
// Update Deck.gl layers
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
2026-02-15 12:11:39 +09:00
|
|
|
if (!map) return;
|
|
|
|
|
const deckTarget = projection === "globe" ? globeDeckLayerRef.current : overlayRef.current;
|
|
|
|
|
if (!deckTarget) return;
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS;
|
|
|
|
|
const layers = [];
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
if (settings.showDensity && projection !== "globe") {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
|
|
|
|
new HexagonLayer<AisTarget>({
|
|
|
|
|
id: "density",
|
|
|
|
|
data: shipData,
|
|
|
|
|
pickable: true,
|
|
|
|
|
extruded: true,
|
|
|
|
|
radius: 2500,
|
|
|
|
|
elevationScale: 35,
|
|
|
|
|
coverage: 0.92,
|
|
|
|
|
opacity: 0.35,
|
|
|
|
|
getPosition: (d) => [d.lon, d.lat],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
|
|
|
|
new ScatterplotLayer<FleetCircle>({
|
|
|
|
|
id: "fleet-circles",
|
|
|
|
|
data: fleetCircles,
|
|
|
|
|
pickable: false,
|
2026-02-15 12:11:39 +09:00
|
|
|
billboard: false,
|
2026-02-15 11:22:38 +09:00
|
|
|
parameters: overlayParams,
|
|
|
|
|
filled: false,
|
|
|
|
|
stroked: true,
|
|
|
|
|
radiusUnits: "meters",
|
|
|
|
|
getRadius: (d) => d.radiusNm * 1852,
|
|
|
|
|
lineWidthUnits: "pixels",
|
|
|
|
|
getLineWidth: 1,
|
|
|
|
|
getLineColor: () => [245, 158, 11, 140],
|
|
|
|
|
getPosition: (d) => d.center,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
|
|
|
|
new ScatterplotLayer<PairRangeCircle>({
|
|
|
|
|
id: "pair-range",
|
|
|
|
|
data: pairRanges,
|
|
|
|
|
pickable: false,
|
2026-02-15 12:11:39 +09:00
|
|
|
billboard: false,
|
2026-02-15 11:22:38 +09:00
|
|
|
parameters: overlayParams,
|
|
|
|
|
filled: false,
|
|
|
|
|
stroked: true,
|
|
|
|
|
radiusUnits: "meters",
|
|
|
|
|
getRadius: (d) => d.radiusNm * 1852,
|
|
|
|
|
radiusMinPixels: 10,
|
|
|
|
|
lineWidthUnits: "pixels",
|
|
|
|
|
getLineWidth: () => 1,
|
|
|
|
|
getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]),
|
|
|
|
|
getPosition: (d) => d.center,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
|
|
|
|
layers.push(
|
|
|
|
|
new LineLayer<PairLink>({
|
|
|
|
|
id: "pair-lines",
|
|
|
|
|
data: pairLinks,
|
|
|
|
|
pickable: false,
|
|
|
|
|
parameters: overlayParams,
|
2026-02-15 12:11:39 +09:00
|
|
|
getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from),
|
|
|
|
|
getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to),
|
2026-02-15 11:22:38 +09:00
|
|
|
getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]),
|
|
|
|
|
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
|
|
|
|
widthUnits: "pixels",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (overlays.fcLines && fcDashed.length > 0) {
|
|
|
|
|
layers.push(
|
|
|
|
|
new LineLayer<DashSeg>({
|
|
|
|
|
id: "fc-lines",
|
|
|
|
|
data: fcDashed,
|
|
|
|
|
pickable: false,
|
|
|
|
|
parameters: overlayParams,
|
2026-02-15 12:11:39 +09:00
|
|
|
getSourcePosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.from[0], d.from[1]) : d.from),
|
|
|
|
|
getTargetPosition: (d) => (projection === "globe" ? lngLatToUnitSphere(d.to[0], d.to[1]) : d.to),
|
2026-02-15 11:22:38 +09:00
|
|
|
getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]),
|
|
|
|
|
getWidth: () => 1.3,
|
|
|
|
|
widthUnits: "pixels",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (settings.showShips && legacyTargets.length > 0) {
|
|
|
|
|
layers.push(
|
|
|
|
|
new ScatterplotLayer<AisTarget>({
|
|
|
|
|
id: "legacy-halo",
|
|
|
|
|
data: legacyTargets,
|
|
|
|
|
pickable: false,
|
|
|
|
|
billboard: projection === "globe",
|
|
|
|
|
// This ring is most prone to z-fighting, so force it into pure painter's-order rendering.
|
|
|
|
|
parameters: overlayParams,
|
|
|
|
|
filled: false,
|
|
|
|
|
stroked: true,
|
|
|
|
|
radiusUnits: "pixels",
|
|
|
|
|
getRadius: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 22 : 16),
|
|
|
|
|
lineWidthUnits: "pixels",
|
|
|
|
|
getLineWidth: 2,
|
|
|
|
|
getLineColor: (d) => {
|
|
|
|
|
const l = legacyHits?.get(d.mmsi);
|
|
|
|
|
const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null;
|
|
|
|
|
if (!rgb) return [245, 158, 11, 200];
|
|
|
|
|
return [rgb[0], rgb[1], rgb[2], 200];
|
|
|
|
|
},
|
2026-02-15 12:11:39 +09:00
|
|
|
getPosition: (d) =>
|
|
|
|
|
projection === "globe"
|
|
|
|
|
? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12))
|
|
|
|
|
: ([d.lon, d.lat] as [number, number]),
|
2026-02-15 11:22:38 +09:00
|
|
|
updateTriggers: {
|
|
|
|
|
getRadius: [selectedMmsi],
|
|
|
|
|
getLineColor: [legacyHits],
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (settings.showShips) {
|
|
|
|
|
layers.push(
|
|
|
|
|
new IconLayer<AisTarget>({
|
|
|
|
|
id: "ships",
|
|
|
|
|
data: shipData,
|
|
|
|
|
pickable: true,
|
|
|
|
|
// Mercator: keep icons horizontal on the sea surface when view is pitched/rotated.
|
|
|
|
|
// Globe: billboard to keep the icon visible and glued to the globe.
|
|
|
|
|
billboard: projection === "globe",
|
|
|
|
|
parameters: projection === "globe" ? ({ ...overlayParams, cullMode: "none" } as const) : overlayParams,
|
|
|
|
|
iconAtlas: "/assets/ship.svg",
|
|
|
|
|
iconMapping: SHIP_ICON_MAPPING,
|
|
|
|
|
getIcon: () => "ship",
|
2026-02-15 12:11:39 +09:00
|
|
|
getPosition: (d) =>
|
|
|
|
|
projection === "globe"
|
|
|
|
|
? (globePosByMmsi?.get(d.mmsi) ?? lngLatToUnitSphere(d.lon, d.lat, 12))
|
|
|
|
|
: ([d.lon, d.lat] as [number, number]),
|
2026-02-15 11:22:38 +09:00
|
|
|
getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0),
|
|
|
|
|
sizeUnits: "pixels",
|
|
|
|
|
getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22),
|
|
|
|
|
getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null),
|
|
|
|
|
alphaCutoff: 0.05,
|
|
|
|
|
updateTriggers: {
|
|
|
|
|
getSize: [selectedMmsi],
|
|
|
|
|
getColor: [selectedMmsi, legacyHits],
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
const deckProps = {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers,
|
|
|
|
|
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}` };
|
|
|
|
|
}
|
|
|
|
|
// zones
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
const obj: any = info.object;
|
|
|
|
|
if (typeof obj.mmsi === "number") {
|
|
|
|
|
const t = obj as AisTarget;
|
|
|
|
|
const name = (t.name || "").trim() || "(no name)";
|
|
|
|
|
const legacy = legacyHits?.get(t.mmsi);
|
|
|
|
|
const legacyHtml = legacy
|
|
|
|
|
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
|
|
|
|
|
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
|
|
|
|
|
</div>`
|
|
|
|
|
: "";
|
|
|
|
|
return {
|
|
|
|
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
|
|
|
|
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
|
|
|
|
<div>MMSI: <b>${t.mmsi}</b> · ${t.vesselType || "Unknown"}</div>
|
|
|
|
|
<div>SOG: <b>${t.sog ?? "?"}</b> kt · COG: <b>${t.cog ?? "?"}</b>°</div>
|
|
|
|
|
<div style="opacity:.8">${t.status || ""}</div>
|
|
|
|
|
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${t.messageTimestamp || ""}</div>
|
|
|
|
|
${legacyHtml}
|
|
|
|
|
</div>`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined;
|
|
|
|
|
const label = p?.zoneName ?? p?.zoneLabel;
|
|
|
|
|
if (label) return { text: label };
|
|
|
|
|
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;
|
|
|
|
|
onSelectMmsi(t.mmsi);
|
|
|
|
|
map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 });
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-02-15 12:11:39 +09:00
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
if (projection === "globe") globeDeckLayerRef.current?.setProps(deckProps);
|
|
|
|
|
else overlayRef.current?.setProps(deckProps as unknown as never);
|
2026-02-15 11:22:38 +09:00
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
shipData,
|
|
|
|
|
zones,
|
|
|
|
|
selectedMmsi,
|
|
|
|
|
overlays.zones,
|
|
|
|
|
settings.showShips,
|
|
|
|
|
settings.showDensity,
|
|
|
|
|
onSelectMmsi,
|
|
|
|
|
legacyHits,
|
|
|
|
|
legacyTargets,
|
|
|
|
|
overlays.pairLines,
|
|
|
|
|
overlays.pairRange,
|
|
|
|
|
overlays.fcLines,
|
|
|
|
|
overlays.fleetCircles,
|
|
|
|
|
pairLinks,
|
|
|
|
|
pairRanges,
|
|
|
|
|
fcDashed,
|
|
|
|
|
fleetCircles,
|
2026-02-15 12:11:39 +09:00
|
|
|
globePosByMmsi,
|
2026-02-15 11:22:38 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
|
|
|
|
}
|