gc-wing/apps/web/src/widgets/map3d/Map3D.tsx

1478 lines
47 KiB
TypeScript
Raw Normal View 히스토리

2026-02-15 11:22:38 +09:00
import { HexagonLayer } from "@deck.gl/aggregation-layers";
import { IconLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers";
2026-02-15 11:22:38 +09:00
import { MapboxOverlay } from "@deck.gl/mapbox";
import { type PickingInfo } from "@deck.gl/core";
2026-02-15 11:22:38 +09:00
import maplibregl, {
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";
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);
}
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 LEGACY_CODE_HEX: Record<string, string> = {
PT: "#1e40af",
"PT-S": "#ea580c",
GN: "#10b981",
OT: "#8b5cf6",
PS: "#ef4444",
FC: "#f59e0b",
};
2026-02-15 11:22:38 +09:00
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);
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
const prevGlobeSelectedRef = useRef<number | 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;
// 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();
// 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 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;
}
2026-02-15 11:22:38 +09:00
if (overlay) {
overlay.finalize();
overlay = null;
}
overlayRef.current = null;
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;
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);
}
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 {
globeLayer.requestFinalize();
map.removeLayer(globeLayer.id);
} catch {
// ignore
}
}
if (!overlayRef.current) {
try {
const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never);
map.addControl(next);
overlayRef.current = next;
} catch (e) {
console.warn("Deck overlay create failed:", e);
}
}
}
2026-02-15 11:22:38 +09:00
};
if (map.isStyleLoaded()) syncProjectionAndDeck();
else map.once("style.load", syncProjectionAndDeck);
2026-02-15 11:22:38 +09:00
return () => {
cancelled = true;
try {
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]);
// Globe rendering + bathymetry tuning.
// Some terrain/hillshade/extrusion effects look unstable under globe and can occlude Deck overlays.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
if (!map.isStyleLoaded()) return;
const disableBathy3D = projection === "globe" && baseMap === "enhanced";
const vis = disableBathy3D ? "none" : "visible";
for (const id of ["bathymetry-extrusion", "bathymetry-hillshade"]) {
try {
if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", vis);
} catch {
// ignore
}
}
};
if (map.isStyleLoaded()) apply();
map.on("style.load", apply);
return () => {
try {
map.off("style.load", apply);
} catch {
// ignore
}
};
}, [projection, baseMap]);
2026-02-15 11:22:38 +09:00
// seamark toggle
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (settings.showSeamark) {
try {
ensureSeamarkOverlay(map, "bathymetry-lines");
map.setPaintProperty("seamark", "raster-opacity", 0.85);
} catch {
// ignore until style is ready
}
return;
}
// If seamark is off, remove the layer+source to avoid unnecessary network tile requests.
try {
if (map.getLayer("seamark")) map.removeLayer("seamark");
} catch {
// ignore
}
try {
if (map.getSource("seamark")) map.removeSource("seamark");
} catch {
// ignore
}
}, [settings.showSeamark]);
// Zones (MapLibre-native GeoJSON layers; works in both mercator + globe)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = "zones-src";
const fillId = "zones-fill";
const lineId = "zones-line";
const zoneColorExpr: unknown[] = ["match", ["get", "zoneId"]];
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
zoneColorExpr.push(k, ZONE_META[k].color);
}
zoneColorExpr.push("#3B82F6");
const ensure = () => {
// Always update visibility if the layers exist.
const visibility = overlays.zones ? "visible" : "none";
try {
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, "visibility", visibility);
} catch {
// ignore
}
try {
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, "visibility", visibility);
} catch {
// ignore
}
if (!zones) return;
if (!map.isStyleLoaded()) return;
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) {
existing.setData(zones);
} else {
map.addSource(srcId, { type: "geojson", data: zones } as GeoJSONSourceSpecification);
}
// Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled.
const style = map.getStyle();
const firstSymbol = (style.layers || []).find((l) => (l as { type?: string } | undefined)?.type === "symbol") as
| { id?: string }
| undefined;
const before = map.getLayer("deck-globe")
? "deck-globe"
: map.getLayer("ships")
? "ships"
: map.getLayer("seamark")
? "seamark"
: firstSymbol?.id;
if (!map.getLayer(fillId)) {
map.addLayer(
{
id: fillId,
type: "fill",
source: srcId,
paint: {
"fill-color": zoneColorExpr as never,
"fill-opacity": 0.12,
},
layout: { visibility },
} as unknown as LayerSpecification,
before,
);
}
if (!map.getLayer(lineId)) {
map.addLayer(
{
id: lineId,
type: "line",
source: srcId,
paint: {
"line-color": zoneColorExpr as never,
"line-opacity": 0.85,
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1],
},
layout: { visibility },
} as unknown as LayerSpecification,
before,
);
}
} catch (e) {
console.warn("Zones layer setup failed:", e);
}
};
if (map.isStyleLoaded()) ensure();
map.on("style.load", ensure);
return () => {
try {
map.off("style.load", ensure);
} catch {
// ignore
}
};
}, [zones, overlays.zones]);
// Ships in globe mode: render with MapLibre symbol layers so icons can be aligned to the globe surface.
// Deck IconLayer billboards or uses a fixed plane, which looks like ships are pointing into the sky/ground on globe.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const imgId = "ship-globe-icon";
const srcId = "ships-globe-src";
const haloId = "ships-globe-halo";
const outlineId = "ships-globe-outline";
const symbolId = "ships-globe";
const remove = () => {
for (const id of [symbolId, outlineId, haloId]) {
try {
if (map.getLayer(id)) map.removeLayer(id);
} catch {
// ignore
}
}
try {
if (map.getSource(srcId)) map.removeSource(srcId);
} catch {
// ignore
}
prevGlobeSelectedRef.current = null;
};
const ensureImage = () => {
if (map.hasImage(imgId)) return;
const size = 96;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Simple top-down ship silhouette, pointing north.
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.beginPath();
ctx.moveTo(size / 2, 6);
ctx.lineTo(size / 2 - 14, 24);
ctx.lineTo(size / 2 - 18, 58);
ctx.lineTo(size / 2 - 10, 88);
ctx.lineTo(size / 2 + 10, 88);
ctx.lineTo(size / 2 + 18, 58);
ctx.lineTo(size / 2 + 14, 24);
ctx.closePath();
ctx.fill();
ctx.fillRect(size / 2 - 8, 34, 16, 18);
const img = ctx.getImageData(0, 0, size, size);
map.addImage(imgId, img, { pixelRatio: 2 });
};
const speedColorExpr: unknown[] = [
"case",
[">=", ["to-number", ["get", "sog"]], 10],
"#3b82f6",
[">=", ["to-number", ["get", "sog"]], 1],
"#22c55e",
"#64748b",
];
const codeColorExpr: unknown[] = ["match", ["get", "code"]];
for (const [k, hex] of Object.entries(LEGACY_CODE_HEX)) codeColorExpr.push(k, hex);
codeColorExpr.push(speedColorExpr);
const ensure = () => {
if (!map.isStyleLoaded()) return;
if (projection !== "globe" || !settings.showShips) {
remove();
return;
}
try {
ensureImage();
} catch (e) {
console.warn("Ship icon image setup failed:", e);
}
const globeShipData = targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
const geojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: "FeatureCollection",
features: globeShipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null;
return {
type: "Feature",
id: t.mmsi,
geometry: { type: "Point", coordinates: [t.lon, t.lat] },
properties: {
mmsi: t.mmsi,
name: t.name || "",
cog: isFiniteNumber(t.cog) ? t.cog : 0,
sog: isFiniteNumber(t.sog) ? t.sog : 0,
permitted: !!legacy,
code: legacy?.shipCode || "",
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(geojson);
else map.addSource(srcId, { type: "geojson", data: geojson } as GeoJSONSourceSpecification);
} catch (e) {
console.warn("Ship source setup failed:", e);
return;
}
const visibility = settings.showShips ? "visible" : "none";
const circleRadius = [
"case",
["boolean", ["feature-state", "selected"], false],
["interpolate", ["linear"], ["zoom"], 3, 5, 7, 8, 10, 10, 14, 14],
["interpolate", ["linear"], ["zoom"], 3, 4, 7, 6, 10, 8, 14, 11],
] as unknown as number[];
// Put ships at the top so they're always visible (especially important under globe projection).
const before = undefined;
if (!map.getLayer(haloId)) {
try {
map.addLayer(
{
id: haloId,
type: "circle",
source: srcId,
layout: { visibility },
paint: {
"circle-radius": circleRadius as never,
"circle-color": codeColorExpr as never,
"circle-opacity": 0.22,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn("Ship halo layer add failed:", e);
}
} else {
try {
map.setLayoutProperty(haloId, "visibility", visibility);
} catch {
// ignore
}
}
if (!map.getLayer(outlineId)) {
try {
map.addLayer(
{
id: outlineId,
type: "circle",
source: srcId,
layout: { visibility },
paint: {
"circle-radius": circleRadius as never,
"circle-color": "rgba(0,0,0,0)",
"circle-stroke-color": codeColorExpr as never,
"circle-stroke-width": [
"case",
["boolean", ["get", "permitted"], false],
["case", ["boolean", ["feature-state", "selected"], false], 2.5, 1.6],
["case", ["boolean", ["feature-state", "selected"], false], 2.0, 0.0],
] as unknown as number[],
"circle-stroke-opacity": 0.8,
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn("Ship outline layer add failed:", e);
}
} else {
try {
map.setLayoutProperty(outlineId, "visibility", visibility);
} catch {
// ignore
}
}
if (!map.getLayer(symbolId)) {
try {
map.addLayer(
{
id: symbolId,
type: "symbol",
source: srcId,
layout: {
visibility,
"icon-image": imgId,
"icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0.32, 7, 0.42, 10, 0.52, 14, 0.72],
"icon-allow-overlap": true,
"icon-ignore-placement": true,
"icon-anchor": "center",
"icon-rotate": ["get", "cog"],
// Keep rotation relative to the map (true-north), but billboard to camera so it
// doesn't look like it's pointing into the sky/ground on globe.
"icon-rotation-alignment": "map",
"icon-pitch-alignment": "viewport",
},
paint: {
"icon-opacity": ["case", ["boolean", ["feature-state", "selected"], false], 1.0, 0.92],
},
} as unknown as LayerSpecification,
before,
);
} catch (e) {
console.warn("Ship symbol layer add failed:", e);
}
} else {
try {
map.setLayoutProperty(symbolId, "visibility", visibility);
} catch {
// ignore
}
}
// Apply selection state for highlight.
try {
const prev = prevGlobeSelectedRef.current;
if (prev && prev !== selectedMmsi) map.setFeatureState({ source: srcId, id: prev }, { selected: false });
} catch {
// ignore
}
try {
if (selectedMmsi) map.setFeatureState({ source: srcId, id: selectedMmsi }, { selected: true });
} catch {
// ignore
}
prevGlobeSelectedRef.current = selectedMmsi;
};
ensure();
map.on("style.load", ensure);
return () => {
try {
map.off("style.load", ensure);
} catch {
// ignore
}
};
}, [projection, settings.showShips, targets, legacyHits, selectedMmsi]);
// 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 onClick = (e: maplibregl.MapMouseEvent) => {
try {
const feats = map.queryRenderedFeatures(e.point, { layers: [symbolId] });
const f = feats?.[0];
const props = (f?.properties || {}) as Record<string, unknown>;
const mmsi = Number(props.mmsi);
if (Number.isFinite(mmsi)) {
onSelectMmsi(mmsi);
return;
}
} catch {
// ignore
}
onSelectMmsi(null);
};
map.on("click", onClick);
return () => {
try {
map.off("click", onClick);
} catch {
// ignore
}
};
}, [projection, settings.showShips, onSelectMmsi]);
2026-02-15 11:22:38 +09:00
const shipData = useMemo(() => {
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
}, [targets]);
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;
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 = [];
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],
}),
);
}
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,
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,
}),
);
}
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,
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,
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,
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 && projection !== "globe" && legacyTargets.length > 0) {
2026-02-15 11:22:38 +09:00
layers.push(
new ScatterplotLayer<AisTarget>({
id: "legacy-halo",
data: legacyTargets,
pickable: false,
billboard: false,
2026-02-15 11:22:38 +09:00
// This ring is most prone to z-fighting, so force it into pure painter's-order rendering.
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: "pixels",
getRadius: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 22 : 16),
lineWidthUnits: "pixels",
getLineWidth: 2,
getLineColor: (d) => {
const l = legacyHits?.get(d.mmsi);
const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null;
if (!rgb) return [245, 158, 11, 200];
return [rgb[0], rgb[1], rgb[2], 200];
},
getPosition: (d) =>
[d.lon, d.lat] as [number, number],
2026-02-15 11:22:38 +09:00
updateTriggers: {
getRadius: [selectedMmsi],
getLineColor: [legacyHits],
},
}),
);
}
if (settings.showShips && projection !== "globe") {
2026-02-15 11:22:38 +09:00
layers.push(
new IconLayer<AisTarget>({
id: "ships",
data: shipData,
pickable: true,
// Keep icons horizontal on the sea surface when view is pitched/rotated.
billboard: false,
parameters: overlayParams,
2026-02-15 11:22:38 +09:00
iconAtlas: "/assets/ship.svg",
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => "ship",
getPosition: (d) =>
[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],
},
}),
);
}
const deckProps = {
2026-02-15 11:22:38 +09:00
layers,
getTooltip:
projection === "globe"
? undefined
: (info: PickingInfo) => {
2026-02-15 11:22:38 +09:00
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:
projection === "globe"
? undefined
: (info: PickingInfo) => {
2026-02-15 11:22:38 +09:00
if (!info.object) {
onSelectMmsi(null);
return;
}
if (info.layer && info.layer.id === "density") return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = info.object;
if (typeof obj.mmsi === "number") {
const t = obj as AisTarget;
onSelectMmsi(t.mmsi);
map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 });
}
},
} as const;
if (projection === "globe") globeDeckLayerRef.current?.setProps(deckProps);
else overlayRef.current?.setProps(deckProps as unknown as never);
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,
globePosByMmsi,
2026-02-15 11:22:38 +09:00
]);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}