gc-wing/apps/web/src/widgets/map3d/Map3D.tsx
htlee 918b80e06a chore: 팀 프로젝트 워크플로우 세팅
- pnpm → npm 전환 (워크스페이스 유지)
- .claude/ 팀 규칙(5), 스킬(4), 설정, hooks 스크립트(3) 추가
- .githooks/ commit-msg, post-checkout, pre-commit 추가
- Nexus npm 프록시 설정 (.npmrc — URL만, 인증 제외)
- .editorconfig, .prettierrc, .node-version(24) 추가
- CLAUDE.md 프로젝트 설명서 생성
- Map3D.tsx 미사용 함수 제거 (getDeckShipAngle)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:18:40 +09:00

5753 lines
182 KiB
TypeScript

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<number, LegacyVesselInfo> | 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<number>();
if (!values) return out;
for (const value of values) {
if (Number.isFinite(value)) {
out.add(value);
}
}
return out;
}
function mergeNumberSets(...sets: Set<number>[]) {
const out = new Set<number>();
for (const s of sets) {
for (const v of s) {
out.add(v);
}
}
return out;
}
function makeSetSignature(values: Set<number>) {
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<string, unknown> | 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<string, unknown> | 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<string>();
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<number, LegacyVesselInfo> | null | undefined, mmsi: number) {
const legacy = legacyHits?.get(mmsi);
if (!legacy) return null;
return `${legacy.permitNo} (${legacy.shipCode})`;
}
function getTargetName(mmsi: number, targetByMmsi: Map<number, AisTarget>, legacyHits: Map<number, LegacyVesselInfo> | 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<number, AisTarget>;
legacyHits: Map<number, LegacyVesselInfo> | 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
? `<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>유효범위: ${legacy.workSeaArea || "-"}</div>
</div>`
: "";
return {
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ""}</div>
<div>SOG: <b>${sog ?? "?"}</b> kt · COG: <b>${cog ?? "?"}</b>°</div>
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${msg}</div>` : ""}
${legacyHtml}
</div>`,
};
}
function getPairLinkTooltipHtml({
warn,
distanceNm,
aMmsi,
bMmsi,
legacyHits,
targetByMmsi,
}: {
warn: boolean;
distanceNm: number | null | undefined;
aMmsi: number;
bMmsi: number;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
targetByMmsi: Map<number, AisTarget>;
}) {
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: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결</div>
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${warn ? "주의" : "정상"}</b></div>
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
</div>`,
};
}
function getFcLinkTooltipHtml({
suspicious,
distanceNm,
fcMmsi,
otherMmsi,
legacyHits,
targetByMmsi,
}: {
suspicious: boolean;
distanceNm: number | null | undefined;
fcMmsi: number;
otherMmsi: number;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
targetByMmsi: Map<number, AisTarget>;
}) {
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: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;">환적 연결</div>
<div>${aTag ?? `MMSI ${fcMmsi}`}</div>
<div style="opacity:.85;">→ ${bTag ?? `MMSI ${otherMmsi}`}</div>
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${suspicious ? "의심" : "일반"}</b></div>
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
</div>`,
};
}
function getRangeTooltipHtml({
warn,
distanceNm,
aMmsi,
bMmsi,
legacyHits,
}: {
warn: boolean;
distanceNm: number | null | undefined;
aMmsi: number;
bMmsi: number;
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
}) {
const d = formatNm(distanceNm);
const aTag = getLegacyTag(legacyHits, aMmsi);
const bTag = getLegacyTag(legacyHits, bMmsi);
const radiusNm = toSafeNumber(distanceNm);
return {
html: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결범위</div>
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
<div style="margin-top: 4px;">범위: <b>${d}</b> · 반경: <b>${formatNm(radiusNm == null ? null : radiusNm / 2)}</b> · 상태: <b>${warn ? "주의" : "정상"}</b></div>
</div>`,
};
}
function getFleetCircleTooltipHtml({
ownerKey,
ownerLabel,
count,
}: {
ownerKey: string;
ownerLabel?: string;
count: number;
}) {
const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey;
return {
html: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;">선단 범위</div>
<div>소유주: ${displayOwner || "-"}</div>
<div>선박 수: <b>${count}</b></div>
</div>`,
};
}
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<number>();
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<string>();
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<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,
highlightedMmsis: Set<number>,
): [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<unknown>) 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<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const overlayInteractionRef = useRef<MapboxOverlay | null>(null);
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
const globeShipsEpochRef = useRef(-1);
const showSeamarkRef = useRef(settings.showSeamark);
const baseMapRef = useRef<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(projection);
const globeShipIconLoadingRef = useRef(false);
const projectionBusyRef = useRef(false);
const projectionBusyTokenRef = useRef(0);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const projectionPrevRef = useRef<MapProjectionId>(projection);
const bathyZoomProfileKeyRef = useRef<string>("");
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
const deckHoverRafRef = useRef<number | null>(null);
const deckHoverHasHitRef = useRef(false);
const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState<number[]>([]);
const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState<number[]>([]);
const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState<string | null>(null);
const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState<number[]>([]);
const [hoveredZoneId, setHoveredZoneId] = useState<string | null>(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<string>();
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<number>();
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<number, AisTarget>();
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<number[]>([]);
const mapDeckPairHoverRef = useRef<number[]>([]);
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<string, unknown>)["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<GeoJSON.LineString>[] = [];
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<GeoJSON.LineString> = { 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<GeoJSON.Point>[] = [];
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<GeoJSON.Point> = { 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<typeof window.setTimeout> | 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<GeoJSON.Point> = {
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<GeoJSON.Point> = {
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<string, unknown> } | 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<GeoJSON.LineString> = {
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<GeoJSON.LineString> = {
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<GeoJSON.LineString> = {
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<GeoJSON.Polygon> = {
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<GeoJSON.LineString> = {
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<string, unknown> | 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: `<div style="font-size: 12px; font-family: system-ui;">${zoneLabel}</div>` };
}
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<string, unknown> | null; layer?: { id?: string } }> = [];
try {
rendered = map.queryRenderedFeatures(e.point, { layers: candidateLayerIds }) as unknown as Array<{
properties?: Record<string, unknown> | 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<string, unknown> | 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<AisTarget>({
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<PairRangeCircle>({
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<PairLink>({
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<DashSeg>({
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<FleetCircle>({
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<FleetCircle>({
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<AisTarget>({
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<AisTarget>({
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<AisTarget>({
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<AisTarget>({
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<PairRangeCircle>({
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<PairLink>({
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<DashSeg>({
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<FleetCircle>({
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<FleetCircle>({
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<AisTarget>({
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<AisTarget>({
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<PairRangeCircle>({
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<PairLink>({
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<DashSeg>({
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<FleetCircle>({
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<FleetCircle>({
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<AisTarget>({
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 <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}