From 30e6e584ee553d3ba063d0a333833780176f871b Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 16:35:05 +0900 Subject: [PATCH] refactor(map3d): isolate ship hover overlay for icon flicker reduction --- apps/web/src/widgets/map3d/Map3D.tsx | 142 ++++++++++++++++----------- 1 file changed, 84 insertions(+), 58 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 2050554..2468837 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -575,6 +575,7 @@ const FLAT_SHIP_ICON_SIZE_HIGHLIGHTED = 25; const FLAT_LEGACY_HALO_RADIUS = 14; const FLAT_LEGACY_HALO_RADIUS_SELECTED = 18; const FLAT_LEGACY_HALO_RADIUS_HIGHLIGHTED = 16; +const EMPTY_MMSI_SET = new Set(); const GLOBE_OVERLAY_PARAMS = { // In globe mode we want depth-testing against the globe so features on the far side don't draw through. @@ -1079,6 +1080,13 @@ export function Map3D({ onHoverPair, onClearPairHover, }: Props) { + void onHoverFleet; + void onClearFleetHover; + void onHoverMmsi; + void onClearMmsiHover; + void onHoverPair; + void onClearPairHover; + const containerRef = useRef(null); const mapRef = useRef(null); const overlayRef = useRef(null); @@ -1196,6 +1204,18 @@ export function Map3D({ (mmsi: number) => highlightedMmsiSetCombined.has(mmsi), [highlightedMmsiSetCombined], ); + const baseHighlightedMmsiSet = useMemo(() => { + const out = new Set(); + if (selectedMmsi != null) out.add(selectedMmsi); + externalHighlightedSetRef.forEach((value) => { + out.add(value); + }); + return out; + }, [selectedMmsi, externalHighlightedSetRef]); + const isBaseHighlightedMmsi = useCallback( + (mmsi: number) => baseHighlightedMmsiSet.has(mmsi), + [baseHighlightedMmsiSet], + ); const isHighlightedPair = useCallback( (aMmsi: number, bMmsi: number) => @@ -1274,12 +1294,6 @@ export function Map3D({ setHoveredDeckFleetOwnerKey((prev) => (prev === ownerKey ? prev : ownerKey)); }, []); - 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<{ @@ -1287,43 +1301,20 @@ export function Map3D({ vesselMmsis: number[]; }>({ ownerKey: null, vesselMmsis: [] }); - useEffect(() => { - onHoverFleetRef.current = onHoverFleet; - onClearFleetHoverRef.current = onClearFleetHover; - onHoverMmsiRef.current = onHoverMmsi; - onClearMmsiHoverRef.current = onClearMmsiHover; - onHoverPairRef.current = onHoverPair; - onClearPairHoverRef.current = onClearPairHover; - }, [onHoverFleet, onClearFleetHover, onHoverMmsi, onClearMmsiHover, onHoverPair, onClearPairHover]); - const clearMapFleetHoverState = useCallback(() => { - const nextOwner = null; - const prev = mapFleetHoverStateRef.current; - const shouldNotify = prev.ownerKey !== null || prev.vesselMmsis.length !== 0; - mapFleetHoverStateRef.current = { ownerKey: nextOwner, vesselMmsis: [] }; + mapFleetHoverStateRef.current = { ownerKey: null, vesselMmsis: [] }; setHoveredDeckFleetOwner(null); setHoveredDeckFleetMmsis([]); - if (shouldNotify) { - onClearFleetHoverRef.current?.(); - } }, [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(() => { @@ -1352,10 +1343,7 @@ export function Map3D({ 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); - } + mapDeckMmsiHoverRef.current = normalized; }, [setHoveredDeckMmsiSet, touchDeckHoverState], ); @@ -1365,10 +1353,7 @@ export function Map3D({ 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); - } + mapDeckPairHoverRef.current = normalized; }, [setHoveredDeckPairMmsiSet, touchDeckHoverState], ); @@ -1384,7 +1369,6 @@ export function Map3D({ setHoveredDeckFleetOwner(ownerKey); setHoveredDeckFleetMmsis(normalized); mapFleetHoverStateRef.current = { ownerKey, vesselMmsis: normalized }; - onHoverFleetRef.current?.(ownerKey, normalized); }, [setHoveredDeckFleetOwner, setHoveredDeckFleetMmsis, touchDeckHoverState], ); @@ -2360,7 +2344,7 @@ 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 highlighted = isBaseHighlightedMmsi(t.mmsi); const selectedScale = selected ? 1.08 : 1; const highlightScale = highlighted ? 1.06 : 1; const iconScale = selected ? selectedScale : highlightScale; @@ -2639,10 +2623,7 @@ export function Map3D({ shipData, legacyHits, selectedMmsi, - hoveredMmsiSetRef, - hoveredFleetMmsiSetRef, - hoveredPairMmsiSetRef, - isHighlightedMmsi, + isBaseHighlightedMmsi, mapSyncEpoch, reorderGlobeFeatureLayers, ]); @@ -3368,10 +3349,8 @@ export function Map3D({ const shipLayerData = useMemo(() => { if (shipData.length === 0) return shipData; - const layer = [...shipData]; - layer.sort((a, b) => a.mmsi - b.mmsi); - return layer; - }, [shipData, isHighlightedMmsi, selectedMmsi]); + return [...shipData]; + }, [shipData]); const shipHighlightSet = useMemo(() => { const out = new Set(highlightedMmsiSetCombined); @@ -3380,9 +3359,9 @@ export function Map3D({ }, [highlightedMmsiSetCombined, selectedMmsi]); const shipOverlayLayerData = useMemo(() => { - if (shipHighlightSet.size === 0) return []; - return shipLayerData.filter((target) => shipHighlightSet.has(target.mmsi)); - }, [shipLayerData, shipHighlightSet]); + if (shipLayerData.length === 0) return shipLayerData; + return shipLayerData; + }, [shipLayerData]); const clearGlobeTooltip = useCallback(() => { if (!mapTooltipRef.current) return; @@ -4029,7 +4008,7 @@ export function Map3D({ d, null, legacyHits?.get(d.mmsi)?.shipCode ?? null, - new Set(), + EMPTY_MMSI_SET, ), onHover: (info) => { if (!info.object) { @@ -4337,14 +4316,20 @@ export function Map3D({ heading: d.heading, }), sizeUnits: "pixels", - getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? FLAT_SHIP_ICON_SIZE_SELECTED : FLAT_SHIP_ICON_SIZE_HIGHLIGHTED), - getColor: (d) => - getShipColor( + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => { + if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; + return getShipColor( d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null, shipHighlightSet, - ), + ); + }, }), ); } @@ -4603,11 +4588,15 @@ export function Map3D({ 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), + getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + EMPTY_MMSI_SET, + ), onHover: (info) => { if (!info.object) { clearDeckHoverPairs(); @@ -4626,6 +4615,43 @@ export function Map3D({ ); } + if (settings.showShips) { + globeLayers.push( + new IconLayer({ + id: "ships-globe-hover", + data: shipLayerData, + pickable: false, + billboard: false, + parameters: overlayParams, + iconAtlas: "/assets/ship.svg", + iconMapping: SHIP_ICON_MAPPING, + getIcon: () => "ship", + getPosition: (d) => [d.lon, d.lat] as [number, number], + getAngle: (d) => + getDisplayHeading({ + cog: d.cog, + heading: d.heading, + }), + sizeUnits: "pixels", + getSize: (d) => { + if (selectedMmsi != null && d.mmsi === selectedMmsi) return FLAT_SHIP_ICON_SIZE_SELECTED; + if (shipHighlightSet.has(d.mmsi)) return FLAT_SHIP_ICON_SIZE_HIGHLIGHTED; + return 0; + }, + getColor: (d) => { + if (!shipHighlightSet.has(d.mmsi) && !(selectedMmsi != null && d.mmsi === selectedMmsi)) return [0, 0, 0, 0]; + return getShipColor( + d, + selectedMmsi, + legacyHits?.get(d.mmsi)?.shipCode ?? null, + shipHighlightSet, + ); + }, + alphaCutoff: 0.05, + }), + ); + } + const normalizedLayers = sanitizeDeckLayerList(globeLayers); const globeDeckProps = { layers: normalizedLayers,