From 05b0c6b881351f877a31c9ad0903cf091b3633b0 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:09:21 +0900 Subject: [PATCH] feat(map3d): stabilize globe overlays and hover-highlight sync --- .../features/legacyDashboard/model/derive.ts | 5 + .../features/legacyDashboard/model/types.ts | 2 +- .../web/src/pages/dashboard/DashboardPage.tsx | 4 + apps/web/src/widgets/map3d/Map3D.tsx | 634 +++++++++++++----- .../web/src/widgets/vesselList/VesselList.tsx | 34 +- 5 files changed, 516 insertions(+), 163 deletions(-) diff --git a/apps/web/src/features/legacyDashboard/model/derive.ts b/apps/web/src/features/legacyDashboard/model/derive.ts index 10f2778..cf2d569 100644 --- a/apps/web/src/features/legacyDashboard/model/derive.ts +++ b/apps/web/src/features/legacyDashboard/model/derive.ts @@ -188,12 +188,17 @@ export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle const out: FleetCircle[] = []; for (const [ownerKey, vs] of groups.entries()) { if (vs.length < 3) continue; + const ownerLabel = + vs.find((v) => v.ownerCn)?.ownerCn ?? + vs.find((v) => v.ownerRoman)?.ownerRoman ?? + ownerKey; const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length; const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length; let radiusNm = 0; for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon)); out.push({ ownerKey, + ownerLabel, center: [lon, lat], radiusNm: Math.max(0.2, radiusNm), count: vs.length, diff --git a/apps/web/src/features/legacyDashboard/model/types.ts b/apps/web/src/features/legacyDashboard/model/types.ts index b2a3661..3ff2bf5 100644 --- a/apps/web/src/features/legacyDashboard/model/types.ts +++ b/apps/web/src/features/legacyDashboard/model/types.ts @@ -56,6 +56,7 @@ export type FcLink = { export type FleetCircle = { ownerKey: string; + ownerLabel: string; center: [number, number]; radiusNm: number; count: number; @@ -71,4 +72,3 @@ export type LegacyAlarm = { text: string; relatedMmsi: number[]; }; - diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 0ae6c5c..edd7764 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -610,6 +610,10 @@ export function DashboardPage() { fleetCircles={fleetCirclesForMap} fleetFocus={fleetFocus} onProjectionLoadingChange={setIsProjectionLoading} + onHoverMmsi={(mmsis) => setHoveredMmsiSet(setUniqueSorted(mmsis))} + onClearMmsiHover={() => setHoveredMmsiSet((prev) => (prev.length === 0 ? prev : []))} + onHoverPair={(pairMmsis) => setHoveredPairMmsiSet(setUniqueSorted(pairMmsis))} + onClearPairHover={() => setHoveredPairMmsiSet([])} onHoverFleet={(ownerKey, fleetMmsis) => { setHoveredFleetOwnerKey(ownerKey); setHoveredFleetMmsiSet(setSortedIfChanged(fleetMmsis)); diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 43119ee..d341d8f 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -56,6 +56,10 @@ type Props = { }; onHoverFleet?: (ownerKey: string | null, fleetMmsiSet: number[]) => void; onClearFleetHover?: () => void; + onHoverMmsi?: (mmsiList: number[]) => void; + onClearMmsiHover?: () => void; + onHoverPair?: (mmsiList: number[]) => void; + onClearPairHover?: () => void; }; function toNumberSet(values: number[] | undefined | null) { @@ -141,6 +145,49 @@ function makeFleetCircleFeatureId(ownerKey: string) { return `fleet-${ownerKey}`; } +function makeMmsiPairHighlightExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length < 2) { + return false; + } + const inA = ["in", ["to-number", ["get", aField]], ["literal", hoveredMmsiList]] as unknown[]; + const inB = ["in", ["to-number", ["get", bField]], ["literal", hoveredMmsiList]] as unknown[]; + return ["all", inA, inB] as unknown[]; +} + +function makeMmsiAnyEndpointExpr(aField: string, bField: string, hoveredMmsiList: number[]) { + if (!Array.isArray(hoveredMmsiList) || hoveredMmsiList.length === 0) { + return false; + } + const literal = ["literal", hoveredMmsiList] as unknown[]; + return [ + "any", + ["in", ["to-number", ["get", aField]], literal], + ["in", ["to-number", ["get", bField]], literal], + ] as unknown[]; +} + +function makeFleetOwnerMatchExpr(hoveredOwnerKeys: string[]) { + if (!Array.isArray(hoveredOwnerKeys) || hoveredOwnerKeys.length === 0) { + return false; + } + const expr = ["match", ["to-string", ["coalesce", ["get", "ownerKey"], ""]]] as unknown[]; + for (const ownerKey of hoveredOwnerKeys) { + expr.push(String(ownerKey), true); + } + expr.push(false); + return expr; +} + +function makeFleetMemberMatchExpr(hoveredFleetMmsiList: number[]) { + if (!Array.isArray(hoveredFleetMmsiList) || hoveredFleetMmsiList.length === 0) { + return false; + } + const clauses = hoveredFleetMmsiList.map((mmsi) => + ["in", mmsi, ["coalesce", ["get", "vesselMmsis"], []]] as unknown[], + ); + return ["any", ...clauses] as unknown[]; +} + const SHIP_ICON_MAPPING = { ship: { x: 0, @@ -922,7 +969,37 @@ type PairRangeCircle = { distanceNm: number; }; -const makeUniqueSorted = (values: number[]) => Array.from(new Set(values.filter((v) => Number.isFinite(v)))).sort((a, b) => a - b); +const toNumberArray = (values: unknown): number[] => { + if (values == null) return []; + if (Array.isArray(values)) { + return values as unknown as number[]; + } + if (typeof values === "number" && Number.isFinite(values)) { + return [values]; + } + if (typeof values === "string") { + const value = toSafeNumber(Number(values)); + return value == null ? [] : [value]; + } + if (typeof values === "object") { + if (typeof (values as { [Symbol.iterator]?: unknown })?.[Symbol.iterator] === "function") { + try { + return Array.from(values as Iterable) as number[]; + } catch { + return []; + } + } + } + return []; +}; + +const makeUniqueSorted = (values: unknown) => { + const maybeArray = toNumberArray(values); + const normalized = Array.isArray(maybeArray) ? maybeArray : []; + const unique = Array.from(new Set(normalized.filter((value) => Number.isFinite(value)))); + unique.sort((a, b) => a - b); + return unique; +}; const equalNumberArrays = (a: number[], b: number[]) => { if (a.length !== b.length) return false; @@ -959,6 +1036,10 @@ export function Map3D({ fleetFocus, onHoverFleet, onClearFleetHover, + onHoverMmsi, + onClearMmsiHover, + onHoverPair, + onClearPairHover, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -973,6 +1054,8 @@ export function Map3D({ const projectionBusyTimerRef = useRef | null>(null); const projectionPrevRef = useRef(projection); const mapTooltipRef = useRef(null); + const deckHoverRafRef = useRef(null); + const deckHoverHasHitRef = useRef(false); const [hoveredDeckMmsiSet, setHoveredDeckMmsiSet] = useState([]); const [hoveredDeckPairMmsiSet, setHoveredDeckPairMmsiSet] = useState([]); const [hoveredDeckFleetOwnerKey, setHoveredDeckFleetOwnerKey] = useState(null); @@ -991,13 +1074,16 @@ export function Map3D({ if (hoveredDeckFleetOwnerKey) keys.add(hoveredDeckFleetOwnerKey); return keys; }, [hoveredFleetOwnerKey, hoveredDeckFleetOwnerKey]); + const fleetFocusId = fleetFocus?.id; + const fleetFocusLon = fleetFocus?.center?.[0]; + const fleetFocusLat = fleetFocus?.center?.[1]; + const fleetFocusZoom = fleetFocus?.zoom; - const reorderGlobeFeatureLayers = useCallback((options?: { shipTop?: boolean }) => { + const reorderGlobeFeatureLayers = useCallback(() => { const map = mapRef.current; if (!map || projectionRef.current !== "globe") return; if (projectionBusyRef.current) return; - const shipTop = options?.shipTop === true; const ordering = [ "zones-fill", "zones-line", @@ -1020,17 +1106,6 @@ export function Map3D({ } } - if (!shipTop) return; - - const shipOrdering = ["ships-globe-halo", "ships-globe-outline", "ships-globe"]; - for (const layerId of shipOrdering) { - try { - if (map.getLayer(layerId)) map.moveLayer(layerId); - } catch { - // ignore - } - } - kickRepaint(map); }, []); @@ -1078,6 +1153,9 @@ export function Map3D({ [effectiveHoveredFleetMmsiSet, hoveredFleetOwnerKeys], ); const hoveredPairSignature = useMemo(() => makeSetSignature(effectiveHoveredPairMmsiSet), [effectiveHoveredPairMmsiSet]); + const hoveredPairMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredPairMmsiSet)), [effectiveHoveredPairMmsiSet]); + const hoveredFleetOwnerKeyList = useMemo(() => Array.from(hoveredFleetOwnerKeys).sort(), [hoveredFleetOwnerKeys]); + const hoveredFleetMmsiList = useMemo(() => makeUniqueSorted(Array.from(effectiveHoveredFleetMmsiSet)), [effectiveHoveredFleetMmsiSet]); const isHighlightedMmsi = useCallback( (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), @@ -1100,6 +1178,16 @@ export function Map3D({ [hoveredFleetOwnerKeys, isHighlightedMmsi], ); + const shipData = useMemo(() => { + return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); + }, [targets]); + + const shipByMmsi = useMemo(() => { + const byMmsi = new Map(); + for (const t of shipData) byMmsi.set(t.mmsi, t); + return byMmsi; + }, [shipData]); + const hasAuxiliarySelectModifier = (ev?: { shiftKey?: boolean; ctrlKey?: boolean; @@ -1109,21 +1197,6 @@ export function Map3D({ 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)); @@ -1135,6 +1208,12 @@ export function Map3D({ const onHoverFleetRef = useRef(onHoverFleet); const onClearFleetHoverRef = useRef(onClearFleetHover); + const onHoverMmsiRef = useRef(onHoverMmsi); + const onClearMmsiHoverRef = useRef(onClearMmsiHover); + const onHoverPairRef = useRef(onHoverPair); + const onClearPairHoverRef = useRef(onClearPairHover); + const mapDeckMmsiHoverRef = useRef([]); + const mapDeckPairHoverRef = useRef([]); const mapFleetHoverStateRef = useRef<{ ownerKey: string | null; vesselMmsis: number[]; @@ -1143,22 +1222,11 @@ export function Map3D({ 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], - ); + onHoverMmsiRef.current = onHoverMmsi; + onClearMmsiHoverRef.current = onClearMmsiHover; + onHoverPairRef.current = onHoverPair; + onClearPairHoverRef.current = onClearPairHover; + }, [onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover]); const clearMapFleetHoverState = useCallback(() => { const nextOwner = null; @@ -1172,6 +1240,97 @@ export function Map3D({ } }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis]); + const clearDeckHoverPairs = useCallback(() => { + const prev = mapDeckPairHoverRef.current; + mapDeckPairHoverRef.current = []; + setHoveredDeckPairMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); + if (prev.length > 0) { + onClearPairHoverRef.current?.(); + } + }, [setHoveredDeckPairMmsiSet]); + + const clearDeckHoverMmsi = useCallback(() => { + const prev = mapDeckMmsiHoverRef.current; + mapDeckMmsiHoverRef.current = []; + setHoveredDeckMmsiSet((prevState) => (prevState.length === 0 ? prevState : [])); + if (prev.length > 0) { + onClearMmsiHoverRef.current?.(); + } + }, [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)); + if (!equalNumberArrays(mapDeckMmsiHoverRef.current, normalized)) { + mapDeckMmsiHoverRef.current = normalized; + onHoverMmsiRef.current?.(normalized); + } + }, + [setHoveredDeckMmsiSet, touchDeckHoverState], + ); + + const setDeckHoverPairs = useCallback( + (next: number[]) => { + const normalized = makeUniqueSorted(next); + touchDeckHoverState(normalized.length > 0); + setHoveredDeckPairMmsiSet((prev) => (equalNumberArrays(prev, normalized) ? prev : normalized)); + if (!equalNumberArrays(mapDeckPairHoverRef.current, normalized)) { + mapDeckPairHoverRef.current = normalized; + onHoverPairRef.current?.(normalized); + } + }, + [setHoveredDeckPairMmsiSet, touchDeckHoverState], + ); + + const setMapFleetHoverState = useCallback( + (ownerKey: string | null, vesselMmsis: number[]) => { + const normalized = makeUniqueSorted(vesselMmsis); + const prev = mapFleetHoverStateRef.current; + if (prev.ownerKey === ownerKey && equalNumberArrays(prev.vesselMmsis, normalized)) { + return; + } + touchDeckHoverState(!!ownerKey || normalized.length > 0); + setHoveredDeckFleetOwner(ownerKey); + setHoveredDeckFleetMmsis(normalized); + mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; + onHoverFleetRef.current?.(ownerKey, normalized); + }, + [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], + ); + + useEffect(() => { + return () => { + if (deckHoverRafRef.current != null) { + window.cancelAnimationFrame(deckHoverRafRef.current); + deckHoverRafRef.current = null; + } + deckHoverHasHitRef.current = false; + }; + }, []); + useEffect(() => { mapFleetHoverStateRef.current = { ownerKey: hoveredFleetOwnerKey, @@ -2155,19 +2314,7 @@ export function Map3D({ const visibility = settings.showShips ? "visible" : "none"; - // Put ships at the top so they're always visible (especially important under globe projection). const before = undefined; - const bringShipLayersToFront = () => { - const ids = [haloId, outlineId, symbolId]; - for (const id of ids) { - if (!map.getLayer(id)) continue; - try { - map.moveLayer(id); - } catch { - // ignore - } - } - }; if (!map.getLayer(haloId)) { try { @@ -2181,10 +2328,12 @@ export function Map3D({ "circle-sort-key": [ "case", ["==", ["get", "selected"], 1], - 30, + 90, ["==", ["get", "highlighted"], 1], - 25, - 10, + 80, + ["==", ["get", "permitted"], 1], + 60, + 20, ] as never, }, paint: { @@ -2208,7 +2357,20 @@ 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-color", + [ + "case", + ["==", ["get", "selected"], 1], + "rgba(14,234,255,1)", + ["==", ["get", "highlighted"], 1], + "rgba(245,158,11,1)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.95)", + "rgba(59,130,246,1)", + ] as never, + ); map.setPaintProperty(haloId, "circle-opacity", [ "case", ["==", ["get", "selected"], 1], @@ -2239,6 +2401,8 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.95)", "rgba(59,130,246,0.75)", ] as never, "circle-stroke-width": [ @@ -2247,6 +2411,8 @@ export function Map3D({ 3.4, ["==", ["get", "highlighted"], 1], 2.7, + ["==", ["get", "permitted"], 1], + 1.8, 0.0, ] as never, "circle-stroke-opacity": 0.85, @@ -2256,10 +2422,12 @@ export function Map3D({ "circle-sort-key": [ "case", ["==", ["get", "selected"], 1], - 40, + 100, ["==", ["get", "highlighted"], 1], - 35, - 15, + 90, + ["==", ["get", "permitted"], 1], + 70, + 30, ] as never, }, } as unknown as LayerSpecification, @@ -2280,6 +2448,8 @@ export function Map3D({ "rgba(14,234,255,0.95)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.95)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.95)", "rgba(59,130,246,0.75)", ] as never, ); @@ -2292,6 +2462,8 @@ export function Map3D({ 3.4, ["==", ["get", "highlighted"], 1], 2.7, + ["==", ["get", "permitted"], 1], + 1.8, 0.0, ] as never, ); @@ -2312,10 +2484,12 @@ export function Map3D({ "symbol-sort-key": [ "case", ["==", ["get", "selected"], 1], - 50, + 95, ["==", ["get", "highlighted"], 1], + 85, + ["==", ["get", "permitted"], 1], + 65, 45, - 20, ] as never, "icon-image": imgId, "icon-size": [ @@ -2346,6 +2520,8 @@ export function Map3D({ "rgba(14,234,255,1)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,1)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,1)", "rgba(59,130,246,1)", ] as never, "icon-opacity": [ @@ -2391,6 +2567,8 @@ export function Map3D({ "rgba(14,234,255,1)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,1)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,1)", ["coalesce", ["get", "shipColor"], "#64748b"], ] as never, ); @@ -2415,6 +2593,8 @@ export function Map3D({ "rgba(14,234,255,0.68)", ["==", ["get", "highlighted"], 1], "rgba(245,158,11,0.72)", + ["==", ["get", "permitted"], 1], + "rgba(125,211,252,0.58)", "rgba(15,23,42,0.25)", ] as never, ); @@ -2436,8 +2616,7 @@ export function Map3D({ } // Selection and highlight are now source-data driven. - bringShipLayersToFront(); - reorderGlobeFeatureLayers({ shipTop: true }); + reorderGlobeFeatureLayers(); kickRepaint(map); }; @@ -2448,7 +2627,7 @@ export function Map3D({ }, [ projection, settings.showShips, - targets, + shipData, legacyHits, selectedMmsi, hoveredMmsiSetRef, @@ -2566,14 +2745,13 @@ export function Map3D({ 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, - highlighted: isHighlightedPair(p.aMmsi, p.bMmsi) ? 1 : 0, - }, + properties: { + type: "pair", + aMmsi: p.aMmsi, + bMmsi: p.bMmsi, + distanceNm: p.distanceNm, + warn: p.warn, + }, })), }; @@ -2643,9 +2821,6 @@ export function Map3D({ overlays.pairLines, pairLinks, mapSyncEpoch, - hoveredShipSignature, - hoveredPairSignature, - isHighlightedPair, reorderGlobeFeatureLayers, ]); @@ -2687,14 +2862,13 @@ export function Map3D({ type: "Feature", id: makeFcSegmentFeatureId(s.fromMmsi ?? -1, s.toMmsi ?? -1, idx), geometry: { type: "LineString", coordinates: [s.from, s.to] }, - properties: { - type: "fc", - suspicious: s.suspicious, - distanceNm: s.distanceNm, - fcMmsi: s.fromMmsi ?? -1, - otherMmsi: s.toMmsi ?? -1, - highlighted: s.fromMmsi != null && isHighlightedMmsi(s.fromMmsi) ? 1 : 0, - }, + properties: { + type: "fc", + suspicious: s.suspicious, + distanceNm: s.distanceNm, + fcMmsi: s.fromMmsi ?? -1, + otherMmsi: s.toMmsi ?? -1, + }, })), }; @@ -2757,8 +2931,6 @@ export function Map3D({ overlays.fcLines, fcLinks, mapSyncEpoch, - hoveredShipSignature, - isHighlightedMmsi, reorderGlobeFeatureLayers, ]); @@ -2806,10 +2978,10 @@ export function Map3D({ ownerLabel: c.ownerLabel, count: c.count, vesselMmsis: c.vesselMmsis, - highlighted: - isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) - ? 1 - : 0, + // Kept for backward compatibility with existing paint expressions. + // Actual hover-state highlighting is now handled in + // updateGlobeOverlayPaintStates. + highlighted: 0, }, }; }), @@ -2829,10 +3001,10 @@ export function Map3D({ ownerLabel: c.ownerLabel, count: c.count, vesselMmsis: c.vesselMmsis, - highlighted: - isHighlightedFleet(c.ownerKey, c.vesselMmsis) || c.vesselMmsis.some((m) => isHighlightedMmsi(m)) - ? 1 - : 0, + // Kept for backward compatibility with existing paint expressions. + // Actual hover-state highlighting is now handled in + // updateGlobeOverlayPaintStates. + highlighted: 0, }, }; }), @@ -2930,11 +3102,6 @@ export function Map3D({ overlays.fleetCircles, fleetCircles, mapSyncEpoch, - hoveredShipSignature, - hoveredFleetSignature, - hoveredFleetOwnerKey, - isHighlightedFleet, - isHighlightedMmsi, reorderGlobeFeatureLayers, ]); @@ -2992,7 +3159,10 @@ export function Map3D({ aMmsi: c.aMmsi, bMmsi: c.bMmsi, distanceNm: c.distanceNm, - highlighted: isHighlightedPair(c.aMmsi, c.bMmsi) ? 1 : 0, + // Kept for backward compatibility with existing paint expressions. + // Actual hover-state highlighting is now handled in + // updateGlobeOverlayPaintStates. + highlighted: 0, }, }; }), @@ -3056,22 +3226,148 @@ export function Map3D({ overlays.pairRange, pairLinks, mapSyncEpoch, - hoveredShipSignature, - hoveredPairSignature, - hoveredFleetSignature, - isHighlightedPair, reorderGlobeFeatureLayers, ]); - const shipData = useMemo(() => { - return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon) && isFiniteNumber(t.mmsi)); - }, [targets]); + const updateGlobeOverlayPaintStates = useCallback(() => { + if (projection !== "globe" || projectionBusyRef.current) return; - const shipByMmsi = useMemo(() => { - const byMmsi = new Map(); - for (const t of shipData) byMmsi.set(t.mmsi, t); - return byMmsi; - }, [shipData]); + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + + const fleetAwarePairMmsiList = makeUniqueSorted([...hoveredPairMmsiList, ...hoveredFleetMmsiList]); + + const pairHighlightExpr = hoveredPairMmsiList.length >= 2 + ? makeMmsiPairHighlightExpr("aMmsi", "bMmsi", hoveredPairMmsiList) + : false; + + const fcEndpointHighlightExpr = fleetAwarePairMmsiList.length > 0 + ? makeMmsiAnyEndpointExpr("fcMmsi", "otherMmsi", fleetAwarePairMmsiList) + : false; + + const fleetOwnerMatchExpr = + hoveredFleetOwnerKeyList.length > 0 ? makeFleetOwnerMatchExpr(hoveredFleetOwnerKeyList) : false; + const fleetMemberExpr = + hoveredFleetMmsiList.length > 0 ? makeFleetMemberMatchExpr(hoveredFleetMmsiList) : false; + const fleetHighlightExpr = + hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0 + ? (["any", fleetOwnerMatchExpr, fleetMemberExpr] as never) + : false; + + try { + if (map.getLayer("pair-lines-ml")) { + map.setPaintProperty( + "pair-lines-ml", + "line-color", + ["case", pairHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "warn"], false], "rgba(59,130,246,0.55)", "rgba(59,130,246,0.55)"] as never, + ); + map.setPaintProperty( + "pair-lines-ml", + "line-width", + ["case", pairHighlightExpr, 2.8, ["boolean", ["get", "warn"], false], 2.2, 1.4] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer("fc-lines-ml")) { + map.setPaintProperty( + "fc-lines-ml", + "line-color", + ["case", fcEndpointHighlightExpr, "rgba(245,158,11,0.98)", ["boolean", ["get", "suspicious"], false], "rgba(239,68,68,0.95)", "rgba(217,119,6,0.92)"] as never, + ); + map.setPaintProperty( + "fc-lines-ml", + "line-width", + ["case", fcEndpointHighlightExpr, 2.0, 1.3] as never, + ); + } + } catch { + // ignore + } + + try { + if (map.getLayer("pair-range-ml")) { + map.setPaintProperty( + "pair-range-ml", + "line-color", + [ + "case", + pairHighlightExpr, + "rgba(245,158,11,0.92)", + ["boolean", ["get", "warn"], false], + "rgba(245,158,11,0.75)", + "rgba(59,130,246,0.45)", + ] 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, + "rgba(245,158,11,0.24)", + "rgba(245,158,11,0.02)", + ] as never, + ); + map.setPaintProperty( + "fleet-circles-ml-fill", + "fill-opacity", + ["case", fleetHighlightExpr, 0.7, 0.28] as never, + ); + } + if (map.getLayer("fleet-circles-ml")) { + map.setPaintProperty( + "fleet-circles-ml", + "line-color", + ["case", fleetHighlightExpr, "rgba(245,158,11,0.95)", "rgba(245,158,11,0.65)"] as never, + ); + map.setPaintProperty( + "fleet-circles-ml", + "line-width", + ["case", fleetHighlightExpr, 2, 1.1] as never, + ); + } + } catch { + // ignore + } + }, [projection, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const stop = onMapStyleReady(map, updateGlobeOverlayPaintStates); + updateGlobeOverlayPaintStates(); + return () => { + stop(); + }; + }, [mapSyncEpoch, hoveredFleetMmsiList, hoveredFleetOwnerKeyList, hoveredPairMmsiList, projection, updateGlobeOverlayPaintStates]); + + const shipLayerData = useMemo(() => { + if (shipData.length === 0) return shipData; + const layer = [...shipData]; + layer.sort((a, b) => { + const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; + const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; + if (aPriority !== bPriority) return aPriority - bPriority; + return a.mmsi - b.mmsi; + }); + return layer; + }, [shipData, isHighlightedMmsi, selectedMmsi]); const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; @@ -3178,15 +3474,15 @@ export function Map3D({ if (!map) return; const clearDeckGlobeHoverState = () => { - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); setHoveredZoneId((prev) => (prev === null ? prev : null)); clearMapFleetHoverState(); }; const resetGlobeHoverStates = () => { - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); setHoveredZoneId((prev) => (prev === null ? prev : null)); clearMapFleetHoverState(); }; @@ -3287,36 +3583,36 @@ export function Map3D({ if (isShipLayer) { const mmsi = toIntMmsi(props.mmsi); - setHoveredDeckMmsiSingle(mmsi); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setDeckHoverMmsi(mmsi == null ? [] : [mmsi]); + clearDeckHoverPairs(); clearMapFleetHoverState(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isPairLayer) { const aMmsi = toIntMmsi(props.aMmsi); const bMmsi = toIntMmsi(props.bMmsi); - setHoveredDeckPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); + setDeckHoverPairs([...(aMmsi == null ? [] : [aMmsi]), ...(bMmsi == null ? [] : [bMmsi])]); + clearDeckHoverMmsi(); clearMapFleetHoverState(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isFcLayer) { const from = toIntMmsi(props.fcMmsi); const to = toIntMmsi(props.otherMmsi); const fromTo = [from, to].filter((v): v is number => v != null); - setHoveredDeckPairs(fromTo); - setHoveredDeckMmsiSet((prev) => (equalNumberArrays(prev, fromTo) ? prev : fromTo)); + setDeckHoverPairs(fromTo); + setDeckHoverMmsi(fromTo); clearMapFleetHoverState(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isFleetLayer) { const ownerKey = String(props.ownerKey ?? ""); const list = normalizeMmsiList(props.vesselMmsis); setMapFleetHoverState(ownerKey || null, list); - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); setHoveredZoneId((prev) => (prev === null ? prev : null)); } else if (isZoneLayer) { clearMapFleetHoverState(); - setHoveredDeckMmsiSet((prev) => (prev.length === 0 ? prev : [])); - setHoveredDeckPairMmsiSet((prev) => (prev.length === 0 ? prev : [])); + clearDeckHoverMmsi(); + clearDeckHoverPairs(); const zoneId = getZoneIdFromProps(props); setHoveredZoneId(zoneId || null); } else { @@ -3358,8 +3654,10 @@ export function Map3D({ buildGlobeFeatureTooltip, clearGlobeTooltip, clearMapFleetHoverState, - setHoveredDeckPairs, - setHoveredDeckMmsiSingle, + clearDeckHoverPairs, + clearDeckHoverMmsi, + setDeckHoverPairs, + setDeckHoverMmsi, setMapFleetHoverState, setGlobeTooltip, ]); @@ -3369,6 +3667,18 @@ export function Map3D({ return shipData.filter((t) => legacyHits.has(t.mmsi)); }, [shipData, legacyHits]); + const legacyTargetsOrdered = useMemo(() => { + if (legacyTargets.length === 0) return legacyTargets; + const layer = [...legacyTargets]; + layer.sort((a, b) => { + const aPriority = a.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(a.mmsi) ? 2 : 0; + const bPriority = b.mmsi === selectedMmsi ? 3 : isHighlightedMmsi(b.mmsi) ? 2 : 0; + if (aPriority !== bPriority) return aPriority - bPriority; + return a.mmsi - b.mmsi; + }); + return layer; + }, [legacyTargets, isHighlightedMmsi, selectedMmsi]); + const fcDashed = useMemo(() => { const segs: DashSeg[] = []; for (const l of fcLinks || []) { @@ -3405,14 +3715,16 @@ export function Map3D({ useEffect(() => { const map = mapRef.current; - if (!map || !fleetFocus) return; - const [lon, lat] = fleetFocus.center; - if (!Number.isFinite(lon) || !Number.isFinite(lat)) return; + if (!map || fleetFocusLon == null || fleetFocusLat == null || !Number.isFinite(fleetFocusLon) || !Number.isFinite(fleetFocusLat)) + return; + const lon = fleetFocusLon; + const lat = fleetFocusLat; + const zoom = fleetFocusZoom ?? 10; const apply = () => { map.easeTo({ center: [lon, lat], - zoom: fleetFocus.zoom ?? 10, + zoom, duration: 700, }); }; @@ -3426,7 +3738,7 @@ export function Map3D({ return () => { stop(); }; - }, [fleetFocus?.id, fleetFocus?.center?.[0], fleetFocus?.center?.[1], fleetFocus?.zoom]); + }, [fleetFocusId, fleetFocusLon, fleetFocusLat, fleetFocusZoom]); // Update Deck.gl layers useEffect(() => { @@ -3452,9 +3764,7 @@ export function Map3D({ const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS; const layers = []; const clearDeckHover = () => { - setHoveredMmsiList([]); - setHoveredDeckPairs([]); - clearMapFleetHoverState(); + touchDeckHoverState(false); }; const toFleetMmsiList = (value: unknown) => { @@ -3488,7 +3798,7 @@ export function Map3D({ layers.push( new HexagonLayer({ id: "density", - data: shipData, + data: shipLayerData, pickable: true, extruded: true, radius: 2500, @@ -3525,11 +3835,12 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const p = info.object as PairRangeCircle; const aMmsi = p.aMmsi; const bMmsi = p.bMmsi; - setHoveredDeckPairs([aMmsi, bMmsi]); - setHoveredMmsiList([aMmsi, bMmsi]); + setDeckHoverPairs([aMmsi, bMmsi]); + setDeckHoverMmsi([aMmsi, bMmsi]); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3574,9 +3885,10 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as PairLink; - setHoveredDeckPairs([obj.aMmsi, obj.bMmsi]); - setHoveredMmsiList([obj.aMmsi, obj.bMmsi]); + setDeckHoverPairs([obj.aMmsi, obj.bMmsi]); + setDeckHoverMmsi([obj.aMmsi, obj.bMmsi]); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3622,15 +3934,16 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as DashSeg; const aMmsi = obj.fromMmsi; const bMmsi = obj.toMmsi; if (aMmsi == null || bMmsi == null) { - setHoveredMmsiList([]); + clearDeckHover(); return; } - setHoveredDeckPairs([aMmsi, bMmsi]); - setHoveredMmsiList([aMmsi, bMmsi]); + setDeckHoverPairs([aMmsi, bMmsi]); + setDeckHoverMmsi([aMmsi, bMmsi]); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3679,11 +3992,12 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as FleetCircle; const list = toFleetMmsiList(obj.vesselMmsis); setMapFleetHoverState(obj.ownerKey || null, list); - setHoveredMmsiList(list); - setHoveredDeckPairs([]); + setDeckHoverMmsi(list); + clearDeckHoverPairs(); }, onClick: (info) => { if (!info.object) return; @@ -3735,7 +4049,7 @@ export function Map3D({ layers.push( new ScatterplotLayer({ id: "legacy-halo", - data: legacyTargets, + data: legacyTargetsOrdered, pickable: false, billboard: false, // This ring is most prone to z-fighting, so force it into pure painter's-order rendering. @@ -3778,7 +4092,7 @@ export function Map3D({ layers.push( new IconLayer({ id: "ships", - data: shipData, + data: shipLayerData, pickable: true, // Keep icons horizontal on the sea surface when view is pitched/rotated. billboard: false, @@ -3810,9 +4124,10 @@ export function Map3D({ clearDeckHover(); return; } + touchDeckHoverState(true); const obj = info.object as AisTarget; - setHoveredMmsiList([obj.mmsi]); - setHoveredDeckPairs([]); + setDeckHoverMmsi([obj.mmsi]); + clearDeckHoverPairs(); clearMapFleetHoverState(); }, onClick: (info) => { @@ -3969,7 +4284,8 @@ export function Map3D({ applyDeckProps(); }, [ projection, - shipData, + shipLayerData, + legacyTargetsOrdered, baseMap, zones, selectedMmsi, @@ -3993,12 +4309,18 @@ export function Map3D({ hoveredFleetSignature, hoveredPairSignature, hoveredFleetOwnerKey, - highlightedMmsiSet, + highlightedMmsiSetCombined, + onToggleHighlightMmsi, isHighlightedMmsi, isHighlightedFleet, isHighlightedPair, + setDeckHoverMmsi, + clearDeckHoverMmsi, + setDeckHoverPairs, + clearDeckHoverPairs, clearMapFleetHoverState, setMapFleetHoverState, + touchDeckHoverState, ensureMercatorOverlay, ]); diff --git a/apps/web/src/widgets/vesselList/VesselList.tsx b/apps/web/src/widgets/vesselList/VesselList.tsx index dbbe1b1..112b7ba 100644 --- a/apps/web/src/widgets/vesselList/VesselList.tsx +++ b/apps/web/src/widgets/vesselList/VesselList.tsx @@ -1,17 +1,38 @@ import { VESSEL_TYPES } from "../../entities/vessel/model/meta"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; +import type { MouseEvent } from "react"; type Props = { vessels: DerivedLegacyVessel[]; selectedMmsi: number | null; + highlightedMmsiSet?: number[]; + onToggleHighlightMmsi: (mmsi: number) => void; onSelectMmsi: (mmsi: number) => void; + onHoverMmsi?: (mmsi: number) => void; + onClearHover?: () => void; }; -function isFiniteNumber(x: unknown): x is number { +export function VesselList({ + vessels, + selectedMmsi, + highlightedMmsiSet = [], + onToggleHighlightMmsi, + onSelectMmsi, + onHoverMmsi, + onClearHover, +}: Props) { + const handlePrimaryAction = (e: MouseEvent, mmsi: number) => { + if (e.shiftKey || e.ctrlKey || e.metaKey) { + onToggleHighlightMmsi(mmsi); + return; + } + onSelectMmsi(mmsi); + }; + + function isFiniteNumber(x: unknown): x is number { return typeof x === "number" && Number.isFinite(x); } -export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) { const sorted = vessels .slice() .sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1)) @@ -29,13 +50,15 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) { const speedColor = inRange ? "#22C55E" : (v.sog ?? 0) > 5 ? "#3B82F6" : "var(--muted)"; const hasPair = v.pairPermitNo ? "⛓" : ""; const sel = selectedMmsi === v.mmsi; + const hl = highlightedMmsiSet.includes(v.mmsi); return (
onSelectMmsi(v.mmsi)} - style={sel ? { background: "rgba(59,130,246,.12)", border: "1px solid rgba(59,130,246,.45)" } : undefined} + className={`vi ${sel ? "sel" : ""} ${hl ? "hl" : ""}`} + onClick={(e) => handlePrimaryAction(e, v.mmsi)} + onMouseEnter={() => onHoverMmsi?.(v.mmsi)} + onMouseLeave={() => onClearHover?.()} title={v.name} >
@@ -56,4 +79,3 @@ export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
); } -