From ed5b0da5f9f0d9afa452d02c0bd73e99c0472179 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 15:17:48 +0900 Subject: [PATCH] fix: prevent hover update loop and map style ready guard --- .../web/src/pages/dashboard/DashboardPage.tsx | 103 +- apps/web/src/widgets/map3d/Map3D.tsx | 1318 +++++++++++++++-- 2 files changed, 1265 insertions(+), 156 deletions(-) diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 001d320..61630b2 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -4,6 +4,7 @@ import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettings import type { MapToggleState } from "../../features/mapToggles/MapToggles"; import { MapToggles } from "../../features/mapToggles/MapToggles"; import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid"; +import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels"; import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib"; import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib"; @@ -87,6 +88,11 @@ export function DashboardPage() { }); const [selectedMmsi, setSelectedMmsi] = useState(null); + const [highlightedMmsiSet, setHighlightedMmsiSet] = useState([]); + const [hoveredMmsiSet, setHoveredMmsiSet] = useState([]); + const [hoveredFleetMmsiSet, setHoveredFleetMmsiSet] = useState([]); + const [hoveredPairMmsiSet, setHoveredPairMmsiSet] = useState([]); + const [hoveredFleetOwnerKey, setHoveredFleetOwnerKey] = useState(null); const [typeEnabled, setTypeEnabled] = useState>({ PT: true, "PT-S": true, @@ -109,6 +115,8 @@ export function DashboardPage() { fleetCircles: true, }); + const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined); + const [settings, setSettings] = useState({ showShips: true, showDensity: false, @@ -192,6 +200,52 @@ export function DashboardPage() { return legacyHits.get(selectedMmsi) ?? null; }, [selectedMmsi, legacyHits]); + const availableTargetMmsiSet = useMemo( + () => new Set(targetsInScope.map((t) => t.mmsi).filter((mmsi) => Number.isFinite(mmsi))), + [targetsInScope], + ); + const activeHighlightedMmsiSet = useMemo( + () => highlightedMmsiSet.filter((mmsi) => availableTargetMmsiSet.has(mmsi)), + [highlightedMmsiSet, availableTargetMmsiSet], + ); + + const setUniqueSorted = (items: number[]) => + Array.from(new Set(items.filter((item) => Number.isFinite(item)))).sort((a, b) => a - b); + + const setSortedIfChanged = (next: number[]) => { + const sorted = setUniqueSorted(next); + return (prev: number[]) => (prev.length === sorted.length && prev.every((v, i) => v === sorted[i]) ? prev : sorted); + }; + + const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => { + if (!mmsis.length) return; + const members = mmsis + .map((mmsi) => legacyVesselsFiltered.find((v): v is DerivedLegacyVessel => v.mmsi === mmsi)) + .filter( + (v): v is DerivedLegacyVessel & { lat: number; lon: number } => + v != null && typeof v.lat === "number" && typeof v.lon === "number" && Number.isFinite(v.lat) && Number.isFinite(v.lon), + ); + + if (members.length === 0) return; + const sumLon = members.reduce((acc, v) => acc + v.lon, 0); + const sumLat = members.reduce((acc, v) => acc + v.lat, 0); + const center: [number, number] = [sumLon / members.length, sumLat / members.length]; + setFleetFocus({ + id: `${ownerKey}-${Date.now()}`, + center, + zoom: 9, + }); + }; + + const toggleHighlightedMmsi = (mmsi: number) => { + setHighlightedMmsiSet((prev) => { + const next = new Set(prev); + if (next.has(mmsi)) next.delete(mmsi); + else next.add(mmsi); + return Array.from(next).sort((a, b) => a - b); + }); + }; + const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length; const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length; @@ -299,11 +353,27 @@ export function DashboardPage() {
- setHoveredMmsiSet(setUniqueSorted(mmsis))} + onClearHover={() => setHoveredMmsiSet([])} + onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} + onClearPairHover={() => setHoveredPairMmsiSet([])} + onHoverFleet={(ownerKey, fleetMmsis) => { + setHoveredFleetOwnerKey(ownerKey); + setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); + }} + onClearFleetHover={() => { + setHoveredFleetOwnerKey(null); + setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); + }} + hoveredFleetOwnerKey={hoveredFleetOwnerKey} + hoveredFleetMmsiSet={hoveredFleetMmsiSet} + onContextMenuFleet={handleFleetContextMenu} />
@@ -315,7 +385,15 @@ export function DashboardPage() { ({legacyVesselsFiltered.length}척) - + setHoveredMmsiSet([mmsi])} + onClearHover={() => setHoveredMmsiSet([])} + />
@@ -489,17 +567,32 @@ export function DashboardPage() { targets={targetsForMap} zones={zones} selectedMmsi={selectedMmsi} + highlightedMmsiSet={activeHighlightedMmsiSet} + hoveredMmsiSet={hoveredMmsiSet} + hoveredFleetMmsiSet={hoveredFleetMmsiSet} + hoveredPairMmsiSet={hoveredPairMmsiSet} + hoveredFleetOwnerKey={hoveredFleetOwnerKey} settings={settings} baseMap={baseMap} projection={projection} overlays={overlays} onSelectMmsi={setSelectedMmsi} + onToggleHighlightMmsi={toggleHighlightedMmsi} onViewBboxChange={setViewBbox} legacyHits={legacyHits} pairLinks={pairLinksForMap} fcLinks={fcLinksForMap} fleetCircles={fleetCirclesForMap} + fleetFocus={fleetFocus} onProjectionLoadingChange={setIsProjectionLoading} + onHoverFleet={(ownerKey, fleetMmsis) => { + setHoveredFleetOwnerKey(ownerKey); + setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); + }} + onClearFleetHover={() => { + setHoveredFleetOwnerKey(null); + setHoveredFleetMmsiSet((prev) => (prev.length === 0 ? prev : [])); + }} /> {selectedLegacyVessel ? ( diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 085674d..453a8e2 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -32,19 +32,57 @@ type Props = { targets: AisTarget[]; zones: ZonesGeoJson | null; selectedMmsi: number | null; + hoveredMmsiSet?: number[]; + hoveredFleetMmsiSet?: number[]; + hoveredPairMmsiSet?: number[]; + hoveredFleetOwnerKey?: string | null; + highlightedMmsiSet?: number[]; settings: Map3DSettings; baseMap: BaseMapId; projection: MapProjectionId; overlays: MapToggleState; onSelectMmsi: (mmsi: number | null) => void; + onToggleHighlightMmsi?: (mmsi: number) => void; onViewBboxChange?: (bbox: [number, number, number, number]) => void; legacyHits?: Map | null; pairLinks?: PairLink[]; fcLinks?: FcLink[]; fleetCircles?: FleetCircle[]; onProjectionLoadingChange?: (loading: boolean) => void; + fleetFocus?: { + id: string | number; + center: [number, number]; + zoom?: number; + }; + onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; + onClearFleetHover?: () => void; }; +function toNumberSet(values: number[] | undefined | null) { + const out = new Set(); + if (!values) return out; + for (const value of values) { + if (Number.isFinite(value)) { + out.add(value); + } + } + return out; +} + +function mergeNumberSets(...sets: Set[]) { + const out = new Set(); + for (const s of sets) { + for (const v of s) { + out.add(v); + } + } + return out; +} + +function makeSetSignature(values: Set) { + return Array.from(values).sort((a, b) => a - b).join(","); +} + const SHIP_ICON_MAPPING = { ship: { x: 0, @@ -88,7 +126,12 @@ function kickRepaint(map: maplibregl.Map | null) { } } -function onMapStyleReady(map: maplibregl.Map, callback: () => void) { +function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) { + if (!map) { + return () => { + // noop + }; + } if (map.isStyleLoaded()) { callback(); return () => { @@ -98,7 +141,7 @@ function onMapStyleReady(map: maplibregl.Map, callback: () => void) { let fired = false; const runOnce = () => { - if (fired || !map.isStyleLoaded()) return; + if (!map || fired || !map.isStyleLoaded()) return; fired = true; callback(); try { @@ -118,6 +161,7 @@ function onMapStyleReady(map: maplibregl.Map, callback: () => void) { if (fired) return; fired = true; try { + if (!map) return; map.off("style.load", runOnce); map.off("styledata", runOnce); map.off("idle", runOnce); @@ -139,6 +183,9 @@ function extractProjectionType(map: maplibregl.Map): MapProjectionId | undefined const DEG2RAD = Math.PI / 180; const GLOBE_ICON_HEADING_OFFSET_DEG = -90; +const MAP_SELECTED_SHIP_RGB: [number, number, number] = [34, 211, 238]; +const MAP_HIGHLIGHT_SHIP_RGB: [number, number, number] = [245, 158, 11]; +const MAP_DEFAULT_SHIP_RGB: [number, number, number] = [100, 116, 139]; const clampNumber = (value: number, minValue: number, maxValue: number) => Math.max(minValue, Math.min(maxValue, value)); @@ -317,15 +364,18 @@ function getRangeTooltipHtml({ function getFleetCircleTooltipHtml({ ownerKey, + ownerLabel, count, }: { ownerKey: string; + ownerLabel?: string; count: number; }) { + const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey; return { html: `
선단 범위
-
소유주: ${ownerKey || "-"}
+
소유주: ${displayOwner || "-"}
선박 수: ${count}
`, }; @@ -345,16 +395,13 @@ function lightenColor(rgb: [number, number, number], ratio = 0.32) { return out; } -function getGlobeShipColor({ - selected, +function getGlobeBaseShipColor({ legacy, sog, }: { - selected: boolean; legacy: string | null; sog: number | null; }) { - if (selected) return "rgba(255,255,255,0.98)"; if (legacy) { const rgb = LEGACY_CODE_COLORS[legacy]; if (rgb) return rgbToHex(lightenColor(rgb, 0.38)); @@ -386,8 +433,10 @@ const DEPTH_DISABLED_PARAMS = { const FLAT_SHIP_ICON_SIZE = 19; const FLAT_SHIP_ICON_SIZE_SELECTED = 28; +const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; const FLAT_LEGACY_HALO_RADIUS = 14; const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; +const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; const GLOBE_OVERLAY_PARAMS = { // In globe mode we want depth-testing against the globe so features on the far side don't draw through. @@ -668,17 +717,24 @@ function getShipColor( t: AisTarget, selectedMmsi: number | null, legacyShipCode: string | null, + highlightedMmsis: Set, ): [number, number, number, number] { - if (selectedMmsi && t.mmsi === selectedMmsi) return [255, 255, 255, 255]; + if (selectedMmsi && t.mmsi === selectedMmsi) { + return [MAP_SELECTED_SHIP_RGB[0], MAP_SELECTED_SHIP_RGB[1], MAP_SELECTED_SHIP_RGB[2], 255]; + } + + if (highlightedMmsis.has(t.mmsi)) { + return [MAP_HIGHLIGHT_SHIP_RGB[0], MAP_HIGHLIGHT_SHIP_RGB[1], MAP_HIGHLIGHT_SHIP_RGB[2], 235]; + } if (legacyShipCode) { const rgb = LEGACY_CODE_COLORS[legacyShipCode]; if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; return [245, 158, 11, 235]; } - if (!isFiniteNumber(t.sog)) return [100, 116, 139, 160]; + if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; if (t.sog >= 10) return [59, 130, 246, 220]; if (t.sog >= 1) return [34, 197, 94, 210]; - return [100, 116, 139, 160]; + return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 160]; } type DashSeg = { @@ -748,6 +804,16 @@ type PairRangeCircle = { distanceNm: number; }; +const makeUniqueSorted = (values: number[]) => Array.from(new Set(values.filter((v) => Number.isFinite(v)))).sort((a, b) => a - b); + +const equalNumberArrays = (a: number[], b: number[]) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +}; + const EARTH_RADIUS_M = 6371008.8; // MapLibre's `earthRadius` const DECK_VIEW_ID = "mapbox"; @@ -755,17 +821,26 @@ export function Map3D({ targets, zones, selectedMmsi, + hoveredMmsiSet = [], + hoveredFleetMmsiSet = [], + hoveredPairMmsiSet = [], + hoveredFleetOwnerKey = null, + highlightedMmsiSet = [], settings, baseMap, projection, overlays, onSelectMmsi, + onToggleHighlightMmsi, onViewBboxChange, legacyHits, pairLinks, fcLinks, fleetCircles, onProjectionLoadingChange, + fleetFocus, + onHoverFleet, + onClearFleetHover, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -780,7 +855,168 @@ export function Map3D({ const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); const mapTooltipRef = useRef(null); + const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); + const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); + const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); + const [hoveredDeckFleetMmsiSet, setHoveredDeckFleetMmsiSet] = useState([]); + const [hoveredZoneId, setHoveredZoneId] = useState(null); + const hoveredMmsiSetRef = useMemo(() => toNumberSet(hoveredMmsiSet), [hoveredMmsiSet]); + const hoveredFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredFleetMmsiSet), [hoveredFleetMmsiSet]); + const hoveredPairMmsiSetRef = useMemo(() => toNumberSet(hoveredPairMmsiSet), [hoveredPairMmsiSet]); + const externalHighlightedSetRef = useMemo(() => toNumberSet(highlightedMmsiSet), [highlightedMmsiSet]); + const hoveredDeckMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckMmsiSet), [hoveredDeckMmsiSet]); + const hoveredDeckPairMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckPairMmsiSet), [hoveredDeckPairMmsiSet]); + const hoveredDeckFleetMmsiSetRef = useMemo(() => toNumberSet(hoveredDeckFleetMmsiSet), [hoveredDeckFleetMmsiSet]); + const hoveredFleetOwnerKeys = useMemo(() => { + const keys = new Set(); + if (hoveredFleetOwnerKey) keys.add(hoveredFleetOwnerKey); + if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); + return keys; + }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + const effectiveHoveredPairMmsiSet = useMemo( + () => mergeNumberSets(hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef), + [hoveredPairMmsiSetRef, hoveredDeckPairMmsiSetRef], + ); + const effectiveHoveredFleetMmsiSet = useMemo( + () => mergeNumberSets(hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef), + [hoveredFleetMmsiSetRef, hoveredDeckFleetMmsiSetRef], + ); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); + const highlightedMmsiSetCombined = useMemo( + () => + mergeNumberSets( + hoveredMmsiSetRef, + hoveredDeckMmsiSetRef, + externalHighlightedSetRef, + effectiveHoveredFleetMmsiSet, + effectiveHoveredPairMmsiSet, + ), + [ + hoveredMmsiSetRef, + hoveredDeckMmsiSetRef, + externalHighlightedSetRef, + effectiveHoveredFleetMmsiSet, + effectiveHoveredPairMmsiSet, + ], + ); + const hoveredShipSignature = useMemo( + () => + `${makeSetSignature(hoveredMmsiSetRef)}|${makeSetSignature(externalHighlightedSetRef)}|${makeSetSignature( + hoveredDeckMmsiSetRef, + )}|${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${makeSetSignature(effectiveHoveredPairMmsiSet)}`, + [ + hoveredMmsiSetRef, + externalHighlightedSetRef, + hoveredDeckMmsiSetRef, + effectiveHoveredFleetMmsiSet, + effectiveHoveredPairMmsiSet, + ], + ); + const hoveredFleetSignature = useMemo( + () => `${makeSetSignature(effectiveHoveredFleetMmsiSet)}|${[...hoveredFleetOwnerKeys].sort().join(",")}`, + [effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys], + ); + const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]); + + const isHighlightedMmsi = useCallback( + (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), + [highlightedMmsiSetCombined], + ); + + const isHighlightedPair = useCallback( + (aMmsi: number, bMmsi: number) => + effectiveHoveredPairMmsiSet.size === 2 && + effectiveHoveredPairMmsiSet.has(aMmsi) && + effectiveHoveredPairMmsiSet.has(bMmsi), + [effectiveHoveredPairMmsiSet], + ); + + const isHighlightedFleet = useCallback( + (ownerKey: string, vesselMmsis: number[]) => { + if (hoveredFleetOwnerKeys.has(ownerKey)) return true; + return vesselMmsis.some((x) => isHighlightedMmsi(x)); + }, + [hoveredFleetOwnerKeys, isHighlightedMmsi], + ); + + const hasAuxiliarySelectModifier = (ev?: { + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + } | null): boolean => { + if (!ev) return false; + return !!(ev.shiftKey || ev.ctrlKey || ev.metaKey); + }; + + const setHoveredMmsiList = useCallback((next: number[]) => { + const normalized = makeUniqueSorted(next); + setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckMmsiSingle = useCallback((mmsi: number | null) => { + const normalized = mmsi == null ? [] : [mmsi]; + setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckPairs = useCallback((next: number[]) => { + const normalized = makeUniqueSorted(next); + setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckFleetMmsis = useCallback((next: number[]) => { + const normalized = makeUniqueSorted(next); + setHoveredDeckFleetMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + }, []); + + const setHoveredDeckFleetOwner = useCallback((ownerKey: string | null) => { + setHoveredDeckFleetOwnerKey((prev) => (prev === ownerKey ? prev : ownerKey)); + }, []); + + const onHoverFleetRef = useRef(onHoverFleet); + const onClearFleetHoverRef = useRef(onClearFleetHover); + const mapFleetHoverStateRef = useRef<{ + ownerKey: string | null; + vesselMmsis: number[]; + }>({ ownerKey: null, vesselMmsis: [] }); + + useEffect(() => { + onHoverFleetRef.current = onHoverFleet; + onClearFleetHoverRef.current = onClearFleetHover; + }, [onHoverFleet, onClearFleetHover]); + + 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; + } + setHoveredDeckFleetOwner(ownerKey); + setHoveredDeckFleetMmsis(normalized); + mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; + onHoverFleetRef.current?.(ownerKey, normalized); + }, + [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis], + ); + + const clearMapFleetHoverState = useCallback(() => { + const nextOwner = null; + const prev = mapFleetHoverStateRef.current; + const shouldNotify = prev.ownerKey !== null || prev.vesselMmsis.length !== 0; + mapFleetHoverStateRef.current = { ownerKey: nextOwner, vesselMmsis: [] }; + setHoveredDeckFleetOwner(null); + setHoveredDeckFleetMmsis([]); + if (shouldNotify) { + onClearFleetHoverRef.current?.(); + } + }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); + + useEffect(() => { + mapFleetHoverStateRef.current = { + ownerKey: hoveredFleetOwnerKey, + vesselMmsis: hoveredFleetMmsiSet, + }; + }, [hoveredFleetOwnerKey, hoveredFleetMmsiSet]); const clearProjectionBusyTimer = useCallback(() => { if (projectionBusyTimerRef.current == null) return; @@ -887,6 +1123,7 @@ export function Map3D({ "ships-globe", "pair-lines-ml", "fc-lines-ml", + "fleet-circles-ml-fill", "fleet-circles-ml", "pair-range-ml", "deck-globe", @@ -896,7 +1133,14 @@ export function Map3D({ removeLayerIfExists(map, id); } - const sourceIds = ["ships-globe-src", "pair-lines-ml-src", "fc-lines-ml-src", "fleet-circles-ml-src", "pair-range-ml-src"]; + const sourceIds = [ + "ships-globe-src", + "pair-lines-ml-src", + "fc-lines-ml-src", + "fleet-circles-ml-src", + "fleet-circles-ml-fill-src", + "pair-range-ml-src", + ]; for (const id of sourceIds) { removeSourceIfExists(map, id); } @@ -1307,7 +1551,9 @@ export function Map3D({ // Vector basemap water layers can be tuned per-style. Keep visible by default, // only toggling layers that match an explicit water/sea signature. try { - for (const layer of map.getStyle().layers || []) { + const style = map.getStyle(); + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + for (const layer of styleLayers) { const id = String(layer.id ?? ""); if (!id) continue; const sourceLayer = String((layer as Record)["source-layer"] ?? "").toLowerCase(); @@ -1415,7 +1661,8 @@ export function Map3D({ // Keep zones below Deck layers (ships / deck-globe), and below seamarks if enabled. const style = map.getStyle(); - const firstSymbol = (style.layers || []).find((l) => (l as { type?: string } | undefined)?.type === "symbol") as + const styleLayers = style && Array.isArray(style.layers) ? style.layers : []; + const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === "symbol") as | { id?: string } | undefined; const before = map.getLayer("deck-globe") @@ -1426,6 +1673,58 @@ export function Map3D({ ? "seamark" : firstSymbol?.id; + const zoneMatchExpr = + hoveredZoneId !== null + ? (["==", ["to-string", ["coalesce", ["get", "zoneId"], ""]], hoveredZoneId] as unknown[]) + : false; + + if (map.getLayer(fillId)) { + try { + map.setPaintProperty( + fillId, + "fill-opacity", + hoveredZoneId ? (["case", zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12, + ); + } catch { + // ignore + } + } + + if (map.getLayer(lineId)) { + try { + map.setPaintProperty( + lineId, + "line-color", + hoveredZoneId + ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) + : (zoneColorExpr as never), + ); + } catch { + // ignore + } + try { + map.setPaintProperty(lineId, "line-opacity", hoveredZoneId ? (["case", zoneMatchExpr, 1, 0.85] as never) : 0.85); + } catch { + // ignore + } + try { + map.setPaintProperty( + lineId, + "line-width", + hoveredZoneId + ? ([ + "case", + zoneMatchExpr, + ["interpolate", ["linear"], ["zoom"], 4, 1.6, 10, 2.0, 14, 2.8], + ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + ] as never) + : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never), + ); + } catch { + // ignore + } + } + if (!map.getLayer(fillId)) { map.addLayer( { @@ -1434,7 +1733,14 @@ export function Map3D({ source: srcId, paint: { "fill-color": zoneColorExpr as never, - "fill-opacity": 0.12, + "fill-opacity": hoveredZoneId + ? ([ + "case", + zoneMatchExpr, + 0.24, + 0.1, + ] as unknown as number) + : 0.12, }, layout: { visibility }, } as unknown as LayerSpecification, @@ -1449,9 +1755,20 @@ export function Map3D({ type: "line", source: srcId, paint: { - "line-color": zoneColorExpr as never, - "line-opacity": 0.85, - "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + "line-color": hoveredZoneId + ? (["case", zoneMatchExpr, "rgba(125,211,252,0.98)", zoneColorExpr as never] as never) + : (zoneColorExpr as never), + "line-opacity": hoveredZoneId + ? (["case", zoneMatchExpr, 1, 0.85] as never) + : 0.85, + "line-width": hoveredZoneId + ? ([ + "case", + zoneMatchExpr, + ["interpolate", ["linear"], ["zoom"], 4, 1.6, 10, 2.0, 14, 2.8], + ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1], + ] as unknown as number[]) + : (["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 1.4, 14, 2.1] as never), }, layout: { visibility }, } as unknown as LayerSpecification, @@ -1497,7 +1814,7 @@ export function Map3D({ return () => { stop(); }; - }, [zones, overlays.zones, projection, baseMap, mapSyncEpoch]); + }, [zones, overlays.zones, projection, baseMap, hoveredZoneId, 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. @@ -1627,7 +1944,10 @@ export function Map3D({ const hull = clampNumber((isFiniteNumber(t.length) ? t.length : 0) + (isFiniteNumber(t.width) ? t.width : 0) * 3, 50, 420); const sizeScale = clampNumber(0.85 + (hull - 50) / 420, 0.82, 1.85); const selected = t.mmsi === selectedMmsi; + const highlighted = isHighlightedMmsi(t.mmsi); const selectedScale = selected ? 1.08 : 1; + const highlightScale = highlighted ? 1.06 : 1; + const iconScale = selected ? selectedScale : highlightScale; const iconSize3 = clampNumber(0.35 * sizeScale * selectedScale, 0.25, 1.3); const iconSize7 = clampNumber(0.45 * sizeScale * selectedScale, 0.3, 1.45); const iconSize10 = clampNumber(0.56 * sizeScale * selectedScale, 0.35, 1.7); @@ -1642,17 +1962,17 @@ export function Map3D({ cog: heading, heading, sog: isFiniteNumber(t.sog) ? t.sog : 0, - shipColor: getGlobeShipColor({ - selected, + shipColor: getGlobeBaseShipColor({ legacy: legacy?.shipCode || null, sog: isFiniteNumber(t.sog) ? t.sog : null, }), - iconSize3, - iconSize7, - iconSize10, - iconSize14, + iconSize3: iconSize3 * iconScale, + iconSize7: iconSize7 * iconScale, + iconSize10: iconSize10 * iconScale, + iconSize14: iconSize14 * iconScale, sizeScale, selected: selected ? 1 : 0, + highlighted: highlighted ? 1 : 0, permitted: !!legacy, code: legacy?.shipCode || "", }, @@ -1670,7 +1990,7 @@ export function Map3D({ } const visibility = settings.showShips ? "visible" : "none"; - const circleRadius = [ + const baseCircleRadius: unknown[] = [ "interpolate", ["linear"], ["zoom"], @@ -1683,6 +2003,38 @@ export function Map3D({ 14, 11, ] as const; + const highlightedCircleRadius: unknown[] = [ + "case", + ["==", ["get", "selected"], 1], + [ + "interpolate", + ["linear"], + ["zoom"], + 3, + 4.6, + 7, + 6.8, + 10, + 9.0, + 14, + 11.8, + ] as unknown[], + ["==", ["get", "highlighted"], 1], + [ + "interpolate", + ["linear"], + ["zoom"], + 3, + 4.2, + 7, + 6.2, + 10, + 8.2, + 14, + 10.8, + ] as unknown[], + baseCircleRadius, + ]; // Put ships at the top so they're always visible (especially important under globe projection). const before = undefined; @@ -1694,11 +2046,28 @@ export function Map3D({ id: haloId, type: "circle", source: srcId, - layout: { visibility }, + layout: { + visibility, + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 30, + ["==", ["get", "highlighted"], 1], + 25, + 10, + ] as never, + }, paint: { - "circle-radius": circleRadius as never, + "circle-radius": highlightedCircleRadius as never, "circle-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "circle-opacity": 0.22, + "circle-opacity": [ + "case", + ["==", ["get", "selected"], 1], + 0.38, + ["==", ["get", "highlighted"], 1], + 0.34, + 0.16, + ] as never, }, } as unknown as LayerSpecification, before, @@ -1709,6 +2078,16 @@ export function Map3D({ } else { try { map.setLayoutProperty(haloId, "visibility", visibility); + map.setPaintProperty(haloId, "circle-color", ["case", ["==", ["get", "highlighted"], 1], ["coalesce", ["get", "shipColor"], "#64748b"], ["coalesce", ["get", "shipColor"], "#64748b"]] as never); + map.setPaintProperty(haloId, "circle-opacity", [ + "case", + ["==", ["get", "selected"], 1], + 0.38, + ["==", ["get", "highlighted"], 1], + 0.34, + 0.16, + ] as never); + map.setPaintProperty(haloId, "circle-radius", highlightedCircleRadius as never); } catch { // ignore } @@ -1721,18 +2100,37 @@ export function Map3D({ id: outlineId, type: "circle", source: srcId, - layout: { visibility }, paint: { - "circle-radius": circleRadius as never, + "circle-radius": highlightedCircleRadius as never, "circle-color": "rgba(0,0,0,0)", - "circle-stroke-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, + "circle-stroke-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.95)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.95)", + "rgba(59,130,246,0.75)", + ] as never, "circle-stroke-width": [ "case", - ["boolean", ["get", "permitted"], false], - ["case", ["==", ["get", "selected"], 1], 2.5, 1.6], - ["case", ["==", ["get", "selected"], 1], 2.0, 0.0], - ] as unknown as number[], - "circle-stroke-opacity": 0.8, + ["==", ["get", "selected"], 1], + 3.4, + ["==", ["get", "highlighted"], 1], + 2.7, + 0.0, + ] as never, + "circle-stroke-opacity": 0.85, + }, + layout: { + visibility, + "circle-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 40, + ["==", ["get", "highlighted"], 1], + 35, + 15, + ] as never, }, } as unknown as LayerSpecification, before, @@ -1743,6 +2141,30 @@ export function Map3D({ } else { try { map.setLayoutProperty(outlineId, "visibility", visibility); + 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)", + "rgba(59,130,246,0.75)", + ] as never, + ); + map.setPaintProperty( + outlineId, + "circle-stroke-width", + [ + "case", + ["==", ["get", "selected"], 1], + 3.4, + ["==", ["get", "highlighted"], 1], + 2.7, + 0.0, + ] as never, + ); } catch { // ignore } @@ -1757,6 +2179,14 @@ export function Map3D({ source: srcId, layout: { visibility, + "symbol-sort-key": [ + "case", + ["==", ["get", "selected"], 1], + 50, + ["==", ["get", "highlighted"], 1], + 45, + 20, + ] as never, "icon-image": imgId, "icon-size": [ "interpolate", @@ -1780,10 +2210,38 @@ export function Map3D({ "icon-pitch-alignment": "map", }, paint: { - "icon-color": ["coalesce", ["get", "shipColor"], "#64748b"] as never, - "icon-opacity": ["case", ["==", ["get", "selected"], 1], 1.0, 0.92], - "icon-halo-color": "rgba(15,23,42,0.25)", - "icon-halo-width": 1, + "icon-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,1)", + "rgba(59,130,246,1)", + ] as never, + "icon-opacity": [ + "case", + ["==", ["get", "selected"], 1], + 1.0, + ["==", ["get", "highlighted"], 1], + 1, + 0.9, + ] as never, + "icon-halo-color": [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.68)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.72)", + "rgba(15,23,42,0.25)", + ] as never, + "icon-halo-width": [ + "case", + ["==", ["get", "selected"], 1], + 2.2, + ["==", ["get", "highlighted"], 1], + 1.5, + 0, + ] as never, }, } as unknown as LayerSpecification, before, @@ -1794,12 +2252,60 @@ export function Map3D({ } else { try { map.setLayoutProperty(symbolId, "visibility", visibility); + map.setPaintProperty( + symbolId, + "icon-color", + [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,1)", + ["coalesce", ["get", "shipColor"], "#64748b"], + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-opacity", + [ + "case", + ["==", ["get", "selected"], 1], + 1.0, + ["==", ["get", "highlighted"], 1], + 1, + 0.9, + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-halo-color", + [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,0.68)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.72)", + "rgba(15,23,42,0.25)", + ] as never, + ); + map.setPaintProperty( + symbolId, + "icon-halo-width", + [ + "case", + ["==", ["get", "selected"], 1], + 2.2, + ["==", ["get", "highlighted"], 1], + 1.5, + 0, + ] as never, + ); } catch { // ignore } } - // Selection is now source-data driven (`selected` property), no per-feature state update needed. + // Selection and highlight are now source-data driven. kickRepaint(map); }; @@ -1807,7 +2313,7 @@ export function Map3D({ return () => { stop(); }; - }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, mapSyncEpoch]); + }, [projection, settings.showShips, targets, legacyHits, selectedMmsi, hoveredMmsiSetRef, hoveredFleetMmsiSetRef, hoveredPairMmsiSetRef, isHighlightedMmsi, mapSyncEpoch]); // Globe ship click selection (MapLibre-native ships layer) useEffect(() => { @@ -1838,6 +2344,10 @@ export function Map3D({ >; 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; } @@ -1857,6 +2367,10 @@ export function Map3D({ } } if (bestMmsi != null) { + if (hasAuxiliarySelectModifier(e as unknown as { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean })) { + onToggleHighlightMmsi?.(bestMmsi); + return; + } onSelectMmsi(bestMmsi); return; } @@ -1874,7 +2388,7 @@ export function Map3D({ // ignore } }; - }, [projection, settings.showShips, onSelectMmsi, mapSyncEpoch, targets]); + }, [projection, settings.showShips, onSelectMmsi, onToggleHighlightMmsi, mapSyncEpoch, targets]); // Globe overlays (pair links / FC links / ranges) rendered as MapLibre GeoJSON layers. @@ -1918,6 +2432,7 @@ export function Map3D({ bMmsi: p.bMmsi, distanceNm: p.distanceNm, warn: p.warn, + highlighted: isHighlightedPair(p.aMmsi, p.bMmsi) ? 1 : 0, }, })), }; @@ -1944,11 +2459,20 @@ export function Map3D({ paint: { "line-color": [ "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.98)", ["boolean", ["get", "warn"], false], "rgba(245,158,11,0.95)", "rgba(59,130,246,0.55)", ] as never, - "line-width": ["case", ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, + "line-width": [ + "case", + ["==", ["get", "highlighted"], 1], + 2.8, + ["boolean", ["get", "warn"], false], + 2.2, + 1.4, + ] as never, "line-opacity": 0.9, }, } as unknown as LayerSpecification, @@ -1968,7 +2492,7 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch]); + }, [projection, overlays.pairLines, pairLinks, mapSyncEpoch, hoveredShipSignature, hoveredPairSignature, isHighlightedPair]); useEffect(() => { const map = mapRef.current; @@ -2018,6 +2542,7 @@ export function Map3D({ distanceNm: s.distanceNm, fcMmsi: s.fromMmsi ?? -1, otherMmsi: s.toMmsi ?? -1, + highlighted: s.fromMmsi != null && isHighlightedMmsi(s.fromMmsi) ? 1 : 0, }, })), }; @@ -2044,11 +2569,13 @@ export function Map3D({ paint: { "line-color": [ "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.98)", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)", ] as never, - "line-width": 1.3, + "line-width": ["case", ["==", ["get", "highlighted"], 1], 2.0, 1.3] as never, "line-opacity": 0.9, }, } as unknown as LayerSpecification, @@ -2068,16 +2595,23 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch]); + }, [projection, overlays.fcLines, fcLinks, mapSyncEpoch, hoveredShipSignature, isHighlightedMmsi]); useEffect(() => { const map = mapRef.current; if (!map) return; const srcId = "fleet-circles-ml-src"; + const fillSrcId = "fleet-circles-ml-fill-src"; const layerId = "fleet-circles-ml"; + const fillLayerId = "fleet-circles-ml-fill"; const remove = () => { + try { + if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId); + } catch { + // ignore + } try { if (map.getLayer(layerId)) map.removeLayer(layerId); } catch { @@ -2088,6 +2622,11 @@ export function Map3D({ } catch { // ignore } + try { + if (map.getSource(fillSrcId)) map.removeSource(fillSrcId); + } catch { + // ignore + } }; const ensure = () => { @@ -2097,7 +2636,7 @@ export function Map3D({ return; } - const fc: GeoJSON.FeatureCollection = { + const fcLine: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: (fleetCircles || []).map((c, idx) => { const ring = circleRingLngLat(c.center, c.radiusNm * 1852); @@ -2108,8 +2647,36 @@ export function Map3D({ properties: { type: "fleet", ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, count: c.count, - vesselMmsis: c.vesselMmsis.length, + vesselMmsis: c.vesselMmsis, + highlighted: + isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) + ? 1 + : 0, + }, + }; + }), + }; + + const fcFill: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: (fleetCircles || []).map((c, idx) => { + const ring = circleRingLngLat(c.center, c.radiusNm * 1852); + return { + type: "Feature", + id: `fleet-fill-${c.ownerKey}-${idx}`, + geometry: { type: "Polygon", coordinates: [ring] }, + properties: { + type: "fleet-fill", + ownerKey: c.ownerKey, + ownerLabel: c.ownerLabel, + count: c.count, + vesselMmsis: c.vesselMmsis, + highlighted: + isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) + ? 1 + : 0, }, }; }), @@ -2117,8 +2684,17 @@ export function Map3D({ try { const existing = map.getSource(srcId) as GeoJSONSource | undefined; - if (existing) existing.setData(fc); - else map.addSource(srcId, { type: "geojson", data: fc } as GeoJSONSourceSpecification); + if (existing) existing.setData(fcLine); + else map.addSource(srcId, { type: "geojson", data: fcLine } as GeoJSONSourceSpecification); + } catch (e) { + console.warn("Fleet circles source setup failed:", e); + return; + } + + try { + const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined; + if (existingFill) existingFill.setData(fcFill); + else map.addSource(fillSrcId, { type: "geojson", data: fcFill } as GeoJSONSourceSpecification); } catch (e) { console.warn("Fleet circles source setup failed:", e); return; @@ -2126,6 +2702,31 @@ export function Map3D({ const before = undefined; + if (!map.getLayer(fillLayerId)) { + try { + map.addLayer( + { + id: fillLayerId, + type: "fill", + source: fillSrcId, + layout: { visibility: "visible" }, + paint: { + "fill-color": [ + "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.16)", + "rgba(245,158,11,0.02)", + ] 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); + } + } + if (!map.getLayer(layerId)) { try { map.addLayer( @@ -2135,8 +2736,8 @@ export function Map3D({ source: srcId, layout: { "line-cap": "round", "line-join": "round", visibility: "visible" }, paint: { - "line-color": "rgba(245,158,11,0.65)", - "line-width": 1.1, + "line-color": ["case", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + "line-width": ["case", ["==", ["get", "highlighted"], 1], 2, 1.1] as never, "line-opacity": 0.85, }, } as unknown as LayerSpecification, @@ -2156,7 +2757,17 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.fleetCircles, fleetCircles, mapSyncEpoch]); + }, [ + projection, + overlays.fleetCircles, + fleetCircles, + mapSyncEpoch, + hoveredShipSignature, + hoveredFleetSignature, + hoveredFleetOwnerKey, + isHighlightedFleet, + isHighlightedMmsi, + ]); useEffect(() => { const map = mapRef.current; @@ -2216,6 +2827,7 @@ export function Map3D({ aMmsi: c.aMmsi, bMmsi: c.bMmsi, distanceNm: c.distanceNm, + highlighted: isHighlightedPair(c.aMmsi, c.bMmsi) ? 1 : 0, }, }; }), @@ -2243,11 +2855,13 @@ export function Map3D({ paint: { "line-color": [ "case", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,0.92)", ["boolean", ["get", "warn"], false], "rgba(245,158,11,0.75)", "rgba(59,130,246,0.45)", ] as never, - "line-width": 1.0, + "line-width": ["case", ["==", ["get", "highlighted"], 1], 1.6, 1.0] as never, "line-opacity": 0.85, }, } as unknown as LayerSpecification, @@ -2267,7 +2881,7 @@ export function Map3D({ stop(); remove(); }; - }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch]); + }, [projection, overlays.pairRange, pairLinks, mapSyncEpoch, hoveredShipSignature, hoveredPairSignature, hoveredFleetSignature, isHighlightedPair]); const shipData = useMemo(() => { return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon)); @@ -2289,6 +2903,25 @@ export function Map3D({ mapTooltipRef.current = null; }, []); + const setGlobeTooltip = useCallback((lngLat: maplibregl.LngLatLike, tooltipHtml: string) => { + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + if (!mapTooltipRef.current) { + mapTooltipRef.current = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false, + maxWidth: "360px", + className: "maplibre-tooltip-popup", + }); + } + + const container = document.createElement("div"); + container.className = "maplibre-tooltip-popup__content"; + container.innerHTML = tooltipHtml; + + mapTooltipRef.current.setLngLat(lngLat).setDOMContent(container).addTo(map); + }, []); + const buildGlobeFeatureTooltip = useCallback( (feature: { properties?: Record | null; layer?: { id?: string } } | null | undefined) => { if (!feature) return null; @@ -2342,9 +2975,10 @@ export function Map3D({ }); } - if (layerId === "fleet-circles-ml") { + if (layerId === "fleet-circles-ml" || layerId === "fleet-circles-ml-fill") { return getFleetCircleTooltipHtml({ ownerKey: String(props.ownerKey ?? ""), + ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ""), count: Number(props.count ?? 0), }); } @@ -2364,26 +2998,63 @@ export function Map3D({ const map = mapRef.current; if (!map) return; + const clearDeckGlobeHoverState = () => { + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const resetGlobeHoverStates = () => { + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + clearMapFleetHoverState(); + }; + + const normalizeMmsiList = (value: unknown): number[] => { + if (!Array.isArray(value)) return []; + const out: number[] = []; + for (const n of value) { + const m = toIntMmsi(n); + if (m != null) out.push(m); + } + return out; + }; + const onMouseMove = (e: maplibregl.MapMouseEvent) => { if (projection !== "globe") { + clearGlobeTooltip(); + resetGlobeHoverStates(); + return; + } + if (!map.isStyleLoaded()) { + clearDeckGlobeHoverState(); clearGlobeTooltip(); return; } - const candidateLayerIds = [ - "ships-globe", - "ships-globe-halo", - "ships-globe-outline", - "pair-lines-ml", - "fc-lines-ml", - "fleet-circles-ml", - "pair-range-ml", - "zones-fill", - "zones-line", - "zones-label", - ].filter((id) => map.getLayer(id)); + let candidateLayerIds: string[] = []; + try { + candidateLayerIds = [ + "ships-globe", + "ships-globe-halo", + "ships-globe-outline", + "pair-lines-ml", + "fc-lines-ml", + "fleet-circles-ml", + "fleet-circles-ml-fill", + "pair-range-ml", + "zones-fill", + "zones-line", + "zones-label", + ].filter((id) => map.getLayer(id)); + } catch { + candidateLayerIds = []; + } if (candidateLayerIds.length === 0) { + resetGlobeHoverStates(); clearGlobeTooltip(); return; } @@ -2398,30 +3069,95 @@ export function Map3D({ rendered = []; } - const first = rendered[0]; - const tooltip = buildGlobeFeatureTooltip(first); - if (!tooltip) { + const priority = [ + "ships-globe", + "ships-globe-halo", + "ships-globe-outline", + "pair-lines-ml", + "fc-lines-ml", + "pair-range-ml", + "fleet-circles-ml-fill", + "fleet-circles-ml", + "zones-fill", + "zones-line", + "zones-label", + ]; + + const first = priority.map((id) => rendered.find((r) => r.layer?.id === id)).find(Boolean) as + | { properties?: Record | null; layer?: { id?: string } } + | undefined; + + if (!first) { + resetGlobeHoverStates(); clearGlobeTooltip(); return; } - if (!mapTooltipRef.current) { - mapTooltipRef.current = new maplibregl.Popup({ - closeButton: false, - closeOnClick: false, - className: "maplibre-tooltip-popup", - }); + 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); + setHoveredDeckMmsiSingle(mmsi); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isPairLayer) { + const aMmsi = toIntMmsi(props.aMmsi); + const bMmsi = toIntMmsi(props.bMmsi); + setHoveredDeckPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + 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); + setHoveredDeckPairs(fromTo); + setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, fromTo) ? prev : fromTo)); + clearMapFleetHoverState(); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isFleetLayer) { + const ownerKey = String(props.ownerKey ?? ""); + const list = normalizeMmsiList(props.vesselMmsis); + setMapFleetHoverState(ownerKey || null, list); + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredZoneId((prev) => (prev === null ? prev : null)); + } else if (isZoneLayer) { + clearMapFleetHoverState(); + setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + const zoneId = String((props.zoneId ?? "").toString()); + setHoveredZoneId(zoneId || null); + } else { + resetGlobeHoverStates(); + } + + const tooltip = buildGlobeFeatureTooltip(first); + if (!tooltip) { + if (!isZoneLayer) { + resetGlobeHoverStates(); + } + clearGlobeTooltip(); + return; } const content = tooltip?.html ?? ""; if (content) { - mapTooltipRef.current.setLngLat(e.lngLat).setHTML(content).addTo(map); + setGlobeTooltip(e.lngLat, content); return; } clearGlobeTooltip(); }; const onMouseOut = () => { + resetGlobeHoverStates(); clearGlobeTooltip(); }; @@ -2433,7 +3169,16 @@ export function Map3D({ map.off("mouseout", onMouseOut); clearGlobeTooltip(); }; - }, [projection, buildGlobeFeatureTooltip, clearGlobeTooltip]); + }, [ + projection, + buildGlobeFeatureTooltip, + clearGlobeTooltip, + clearMapFleetHoverState, + setHoveredDeckPairs, + setHoveredDeckMmsiSingle, + setMapFleetHoverState, + setGlobeTooltip, + ]); const legacyTargets = useMemo(() => { if (!legacyHits) return []; @@ -2474,6 +3219,31 @@ export function Map3D({ map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); }, [selectedMmsi, shipData]); + useEffect(() => { + const map = mapRef.current; + if (!map || !fleetFocus) return; + const [lon, lat] = fleetFocus.center; + if (!Number.isFinite(lon) || !Number.isFinite(lat)) return; + + const apply = () => { + map.easeTo({ + center: [lon, lat], + zoom: fleetFocus.zoom ?? 10, + duration: 700, + }); + }; + + if (map.isStyleLoaded()) { + apply(); + return; + } + + const stop = onMapStyleReady(map, apply); + return () => { + stop(); + }; + }, [fleetFocus?.id, fleetFocus?.center?.[0], fleetFocus?.center?.[1], fleetFocus?.zoom]); + // Update Deck.gl layers useEffect(() => { const map = mapRef.current; @@ -2495,6 +3265,38 @@ export function Map3D({ const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const layers = []; + const clearDeckHover = () => { + setHoveredMmsiList([]); + setHoveredDeckPairs([]); + clearMapFleetHoverState(); + }; + + const toFleetMmsiList = (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 = (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); + }; if (settings.showDensity && projection !== "globe") { layers.push( @@ -2512,69 +3314,6 @@ export function Map3D({ ); } - if (settings.showShips && projection !== "globe") { - layers.push( - new IconLayer({ - id: "ships", - data: shipData, - pickable: true, - // Keep icons horizontal on the sea surface when view is pitched/rotated. - billboard: false, - parameters: overlayParams, - iconAtlas: "/assets/ship.svg", - iconMapping: SHIP_ICON_MAPPING, - getIcon: () => "ship", - getPosition: (d) => - [d.lon, d.lat] as [number, number], - getAngle: (d) => - getDisplayHeading({ - cog: d.cog, - heading: d.heading, - }), - sizeUnits: "pixels", - getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE), - getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null), - alphaCutoff: 0.05, - updateTriggers: { - getSize: [selectedMmsi], - getColor: [selectedMmsi, legacyHits], - }, - }), - ); - } - - if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { - layers.push( - new ScatterplotLayer({ - id: "legacy-halo", - data: legacyTargets, - pickable: false, - billboard: false, - // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. - parameters: overlayParams, - filled: false, - stroked: true, - radiusUnits: "pixels", - getRadius: (d) => - (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_LEGACY_HALO_RADIUS_SELECTED : 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], - updateTriggers: { - getRadius: [selectedMmsi], - getLineColor: [legacyHits], - }, - }), - ); - } - if (overlays.pairRange && projection !== "globe" && pairRanges.length > 0) { layers.push( new ScatterplotLayer({ @@ -2589,9 +3328,42 @@ export function Map3D({ getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: "pixels", - getLineWidth: () => 1, - getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]), + getLineWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.2 : 1), + getLineColor: (d) => { + if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 220]; + return d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]; + }, getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const p = info.object as PairRangeCircle; + const aMmsi = p.aMmsi; + const bMmsi = p.bMmsi; + setHoveredDeckPairs([aMmsi, bMmsi]); + setHoveredMmsiList([aMmsi, bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + const obj = info.object as PairRangeCircle; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + updateTriggers: { + getLineWidth: [hoveredPairSignature], + getLineColor: [hoveredPairSignature], + }, }), ); } @@ -2605,9 +3377,37 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]), - getWidth: (d) => (d.warn ? 2.2 : 1.4), + getColor: (d) => { + if (isHighlightedPair(d.aMmsi, d.bMmsi)) return [245, 158, 11, 245]; + return d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]; + }, + getWidth: (d) => (isHighlightedPair(d.aMmsi, d.bMmsi) ? 2.6 : d.warn ? 2.2 : 1.4), widthUnits: "pixels", + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const obj = info.object as PairLink; + setHoveredDeckPairs([obj.aMmsi, obj.bMmsi]); + setHoveredMmsiList([obj.aMmsi, obj.bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as PairLink; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.aMmsi); + onToggleHighlightMmsi?.(obj.bMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.aMmsi }); + }, + updateTriggers: { + getColor: [hoveredPairSignature], + getWidth: [hoveredPairSignature], + }, }), ); } @@ -2621,9 +3421,50 @@ export function Map3D({ parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, - getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]), - getWidth: () => 1.3, + getColor: (d) => { + const isHighlighted = [d.fromMmsi, d.toMmsi].some((v) => isHighlightedMmsi(v ?? -1)); + if (isHighlighted) return [245, 158, 11, 230]; + return d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]; + }, + 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) { + clearDeckHover(); + return; + } + const obj = info.object as DashSeg; + const aMmsi = obj.fromMmsi; + const bMmsi = obj.toMmsi; + if (aMmsi == null || bMmsi == null) { + setHoveredMmsiList([]); + return; + } + setHoveredDeckPairs([aMmsi, bMmsi]); + setHoveredMmsiList([aMmsi, bMmsi]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as DashSeg; + if (obj.fromMmsi == null || obj.toMmsi == null) { + return; + } + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(obj.fromMmsi); + onToggleHighlightMmsi?.(obj.toMmsi); + return; + } + onDeckSelectOrHighlight({ mmsi: obj.fromMmsi }); + }, + updateTriggers: { + getColor: [hoveredShipSignature], + getWidth: [hoveredShipSignature], + }, }), ); } @@ -2641,9 +3482,168 @@ export function Map3D({ radiusUnits: "meters", getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: "pixels", - getLineWidth: 1, - getLineColor: () => [245, 158, 11, 140], + getLineWidth: (d) => (isHighlightedFleet(d.ownerKey, d.vesselMmsis) ? 1.8 : 1.1), + getLineColor: (d) => { + const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); + return isHighlighted ? [245, 158, 11, 220] : [245, 158, 11, 140]; + }, getPosition: (d) => d.center, + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + setMapFleetHoverState(obj.ownerKey || null, list); + setHoveredMmsiList(list); + setHoveredDeckPairs([]); + }, + onClick: (info) => { + if (!info.object) return; + const obj = info.object as FleetCircle; + const list = toFleetMmsiList(obj.vesselMmsis); + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + for (const mmsi of list) onToggleHighlightMmsi?.(mmsi); + return; + } + const first = list[0]; + if (first != null) { + onDeckSelectOrHighlight({ mmsi: first }); + } + }, + updateTriggers: { + getLineWidth: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], + getLineColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], + }, + }), + ); + } + + if (overlays.fleetCircles && projection !== "globe" && (fleetCircles?.length ?? 0) > 0) { + layers.push( + new ScatterplotLayer({ + id: "fleet-circles-fill", + data: fleetCircles, + pickable: false, + billboard: false, + parameters: overlayParams, + filled: true, + stroked: false, + radiusUnits: "meters", + getRadius: (d) => d.radiusNm * 1852, + getFillColor: (d) => { + const isHighlighted = isHighlightedFleet(d.ownerKey, d.vesselMmsis); + return isHighlighted ? [245, 158, 11, 42] : [245, 158, 11, 6]; + }, + getPosition: (d) => d.center, + updateTriggers: { + getFillColor: [hoveredFleetSignature, hoveredFleetOwnerKey, hoveredShipSignature], + }, + }), + ); + } + + if (settings.showShips && projection !== "globe" && legacyTargets.length > 0) { + layers.push( + new ScatterplotLayer({ + id: "legacy-halo", + data: legacyTargets, + pickable: false, + billboard: false, + // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. + parameters: overlayParams, + filled: false, + stroked: true, + radiusUnits: "pixels", + getRadius: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_LEGACY_HALO_RADIUS_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED; + return FLAT_LEGACY_HALO_RADIUS; + }, + lineWidthUnits: "pixels", + getLineWidth: (d) => { + const isHighlighted = isHighlightedMmsi(d.mmsi); + return selectedMmsi && d.mmsi === selectedMmsi + ? 2.5 + : isHighlighted + ? 2.2 + : 2; + }, + getLineColor: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return [14, 234, 255, 230]; + if (isHighlightedMmsi(d.mmsi)) return [245, 158, 11, 210]; + 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], + updateTriggers: { + getRadius: [selectedMmsi, hoveredShipSignature], + getLineColor: [selectedMmsi, legacyHits, hoveredShipSignature], + }, + }), + ); + } + + if (settings.showShips && projection !== "globe") { + layers.push( + new IconLayer({ + id: "ships", + data: shipData, + pickable: true, + // Keep icons horizontal on the sea surface when view is pitched/rotated. + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => { + if (selectedMmsi && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (isHighlightedMmsi(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return FLAT_SHIP_ICON_SIZE; + }, + getColor: (d) => + getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + highlightedMmsiSetCombined, + ), + onHover: (info) => { + if (!info.object) { + clearDeckHover(); + return; + } + const obj = info.object as AisTarget; + setHoveredMmsiList([obj.mmsi]); + setHoveredDeckPairs([]); + clearMapFleetHoverState(); + }, + onClick: (info) => { + if (!info.object) { + onSelectMmsi(null); + return; + } + onDeckSelectOrHighlight({ + mmsi: info.object.mmsi, + srcEvent: (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent, + }, true); + }, + alphaCutoff: 0.05, + updateTriggers: { + getSize: [selectedMmsi, hoveredShipSignature], + getColor: [selectedMmsi, legacyHits, hoveredShipSignature], + }, }), ); } @@ -2711,6 +3711,7 @@ export function Map3D({ if (info.layer && info.layer.id === "fleet-circles") { return getFleetCircleTooltipHtml({ ownerKey: String(obj.ownerKey ?? ""), + ownerLabel: String(obj.ownerLabel ?? obj.ownerKey ?? ""), count: Number(obj.count ?? 0), }); } @@ -2733,6 +3734,11 @@ export function Map3D({ const obj: any = info.object; if (typeof obj.mmsi === "number") { const t = obj as AisTarget; + const sourceEvent = (info as { srcEvent?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } | null }).srcEvent; + if (sourceEvent && hasAuxiliarySelectModifier(sourceEvent)) { + onToggleHighlightMmsi?.(t.mmsi); + return; + } onSelectMmsi(t.mmsi); map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 }); } @@ -2763,6 +3769,16 @@ export function Map3D({ fleetCircles, shipByMmsi, mapSyncEpoch, + hoveredShipSignature, + hoveredFleetSignature, + hoveredPairSignature, + hoveredFleetOwnerKey, + highlightedMmsiSet, + isHighlightedMmsi, + isHighlightedFleet, + isHighlightedPair, + clearMapFleetHoverState, + setMapFleetHoverState, ensureMercatorOverlay, ]);