refactor(map3d): isolate ship hover overlay for icon flicker reduction

This commit is contained in:
htlee 2026-02-15 16:35:05 +09:00
부모 54d33a8670
커밋 30e6e584ee

파일 보기

@ -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<number>();
const GLOBE_OVERLAY_PARAMS = {
// In globe mode we want depth-testing against the globe so features on the far side don't draw through.
@ -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<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
@ -1196,6 +1204,18 @@ export function Map3D({
(mmsi: number) => highlightedMmsiSetCombined.has(mmsi),
[highlightedMmsiSetCombined],
);
const baseHighlightedMmsiSet = useMemo(() => {
const out = new Set<number>();
if (selectedMmsi != null) out.add(selectedMmsi);
externalHighlightedSetRef.forEach((value) => {
out.add(value);
});
return out;
}, [selectedMmsi, externalHighlightedSetRef]);
const isBaseHighlightedMmsi = useCallback(
(mmsi: number) => baseHighlightedMmsiSet.has(mmsi),
[baseHighlightedMmsiSet],
);
const isHighlightedPair = useCallback(
(aMmsi: number, bMmsi: number) =>
@ -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<number[]>([]);
const mapDeckPairHoverRef = useRef<number[]>([]);
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<AisTarget>({
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,