2026-02-15 11:22:38 +09:00
|
|
|
import { HexagonLayer } from "@deck.gl/aggregation-layers";
|
2026-02-15 12:11:39 +09:00
|
|
|
import { IconLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers";
|
2026-02-15 11:22:38 +09:00
|
|
|
import { MapboxOverlay } from "@deck.gl/mapbox";
|
2026-02-15 12:11:39 +09:00
|
|
|
import { type PickingInfo } from "@deck.gl/core";
|
2026-02-15 11:22:38 +09:00
|
|
|
import maplibregl, {
|
2026-02-15 12:11:39 +09:00
|
|
|
type GeoJSONSource,
|
|
|
|
|
type GeoJSONSourceSpecification,
|
2026-02-15 11:22:38 +09:00
|
|
|
type LayerSpecification,
|
|
|
|
|
type StyleSpecification,
|
|
|
|
|
} from "maplibre-gl";
|
2026-02-15 14:33:50 +09:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2026-02-15 11:22:38 +09:00
|
|
|
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
|
|
|
|
import type { ZoneId } from "../../entities/zone/model/meta";
|
|
|
|
|
import { ZONE_META } from "../../entities/zone/model/meta";
|
2026-02-15 23:57:38 +09:00
|
|
|
import type { FleetCircle, PairLink } from "../../features/legacyDashboard/model/types";
|
|
|
|
|
import { LEGACY_CODE_COLORS_RGB, OTHER_AIS_SPEED_RGB, rgba as rgbaCss } from "../../shared/lib/map/palette";
|
2026-02-15 12:11:39 +09:00
|
|
|
import { MaplibreDeckCustomLayer } from "./MaplibreDeckCustomLayer";
|
2026-02-15 23:57:38 +09:00
|
|
|
import type { BaseMapId, Map3DProps, MapProjectionId } from "./types";
|
|
|
|
|
import type { DashSeg, PairRangeCircle } from "./types";
|
|
|
|
|
import {
|
|
|
|
|
SHIP_ICON_MAPPING,
|
|
|
|
|
ANCHORED_SHIP_ICON_ID,
|
|
|
|
|
DEG2RAD,
|
|
|
|
|
GLOBE_ICON_HEADING_OFFSET_DEG,
|
|
|
|
|
FLAT_SHIP_ICON_SIZE,
|
|
|
|
|
FLAT_SHIP_ICON_SIZE_SELECTED,
|
|
|
|
|
FLAT_SHIP_ICON_SIZE_HIGHLIGHTED,
|
|
|
|
|
FLAT_LEGACY_HALO_RADIUS,
|
|
|
|
|
FLAT_LEGACY_HALO_RADIUS_SELECTED,
|
|
|
|
|
FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED,
|
|
|
|
|
EMPTY_MMSI_SET,
|
|
|
|
|
DECK_VIEW_ID,
|
|
|
|
|
DEPTH_DISABLED_PARAMS,
|
|
|
|
|
GLOBE_OVERLAY_PARAMS,
|
|
|
|
|
LEGACY_CODE_COLORS,
|
|
|
|
|
PAIR_RANGE_NORMAL_DECK,
|
|
|
|
|
PAIR_RANGE_WARN_DECK,
|
|
|
|
|
PAIR_LINE_NORMAL_DECK,
|
|
|
|
|
PAIR_LINE_WARN_DECK,
|
|
|
|
|
FC_LINE_NORMAL_DECK,
|
|
|
|
|
FC_LINE_SUSPICIOUS_DECK,
|
|
|
|
|
FLEET_RANGE_LINE_DECK,
|
|
|
|
|
FLEET_RANGE_FILL_DECK,
|
|
|
|
|
PAIR_RANGE_NORMAL_DECK_HL,
|
|
|
|
|
PAIR_RANGE_WARN_DECK_HL,
|
|
|
|
|
PAIR_LINE_NORMAL_DECK_HL,
|
|
|
|
|
PAIR_LINE_WARN_DECK_HL,
|
|
|
|
|
FC_LINE_NORMAL_DECK_HL,
|
|
|
|
|
FC_LINE_SUSPICIOUS_DECK_HL,
|
|
|
|
|
FLEET_RANGE_LINE_DECK_HL,
|
|
|
|
|
FLEET_RANGE_FILL_DECK_HL,
|
|
|
|
|
PAIR_LINE_NORMAL_ML,
|
|
|
|
|
PAIR_LINE_WARN_ML,
|
|
|
|
|
PAIR_LINE_NORMAL_ML_HL,
|
|
|
|
|
PAIR_LINE_WARN_ML_HL,
|
|
|
|
|
PAIR_RANGE_NORMAL_ML,
|
|
|
|
|
PAIR_RANGE_WARN_ML,
|
|
|
|
|
PAIR_RANGE_NORMAL_ML_HL,
|
|
|
|
|
PAIR_RANGE_WARN_ML_HL,
|
|
|
|
|
FC_LINE_NORMAL_ML,
|
|
|
|
|
FC_LINE_SUSPICIOUS_ML,
|
|
|
|
|
FC_LINE_NORMAL_ML_HL,
|
|
|
|
|
FC_LINE_SUSPICIOUS_ML_HL,
|
|
|
|
|
FLEET_FILL_ML,
|
|
|
|
|
FLEET_FILL_ML_HL,
|
|
|
|
|
FLEET_LINE_ML,
|
|
|
|
|
FLEET_LINE_ML_HL,
|
|
|
|
|
} from "./constants";
|
|
|
|
|
import {
|
|
|
|
|
mergeNumberSets,
|
|
|
|
|
makeSetSignature,
|
|
|
|
|
isFiniteNumber,
|
|
|
|
|
toSafeNumber,
|
|
|
|
|
toIntMmsi,
|
|
|
|
|
makeUniqueSorted,
|
|
|
|
|
equalNumberArrays,
|
|
|
|
|
} from "./lib/setUtils";
|
|
|
|
|
import { getZoneIdFromProps, getZoneDisplayNameFromProps } from "./lib/zoneUtils";
|
|
|
|
|
import {
|
|
|
|
|
makePairLinkFeatureId,
|
|
|
|
|
makeFcSegmentFeatureId,
|
|
|
|
|
makeFleetCircleFeatureId,
|
|
|
|
|
} from "./lib/featureIds";
|
|
|
|
|
import {
|
|
|
|
|
makeMmsiPairHighlightExpr,
|
|
|
|
|
makeMmsiAnyEndpointExpr,
|
|
|
|
|
makeFleetOwnerMatchExpr,
|
|
|
|
|
makeFleetMemberMatchExpr,
|
|
|
|
|
GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
|
|
|
|
} from "./lib/mlExpressions";
|
|
|
|
|
import {
|
|
|
|
|
toValidBearingDeg,
|
|
|
|
|
isAnchoredShip,
|
|
|
|
|
getDisplayHeading,
|
|
|
|
|
lightenColor,
|
|
|
|
|
getGlobeBaseShipColor,
|
|
|
|
|
getShipColor,
|
|
|
|
|
} from "./lib/shipUtils";
|
|
|
|
|
import {
|
|
|
|
|
getShipTooltipHtml,
|
|
|
|
|
getPairLinkTooltipHtml,
|
|
|
|
|
getFcLinkTooltipHtml,
|
|
|
|
|
getRangeTooltipHtml,
|
|
|
|
|
getFleetCircleTooltipHtml,
|
|
|
|
|
} from "./lib/tooltips";
|
|
|
|
|
import {
|
|
|
|
|
buildFallbackGlobeAnchoredShipIcon,
|
|
|
|
|
ensureFallbackShipImage,
|
|
|
|
|
} from "./lib/globeShipIcon";
|
|
|
|
|
import { kickRepaint, onMapStyleReady, extractProjectionType, getLayerId, sanitizeDeckLayerList } from "./lib/mapCore";
|
|
|
|
|
import { destinationPointLngLat, circleRingLngLat, clampNumber } from "./lib/geometry";
|
|
|
|
|
import { dashifyLine } from "./lib/dashifyLine";
|
|
|
|
|
import { ensureSeamarkOverlay } from "./layers/seamark";
|
|
|
|
|
import { applyBathymetryZoomProfile, resolveMapStyle } from "./layers/bathymetry";
|
|
|
|
|
import { useHoverState } from "./hooks/useHoverState";
|
|
|
|
|
|
|
|
|
|
export type { Map3DSettings, BaseMapId, MapProjectionId } from "./types";
|
|
|
|
|
|
|
|
|
|
type Props = Map3DProps;
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
export function Map3D({
|
|
|
|
|
targets,
|
|
|
|
|
zones,
|
|
|
|
|
selectedMmsi,
|
2026-02-15 15:17:48 +09:00
|
|
|
hoveredMmsiSet = [],
|
|
|
|
|
hoveredFleetMmsiSet = [],
|
|
|
|
|
hoveredPairMmsiSet = [],
|
|
|
|
|
hoveredFleetOwnerKey = null,
|
|
|
|
|
highlightedMmsiSet = [],
|
2026-02-15 11:22:38 +09:00
|
|
|
settings,
|
|
|
|
|
baseMap,
|
|
|
|
|
projection,
|
|
|
|
|
overlays,
|
|
|
|
|
onSelectMmsi,
|
2026-02-15 15:17:48 +09:00
|
|
|
onToggleHighlightMmsi,
|
2026-02-15 11:22:38 +09:00
|
|
|
onViewBboxChange,
|
|
|
|
|
legacyHits,
|
|
|
|
|
pairLinks,
|
|
|
|
|
fcLinks,
|
|
|
|
|
fleetCircles,
|
2026-02-15 14:42:07 +09:00
|
|
|
onProjectionLoadingChange,
|
2026-02-15 15:17:48 +09:00
|
|
|
fleetFocus,
|
|
|
|
|
onHoverFleet,
|
|
|
|
|
onClearFleetHover,
|
2026-02-15 16:09:21 +09:00
|
|
|
onHoverMmsi,
|
|
|
|
|
onClearMmsiHover,
|
|
|
|
|
onHoverPair,
|
|
|
|
|
onClearPairHover,
|
2026-02-15 11:22:38 +09:00
|
|
|
}: Props) {
|
2026-02-15 16:35:05 +09:00
|
|
|
void onHoverFleet;
|
|
|
|
|
void onClearFleetHover;
|
|
|
|
|
void onHoverMmsi;
|
|
|
|
|
void onClearMmsiHover;
|
|
|
|
|
void onHoverPair;
|
|
|
|
|
void onClearPairHover;
|
|
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
|
|
|
|
const overlayRef = useRef<MapboxOverlay | null>(null);
|
2026-02-15 16:28:04 +09:00
|
|
|
const overlayInteractionRef = useRef<MapboxOverlay | null>(null);
|
2026-02-15 12:11:39 +09:00
|
|
|
const globeDeckLayerRef = useRef<MaplibreDeckCustomLayer | null>(null);
|
2026-02-15 14:04:37 +09:00
|
|
|
const globeShipsEpochRef = useRef(-1);
|
2026-02-15 11:22:38 +09:00
|
|
|
const showSeamarkRef = useRef(settings.showSeamark);
|
|
|
|
|
const baseMapRef = useRef<BaseMapId>(baseMap);
|
|
|
|
|
const projectionRef = useRef<MapProjectionId>(projection);
|
2026-02-15 14:38:25 +09:00
|
|
|
const globeShipIconLoadingRef = useRef(false);
|
2026-02-15 14:42:07 +09:00
|
|
|
const projectionBusyRef = useRef(false);
|
2026-02-15 16:12:10 +09:00
|
|
|
const projectionBusyTokenRef = useRef(0);
|
2026-02-15 14:42:07 +09:00
|
|
|
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
|
|
|
|
const projectionPrevRef = useRef<MapProjectionId>(projection);
|
2026-02-15 18:47:52 +09:00
|
|
|
const bathyZoomProfileKeyRef = useRef<string>("");
|
2026-02-15 14:52:57 +09:00
|
|
|
const mapTooltipRef = useRef<maplibregl.Popup | null>(null);
|
2026-02-15 16:09:21 +09:00
|
|
|
const deckHoverRafRef = useRef<number | null>(null);
|
|
|
|
|
const deckHoverHasHitRef = useRef(false);
|
2026-02-15 23:57:38 +09:00
|
|
|
const {
|
|
|
|
|
setHoveredDeckMmsiSet,
|
|
|
|
|
setHoveredDeckPairMmsiSet,
|
|
|
|
|
setHoveredDeckFleetOwnerKey,
|
|
|
|
|
setHoveredDeckFleetMmsiSet,
|
|
|
|
|
hoveredZoneId, setHoveredZoneId,
|
|
|
|
|
hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef,
|
|
|
|
|
externalHighlightedSetRef,
|
|
|
|
|
hoveredDeckMmsiSetRef, hoveredDeckPairMmsiSetRef, hoveredDeckFleetMmsiSetRef,
|
|
|
|
|
hoveredFleetOwnerKeys,
|
|
|
|
|
} = useHoverState({
|
|
|
|
|
hoveredMmsiSet, hoveredFleetMmsiSet, hoveredPairMmsiSet,
|
|
|
|
|
hoveredFleetOwnerKey, highlightedMmsiSet,
|
|
|
|
|
});
|
2026-02-15 16:09:21 +09:00
|
|
|
const fleetFocusId = fleetFocus?.id;
|
|
|
|
|
const fleetFocusLon = fleetFocus?.center?.[0];
|
|
|
|
|
const fleetFocusLat = fleetFocus?.center?.[1];
|
|
|
|
|
const fleetFocusZoom = fleetFocus?.zoom;
|
2026-02-15 15:43:36 +09:00
|
|
|
|
2026-02-15 19:41:15 +09:00
|
|
|
const reorderGlobeFeatureLayers = useCallback(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map || projectionRef.current !== "globe") return;
|
|
|
|
|
if (projectionBusyRef.current) return;
|
2026-02-15 23:57:38 +09:00
|
|
|
if (!map.isStyleLoaded()) return;
|
2026-02-15 15:43:36 +09:00
|
|
|
|
2026-02-15 19:15:20 +09:00
|
|
|
const ordering = [
|
|
|
|
|
"zones-fill",
|
|
|
|
|
"zones-line",
|
|
|
|
|
"zones-label",
|
2026-02-15 19:41:15 +09:00
|
|
|
"predict-vectors-outline",
|
2026-02-15 19:15:20 +09:00
|
|
|
"predict-vectors",
|
2026-02-15 19:41:15 +09:00
|
|
|
"predict-vectors-hl-outline",
|
2026-02-15 19:15:20 +09:00
|
|
|
"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",
|
2026-02-15 15:43:36 +09:00
|
|
|
"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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
const effectiveHoveredPairMmsiSet = useMemo(
|
|
|
|
|
() => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef),
|
|
|
|
|
[hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef],
|
|
|
|
|
);
|
|
|
|
|
const effectiveHoveredFleetMmsiSet = useMemo(
|
|
|
|
|
() => mergeNumberSets(hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef),
|
|
|
|
|
[hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef],
|
|
|
|
|
);
|
2026-02-15 14:04:37 +09:00
|
|
|
const [mapSyncEpoch, setMapSyncEpoch] = useState(0);
|
2026-02-15 15:17:48 +09:00
|
|
|
const highlightedMmsiSetCombined = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
mergeNumberSets(
|
|
|
|
|
hoveredMmsiSetRef,
|
|
|
|
|
hoveredDeckMmsiSetRef,
|
|
|
|
|
externalHighlightedSetRef,
|
|
|
|
|
effectiveHoveredFleetMmsiSet,
|
|
|
|
|
effectiveHoveredPairMmsiSet,
|
|
|
|
|
),
|
|
|
|
|
[
|
|
|
|
|
hoveredMmsiSetRef,
|
|
|
|
|
hoveredDeckMmsiSetRef,
|
|
|
|
|
externalHighlightedSetRef,
|
|
|
|
|
effectiveHoveredFleetMmsiSet,
|
|
|
|
|
effectiveHoveredPairMmsiSet,
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-02-15 18:42:49 +09:00
|
|
|
const highlightedMmsiSetForShips = useMemo(
|
|
|
|
|
() => (projection === "globe" ? mergeNumberSets(hoveredMmsiSetRef, externalHighlightedSetRef) : highlightedMmsiSetCombined),
|
|
|
|
|
[projection, hoveredMmsiSetRef, externalHighlightedSetRef, highlightedMmsiSetCombined],
|
|
|
|
|
);
|
2026-02-15 15:17:48 +09:00
|
|
|
const hoveredShipSignature = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
`${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature(
|
|
|
|
|
hoveredDeckMmsiSetRef,
|
|
|
|
|
)}|${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${makeSetSignature(effectiveHoveredPairMmsiSet)}`,
|
|
|
|
|
[
|
|
|
|
|
hoveredMmsiSetRef,
|
|
|
|
|
externalHighlightedSetRef,
|
|
|
|
|
hoveredDeckMmsiSetRef,
|
|
|
|
|
effectiveHoveredFleetMmsiSet,
|
|
|
|
|
effectiveHoveredPairMmsiSet,
|
|
|
|
|
],
|
|
|
|
|
);
|
2026-02-15 16:09:21 +09:00
|
|
|
const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]);
|
|
|
|
|
const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]);
|
|
|
|
|
const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]);
|
2026-02-15 15:17:48 +09:00
|
|
|
|
|
|
|
|
const isHighlightedMmsi = useCallback(
|
|
|
|
|
(mmsi: number) => highlightedMmsiSetCombined.has(mmsi),
|
|
|
|
|
[highlightedMmsiSetCombined],
|
|
|
|
|
);
|
2026-02-15 16:35:05 +09:00
|
|
|
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],
|
|
|
|
|
);
|
2026-02-15 15:17:48 +09:00
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-15 16:09:21 +09:00
|
|
|
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]);
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
2026-02-15 15:17:48 +09:00
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
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));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-15 16:09:21 +09:00
|
|
|
const mapDeckMmsiHoverRef = useRef<number[]>([]);
|
|
|
|
|
const mapDeckPairHoverRef = useRef<number[]>([]);
|
2026-02-15 15:17:48 +09:00
|
|
|
const mapFleetHoverStateRef = useRef<{
|
|
|
|
|
ownerKey: string | null;
|
|
|
|
|
vesselMmsis: number[];
|
|
|
|
|
}>({ ownerKey: null, vesselMmsis: [] });
|
2026-02-15 18:42:49 +09:00
|
|
|
const globeHoverShipSignatureRef = useRef("");
|
2026-02-15 15:17:48 +09:00
|
|
|
|
2026-02-15 16:09:21 +09:00
|
|
|
const clearMapFleetHoverState = useCallback(() => {
|
2026-02-15 16:35:05 +09:00
|
|
|
mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] };
|
2026-02-15 16:09:21 +09:00
|
|
|
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));
|
2026-02-15 16:35:05 +09:00
|
|
|
mapDeckMmsiHoverRef.current = normalized;
|
2026-02-15 16:09:21 +09:00
|
|
|
},
|
|
|
|
|
[setHoveredDeckMmsiSet, touchDeckHoverState],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const setDeckHoverPairs = useCallback(
|
|
|
|
|
(next: number[]) => {
|
|
|
|
|
const normalized = makeUniqueSorted(next);
|
|
|
|
|
touchDeckHoverState(normalized.length > 0);
|
|
|
|
|
setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized));
|
2026-02-15 16:35:05 +09:00
|
|
|
mapDeckPairHoverRef.current = normalized;
|
2026-02-15 16:09:21 +09:00
|
|
|
},
|
|
|
|
|
[setHoveredDeckPairMmsiSet, touchDeckHoverState],
|
|
|
|
|
);
|
2026-02-15 15:17:48 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-02-15 16:09:21 +09:00
|
|
|
touchDeckHoverState(!!ownerKey || normalized.length > 0);
|
2026-02-15 15:17:48 +09:00
|
|
|
setHoveredDeckFleetOwner(ownerKey);
|
|
|
|
|
setHoveredDeckFleetMmsis(normalized);
|
|
|
|
|
mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized };
|
|
|
|
|
},
|
2026-02-15 16:09:21 +09:00
|
|
|
[setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState],
|
2026-02-15 15:17:48 +09:00
|
|
|
);
|
|
|
|
|
|
2026-02-15 16:09:21 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (deckHoverRafRef.current != null) {
|
|
|
|
|
window.cancelAnimationFrame(deckHoverRafRef.current);
|
|
|
|
|
deckHoverRafRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
deckHoverHasHitRef.current = false;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
2026-02-15 15:17:48 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
mapFleetHoverStateRef.current = {
|
|
|
|
|
ownerKey: hoveredFleetOwnerKey,
|
|
|
|
|
vesselMmsis: hoveredFleetMmsiSet,
|
|
|
|
|
};
|
|
|
|
|
}, [hoveredFleetOwnerKey, hoveredFleetMmsiSet]);
|
2026-02-15 11:22:38 +09:00
|
|
|
|
2026-02-15 14:42:07 +09:00
|
|
|
const clearProjectionBusyTimer = useCallback(() => {
|
|
|
|
|
if (projectionBusyTimerRef.current == null) return;
|
|
|
|
|
clearTimeout(projectionBusyTimerRef.current);
|
|
|
|
|
projectionBusyTimerRef.current = null;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-15 16:12:10 +09:00
|
|
|
const endProjectionLoading = useCallback(() => {
|
|
|
|
|
if (!projectionBusyRef.current) return;
|
|
|
|
|
projectionBusyRef.current = false;
|
|
|
|
|
clearProjectionBusyTimer();
|
|
|
|
|
if (onProjectionLoadingChange) {
|
|
|
|
|
onProjectionLoadingChange(false);
|
|
|
|
|
}
|
2026-02-15 18:42:49 +09:00
|
|
|
// 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);
|
2026-02-15 23:57:38 +09:00
|
|
|
kickRepaint(mapRef.current);
|
2026-02-15 16:12:10 +09:00
|
|
|
}, [clearProjectionBusyTimer, onProjectionLoadingChange]);
|
|
|
|
|
|
2026-02-15 14:42:07 +09:00
|
|
|
const setProjectionLoading = useCallback(
|
|
|
|
|
(loading: boolean) => {
|
|
|
|
|
if (projectionBusyRef.current === loading) return;
|
2026-02-15 16:12:10 +09:00
|
|
|
if (!loading) {
|
|
|
|
|
endProjectionLoading();
|
|
|
|
|
return;
|
2026-02-15 14:42:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:12:10 +09:00
|
|
|
clearProjectionBusyTimer();
|
|
|
|
|
projectionBusyRef.current = true;
|
|
|
|
|
const token = ++projectionBusyTokenRef.current;
|
2026-02-15 14:42:07 +09:00
|
|
|
if (onProjectionLoadingChange) {
|
2026-02-15 16:12:10 +09:00
|
|
|
onProjectionLoadingChange(true);
|
2026-02-15 14:42:07 +09:00
|
|
|
}
|
2026-02-15 16:12:10 +09:00
|
|
|
|
|
|
|
|
projectionBusyTimerRef.current = setTimeout(() => {
|
|
|
|
|
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
|
|
|
|
|
console.debug("Projection loading fallback timeout reached.");
|
|
|
|
|
endProjectionLoading();
|
|
|
|
|
}, 4000);
|
2026-02-15 14:42:07 +09:00
|
|
|
},
|
2026-02-15 16:12:10 +09:00
|
|
|
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
|
2026-02-15 14:42:07 +09:00
|
|
|
);
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const pulseMapSync = () => {
|
|
|
|
|
setMapSyncEpoch((prev) => prev + 1);
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
kickRepaint(mapRef.current);
|
|
|
|
|
setMapSyncEpoch((prev) => prev + 1);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:42:07 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
clearProjectionBusyTimer();
|
2026-02-15 16:12:10 +09:00
|
|
|
endProjectionLoading();
|
2026-02-15 14:42:07 +09:00
|
|
|
};
|
2026-02-15 16:12:10 +09:00
|
|
|
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
2026-02-15 14:42:07 +09:00
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
showSeamarkRef.current = settings.showSeamark;
|
|
|
|
|
}, [settings.showSeamark]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
baseMapRef.current = baseMap;
|
|
|
|
|
}, [baseMap]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
projectionRef.current = projection;
|
|
|
|
|
}, [projection]);
|
|
|
|
|
|
2026-02-15 15:27:57 +09:00
|
|
|
const removeLayerIfExists = useCallback((map: maplibregl.Map, layerId: string | null | undefined) => {
|
|
|
|
|
if (!layerId) return;
|
2026-02-15 14:33:50 +09:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
const ensureMercatorOverlay = useCallback(() => {
|
2026-02-15 14:33:50 +09:00
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return null;
|
2026-02-15 18:42:49 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-15 14:33:50 +09:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const clearGlobeNativeLayers = useCallback(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
2026-02-15 19:15:20 +09:00
|
|
|
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",
|
2026-02-15 15:17:48 +09:00
|
|
|
"fleet-circles-ml-fill",
|
2026-02-15 14:33:50 +09:00
|
|
|
"fleet-circles-ml",
|
|
|
|
|
"pair-range-ml",
|
|
|
|
|
"deck-globe",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const id of layerIds) {
|
|
|
|
|
removeLayerIfExists(map, id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
const sourceIds = [
|
|
|
|
|
"ships-globe-src",
|
2026-02-15 18:42:49 +09:00
|
|
|
"ships-globe-hover-src",
|
2026-02-15 15:17:48 +09:00
|
|
|
"pair-lines-ml-src",
|
|
|
|
|
"fc-lines-ml-src",
|
|
|
|
|
"fleet-circles-ml-src",
|
|
|
|
|
"fleet-circles-ml-fill-src",
|
|
|
|
|
"pair-range-ml-src",
|
|
|
|
|
];
|
2026-02-15 14:33:50 +09:00
|
|
|
for (const id of sourceIds) {
|
|
|
|
|
removeSourceIfExists(map, id);
|
|
|
|
|
}
|
|
|
|
|
}, [removeLayerIfExists, removeSourceIfExists]);
|
|
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
// 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;
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
// Initial Deck integration:
|
|
|
|
|
// - mercator: MapboxOverlay interleaved (fast, feature-rich)
|
|
|
|
|
// - globe: MapLibre custom layer that feeds Deck the globe MVP matrix (keeps basemap+layers aligned)
|
|
|
|
|
if (projectionRef.current === "mercator") {
|
2026-02-15 18:42:49 +09:00
|
|
|
const overlay = ensureMercatorOverlay();
|
|
|
|
|
if (!overlay) return;
|
|
|
|
|
overlayRef.current = overlay;
|
2026-02-15 12:11:39 +09:00
|
|
|
} else {
|
|
|
|
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
|
|
|
|
id: "deck-globe",
|
|
|
|
|
viewId: DECK_VIEW_ID,
|
|
|
|
|
deckProps: { layers: [] },
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
function applyProjection() {
|
|
|
|
|
if (!map) return;
|
|
|
|
|
const next = projectionRef.current;
|
|
|
|
|
if (next === "mercator") return;
|
|
|
|
|
try {
|
|
|
|
|
map.setProjection({ type: next });
|
|
|
|
|
// Globe mode renders a single world; copies can look odd and aren't needed for KR region.
|
|
|
|
|
map.setRenderWorldCopies(next !== "globe");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Projection apply failed:", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure the seamark raster overlay exists even when using MapTiler vector styles.
|
2026-02-15 14:17:27 +09:00
|
|
|
onMapStyleReady(map, () => {
|
2026-02-15 11:22:38 +09:00
|
|
|
applyProjection();
|
2026-02-15 12:11:39 +09:00
|
|
|
// Globe deck layer lives inside the style and must be re-added after any style swap.
|
2026-02-15 15:27:57 +09:00
|
|
|
const deckLayer = globeDeckLayerRef.current;
|
|
|
|
|
if (projectionRef.current === "globe" && deckLayer && !map!.getLayer(deckLayer.id)) {
|
2026-02-15 12:11:39 +09:00
|
|
|
try {
|
2026-02-15 15:27:57 +09:00
|
|
|
map!.addLayer(deckLayer);
|
2026-02-15 12:11:39 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
if (!showSeamarkRef.current) return;
|
|
|
|
|
try {
|
|
|
|
|
ensureSeamarkOverlay(map!, "bathymetry-lines");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore (style not ready / already has it)
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Send initial bbox and update on move end (useful for lists / debug)
|
|
|
|
|
const emitBbox = () => {
|
|
|
|
|
const cb = onViewBboxChange;
|
|
|
|
|
if (!cb || !map) return;
|
|
|
|
|
const b = map.getBounds();
|
|
|
|
|
cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
|
|
|
|
};
|
|
|
|
|
map.on("load", emitBbox);
|
|
|
|
|
map.on("moveend", emitBbox);
|
|
|
|
|
|
|
|
|
|
function applySeamarkOpacity() {
|
|
|
|
|
if (!map) return;
|
|
|
|
|
const opacity = settings.showSeamark ? 0.85 : 0;
|
|
|
|
|
try {
|
|
|
|
|
map.setPaintProperty("seamark", "raster-opacity", opacity);
|
|
|
|
|
} catch {
|
|
|
|
|
// style not ready yet
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
map.once("load", () => {
|
|
|
|
|
if (showSeamarkRef.current) {
|
|
|
|
|
try {
|
|
|
|
|
ensureSeamarkOverlay(map!, "bathymetry-lines");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
2026-02-15 11:22:38 +09:00
|
|
|
}
|
2026-02-15 14:17:27 +09:00
|
|
|
applySeamarkOpacity();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-15 11:22:38 +09:00
|
|
|
})();
|
|
|
|
|
|
2026-02-15 12:36:25 +09:00
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
controller.abort();
|
|
|
|
|
|
|
|
|
|
// If we are unmounting, ensure the globe Deck instance is finalized (style reload would keep it alive).
|
|
|
|
|
try {
|
|
|
|
|
globeDeckLayerRef.current?.requestFinalize();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (map) {
|
|
|
|
|
map.remove();
|
|
|
|
|
map = null;
|
|
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
if (overlayRef.current) {
|
|
|
|
|
overlayRef.current.finalize();
|
|
|
|
|
overlayRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
if (overlayInteractionRef.current) {
|
|
|
|
|
overlayInteractionRef.current.finalize();
|
|
|
|
|
overlayInteractionRef.current = null;
|
2026-02-15 11:22:38 +09:00
|
|
|
}
|
2026-02-15 12:11:39 +09:00
|
|
|
globeDeckLayerRef.current = null;
|
2026-02-15 11:22:38 +09:00
|
|
|
mapRef.current = null;
|
|
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Projection toggle (mercator <-> globe)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
let cancelled = false;
|
2026-02-15 14:04:37 +09:00
|
|
|
let retries = 0;
|
2026-02-15 14:24:00 +09:00
|
|
|
const maxRetries = 18;
|
2026-02-15 14:42:07 +09:00
|
|
|
const isTransition = projectionPrevRef.current !== projection;
|
|
|
|
|
projectionPrevRef.current = projection;
|
2026-02-15 14:45:31 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-15 23:57:38 +09:00
|
|
|
requestAnimationFrame(finalize);
|
2026-02-15 14:45:31 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
};
|
2026-02-15 14:42:07 +09:00
|
|
|
|
|
|
|
|
if (isTransition) setProjectionLoading(true);
|
2026-02-15 14:24:00 +09:00
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
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");
|
2026-02-15 14:27:08 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const disposeGlobeDeckLayer = () => {
|
|
|
|
|
const current = globeDeckLayerRef.current;
|
|
|
|
|
if (!current) return;
|
2026-02-15 14:33:50 +09:00
|
|
|
removeLayerIfExists(map, current.id);
|
2026-02-15 14:27:08 +09:00
|
|
|
try {
|
|
|
|
|
current.requestFinalize();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
globeDeckLayerRef.current = null;
|
2026-02-15 14:24:00 +09:00
|
|
|
};
|
2026-02-15 11:22:38 +09:00
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
const syncProjectionAndDeck = () => {
|
2026-02-15 11:22:38 +09:00
|
|
|
if (cancelled) return;
|
2026-02-15 14:42:07 +09:00
|
|
|
if (!isTransition) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 14:04:37 +09:00
|
|
|
|
|
|
|
|
if (!map.isStyleLoaded()) {
|
|
|
|
|
if (!cancelled && retries < maxRetries) {
|
|
|
|
|
retries += 1;
|
|
|
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const next = projection;
|
2026-02-15 14:27:08 +09:00
|
|
|
const currentProjection = extractProjectionType(map);
|
|
|
|
|
const shouldSwitchProjection = currentProjection !== next;
|
|
|
|
|
|
2026-02-15 14:29:19 +09:00
|
|
|
if (projection === "globe") {
|
2026-02-15 16:28:04 +09:00
|
|
|
disposeMercatorOverlays();
|
2026-02-15 14:33:50 +09:00
|
|
|
clearGlobeNativeLayers();
|
2026-02-15 14:29:19 +09:00
|
|
|
} else {
|
|
|
|
|
disposeGlobeDeckLayer();
|
2026-02-15 14:33:50 +09:00
|
|
|
clearGlobeNativeLayers();
|
2026-02-15 14:29:19 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
try {
|
2026-02-15 14:27:08 +09:00
|
|
|
if (shouldSwitchProjection) {
|
2026-02-15 14:24:00 +09:00
|
|
|
map.setProjection({ type: next });
|
|
|
|
|
}
|
2026-02-15 14:04:37 +09:00
|
|
|
map.setRenderWorldCopies(next !== "globe");
|
2026-02-15 14:29:19 +09:00
|
|
|
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
|
|
|
|
|
retries += 1;
|
|
|
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
|
|
|
return;
|
2026-02-15 14:27:08 +09:00
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
} catch (e) {
|
2026-02-15 14:04:37 +09:00
|
|
|
if (!cancelled && retries < maxRetries) {
|
|
|
|
|
retries += 1;
|
|
|
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 14:42:07 +09:00
|
|
|
if (isTransition) setProjectionLoading(false);
|
2026-02-15 11:22:38 +09:00
|
|
|
console.warn("Projection switch failed:", e);
|
|
|
|
|
}
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
if (projection === "globe") {
|
2026-02-15 14:27:08 +09:00
|
|
|
// Start with a clean globe Deck layer state to avoid partially torn-down renders.
|
|
|
|
|
disposeGlobeDeckLayer();
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
if (!globeDeckLayerRef.current) {
|
|
|
|
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
|
|
|
|
id: "deck-globe",
|
|
|
|
|
viewId: DECK_VIEW_ID,
|
|
|
|
|
deckProps: { layers: [] },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const layer = globeDeckLayerRef.current;
|
2026-02-15 15:27:57 +09:00
|
|
|
const layerId = layer?.id;
|
|
|
|
|
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
2026-02-15 12:11:39 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(layer);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 15:27:57 +09:00
|
|
|
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
|
2026-02-15 14:24:00 +09:00
|
|
|
retries += 1;
|
|
|
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 12:11:39 +09:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Tear down globe custom layer (if present), restore MapboxOverlay interleaved.
|
2026-02-15 14:27:08 +09:00
|
|
|
disposeGlobeDeckLayer();
|
2026-02-15 12:11:39 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
ensureMercatorOverlay();
|
2026-02-15 12:11:39 +09:00
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
|
|
|
|
|
// 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.
|
2026-02-15 15:43:36 +09:00
|
|
|
reorderGlobeFeatureLayers();
|
2026-02-15 13:46:01 +09:00
|
|
|
kickRepaint(map);
|
2026-02-15 13:58:07 +09:00
|
|
|
try {
|
|
|
|
|
map.resize();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 14:42:07 +09:00
|
|
|
if (isTransition) {
|
2026-02-15 14:45:31 +09:00
|
|
|
startProjectionSettle();
|
2026-02-15 14:42:07 +09:00
|
|
|
}
|
2026-02-15 14:17:27 +09:00
|
|
|
pulseMapSync();
|
2026-02-15 11:22:38 +09:00
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:42:07 +09:00
|
|
|
if (!isTransition) return;
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
2026-02-15 14:17:27 +09:00
|
|
|
else {
|
|
|
|
|
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
2026-02-15 14:45:31 +09:00
|
|
|
if (settleCleanup) settleCleanup();
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 14:42:07 +09:00
|
|
|
if (isTransition) setProjectionLoading(false);
|
2026-02-15 14:17:27 +09:00
|
|
|
};
|
|
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
2026-02-15 14:45:31 +09:00
|
|
|
if (settleCleanup) settleCleanup();
|
2026-02-15 14:42:07 +09:00
|
|
|
if (isTransition) setProjectionLoading(false);
|
2026-02-15 11:22:38 +09:00
|
|
|
};
|
2026-02-15 18:42:49 +09:00
|
|
|
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, reorderGlobeFeatureLayers, setProjectionLoading]);
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
// Base map toggle
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const controller = new AbortController();
|
2026-02-15 14:17:27 +09:00
|
|
|
let stop: (() => void) | null = null;
|
2026-02-15 11:22:38 +09:00
|
|
|
|
|
|
|
|
(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 });
|
2026-02-15 14:17:27 +09:00
|
|
|
stop = onMapStyleReady(map, () => {
|
2026-02-15 13:58:07 +09:00
|
|
|
kickRepaint(map);
|
|
|
|
|
requestAnimationFrame(() => kickRepaint(map));
|
2026-02-15 14:17:27 +09:00
|
|
|
pulseMapSync();
|
2026-02-15 13:58:07 +09:00
|
|
|
});
|
2026-02-15 11:22:38 +09:00
|
|
|
} catch (e) {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
console.warn("Base map switch failed:", e);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
controller.abort();
|
2026-02-15 14:17:27 +09:00
|
|
|
stop?.();
|
2026-02-15 11:22:38 +09:00
|
|
|
};
|
|
|
|
|
}, [baseMap]);
|
|
|
|
|
|
2026-02-15 12:36:25 +09:00
|
|
|
// Globe rendering + bathymetry tuning.
|
2026-02-15 18:47:52 +09:00
|
|
|
// 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.
|
2026-02-15 12:36:25 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const apply = () => {
|
|
|
|
|
if (!map.isStyleLoaded()) return;
|
2026-02-15 14:29:19 +09:00
|
|
|
const seaVisibility = "visible" as const;
|
2026-02-15 13:58:07 +09:00
|
|
|
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
2026-02-15 13:14:03 +09:00
|
|
|
|
2026-02-15 18:47:52 +09:00
|
|
|
// 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);
|
2026-02-15 12:36:25 +09:00
|
|
|
}
|
2026-02-15 13:58:07 +09:00
|
|
|
|
2026-02-15 14:29:19 +09:00
|
|
|
// Vector basemap water layers can be tuned per-style. Keep visible by default,
|
|
|
|
|
// only toggling layers that match an explicit water/sea signature.
|
2026-02-15 13:58:07 +09:00
|
|
|
try {
|
2026-02-15 15:17:48 +09:00
|
|
|
const style = map.getStyle();
|
|
|
|
|
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
|
|
|
|
for (const layer of styleLayers) {
|
2026-02-15 15:27:57 +09:00
|
|
|
const id = getLayerId(layer);
|
2026-02-15 13:58:07 +09:00
|
|
|
if (!id) continue;
|
|
|
|
|
const sourceLayer = String((layer as Record<string, unknown>)["source-layer"] ?? "").toLowerCase();
|
|
|
|
|
const source = String((layer as { source?: unknown }).source ?? "").toLowerCase();
|
2026-02-15 14:04:37 +09:00
|
|
|
const type = String((layer as { type?: unknown }).type ?? "").toLowerCase();
|
2026-02-15 13:58:07 +09:00
|
|
|
const isSea = seaRegex.test(id) || seaRegex.test(sourceLayer) || seaRegex.test(source);
|
2026-02-15 14:04:37 +09:00
|
|
|
const isRaster = type === "raster";
|
2026-02-15 14:29:19 +09:00
|
|
|
if (!isSea) continue;
|
2026-02-15 13:58:07 +09:00
|
|
|
if (!map.getLayer(id)) continue;
|
2026-02-15 14:04:37 +09:00
|
|
|
if (isRaster && id === "seamark") continue;
|
2026-02-15 13:58:07 +09:00
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(id, "visibility", seaVisibility);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 12:36:25 +09:00
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const stop = onMapStyleReady(map, apply);
|
2026-02-15 12:36:25 +09:00
|
|
|
return () => {
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 12:36:25 +09:00
|
|
|
};
|
2026-02-15 14:04:37 +09:00
|
|
|
}, [projection, baseMap, mapSyncEpoch]);
|
2026-02-15 12:36:25 +09:00
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
// seamark toggle
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
if (settings.showSeamark) {
|
|
|
|
|
try {
|
|
|
|
|
ensureSeamarkOverlay(map, "bathymetry-lines");
|
|
|
|
|
map.setPaintProperty("seamark", "raster-opacity", 0.85);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore until style is ready
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If seamark is off, remove the layer+source to avoid unnecessary network tile requests.
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer("seamark")) map.removeLayer("seamark");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (map.getSource("seamark")) map.removeSource("seamark");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}, [settings.showSeamark]);
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
// Zones (MapLibre-native GeoJSON layers; works in both mercator + globe)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const srcId = "zones-src";
|
|
|
|
|
const fillId = "zones-fill";
|
|
|
|
|
const lineId = "zones-line";
|
2026-02-15 14:52:57 +09:00
|
|
|
const labelId = "zones-label";
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
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");
|
2026-02-15 14:52:57 +09:00
|
|
|
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"], "수역"]);
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
const ensure = () => {
|
2026-02-15 15:22:23 +09:00
|
|
|
if (projectionBusyRef.current) return;
|
2026-02-15 12:11:39 +09:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-02-15 14:52:57 +09:00
|
|
|
try {
|
|
|
|
|
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, "visibility", visibility);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 12:11:39 +09:00
|
|
|
|
|
|
|
|
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();
|
2026-02-15 15:17:48 +09:00
|
|
|
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
|
|
|
|
const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === "symbol") as
|
2026-02-15 12:11:39 +09:00
|
|
|
| { id?: string }
|
|
|
|
|
| undefined;
|
|
|
|
|
const before = map.getLayer("deck-globe")
|
|
|
|
|
? "deck-globe"
|
|
|
|
|
: map.getLayer("ships")
|
|
|
|
|
? "ships"
|
|
|
|
|
: map.getLayer("seamark")
|
|
|
|
|
? "seamark"
|
|
|
|
|
: firstSymbol?.id;
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
const zoneMatchExpr =
|
|
|
|
|
hoveredZoneId !== null
|
|
|
|
|
? (["==", ["to-string", ["coalesce", ["get", "zoneId"], ""]], hoveredZoneId] as unknown[])
|
|
|
|
|
: false;
|
2026-02-15 15:43:36 +09:00
|
|
|
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);
|
2026-02-15 15:17:48 +09:00
|
|
|
|
|
|
|
|
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 {
|
2026-02-15 15:43:36 +09:00
|
|
|
map.setPaintProperty(lineId, "line-width", zoneLineWidthExpr);
|
2026-02-15 15:17:48 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 12:11:39 +09:00
|
|
|
if (!map.getLayer(fillId)) {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: fillId,
|
|
|
|
|
type: "fill",
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: {
|
|
|
|
|
"fill-color": zoneColorExpr as never,
|
2026-02-15 15:17:48 +09:00
|
|
|
"fill-opacity": hoveredZoneId
|
|
|
|
|
? ([
|
|
|
|
|
"case",
|
|
|
|
|
zoneMatchExpr,
|
|
|
|
|
0.24,
|
|
|
|
|
0.1,
|
|
|
|
|
] as unknown as number)
|
|
|
|
|
: 0.12,
|
2026-02-15 12:11:39 +09:00
|
|
|
},
|
|
|
|
|
layout: { visibility },
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(lineId)) {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: lineId,
|
|
|
|
|
type: "line",
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: {
|
2026-02-15 15:17:48 +09:00
|
|
|
"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,
|
2026-02-15 15:43:36 +09:00
|
|
|
"line-width": zoneLineWidthExpr,
|
2026-02-15 12:11:39 +09:00
|
|
|
},
|
|
|
|
|
layout: { visibility },
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-15 14:52:57 +09:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-15 12:11:39 +09:00
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Zones layer setup failed:", e);
|
2026-02-15 13:46:01 +09:00
|
|
|
} finally {
|
2026-02-15 15:43:36 +09:00
|
|
|
reorderGlobeFeatureLayers();
|
2026-02-15 13:46:01 +09:00
|
|
|
kickRepaint(map);
|
2026-02-15 12:11:39 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const stop = onMapStyleReady(map, ensure);
|
2026-02-15 12:11:39 +09:00
|
|
|
return () => {
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 12:11:39 +09:00
|
|
|
};
|
2026-02-15 15:43:36 +09:00
|
|
|
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
2026-02-15 12:11:39 +09:00
|
|
|
|
2026-02-15 19:15:20 +09:00
|
|
|
// Prediction vectors: MapLibre-native GeoJSON line layer so it stays stable in both mercator + globe.
|
2026-02-15 13:03:05 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
2026-02-15 19:15:20 +09:00
|
|
|
const srcId = "predict-vectors-src";
|
2026-02-15 19:41:15 +09:00
|
|
|
const outlineId = "predict-vectors-outline";
|
2026-02-15 19:15:20 +09:00
|
|
|
const lineId = "predict-vectors";
|
2026-02-15 19:41:15 +09:00
|
|
|
const hlOutlineId = "predict-vectors-hl-outline";
|
2026-02-15 19:15:20 +09:00
|
|
|
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;
|
2026-02-15 19:41:15 +09:00
|
|
|
const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading);
|
|
|
|
|
if (sog == null || bearing == null) continue;
|
2026-02-15 19:15:20 +09:00
|
|
|
if (sog < 0.2) continue;
|
|
|
|
|
|
|
|
|
|
const distM = sog * metersPerSecondPerKnot * horizonSeconds;
|
|
|
|
|
if (!Number.isFinite(distM) || distM <= 0) continue;
|
|
|
|
|
|
2026-02-15 19:41:15 +09:00
|
|
|
const to = destinationPointLngLat([t.lon, t.lat], bearing, distM);
|
2026-02-15 19:15:20 +09:00
|
|
|
|
2026-02-15 19:41:15 +09:00
|
|
|
const baseRgb = isTarget
|
2026-02-15 19:15:20 +09:00
|
|
|
? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving
|
|
|
|
|
: OTHER_AIS_SPEED_RGB.moving;
|
2026-02-15 19:41:15 +09:00
|
|
|
const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62);
|
|
|
|
|
const alpha = isTarget ? 0.72 : 0.52;
|
|
|
|
|
const alphaHl = isTarget ? 0.92 : 0.84;
|
2026-02-15 19:15:20 +09:00
|
|
|
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,
|
2026-02-15 19:41:15 +09:00
|
|
|
cog: bearing,
|
2026-02-15 19:15:20 +09:00
|
|
|
target: isTarget ? 1 : 0,
|
|
|
|
|
hl,
|
|
|
|
|
color: rgbaCss(rgb, alpha),
|
2026-02-15 19:41:15 +09:00
|
|
|
colorHl: rgbaCss(rgb, alphaHl),
|
2026-02-15 19:15:20 +09:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 19:41:15 +09:00
|
|
|
const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter: unknown[]) => {
|
2026-02-15 19:15:20 +09:00
|
|
|
if (!map.getLayer(id)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id,
|
|
|
|
|
type: "line",
|
|
|
|
|
source: srcId,
|
2026-02-15 19:41:15 +09:00
|
|
|
filter: filter as never,
|
2026-02-15 19:15:20 +09:00
|
|
|
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);
|
2026-02-15 19:41:15 +09:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 19:15:20 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 19:41:15 +09:00
|
|
|
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,
|
|
|
|
|
);
|
2026-02-15 19:15:20 +09:00
|
|
|
ensureLayer(
|
|
|
|
|
lineId,
|
|
|
|
|
{
|
2026-02-15 19:41:15 +09:00
|
|
|
"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,
|
2026-02-15 19:15:20 +09:00
|
|
|
"line-opacity": 1,
|
2026-02-15 19:41:15 +09:00
|
|
|
"line-blur": 0.25,
|
2026-02-15 19:15:20 +09:00
|
|
|
"line-dasharray": [1.2, 1.8] as never,
|
|
|
|
|
} as never,
|
2026-02-15 19:41:15 +09:00
|
|
|
hlFilter,
|
2026-02-15 19:15:20 +09:00
|
|
|
);
|
|
|
|
|
ensureLayer(
|
|
|
|
|
hlId,
|
|
|
|
|
{
|
2026-02-15 19:41:15 +09:00
|
|
|
"line-color": ["coalesce", ["get", "colorHl"], ["get", "color"], "rgba(241,245,249,0.92)"] as never,
|
|
|
|
|
"line-width": 3.6,
|
2026-02-15 19:15:20 +09:00
|
|
|
"line-opacity": 1,
|
|
|
|
|
"line-dasharray": [1.2, 1.8] as never,
|
|
|
|
|
} as never,
|
2026-02-15 19:41:15 +09:00
|
|
|
hlFilter,
|
2026-02-15 19:15:20 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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";
|
2026-02-15 13:03:05 +09:00
|
|
|
|
|
|
|
|
const remove = () => {
|
2026-02-15 19:15:20 +09:00
|
|
|
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)) {
|
2026-02-15 13:03:05 +09:00
|
|
|
try {
|
2026-02-15 19:15:20 +09:00
|
|
|
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);
|
2026-02-15 13:03:05 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 19:15:20 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-02-15 22:18:40 +09:00
|
|
|
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";
|
2026-02-15 19:15:20 +09:00
|
|
|
|
|
|
|
|
const remove = () => {
|
|
|
|
|
for (const id of [labelId, symbolId, outlineId, haloId]) {
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer(id)) map.removeLayer(id);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 13:03:05 +09:00
|
|
|
try {
|
|
|
|
|
if (map.getSource(srcId)) map.removeSource(srcId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 18:42:49 +09:00
|
|
|
globeHoverShipSignatureRef.current = "";
|
2026-02-15 15:43:36 +09:00
|
|
|
reorderGlobeFeatureLayers();
|
2026-02-15 13:46:01 +09:00
|
|
|
kickRepaint(map);
|
2026-02-15 13:03:05 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensureImage = () => {
|
2026-02-15 16:12:10 +09:00
|
|
|
ensureFallbackShipImage(map, imgId);
|
2026-02-15 22:18:40 +09:00
|
|
|
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
2026-02-15 16:12:10 +09:00
|
|
|
if (globeShipIconLoadingRef.current) return;
|
2026-02-15 22:18:40 +09:00
|
|
|
if (map.hasImage(imgId) && map.hasImage(anchoredImgId)) return;
|
2026-02-15 16:12:10 +09:00
|
|
|
|
2026-02-15 14:38:25 +09:00
|
|
|
const addFallbackImage = () => {
|
2026-02-15 16:12:10 +09:00
|
|
|
ensureFallbackShipImage(map, imgId);
|
2026-02-15 22:18:40 +09:00
|
|
|
ensureFallbackShipImage(map, anchoredImgId, buildFallbackGlobeAnchoredShipIcon);
|
2026-02-15 14:38:25 +09:00
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 16:12:10 +09:00
|
|
|
let fallbackTimer: ReturnType<typeof window.setTimeout> | null = null;
|
2026-02-15 14:38:25 +09:00
|
|
|
try {
|
|
|
|
|
globeShipIconLoadingRef.current = true;
|
2026-02-15 16:12:10 +09:00
|
|
|
fallbackTimer = window.setTimeout(() => {
|
|
|
|
|
addFallbackImage();
|
|
|
|
|
}, 80);
|
2026-02-15 14:38:25 +09:00
|
|
|
void map
|
|
|
|
|
.loadImage("/assets/ship.svg")
|
|
|
|
|
.then((response) => {
|
|
|
|
|
globeShipIconLoadingRef.current = false;
|
2026-02-15 16:12:10 +09:00
|
|
|
if (fallbackTimer != null) {
|
|
|
|
|
clearTimeout(fallbackTimer);
|
|
|
|
|
fallbackTimer = null;
|
|
|
|
|
}
|
2026-02-15 14:38:25 +09:00
|
|
|
|
|
|
|
|
const loadedImage = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data;
|
|
|
|
|
if (!loadedImage) {
|
|
|
|
|
addFallbackImage();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-15 16:12:10 +09:00
|
|
|
if (map.hasImage(imgId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.removeImage(imgId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 22:18:40 +09:00
|
|
|
if (map.hasImage(anchoredImgId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.removeImage(anchoredImgId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 14:38:25 +09:00
|
|
|
map.addImage(imgId, loadedImage, { pixelRatio: 2, sdf: true });
|
2026-02-15 22:18:40 +09:00
|
|
|
map.addImage(anchoredImgId, loadedImage, { pixelRatio: 2, sdf: true });
|
2026-02-15 14:38:25 +09:00
|
|
|
kickRepaint(map);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Ship icon image add failed:", e);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
globeShipIconLoadingRef.current = false;
|
2026-02-15 16:12:10 +09:00
|
|
|
if (fallbackTimer != null) {
|
|
|
|
|
clearTimeout(fallbackTimer);
|
|
|
|
|
fallbackTimer = null;
|
|
|
|
|
}
|
2026-02-15 14:38:25 +09:00
|
|
|
addFallbackImage();
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
globeShipIconLoadingRef.current = false;
|
2026-02-15 16:12:10 +09:00
|
|
|
if (fallbackTimer != null) {
|
|
|
|
|
clearTimeout(fallbackTimer);
|
|
|
|
|
fallbackTimer = null;
|
|
|
|
|
}
|
2026-02-15 14:38:25 +09:00
|
|
|
try {
|
|
|
|
|
addFallbackImage();
|
|
|
|
|
} catch (fallbackError) {
|
|
|
|
|
console.warn("Ship icon image setup failed:", e, fallbackError);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 13:03:05 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
2026-02-15 15:22:23 +09:00
|
|
|
if (projectionBusyRef.current) return;
|
2026-02-15 13:03:05 +09:00
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
|
|
|
|
|
if (projection !== "globe" || !settings.showShips) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:04:37 +09:00
|
|
|
if (globeShipsEpochRef.current !== mapSyncEpoch) {
|
|
|
|
|
globeShipsEpochRef.current = mapSyncEpoch;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 13:03:05 +09:00
|
|
|
try {
|
|
|
|
|
ensureImage();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Ship icon image setup failed:", e);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 19:15:20 +09:00
|
|
|
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 ||
|
|
|
|
|
"";
|
2026-02-15 22:18:40 +09:00
|
|
|
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;
|
2026-02-15 14:04:37 +09:00
|
|
|
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;
|
2026-02-15 16:35:05 +09:00
|
|
|
const highlighted = isBaseHighlightedMmsi(t.mmsi);
|
2026-02-15 14:04:37 +09:00
|
|
|
const selectedScale = selected ? 1.08 : 1;
|
2026-02-15 15:17:48 +09:00
|
|
|
const highlightScale = highlighted ? 1.06 : 1;
|
|
|
|
|
const iconScale = selected ? selectedScale : highlightScale;
|
2026-02-15 14:04:37 +09:00
|
|
|
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);
|
2026-02-15 13:03:05 +09:00
|
|
|
return {
|
|
|
|
|
type: "Feature",
|
2026-02-15 15:27:57 +09:00
|
|
|
...(isFiniteNumber(t.mmsi) ? { id: Math.trunc(t.mmsi) } : {}),
|
2026-02-15 13:03:05 +09:00
|
|
|
geometry: { type: "Point", coordinates: [t.lon, t.lat] },
|
2026-02-15 22:18:40 +09:00
|
|
|
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,
|
|
|
|
|
}),
|
2026-02-15 15:17:48 +09:00
|
|
|
iconSize3: iconSize3 * iconScale,
|
|
|
|
|
iconSize7: iconSize7 * iconScale,
|
|
|
|
|
iconSize10: iconSize10 * iconScale,
|
|
|
|
|
iconSize14: iconSize14 * iconScale,
|
2026-02-15 14:04:37 +09:00
|
|
|
sizeScale,
|
|
|
|
|
selected: selected ? 1 : 0,
|
2026-02-15 15:17:48 +09:00
|
|
|
highlighted: highlighted ? 1 : 0,
|
2026-02-15 23:57:38 +09:00
|
|
|
permitted: legacy ? 1 : 0,
|
2026-02-15 13:03:05 +09:00
|
|
|
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,
|
2026-02-15 15:17:48 +09:00
|
|
|
layout: {
|
|
|
|
|
visibility,
|
|
|
|
|
"circle-sort-key": [
|
|
|
|
|
"case",
|
2026-02-15 18:42:49 +09:00
|
|
|
["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]],
|
|
|
|
|
120,
|
|
|
|
|
["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]],
|
|
|
|
|
115,
|
2026-02-15 16:09:21 +09:00
|
|
|
["==", ["get", "permitted"], 1],
|
2026-02-15 18:42:49 +09:00
|
|
|
110,
|
|
|
|
|
["==", ["get", "selected"], 1],
|
2026-02-15 16:09:21 +09:00
|
|
|
60,
|
2026-02-15 18:42:49 +09:00
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
55,
|
2026-02-15 16:09:21 +09:00
|
|
|
20,
|
2026-02-15 15:17:48 +09:00
|
|
|
] as never,
|
|
|
|
|
},
|
2026-02-15 13:03:05 +09:00
|
|
|
paint: {
|
2026-02-15 15:36:29 +09:00
|
|
|
"circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
2026-02-15 14:17:27 +09:00
|
|
|
"circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never,
|
2026-02-15 15:17:48 +09:00
|
|
|
"circle-opacity": [
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
0.38,
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
0.34,
|
|
|
|
|
0.16,
|
|
|
|
|
] as never,
|
2026-02-15 13:03:05 +09:00
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Ship halo layer add failed:", e);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(haloId, "visibility", visibility);
|
2026-02-15 18:42:49 +09:00
|
|
|
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,
|
|
|
|
|
);
|
2026-02-15 16:09:21 +09:00
|
|
|
map.setPaintProperty(
|
|
|
|
|
haloId,
|
|
|
|
|
"circle-color",
|
|
|
|
|
[
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
"rgba(14,234,255,1)",
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
"rgba(245,158,11,1)",
|
2026-02-15 18:42:49 +09:00
|
|
|
["coalesce", ["get", "shipColor"], "#64748b"],
|
2026-02-15 16:09:21 +09:00
|
|
|
] as never,
|
|
|
|
|
);
|
2026-02-15 15:17:48 +09:00
|
|
|
map.setPaintProperty(haloId, "circle-opacity", [
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
0.38,
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
0.34,
|
|
|
|
|
0.16,
|
|
|
|
|
] as never);
|
2026-02-15 15:36:29 +09:00
|
|
|
map.setPaintProperty(haloId, "circle-radius", GLOBE_SHIP_CIRCLE_RADIUS_EXPR);
|
2026-02-15 13:03:05 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!map.getLayer(outlineId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: outlineId,
|
|
|
|
|
type: "circle",
|
|
|
|
|
source: srcId,
|
|
|
|
|
paint: {
|
2026-02-15 15:36:29 +09:00
|
|
|
"circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
2026-02-15 13:03:05 +09:00
|
|
|
"circle-color": "rgba(0,0,0,0)",
|
2026-02-15 15:17:48 +09:00
|
|
|
"circle-stroke-color": [
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
"rgba(14,234,255,0.95)",
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
"rgba(245,158,11,0.95)",
|
2026-02-15 18:42:49 +09:00
|
|
|
["coalesce", ["get", "shipColor"], "#64748b"],
|
2026-02-15 15:17:48 +09:00
|
|
|
] as never,
|
2026-02-15 13:03:05 +09:00
|
|
|
"circle-stroke-width": [
|
|
|
|
|
"case",
|
2026-02-15 15:17:48 +09:00
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
3.4,
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
2.7,
|
2026-02-15 16:09:21 +09:00
|
|
|
["==", ["get", "permitted"], 1],
|
|
|
|
|
1.8,
|
2026-02-15 15:17:48 +09:00
|
|
|
0.0,
|
|
|
|
|
] as never,
|
|
|
|
|
"circle-stroke-opacity": 0.85,
|
|
|
|
|
},
|
|
|
|
|
layout: {
|
|
|
|
|
visibility,
|
|
|
|
|
"circle-sort-key": [
|
|
|
|
|
"case",
|
2026-02-15 18:42:49 +09:00
|
|
|
["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]],
|
|
|
|
|
130,
|
|
|
|
|
["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]],
|
|
|
|
|
125,
|
2026-02-15 16:09:21 +09:00
|
|
|
["==", ["get", "permitted"], 1],
|
2026-02-15 18:42:49 +09:00
|
|
|
120,
|
|
|
|
|
["==", ["get", "selected"], 1],
|
2026-02-15 16:09:21 +09:00
|
|
|
70,
|
2026-02-15 18:42:49 +09:00
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
65,
|
2026-02-15 16:09:21 +09:00
|
|
|
30,
|
2026-02-15 15:17:48 +09:00
|
|
|
] as never,
|
2026-02-15 13:03:05 +09:00
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Ship outline layer add failed:", e);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(outlineId, "visibility", visibility);
|
2026-02-15 18:42:49 +09:00
|
|
|
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,
|
|
|
|
|
);
|
2026-02-15 15:17:48 +09:00
|
|
|
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)",
|
2026-02-15 18:42:49 +09:00
|
|
|
["coalesce", ["get", "shipColor"], "#64748b"],
|
2026-02-15 15:17:48 +09:00
|
|
|
] as never,
|
|
|
|
|
);
|
|
|
|
|
map.setPaintProperty(
|
|
|
|
|
outlineId,
|
|
|
|
|
"circle-stroke-width",
|
|
|
|
|
[
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
3.4,
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
2.7,
|
2026-02-15 16:09:21 +09:00
|
|
|
["==", ["get", "permitted"], 1],
|
|
|
|
|
1.8,
|
2026-02-15 15:17:48 +09:00
|
|
|
0.0,
|
|
|
|
|
] as never,
|
|
|
|
|
);
|
2026-02-15 13:03:05 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 19:15:20 +09:00
|
|
|
if (!map.getLayer(symbolId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
2026-02-15 13:03:05 +09:00
|
|
|
id: symbolId,
|
|
|
|
|
type: "symbol",
|
|
|
|
|
source: srcId,
|
|
|
|
|
layout: {
|
|
|
|
|
visibility,
|
2026-02-15 15:17:48 +09:00
|
|
|
"symbol-sort-key": [
|
|
|
|
|
"case",
|
2026-02-15 18:42:49 +09:00
|
|
|
["all", ["==", ["get", "selected"], 1], ["==", ["get", "permitted"], 1]],
|
|
|
|
|
140,
|
|
|
|
|
["all", ["==", ["get", "highlighted"], 1], ["==", ["get", "permitted"], 1]],
|
|
|
|
|
135,
|
|
|
|
|
["==", ["get", "permitted"], 1],
|
|
|
|
|
130,
|
2026-02-15 15:17:48 +09:00
|
|
|
["==", ["get", "selected"], 1],
|
2026-02-15 18:42:49 +09:00
|
|
|
80,
|
2026-02-15 15:17:48 +09:00
|
|
|
["==", ["get", "highlighted"], 1],
|
2026-02-15 18:42:49 +09:00
|
|
|
75,
|
2026-02-15 15:17:48 +09:00
|
|
|
45,
|
|
|
|
|
] as never,
|
2026-02-15 22:18:40 +09:00
|
|
|
"icon-image": [
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["to-number", ["get", "isAnchored"], 0], 1],
|
|
|
|
|
anchoredImgId,
|
|
|
|
|
imgId,
|
|
|
|
|
] as never,
|
2026-02-15 13:46:01 +09:00
|
|
|
"icon-size": [
|
|
|
|
|
"interpolate",
|
|
|
|
|
["linear"],
|
|
|
|
|
["zoom"],
|
|
|
|
|
3,
|
2026-02-15 14:04:37 +09:00
|
|
|
["to-number", ["get", "iconSize3"], 0.35],
|
2026-02-15 13:46:01 +09:00
|
|
|
7,
|
2026-02-15 14:04:37 +09:00
|
|
|
["to-number", ["get", "iconSize7"], 0.45],
|
2026-02-15 13:46:01 +09:00
|
|
|
10,
|
2026-02-15 14:04:37 +09:00
|
|
|
["to-number", ["get", "iconSize10"], 0.56],
|
2026-02-15 13:46:01 +09:00
|
|
|
14,
|
2026-02-15 14:04:37 +09:00
|
|
|
["to-number", ["get", "iconSize14"], 0.72],
|
2026-02-15 13:46:01 +09:00
|
|
|
] as unknown as number[],
|
2026-02-15 13:03:05 +09:00
|
|
|
"icon-allow-overlap": true,
|
|
|
|
|
"icon-ignore-placement": true,
|
|
|
|
|
"icon-anchor": "center",
|
2026-02-15 22:18:40 +09:00
|
|
|
"icon-rotate": [
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["to-number", ["get", "isAnchored"], 0], 1],
|
|
|
|
|
0,
|
|
|
|
|
["to-number", ["get", "heading"], 0],
|
|
|
|
|
] as never,
|
2026-02-15 13:46:01 +09:00
|
|
|
// Keep the icon on the sea surface.
|
2026-02-15 13:03:05 +09:00
|
|
|
"icon-rotation-alignment": "map",
|
2026-02-15 14:33:50 +09:00
|
|
|
"icon-pitch-alignment": "map",
|
2026-02-15 13:03:05 +09:00
|
|
|
},
|
|
|
|
|
paint: {
|
2026-02-15 16:14:03 +09:00
|
|
|
"icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never,
|
2026-02-15 18:42:49 +09:00
|
|
|
"icon-opacity": [
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["get", "permitted"], 1],
|
|
|
|
|
1,
|
|
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
0.86,
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
|
|
|
|
0.82,
|
|
|
|
|
0.66,
|
|
|
|
|
] as never,
|
2026-02-15 13:03:05 +09:00
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Ship symbol layer add failed:", e);
|
|
|
|
|
}
|
2026-02-15 19:15:20 +09:00
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(symbolId, "visibility", visibility);
|
|
|
|
|
map.setLayoutProperty(
|
2026-02-15 18:42:49 +09:00
|
|
|
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,
|
|
|
|
|
);
|
2026-02-15 13:03:05 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
2026-02-15 19:15:20 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 13:03:05 +09:00
|
|
|
|
2026-02-15 19:15:20 +09:00
|
|
|
// 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);
|
2026-02-15 23:57:38 +09:00
|
|
|
map.setFilter(labelId, labelFilter as never);
|
|
|
|
|
map.setLayoutProperty(labelId, "text-field", ["get", "labelName"] as never);
|
2026-02-15 19:15:20 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Selection and highlight are now source-data driven.
|
|
|
|
|
reorderGlobeFeatureLayers();
|
|
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
2026-02-15 13:03:05 +09:00
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const stop = onMapStyleReady(map, ensure);
|
2026-02-15 13:03:05 +09:00
|
|
|
return () => {
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 13:03:05 +09:00
|
|
|
};
|
2026-02-15 19:15:20 +09:00
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
settings.showShips,
|
|
|
|
|
overlays.shipLabels,
|
|
|
|
|
shipData,
|
|
|
|
|
legacyHits,
|
|
|
|
|
selectedMmsi,
|
|
|
|
|
isBaseHighlightedMmsi,
|
|
|
|
|
mapSyncEpoch,
|
|
|
|
|
reorderGlobeFeatureLayers,
|
|
|
|
|
]);
|
2026-02-15 13:03:05 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
// Globe hover overlay ships: update only hovered/selected targets to avoid rebuilding full ship layer on every hover.
|
2026-02-15 13:03:05 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
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";
|
2026-02-15 14:04:37 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
const remove = () => {
|
|
|
|
|
for (const id of [symbolId, outlineId, haloId]) {
|
|
|
|
|
try {
|
|
|
|
|
if (map.getLayer(id)) map.removeLayer(id);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
2026-02-15 14:04:37 +09:00
|
|
|
}
|
2026-02-15 13:03:05 +09:00
|
|
|
}
|
|
|
|
|
try {
|
2026-02-15 18:42:49 +09:00
|
|
|
if (map.getSource(srcId)) map.removeSource(srcId);
|
2026-02-15 13:03:05 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 18:42:49 +09:00
|
|
|
globeHoverShipSignatureRef.current = "";
|
|
|
|
|
reorderGlobeFeatureLayers();
|
|
|
|
|
kickRepaint(map);
|
2026-02-15 13:03:05 +09:00
|
|
|
};
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
const ensure = () => {
|
|
|
|
|
if (projectionBusyRef.current) return;
|
|
|
|
|
if (!map.isStyleLoaded()) return;
|
2026-02-15 14:52:57 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
if (projection !== "globe" || !settings.showShips || shipHoverOverlaySet.size === 0) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
if (globeShipsEpochRef.current !== mapSyncEpoch) {
|
|
|
|
|
globeShipsEpochRef.current = mapSyncEpoch;
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
ensureFallbackShipImage(map, imgId);
|
|
|
|
|
if (!map.hasImage(imgId)) {
|
|
|
|
|
return;
|
2026-02-15 13:46:01 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
const hovered = shipLayerData.filter((t) => shipHoverOverlaySet.has(t.mmsi) && !!legacyHits?.has(t.mmsi));
|
|
|
|
|
if (hovered.length === 0) {
|
2026-02-15 13:46:01 +09:00
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 18:42:49 +09:00
|
|
|
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;
|
2026-02-15 13:46:01 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
const hoverGeojson: GeoJSON.FeatureCollection<GeoJSON.Point> = {
|
2026-02-15 13:46:01 +09:00
|
|
|
type: "FeatureCollection",
|
2026-02-15 18:42:49 +09:00
|
|
|
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] },
|
2026-02-15 16:09:21 +09:00
|
|
|
properties: {
|
2026-02-15 18:42:49 +09:00
|
|
|
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,
|
2026-02-15 23:57:38 +09:00
|
|
|
permitted: legacy ? 1 : 0,
|
2026-02-15 16:09:21 +09:00
|
|
|
},
|
2026-02-15 18:42:49 +09:00
|
|
|
};
|
|
|
|
|
}),
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
2026-02-15 18:42:49 +09:00
|
|
|
if (existing) existing.setData(hoverGeojson);
|
|
|
|
|
else map.addSource(srcId, { type: "geojson", data: hoverGeojson } as GeoJSONSourceSpecification);
|
2026-02-15 13:46:01 +09:00
|
|
|
} catch (e) {
|
2026-02-15 18:42:49 +09:00
|
|
|
console.warn("Ship hover source setup failed:", e);
|
2026-02-15 13:46:01 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:24:00 +09:00
|
|
|
const before = undefined;
|
2026-02-15 13:46:01 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
if (!map.getLayer(haloId)) {
|
2026-02-15 13:46:01 +09:00
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
2026-02-15 18:42:49 +09:00
|
|
|
id: haloId,
|
|
|
|
|
type: "circle",
|
2026-02-15 13:46:01 +09:00
|
|
|
source: srcId,
|
2026-02-15 18:42:49 +09:00
|
|
|
layout: {
|
|
|
|
|
visibility: "visible",
|
|
|
|
|
"circle-sort-key": [
|
2026-02-15 13:46:01 +09:00
|
|
|
"case",
|
2026-02-15 18:42:49 +09:00
|
|
|
["==", ["get", "selected"], 1],
|
|
|
|
|
120,
|
|
|
|
|
["==", ["get", "permitted"], 1],
|
|
|
|
|
115,
|
|
|
|
|
110,
|
2026-02-15 13:46:01 +09:00
|
|
|
] as never,
|
2026-02-15 18:42:49 +09:00
|
|
|
},
|
|
|
|
|
paint: {
|
|
|
|
|
"circle-radius": GLOBE_SHIP_CIRCLE_RADIUS_EXPR,
|
|
|
|
|
"circle-color": [
|
2026-02-15 15:17:48 +09:00
|
|
|
"case",
|
2026-02-15 18:42:49 +09:00
|
|
|
["==", ["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],
|
2026-02-15 15:17:48 +09:00
|
|
|
2.8,
|
|
|
|
|
["boolean", ["get", "warn"], false],
|
|
|
|
|
2.2,
|
|
|
|
|
1.4,
|
|
|
|
|
] as never,
|
2026-02-15 13:46:01 +09:00
|
|
|
"line-opacity": 0.9,
|
|
|
|
|
},
|
2026-02-15 15:48:49 +09:00
|
|
|
} as unknown as LayerSpecification,
|
2026-02-15 13:46:01 +09:00
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Pair lines layer add failed:", e);
|
|
|
|
|
}
|
2026-02-15 15:48:49 +09:00
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(layerId, "visibility", "visible");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:43:36 +09:00
|
|
|
reorderGlobeFeatureLayers();
|
2026-02-15 13:46:01 +09:00
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const stop = onMapStyleReady(map, ensure);
|
2026-02-15 13:46:01 +09:00
|
|
|
ensure();
|
|
|
|
|
return () => {
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
2026-02-15 15:43:36 +09:00
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
overlays.pairLines,
|
|
|
|
|
pairLinks,
|
|
|
|
|
mapSyncEpoch,
|
|
|
|
|
reorderGlobeFeatureLayers,
|
|
|
|
|
]);
|
2026-02-15 13:46:01 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const srcId = "fc-lines-ml-src";
|
|
|
|
|
const layerId = "fc-lines-ml";
|
|
|
|
|
|
|
|
|
|
const remove = () => {
|
|
|
|
|
try {
|
2026-02-15 15:48:49 +09:00
|
|
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none");
|
2026-02-15 13:46:01 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
2026-02-15 15:22:23 +09:00
|
|
|
if (projectionBusyRef.current) return;
|
2026-02-15 13:46:01 +09:00
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
if (projection !== "globe" || !overlays.fcLines) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const segs: DashSeg[] = [];
|
2026-02-15 14:52:57 +09:00
|
|
|
for (const l of fcLinks || []) {
|
|
|
|
|
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
if (segs.length === 0) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
|
|
|
|
type: "FeatureCollection",
|
|
|
|
|
features: segs.map((s, idx) => ({
|
|
|
|
|
type: "Feature",
|
2026-02-15 15:48:49 +09:00
|
|
|
id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx),
|
2026-02-15 13:46:01 +09:00
|
|
|
geometry: { type: "LineString", coordinates: [s.from, s.to] },
|
2026-02-15 16:09:21 +09:00
|
|
|
properties: {
|
|
|
|
|
type: "fc",
|
|
|
|
|
suspicious: s.suspicious,
|
|
|
|
|
distanceNm: s.distanceNm,
|
|
|
|
|
fcMmsi: s.fromMmsi ?? -1,
|
|
|
|
|
otherMmsi: s.toMmsi ?? -1,
|
|
|
|
|
},
|
2026-02-15 13:46:01 +09:00
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:24:00 +09:00
|
|
|
const before = undefined;
|
2026-02-15 13:46:01 +09:00
|
|
|
|
|
|
|
|
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",
|
2026-02-15 15:17:48 +09:00
|
|
|
["==", ["get", "highlighted"], 1],
|
2026-02-15 18:42:49 +09:00
|
|
|
["case", ["boolean", ["get", "suspicious"], false], FC_LINE_SUSPICIOUS_ML_HL, FC_LINE_NORMAL_ML_HL],
|
2026-02-15 13:46:01 +09:00
|
|
|
["boolean", ["get", "suspicious"], false],
|
2026-02-15 18:42:49 +09:00
|
|
|
FC_LINE_SUSPICIOUS_ML,
|
|
|
|
|
FC_LINE_NORMAL_ML,
|
2026-02-15 13:46:01 +09:00
|
|
|
] as never,
|
2026-02-15 15:17:48 +09:00
|
|
|
"line-width": ["case", ["==", ["get", "highlighted"], 1], 2.0, 1.3] as never,
|
2026-02-15 13:46:01 +09:00
|
|
|
"line-opacity": 0.9,
|
|
|
|
|
},
|
2026-02-15 15:48:49 +09:00
|
|
|
} as unknown as LayerSpecification,
|
2026-02-15 13:46:01 +09:00
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("FC lines layer add failed:", e);
|
|
|
|
|
}
|
2026-02-15 15:48:49 +09:00
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(layerId, "visibility", "visible");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:43:36 +09:00
|
|
|
reorderGlobeFeatureLayers();
|
2026-02-15 13:46:01 +09:00
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const stop = onMapStyleReady(map, ensure);
|
2026-02-15 13:46:01 +09:00
|
|
|
ensure();
|
|
|
|
|
return () => {
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
2026-02-15 15:43:36 +09:00
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
overlays.fcLines,
|
|
|
|
|
fcLinks,
|
|
|
|
|
mapSyncEpoch,
|
|
|
|
|
reorderGlobeFeatureLayers,
|
|
|
|
|
]);
|
2026-02-15 13:46:01 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const srcId = "fleet-circles-ml-src";
|
2026-02-15 15:17:48 +09:00
|
|
|
const fillSrcId = "fleet-circles-ml-fill-src";
|
2026-02-15 13:46:01 +09:00
|
|
|
const layerId = "fleet-circles-ml";
|
2026-02-15 15:17:48 +09:00
|
|
|
const fillLayerId = "fleet-circles-ml-fill";
|
2026-02-15 13:46:01 +09:00
|
|
|
|
|
|
|
|
const remove = () => {
|
2026-02-15 15:17:48 +09:00
|
|
|
try {
|
2026-02-15 15:48:49 +09:00
|
|
|
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, "visibility", "none");
|
2026-02-15 15:17:48 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
try {
|
2026-02-15 15:48:49 +09:00
|
|
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none");
|
2026-02-15 15:17:48 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
2026-02-15 15:22:23 +09:00
|
|
|
if (projectionBusyRef.current) return;
|
2026-02-15 13:46:01 +09:00
|
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
|
if (projection !== "globe" || !overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
const fcLine: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
2026-02-15 13:46:01 +09:00
|
|
|
type: "FeatureCollection",
|
2026-02-15 15:48:49 +09:00
|
|
|
features: (fleetCircles || []).map((c) => {
|
2026-02-15 13:46:01 +09:00
|
|
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
|
|
|
|
return {
|
|
|
|
|
type: "Feature",
|
2026-02-15 15:48:49 +09:00
|
|
|
id: makeFleetCircleFeatureId(c.ownerKey),
|
2026-02-15 13:46:01 +09:00
|
|
|
geometry: { type: "LineString", coordinates: ring },
|
2026-02-15 14:52:57 +09:00
|
|
|
properties: {
|
|
|
|
|
type: "fleet",
|
|
|
|
|
ownerKey: c.ownerKey,
|
2026-02-15 15:17:48 +09:00
|
|
|
ownerLabel: c.ownerLabel,
|
2026-02-15 14:52:57 +09:00
|
|
|
count: c.count,
|
2026-02-15 15:17:48 +09:00
|
|
|
vesselMmsis: c.vesselMmsis,
|
2026-02-15 16:09:21 +09:00
|
|
|
// Kept for backward compatibility with existing paint expressions.
|
|
|
|
|
// Actual hover-state highlighting is now handled in
|
|
|
|
|
// updateGlobeOverlayPaintStates.
|
|
|
|
|
highlighted: 0,
|
2026-02-15 15:17:48 +09:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
|
|
|
|
|
type: "FeatureCollection",
|
2026-02-15 15:48:49 +09:00
|
|
|
features: (fleetCircles || []).map((c) => {
|
2026-02-15 15:17:48 +09:00
|
|
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
|
|
|
|
return {
|
|
|
|
|
type: "Feature",
|
2026-02-15 15:48:49 +09:00
|
|
|
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`,
|
2026-02-15 15:17:48 +09:00
|
|
|
geometry: { type: "Polygon", coordinates: [ring] },
|
|
|
|
|
properties: {
|
|
|
|
|
type: "fleet-fill",
|
|
|
|
|
ownerKey: c.ownerKey,
|
|
|
|
|
ownerLabel: c.ownerLabel,
|
|
|
|
|
count: c.count,
|
|
|
|
|
vesselMmsis: c.vesselMmsis,
|
2026-02-15 16:09:21 +09:00
|
|
|
// Kept for backward compatibility with existing paint expressions.
|
|
|
|
|
// Actual hover-state highlighting is now handled in
|
|
|
|
|
// updateGlobeOverlayPaintStates.
|
|
|
|
|
highlighted: 0,
|
2026-02-15 14:52:57 +09:00
|
|
|
},
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
2026-02-15 15:17:48 +09:00
|
|
|
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);
|
2026-02-15 13:46:01 +09:00
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Fleet circles source setup failed:", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:24:00 +09:00
|
|
|
const before = undefined;
|
2026-02-15 13:46:01 +09:00
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
if (!map.getLayer(fillLayerId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: fillLayerId,
|
|
|
|
|
type: "fill",
|
|
|
|
|
source: fillSrcId,
|
|
|
|
|
layout: { visibility: "visible" },
|
|
|
|
|
paint: {
|
|
|
|
|
"fill-color": [
|
|
|
|
|
"case",
|
|
|
|
|
["==", ["get", "highlighted"], 1],
|
2026-02-15 18:42:49 +09:00
|
|
|
FLEET_FILL_ML_HL,
|
|
|
|
|
FLEET_FILL_ML,
|
2026-02-15 15:17:48 +09:00
|
|
|
] 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);
|
|
|
|
|
}
|
2026-02-15 15:48:49 +09:00
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(fillLayerId, "visibility", "visible");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 15:17:48 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 13:46:01 +09:00
|
|
|
if (!map.getLayer(layerId)) {
|
|
|
|
|
try {
|
|
|
|
|
map.addLayer(
|
|
|
|
|
{
|
|
|
|
|
id: layerId,
|
|
|
|
|
type: "line",
|
|
|
|
|
source: srcId,
|
|
|
|
|
layout: { "line-cap": "round", "line-join": "round", visibility: "visible" },
|
|
|
|
|
paint: {
|
2026-02-15 18:42:49 +09:00
|
|
|
"line-color": ["case", ["==", ["get", "highlighted"], 1], FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
2026-02-15 15:17:48 +09:00
|
|
|
"line-width": ["case", ["==", ["get", "highlighted"], 1], 2, 1.1] as never,
|
2026-02-15 13:46:01 +09:00
|
|
|
"line-opacity": 0.85,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Fleet circles layer add failed:", e);
|
|
|
|
|
}
|
2026-02-15 15:48:49 +09:00
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(layerId, "visibility", "visible");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:43:36 +09:00
|
|
|
reorderGlobeFeatureLayers();
|
2026-02-15 13:46:01 +09:00
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const stop = onMapStyleReady(map, ensure);
|
2026-02-15 13:46:01 +09:00
|
|
|
ensure();
|
|
|
|
|
return () => {
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
2026-02-15 15:17:48 +09:00
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
overlays.fleetCircles,
|
|
|
|
|
fleetCircles,
|
|
|
|
|
mapSyncEpoch,
|
2026-02-15 15:43:36 +09:00
|
|
|
reorderGlobeFeatureLayers,
|
2026-02-15 15:17:48 +09:00
|
|
|
]);
|
2026-02-15 13:46:01 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
|
|
|
|
const srcId = "pair-range-ml-src";
|
|
|
|
|
const layerId = "pair-range-ml";
|
|
|
|
|
|
|
|
|
|
const remove = () => {
|
|
|
|
|
try {
|
2026-02-15 15:48:49 +09:00
|
|
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", "none");
|
2026-02-15 13:46:01 +09:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ensure = () => {
|
2026-02-15 15:22:23 +09:00
|
|
|
if (projectionBusyRef.current) return;
|
2026-02-15 13:46:01 +09:00
|
|
|
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];
|
2026-02-15 14:52:57 +09:00
|
|
|
ranges.push({
|
|
|
|
|
center,
|
|
|
|
|
radiusNm: Math.max(0.05, p.distanceNm / 2),
|
|
|
|
|
warn: p.warn,
|
|
|
|
|
aMmsi: p.aMmsi,
|
|
|
|
|
bMmsi: p.bMmsi,
|
|
|
|
|
distanceNm: p.distanceNm,
|
|
|
|
|
});
|
2026-02-15 13:46:01 +09:00
|
|
|
}
|
|
|
|
|
if (ranges.length === 0) {
|
|
|
|
|
remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fc: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
|
|
|
|
type: "FeatureCollection",
|
2026-02-15 15:48:49 +09:00
|
|
|
features: ranges.map((c) => {
|
2026-02-15 13:46:01 +09:00
|
|
|
const ring = circleRingLngLat(c.center, c.radiusNm * 1852);
|
|
|
|
|
return {
|
|
|
|
|
type: "Feature",
|
2026-02-15 15:48:49 +09:00
|
|
|
id: makePairLinkFeatureId(c.aMmsi, c.bMmsi),
|
2026-02-15 13:46:01 +09:00
|
|
|
geometry: { type: "LineString", coordinates: ring },
|
2026-02-15 14:52:57 +09:00
|
|
|
properties: {
|
|
|
|
|
type: "pair-range",
|
|
|
|
|
warn: c.warn,
|
|
|
|
|
aMmsi: c.aMmsi,
|
|
|
|
|
bMmsi: c.bMmsi,
|
|
|
|
|
distanceNm: c.distanceNm,
|
2026-02-15 16:09:21 +09:00
|
|
|
// Kept for backward compatibility with existing paint expressions.
|
|
|
|
|
// Actual hover-state highlighting is now handled in
|
|
|
|
|
// updateGlobeOverlayPaintStates.
|
|
|
|
|
highlighted: 0,
|
2026-02-15 14:52:57 +09:00
|
|
|
},
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:24:00 +09:00
|
|
|
const before = undefined;
|
2026-02-15 13:46:01 +09:00
|
|
|
|
|
|
|
|
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",
|
2026-02-15 15:17:48 +09:00
|
|
|
["==", ["get", "highlighted"], 1],
|
2026-02-15 18:42:49 +09:00
|
|
|
["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
2026-02-15 13:46:01 +09:00
|
|
|
["boolean", ["get", "warn"], false],
|
2026-02-15 18:42:49 +09:00
|
|
|
PAIR_RANGE_WARN_ML,
|
|
|
|
|
PAIR_RANGE_NORMAL_ML,
|
2026-02-15 13:46:01 +09:00
|
|
|
] as never,
|
2026-02-15 15:17:48 +09:00
|
|
|
"line-width": ["case", ["==", ["get", "highlighted"], 1], 1.6, 1.0] as never,
|
2026-02-15 13:46:01 +09:00
|
|
|
"line-opacity": 0.85,
|
|
|
|
|
},
|
|
|
|
|
} as unknown as LayerSpecification,
|
|
|
|
|
before,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn("Pair range layer add failed:", e);
|
|
|
|
|
}
|
2026-02-15 15:48:49 +09:00
|
|
|
} else {
|
|
|
|
|
try {
|
|
|
|
|
map.setLayoutProperty(layerId, "visibility", "visible");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 13:46:01 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
kickRepaint(map);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:17:27 +09:00
|
|
|
const stop = onMapStyleReady(map, ensure);
|
2026-02-15 13:46:01 +09:00
|
|
|
ensure();
|
|
|
|
|
return () => {
|
2026-02-15 14:17:27 +09:00
|
|
|
stop();
|
2026-02-15 13:46:01 +09:00
|
|
|
};
|
2026-02-15 15:43:36 +09:00
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
overlays.pairRange,
|
|
|
|
|
pairLinks,
|
|
|
|
|
mapSyncEpoch,
|
|
|
|
|
reorderGlobeFeatureLayers,
|
|
|
|
|
]);
|
2026-02-15 13:46:01 +09:00
|
|
|
|
2026-02-15 16:09:21 +09:00
|
|
|
const updateGlobeOverlayPaintStates = useCallback(() => {
|
|
|
|
|
if (projection !== "globe" || projectionBusyRef.current) return;
|
2026-02-15 11:22:38 +09:00
|
|
|
|
2026-02-15 16:09:21 +09:00
|
|
|
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",
|
2026-02-15 18:42:49 +09:00
|
|
|
[
|
|
|
|
|
"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,
|
2026-02-15 16:09:21 +09:00
|
|
|
);
|
|
|
|
|
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",
|
2026-02-15 18:42:49 +09:00
|
|
|
[
|
|
|
|
|
"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,
|
2026-02-15 16:09:21 +09:00
|
|
|
);
|
|
|
|
|
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,
|
2026-02-15 18:42:49 +09:00
|
|
|
["case", ["boolean", ["get", "warn"], false], PAIR_RANGE_WARN_ML_HL, PAIR_RANGE_NORMAL_ML_HL],
|
2026-02-15 16:09:21 +09:00
|
|
|
["boolean", ["get", "warn"], false],
|
2026-02-15 18:42:49 +09:00
|
|
|
PAIR_RANGE_WARN_ML,
|
|
|
|
|
PAIR_RANGE_NORMAL_ML,
|
2026-02-15 16:09:21 +09:00
|
|
|
] 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,
|
2026-02-15 18:42:49 +09:00
|
|
|
FLEET_FILL_ML_HL,
|
|
|
|
|
FLEET_FILL_ML,
|
2026-02-15 16:09:21 +09:00
|
|
|
] 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",
|
2026-02-15 18:42:49 +09:00
|
|
|
["case", fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never,
|
2026-02-15 16:09:21 +09:00
|
|
|
);
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-02-15 14:52:57 +09:00
|
|
|
const clearGlobeTooltip = useCallback(() => {
|
|
|
|
|
if (!mapTooltipRef.current) return;
|
|
|
|
|
try {
|
|
|
|
|
mapTooltipRef.current.remove();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
mapTooltipRef.current = null;
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-15 14:52:57 +09:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
if (layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill") {
|
2026-02-15 14:52:57 +09:00
|
|
|
return getFleetCircleTooltipHtml({
|
|
|
|
|
ownerKey: String(props.ownerKey ?? ""),
|
2026-02-15 15:17:48 +09:00
|
|
|
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ""),
|
2026-02-15 14:52:57 +09:00
|
|
|
count: Number(props.count ?? 0),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:43:36 +09:00
|
|
|
const zoneLabel = getZoneDisplayNameFromProps(props);
|
2026-02-15 14:52:57 +09:00
|
|
|
if (zoneLabel) {
|
2026-02-15 15:43:36 +09:00
|
|
|
return { html: `<div style="font-size: 12px; font-family: system-ui;">${zoneLabel}</div>` };
|
2026-02-15 14:52:57 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
[legacyHits, shipByMmsi],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!map) return;
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
const clearDeckGlobeHoverState = () => {
|
2026-02-15 16:09:21 +09:00
|
|
|
clearDeckHoverMmsi();
|
|
|
|
|
clearDeckHoverPairs();
|
2026-02-15 15:17:48 +09:00
|
|
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
|
|
|
|
clearMapFleetHoverState();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resetGlobeHoverStates = () => {
|
2026-02-15 16:09:21 +09:00
|
|
|
clearDeckHoverMmsi();
|
|
|
|
|
clearDeckHoverPairs();
|
2026-02-15 15:17:48 +09:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 14:52:57 +09:00
|
|
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
|
|
|
|
if (projection !== "globe") {
|
2026-02-15 15:17:48 +09:00
|
|
|
clearGlobeTooltip();
|
|
|
|
|
resetGlobeHoverStates();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 15:22:23 +09:00
|
|
|
if (projectionBusyRef.current) {
|
|
|
|
|
resetGlobeHoverStates();
|
|
|
|
|
clearGlobeTooltip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 15:17:48 +09:00
|
|
|
if (!map.isStyleLoaded()) {
|
|
|
|
|
clearDeckGlobeHoverState();
|
2026-02-15 14:52:57 +09:00
|
|
|
clearGlobeTooltip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
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 = [];
|
|
|
|
|
}
|
2026-02-15 14:52:57 +09:00
|
|
|
|
|
|
|
|
if (candidateLayerIds.length === 0) {
|
2026-02-15 15:17:48 +09:00
|
|
|
resetGlobeHoverStates();
|
2026-02-15 14:52:57 +09:00
|
|
|
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 = [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
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();
|
2026-02-15 14:52:57 +09:00
|
|
|
clearGlobeTooltip();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 15:17:48 +09:00
|
|
|
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);
|
2026-02-15 16:09:21 +09:00
|
|
|
setDeckHoverMmsi(mmsi == null ? [] : [mmsi]);
|
|
|
|
|
clearDeckHoverPairs();
|
2026-02-15 15:17:48 +09:00
|
|
|
clearMapFleetHoverState();
|
|
|
|
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
|
|
|
|
} else if (isPairLayer) {
|
|
|
|
|
const aMmsi = toIntMmsi(props.aMmsi);
|
|
|
|
|
const bMmsi = toIntMmsi(props.bMmsi);
|
2026-02-15 16:09:21 +09:00
|
|
|
setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]);
|
|
|
|
|
clearDeckHoverMmsi();
|
2026-02-15 15:17:48 +09:00
|
|
|
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);
|
2026-02-15 16:09:21 +09:00
|
|
|
setDeckHoverPairs(fromTo);
|
|
|
|
|
setDeckHoverMmsi(fromTo);
|
2026-02-15 15:17:48 +09:00
|
|
|
clearMapFleetHoverState();
|
|
|
|
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
|
|
|
|
} else if (isFleetLayer) {
|
|
|
|
|
const ownerKey = String(props.ownerKey ?? "");
|
|
|
|
|
const list = normalizeMmsiList(props.vesselMmsis);
|
|
|
|
|
setMapFleetHoverState(ownerKey || null, list);
|
2026-02-15 16:09:21 +09:00
|
|
|
clearDeckHoverMmsi();
|
|
|
|
|
clearDeckHoverPairs();
|
2026-02-15 15:17:48 +09:00
|
|
|
setHoveredZoneId((prev) => (prev === null ? prev : null));
|
|
|
|
|
} else if (isZoneLayer) {
|
|
|
|
|
clearMapFleetHoverState();
|
2026-02-15 16:09:21 +09:00
|
|
|
clearDeckHoverMmsi();
|
|
|
|
|
clearDeckHoverPairs();
|
2026-02-15 15:43:36 +09:00
|
|
|
const zoneId = getZoneIdFromProps(props);
|
2026-02-15 15:17:48 +09:00
|
|
|
setHoveredZoneId(zoneId || null);
|
|
|
|
|
} else {
|
|
|
|
|
resetGlobeHoverStates();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tooltip = buildGlobeFeatureTooltip(first);
|
|
|
|
|
if (!tooltip) {
|
|
|
|
|
if (!isZoneLayer) {
|
|
|
|
|
resetGlobeHoverStates();
|
|
|
|
|
}
|
|
|
|
|
clearGlobeTooltip();
|
|
|
|
|
return;
|
2026-02-15 14:52:57 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = tooltip?.html ?? "";
|
|
|
|
|
if (content) {
|
2026-02-15 15:17:48 +09:00
|
|
|
setGlobeTooltip(e.lngLat, content);
|
2026-02-15 14:52:57 +09:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
clearGlobeTooltip();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onMouseOut = () => {
|
2026-02-15 15:17:48 +09:00
|
|
|
resetGlobeHoverStates();
|
2026-02-15 14:52:57 +09:00
|
|
|
clearGlobeTooltip();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
map.on("mousemove", onMouseMove);
|
|
|
|
|
map.on("mouseout", onMouseOut);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
map.off("mousemove", onMouseMove);
|
|
|
|
|
map.off("mouseout", onMouseOut);
|
|
|
|
|
clearGlobeTooltip();
|
|
|
|
|
};
|
2026-02-15 15:17:48 +09:00
|
|
|
}, [
|
|
|
|
|
projection,
|
|
|
|
|
buildGlobeFeatureTooltip,
|
|
|
|
|
clearGlobeTooltip,
|
|
|
|
|
clearMapFleetHoverState,
|
2026-02-15 16:09:21 +09:00
|
|
|
clearDeckHoverPairs,
|
|
|
|
|
clearDeckHoverMmsi,
|
|
|
|
|
setDeckHoverPairs,
|
|
|
|
|
setDeckHoverMmsi,
|
2026-02-15 15:17:48 +09:00
|
|
|
setMapFleetHoverState,
|
|
|
|
|
setGlobeTooltip,
|
|
|
|
|
]);
|
2026-02-15 14:52:57 +09:00
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
const legacyTargets = useMemo(() => {
|
|
|
|
|
if (!legacyHits) return [];
|
|
|
|
|
return shipData.filter((t) => legacyHits.has(t.mmsi));
|
|
|
|
|
}, [shipData, legacyHits]);
|
|
|
|
|
|
2026-02-15 16:09:21 +09:00
|
|
|
const legacyTargetsOrdered = useMemo(() => {
|
|
|
|
|
if (legacyTargets.length === 0) return legacyTargets;
|
|
|
|
|
const layer = [...legacyTargets];
|
2026-02-15 16:28:04 +09:00
|
|
|
layer.sort((a, b) => a.mmsi - b.mmsi);
|
2026-02-15 16:09:21 +09:00
|
|
|
return layer;
|
2026-02-15 16:28:04 +09:00
|
|
|
}, [legacyTargets]);
|
|
|
|
|
|
|
|
|
|
const legacyOverlayTargets = useMemo(() => {
|
|
|
|
|
if (shipHighlightSet.size === 0) return [];
|
|
|
|
|
return legacyTargets.filter((target) => shipHighlightSet.has(target.mmsi));
|
|
|
|
|
}, [legacyTargets, shipHighlightSet]);
|
2026-02-15 16:09:21 +09:00
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
const fcDashed = useMemo(() => {
|
|
|
|
|
const segs: DashSeg[] = [];
|
2026-02-15 14:52:57 +09:00
|
|
|
for (const l of fcLinks || []) {
|
|
|
|
|
segs.push(...dashifyLine(l.from, l.to, l.suspicious, l.distanceNm, l.fcMmsi, l.otherMmsi));
|
|
|
|
|
}
|
2026-02-15 11:22:38 +09:00
|
|
|
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];
|
2026-02-15 14:52:57 +09:00
|
|
|
out.push({
|
|
|
|
|
center,
|
|
|
|
|
radiusNm: Math.max(0.05, p.distanceNm / 2),
|
|
|
|
|
warn: p.warn,
|
|
|
|
|
aMmsi: p.aMmsi,
|
|
|
|
|
bMmsi: p.bMmsi,
|
|
|
|
|
distanceNm: p.distanceNm,
|
|
|
|
|
});
|
2026-02-15 11:22:38 +09:00
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}, [pairLinks]);
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
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 [];
|
2026-02-15 18:42:49 +09:00
|
|
|
if (highlightedMmsiSetCombined.size === 0) return [];
|
2026-02-15 16:28:04 +09:00
|
|
|
return fcDashed.filter(
|
2026-02-15 18:42:49 +09:00
|
|
|
(line) =>
|
|
|
|
|
[line.fromMmsi, line.toMmsi].some((mmsi) => (mmsi == null ? false : highlightedMmsiSetCombined.has(mmsi))),
|
2026-02-15 16:28:04 +09:00
|
|
|
);
|
2026-02-15 18:42:49 +09:00
|
|
|
}, [fcDashed, hoveredShipSignature, overlays.fcLines, highlightedMmsiSetCombined]);
|
2026-02-15 16:28:04 +09:00
|
|
|
|
|
|
|
|
const fleetCirclesInteractive = useMemo(() => {
|
|
|
|
|
if (!overlays.fleetCircles || (fleetCircles?.length ?? 0) === 0) return [];
|
2026-02-15 18:42:49 +09:00
|
|
|
if (hoveredFleetOwnerKeys.size === 0 && highlightedMmsiSetCombined.size === 0) return [];
|
2026-02-15 16:28:04 +09:00
|
|
|
const circles = fleetCircles || [];
|
|
|
|
|
return circles.filter((item) => isHighlightedFleet(item.ownerKey, item.vesselMmsis));
|
2026-02-15 18:42:49 +09:00
|
|
|
}, [fleetCircles, hoveredFleetOwnerKeys, isHighlightedFleet, overlays.fleetCircles, highlightedMmsiSetCombined]);
|
2026-02-15 16:28:04 +09:00
|
|
|
|
|
|
|
|
// Static deck layers for mercator (positions + base states). Interaction overlays are handled separately.
|
2026-02-15 11:22:38 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
2026-02-15 12:11:39 +09:00
|
|
|
if (!map) return;
|
2026-02-15 16:28:04 +09:00
|
|
|
if (projection !== "mercator" || projectionBusyRef.current) {
|
|
|
|
|
if (projection !== "mercator") {
|
|
|
|
|
try {
|
|
|
|
|
if (overlayRef.current) overlayRef.current.setProps({ layers: [] } as never);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-02-15 14:33:50 +09:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
const deckTarget = ensureMercatorOverlay();
|
2026-02-15 12:11:39 +09:00
|
|
|
if (!deckTarget) return;
|
2026-02-15 11:22:38 +09:00
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
const layers: unknown[] = [];
|
|
|
|
|
const overlayParams = DEPTH_DISABLED_PARAMS;
|
2026-02-15 15:17:48 +09:00
|
|
|
const clearDeckHover = () => {
|
2026-02-15 16:09:21 +09:00
|
|
|
touchDeckHoverState(false);
|
2026-02-15 15:17:48 +09:00
|
|
|
};
|
2026-02-15 18:42:49 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2026-02-15 15:17:48 +09:00
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
if (settings.showDensity) {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
|
|
|
|
new HexagonLayer<AisTarget>({
|
|
|
|
|
id: "density",
|
2026-02-15 16:09:21 +09:00
|
|
|
data: shipLayerData,
|
2026-02-15 11:22:38 +09:00
|
|
|
pickable: true,
|
|
|
|
|
extruded: true,
|
|
|
|
|
radius: 2500,
|
|
|
|
|
elevationScale: 35,
|
|
|
|
|
coverage: 0.92,
|
|
|
|
|
opacity: 0.35,
|
|
|
|
|
getPosition: (d) => [d.lon, d.lat],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
if (overlays.pairRange && pairRanges.length > 0) {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
|
|
|
|
new ScatterplotLayer<PairRangeCircle>({
|
|
|
|
|
id: "pair-range",
|
|
|
|
|
data: pairRanges,
|
2026-02-15 14:52:57 +09:00
|
|
|
pickable: true,
|
2026-02-15 12:11:39 +09:00
|
|
|
billboard: false,
|
2026-02-15 11:22:38 +09:00
|
|
|
parameters: overlayParams,
|
|
|
|
|
filled: false,
|
|
|
|
|
stroked: true,
|
|
|
|
|
radiusUnits: "meters",
|
|
|
|
|
getRadius: (d) => d.radiusNm * 1852,
|
|
|
|
|
radiusMinPixels: 10,
|
|
|
|
|
lineWidthUnits: "pixels",
|
2026-02-15 16:28:04 +09:00
|
|
|
getLineWidth: () => 1,
|
2026-02-15 18:42:49 +09:00
|
|
|
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK : PAIR_RANGE_NORMAL_DECK),
|
2026-02-15 11:22:38 +09:00
|
|
|
getPosition: (d) => d.center,
|
2026-02-15 15:17:48 +09:00
|
|
|
onHover: (info) => {
|
|
|
|
|
if (!info.object) {
|
|
|
|
|
clearDeckHover();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 16:09:21 +09:00
|
|
|
touchDeckHoverState(true);
|
2026-02-15 15:17:48 +09:00
|
|
|
const p = info.object as PairRangeCircle;
|
2026-02-15 16:28:04 +09:00
|
|
|
setDeckHoverPairs([p.aMmsi, p.bMmsi]);
|
|
|
|
|
setDeckHoverMmsi([p.aMmsi, p.bMmsi]);
|
2026-02-15 15:17:48 +09:00
|
|
|
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 });
|
|
|
|
|
},
|
2026-02-15 11:22:38 +09:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
|
|
|
|
new LineLayer<PairLink>({
|
|
|
|
|
id: "pair-lines",
|
|
|
|
|
data: pairLinks,
|
2026-02-15 14:52:57 +09:00
|
|
|
pickable: true,
|
2026-02-15 11:22:38 +09:00
|
|
|
parameters: overlayParams,
|
2026-02-15 13:46:01 +09:00
|
|
|
getSourcePosition: (d) => d.from,
|
|
|
|
|
getTargetPosition: (d) => d.to,
|
2026-02-15 18:42:49 +09:00
|
|
|
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK : PAIR_LINE_NORMAL_DECK),
|
2026-02-15 16:28:04 +09:00
|
|
|
getWidth: (d) => (d.warn ? 2.2 : 1.4),
|
2026-02-15 11:22:38 +09:00
|
|
|
widthUnits: "pixels",
|
2026-02-15 15:17:48 +09:00
|
|
|
onHover: (info) => {
|
|
|
|
|
if (!info.object) {
|
|
|
|
|
clearDeckHover();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 16:09:21 +09:00
|
|
|
touchDeckHoverState(true);
|
2026-02-15 15:17:48 +09:00
|
|
|
const obj = info.object as PairLink;
|
2026-02-15 16:09:21 +09:00
|
|
|
setDeckHoverPairs([obj.aMmsi, obj.bMmsi]);
|
|
|
|
|
setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]);
|
2026-02-15 15:17:48 +09:00
|
|
|
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 });
|
|
|
|
|
},
|
2026-02-15 11:22:38 +09:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
if (overlays.fcLines && fcDashed.length > 0) {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
|
|
|
|
new LineLayer<DashSeg>({
|
|
|
|
|
id: "fc-lines",
|
|
|
|
|
data: fcDashed,
|
2026-02-15 14:52:57 +09:00
|
|
|
pickable: true,
|
2026-02-15 11:22:38 +09:00
|
|
|
parameters: overlayParams,
|
2026-02-15 13:46:01 +09:00
|
|
|
getSourcePosition: (d) => d.from,
|
|
|
|
|
getTargetPosition: (d) => d.to,
|
2026-02-15 18:42:49 +09:00
|
|
|
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK : FC_LINE_NORMAL_DECK),
|
2026-02-15 16:28:04 +09:00
|
|
|
getWidth: () => 1.3,
|
2026-02-15 11:22:38 +09:00
|
|
|
widthUnits: "pixels",
|
2026-02-15 15:17:48 +09:00
|
|
|
onHover: (info) => {
|
|
|
|
|
if (!info.object) {
|
|
|
|
|
clearDeckHover();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 16:09:21 +09:00
|
|
|
touchDeckHoverState(true);
|
2026-02-15 15:17:48 +09:00
|
|
|
const obj = info.object as DashSeg;
|
2026-02-15 16:28:04 +09:00
|
|
|
if (obj.fromMmsi == null || obj.toMmsi == null) {
|
2026-02-15 16:09:21 +09:00
|
|
|
clearDeckHover();
|
2026-02-15 15:17:48 +09:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
setDeckHoverPairs([obj.fromMmsi, obj.toMmsi]);
|
|
|
|
|
setDeckHoverMmsi([obj.fromMmsi, obj.toMmsi]);
|
2026-02-15 15:17:48 +09:00
|
|
|
clearMapFleetHoverState();
|
|
|
|
|
},
|
|
|
|
|
onClick: (info) => {
|
|
|
|
|
if (!info.object) return;
|
|
|
|
|
const obj = info.object as DashSeg;
|
2026-02-15 16:28:04 +09:00
|
|
|
if (obj.fromMmsi == null || obj.toMmsi == null) return;
|
2026-02-15 15:17:48 +09:00
|
|
|
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 });
|
|
|
|
|
},
|
2026-02-15 11:22:38 +09:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
2026-02-15 11:22:38 +09:00
|
|
|
layers.push(
|
2026-02-15 14:24:00 +09:00
|
|
|
new ScatterplotLayer<FleetCircle>({
|
|
|
|
|
id: "fleet-circles",
|
|
|
|
|
data: fleetCircles,
|
2026-02-15 14:52:57 +09:00
|
|
|
pickable: true,
|
2026-02-15 13:03:05 +09:00
|
|
|
billboard: false,
|
2026-02-15 11:22:38 +09:00
|
|
|
parameters: overlayParams,
|
|
|
|
|
filled: false,
|
|
|
|
|
stroked: true,
|
2026-02-15 14:24:00 +09:00
|
|
|
radiusUnits: "meters",
|
|
|
|
|
getRadius: (d) => d.radiusNm * 1852,
|
2026-02-15 11:22:38 +09:00
|
|
|
lineWidthUnits: "pixels",
|
2026-02-15 16:28:04 +09:00
|
|
|
getLineWidth: () => 1.1,
|
2026-02-15 18:42:49 +09:00
|
|
|
getLineColor: () => FLEET_RANGE_LINE_DECK,
|
2026-02-15 14:24:00 +09:00
|
|
|
getPosition: (d) => d.center,
|
2026-02-15 15:17:48 +09:00
|
|
|
onHover: (info) => {
|
|
|
|
|
if (!info.object) {
|
|
|
|
|
clearDeckHover();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 16:09:21 +09:00
|
|
|
touchDeckHoverState(true);
|
2026-02-15 15:17:48 +09:00
|
|
|
const obj = info.object as FleetCircle;
|
|
|
|
|
const list = toFleetMmsiList(obj.vesselMmsis);
|
|
|
|
|
setMapFleetHoverState(obj.ownerKey || null, list);
|
2026-02-15 16:09:21 +09:00
|
|
|
setDeckHoverMmsi(list);
|
|
|
|
|
clearDeckHoverPairs();
|
2026-02-15 15:17:48 +09:00
|
|
|
},
|
|
|
|
|
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];
|
2026-02-15 16:28:04 +09:00
|
|
|
if (first != null) onDeckSelectOrHighlight({ mmsi: first });
|
2026-02-15 15:17:48 +09:00
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
|
2026-02-15 15:17:48 +09:00
|
|
|
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,
|
2026-02-15 18:42:49 +09:00
|
|
|
getFillColor: () => FLEET_RANGE_FILL_DECK,
|
2026-02-15 15:17:48 +09:00
|
|
|
getPosition: (d) => d.center,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
if (settings.showShips) {
|
2026-02-15 18:42:49 +09:00
|
|
|
// Always render non-target ships below target ships.
|
|
|
|
|
const shipOnHover = (info: PickingInfo) => {
|
2026-02-15 11:22:38 +09:00
|
|
|
if (!info.object) {
|
2026-02-15 18:42:49 +09:00
|
|
|
clearDeckHover();
|
2026-02-15 11:22:38 +09:00
|
|
|
return;
|
|
|
|
|
}
|
2026-02-15 18:42:49 +09:00
|
|
|
touchDeckHoverState(true);
|
|
|
|
|
const obj = info.object as AisTarget;
|
|
|
|
|
setDeckHoverMmsi([obj.mmsi]);
|
|
|
|
|
clearDeckHoverPairs();
|
|
|
|
|
clearMapFleetHoverState();
|
|
|
|
|
};
|
|
|
|
|
const shipOnClick = (info: PickingInfo) => {
|
|
|
|
|
if (!info.object) {
|
|
|
|
|
onSelectMmsi(null);
|
|
|
|
|
return;
|
2026-02-15 11:22:38 +09:00
|
|
|
}
|
2026-02-15 18:42:49 +09:00
|
|
|
onDeckSelectOrHighlight(
|
|
|
|
|
{
|
|
|
|
|
mmsi: (info.object as AisTarget).mmsi,
|
|
|
|
|
srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent,
|
|
|
|
|
},
|
|
|
|
|
true,
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-02-15 15:30:09 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
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) =>
|
2026-02-15 21:52:04 +09:00
|
|
|
-getDisplayHeading({
|
2026-02-15 18:42:49 +09:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-02-15 15:30:09 +09:00
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
// 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) =>
|
2026-02-15 21:52:04 +09:00
|
|
|
-getDisplayHeading({
|
2026-02-15 18:42:49 +09:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-02-15 16:28:04 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
// 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],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
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) =>
|
2026-02-15 21:52:04 +09:00
|
|
|
-getDisplayHeading({
|
2026-02-15 18:42:49 +09:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
// Interaction overlays (hover/selection highlights) are appended so they always render above base layers.
|
2026-02-15 16:28:04 +09:00
|
|
|
if (overlays.pairRange && pairRangesInteractive.length > 0) {
|
2026-02-15 18:42:49 +09:00
|
|
|
layers.push(
|
2026-02-15 16:28:04 +09:00
|
|
|
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,
|
2026-02-15 18:42:49 +09:00
|
|
|
getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL),
|
2026-02-15 16:28:04 +09:00
|
|
|
getPosition: (d) => d.center,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (overlays.pairLines && pairLinksInteractive.length > 0) {
|
2026-02-15 18:42:49 +09:00
|
|
|
layers.push(
|
2026-02-15 16:28:04 +09:00
|
|
|
new LineLayer<PairLink>({
|
|
|
|
|
id: "pair-lines-overlay",
|
|
|
|
|
data: pairLinksInteractive,
|
|
|
|
|
pickable: false,
|
|
|
|
|
parameters: overlayParams,
|
|
|
|
|
getSourcePosition: (d) => d.from,
|
|
|
|
|
getTargetPosition: (d) => d.to,
|
2026-02-15 18:42:49 +09:00
|
|
|
getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL),
|
2026-02-15 16:28:04 +09:00
|
|
|
getWidth: () => 2.6,
|
|
|
|
|
widthUnits: "pixels",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (overlays.fcLines && fcLinesInteractive.length > 0) {
|
2026-02-15 18:42:49 +09:00
|
|
|
layers.push(
|
2026-02-15 16:28:04 +09:00
|
|
|
new LineLayer<DashSeg>({
|
|
|
|
|
id: "fc-lines-overlay",
|
|
|
|
|
data: fcLinesInteractive,
|
|
|
|
|
pickable: false,
|
|
|
|
|
parameters: overlayParams,
|
|
|
|
|
getSourcePosition: (d) => d.from,
|
|
|
|
|
getTargetPosition: (d) => d.to,
|
2026-02-15 18:42:49 +09:00
|
|
|
getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL),
|
2026-02-15 16:28:04 +09:00
|
|
|
getWidth: () => 1.9,
|
|
|
|
|
widthUnits: "pixels",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (overlays.fleetCircles && fleetCirclesInteractive.length > 0) {
|
2026-02-15 18:42:49 +09:00
|
|
|
layers.push(
|
2026-02-15 16:28:04 +09:00
|
|
|
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,
|
2026-02-15 18:42:49 +09:00
|
|
|
getFillColor: () => FLEET_RANGE_FILL_DECK_HL,
|
2026-02-15 16:28:04 +09:00
|
|
|
}),
|
|
|
|
|
);
|
2026-02-15 18:42:49 +09:00
|
|
|
layers.push(
|
2026-02-15 16:28:04 +09:00
|
|
|
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,
|
2026-02-15 18:42:49 +09:00
|
|
|
getLineColor: () => FLEET_RANGE_LINE_DECK_HL,
|
2026-02-15 16:28:04 +09:00
|
|
|
getPosition: (d) => d.center,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (settings.showShips && legacyOverlayTargets.length > 0) {
|
2026-02-15 18:42:49 +09:00
|
|
|
layers.push(
|
2026-02-15 16:28:04 +09:00
|
|
|
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],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
if (settings.showShips && shipOverlayTargetData.length > 0) {
|
|
|
|
|
layers.push(
|
2026-02-15 16:28:04 +09:00
|
|
|
new IconLayer<AisTarget>({
|
2026-02-15 18:42:49 +09:00
|
|
|
id: "ships-overlay-target",
|
|
|
|
|
data: shipOverlayTargetData,
|
2026-02-15 16:28:04 +09:00
|
|
|
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) =>
|
2026-02-15 21:52:04 +09:00
|
|
|
-getDisplayHeading({
|
2026-02-15 16:28:04 +09:00
|
|
|
cog: d.cog,
|
|
|
|
|
heading: d.heading,
|
|
|
|
|
}),
|
|
|
|
|
sizeUnits: "pixels",
|
2026-02-15 16:35:05 +09:00
|
|
|
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];
|
2026-02-15 18:42:49 +09:00
|
|
|
return getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet);
|
2026-02-15 16:35:05 +09:00
|
|
|
},
|
2026-02-15 16:28:04 +09:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 18:42:49 +09:00
|
|
|
const normalizedLayers = sanitizeDeckLayerList(layers);
|
|
|
|
|
const deckProps = {
|
2026-02-15 16:28:04 +09:00
|
|
|
layers: normalizedLayers,
|
2026-02-15 18:42:49 +09:00
|
|
|
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);
|
2026-02-15 23:57:38 +09:00
|
|
|
const clickOpts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
|
|
|
|
if (projectionRef.current === "globe") {
|
|
|
|
|
map.flyTo(clickOpts);
|
|
|
|
|
} else {
|
|
|
|
|
map.easeTo(clickOpts);
|
|
|
|
|
}
|
2026-02-15 18:42:49 +09:00
|
|
|
}
|
|
|
|
|
},
|
2026-02-15 16:28:04 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-15 18:42:49 +09:00
|
|
|
deckTarget.setProps(deckProps as never);
|
2026-02-15 16:28:04 +09:00
|
|
|
} catch (e) {
|
2026-02-15 18:42:49 +09:00
|
|
|
console.error("Failed to apply base mercator deck props. Falling back to empty layer set.", e);
|
2026-02-15 16:28:04 +09:00
|
|
|
try {
|
2026-02-15 18:42:49 +09:00
|
|
|
deckTarget.setProps({ ...deckProps, layers: [] as unknown[] } as never);
|
2026-02-15 16:28:04 +09:00
|
|
|
} catch {
|
|
|
|
|
// Ignore secondary failure.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [
|
2026-02-15 18:42:49 +09:00
|
|
|
ensureMercatorOverlay,
|
2026-02-15 16:28:04 +09:00
|
|
|
projection,
|
2026-02-15 18:42:49 +09:00
|
|
|
overlayRef,
|
2026-02-15 16:28:04 +09:00
|
|
|
projectionBusyRef,
|
2026-02-15 18:42:49 +09:00
|
|
|
shipLayerData,
|
|
|
|
|
shipByMmsi,
|
|
|
|
|
pairRanges,
|
|
|
|
|
pairLinks,
|
|
|
|
|
fcDashed,
|
|
|
|
|
fleetCircles,
|
|
|
|
|
legacyTargetsOrdered,
|
|
|
|
|
legacyHits,
|
2026-02-15 16:28:04 +09:00
|
|
|
legacyOverlayTargets,
|
2026-02-15 18:42:49 +09:00
|
|
|
shipOverlayLayerData,
|
2026-02-15 16:28:04 +09:00
|
|
|
pairRangesInteractive,
|
|
|
|
|
pairLinksInteractive,
|
|
|
|
|
fcLinesInteractive,
|
|
|
|
|
fleetCirclesInteractive,
|
|
|
|
|
overlays.pairRange,
|
|
|
|
|
overlays.pairLines,
|
|
|
|
|
overlays.fcLines,
|
|
|
|
|
overlays.fleetCircles,
|
2026-02-15 18:42:49 +09:00
|
|
|
settings.showDensity,
|
2026-02-15 16:28:04 +09:00
|
|
|
settings.showShips,
|
2026-02-15 18:42:49 +09:00
|
|
|
onDeckSelectOrHighlight,
|
|
|
|
|
onSelectMmsi,
|
|
|
|
|
onToggleHighlightMmsi,
|
|
|
|
|
setDeckHoverPairs,
|
|
|
|
|
clearMapFleetHoverState,
|
|
|
|
|
setDeckHoverMmsi,
|
|
|
|
|
clearDeckHoverMmsi,
|
|
|
|
|
toFleetMmsiList,
|
|
|
|
|
touchDeckHoverState,
|
|
|
|
|
hasAuxiliarySelectModifier,
|
2026-02-15 16:28:04 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 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),
|
2026-02-15 18:42:49 +09:00
|
|
|
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;
|
|
|
|
|
},
|
2026-02-15 16:28:04 +09:00
|
|
|
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,
|
2026-02-15 18:42:49 +09:00
|
|
|
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;
|
|
|
|
|
},
|
2026-02-15 16:28:04 +09:00
|
|
|
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));
|
2026-02-15 18:42:49 +09:00
|
|
|
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;
|
2026-02-15 16:28:04 +09:00
|
|
|
},
|
|
|
|
|
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),
|
2026-02-15 18:42:49 +09:00
|
|
|
getLineColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_LINE_DECK_HL : FLEET_RANGE_LINE_DECK),
|
2026-02-15 16:28:04 +09:00
|
|
|
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,
|
2026-02-15 18:42:49 +09:00
|
|
|
getFillColor: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? FLEET_RANGE_FILL_DECK_HL : FLEET_RANGE_FILL_DECK),
|
2026-02-15 16:28:04 +09:00
|
|
|
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) => {
|
2026-02-15 18:42:49 +09:00
|
|
|
return selectedMmsi && d.mmsi === selectedMmsi ? 2.5 : 2;
|
2026-02-15 16:28:04 +09:00
|
|
|
},
|
|
|
|
|
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,
|
2026-02-15 16:09:21 +09:00
|
|
|
clearDeckHoverPairs,
|
2026-02-15 16:28:04 +09:00
|
|
|
clearDeckHoverMmsi,
|
2026-02-15 15:17:48 +09:00
|
|
|
clearMapFleetHoverState,
|
2026-02-15 16:28:04 +09:00
|
|
|
setDeckHoverPairs,
|
|
|
|
|
setDeckHoverMmsi,
|
2026-02-15 15:17:48 +09:00
|
|
|
setMapFleetHoverState,
|
2026-02-15 16:28:04 +09:00
|
|
|
toFleetMmsiList,
|
2026-02-15 16:09:21 +09:00
|
|
|
touchDeckHoverState,
|
2026-02-15 16:28:04 +09:00
|
|
|
legacyHits,
|
2026-02-15 11:22:38 +09:00
|
|
|
]);
|
|
|
|
|
|
2026-02-15 16:28:04 +09:00
|
|
|
// 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;
|
2026-02-15 23:57:38 +09:00
|
|
|
const opts = { center: [t.lon, t.lat] as [number, number], zoom: Math.max(map.getZoom(), 10), duration: 600 };
|
|
|
|
|
if (projectionRef.current === "globe") {
|
|
|
|
|
map.flyTo(opts);
|
|
|
|
|
} else {
|
|
|
|
|
map.easeTo(opts);
|
|
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
}, [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 = () => {
|
2026-02-15 23:57:38 +09:00
|
|
|
const opts = { center: [lon, lat] as [number, number], zoom, duration: 700 };
|
|
|
|
|
if (projectionRef.current === "globe") {
|
|
|
|
|
map.flyTo(opts);
|
|
|
|
|
} else {
|
|
|
|
|
map.easeTo(opts);
|
|
|
|
|
}
|
2026-02-15 16:28:04 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (map.isStyleLoaded()) {
|
|
|
|
|
apply();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stop = onMapStyleReady(map, apply);
|
|
|
|
|
return () => {
|
|
|
|
|
stop();
|
|
|
|
|
};
|
|
|
|
|
}, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]);
|
|
|
|
|
|
2026-02-15 11:22:38 +09:00
|
|
|
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
|
|
|
|
|
}
|