perf(web): reconcile fleet map incrementally

This commit is contained in:
lingniu
2026-07-16 02:25:04 +08:00
parent d995024e06
commit d140a9782b
4 changed files with 126 additions and 24 deletions

View File

@@ -39,6 +39,7 @@ export type AMapMassMarks = {
export type AMapLabelsLayer = { export type AMapLabelsLayer = {
setMap: (map: AMapMap | null) => void; setMap: (map: AMapMap | null) => void;
add: (markers: AMapOverlay | AMapOverlay[]) => void; add: (markers: AMapOverlay | AMapOverlay[]) => void;
remove: (markers: AMapOverlay | AMapOverlay[]) => void;
clear: () => void; clear: () => void;
}; };

View File

@@ -7,6 +7,7 @@ import { FleetMap } from './FleetMap';
const setData = vi.fn<(data: AMapMassPoint[]) => void>(); const setData = vi.fn<(data: AMapMassPoint[]) => void>();
const setStyle = vi.fn(); const setStyle = vi.fn();
const addLabels = vi.fn(); const addLabels = vi.fn();
const removeLabels = vi.fn();
const clearLabels = vi.fn(); const clearLabels = vi.fn();
const setLabelsMap = vi.fn(); const setLabelsMap = vi.fn();
const markerSetMap = vi.fn(); const markerSetMap = vi.fn();
@@ -55,6 +56,7 @@ class TestLabelsLayer {
labelLayerOptions.push(options); labelLayerOptions.push(options);
} }
add = addLabels; add = addLabels;
remove = removeLabels;
clear = clearLabels; clear = clearLabels;
setMap = setLabelsMap; setMap = setLabelsMap;
} }
@@ -144,6 +146,7 @@ afterEach(() => {
setData.mockReset(); setData.mockReset();
setStyle.mockReset(); setStyle.mockReset();
addLabels.mockReset(); addLabels.mockReset();
removeLabels.mockReset();
clearLabels.mockReset(); clearLabels.mockReset();
setLabelsMap.mockReset(); setLabelsMap.mockReset();
markerSetMap.mockReset(); markerSetMap.mockReset();
@@ -210,6 +213,41 @@ test('detaches AMap listeners and destroys the map on unmount', async () => {
expect(mapDestroy).toHaveBeenCalledTimes(1); expect(mapDestroy).toHaveBeenCalledTimes(1);
}); });
test('skips unchanged refresh redraws and replaces only a moved vehicle label', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const view = render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} />);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
const dataCalls = setData.mock.calls.length;
const styleCalls = setStyle.mock.calls.length;
const addCalls = addLabels.mock.calls.length;
const removeCalls = removeLabels.mock.calls.length;
const clearCalls = clearLabels.mock.calls.length;
await act(async () => {
view.rerender(<FleetMap vehicles={[]} monitorMap={{ ...pointMap, asOf: '2026-07-14T01:00:15Z' }} onSelect={() => undefined} />);
await Promise.resolve();
});
expect(setData).toHaveBeenCalledTimes(dataCalls);
expect(setStyle).toHaveBeenCalledTimes(styleCalls);
expect(addLabels).toHaveBeenCalledTimes(addCalls);
expect(removeLabels).toHaveBeenCalledTimes(removeCalls);
expect(clearLabels).toHaveBeenCalledTimes(clearCalls);
view.rerender(<FleetMap vehicles={[]} monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:30Z',
points: [{ ...pointMap.points[0], longitude: 113.27 }, pointMap.points[1]]
}} onSelect={() => undefined} />);
await waitFor(() => expect(setData).toHaveBeenCalledTimes(dataCalls + 1));
expect(setStyle).toHaveBeenCalledTimes(styleCalls);
expect(removeLabels).toHaveBeenCalledTimes(removeCalls + 1);
expect(addLabels).toHaveBeenCalledTimes(addCalls + 1);
const replacementLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(replacementLabels).toHaveLength(1);
expect((replacementLabels[0].options.text as { content: string }).content).toBe('粤A12345');
});
test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => { test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => {
const [west, south] = wgs84ToGcj02(113, 22); const [west, south] = wgs84ToGcj02(113, 22);
const [east, north] = wgs84ToGcj02(114, 24); const [east, north] = wgs84ToGcj02(114, 24);
@@ -334,10 +372,10 @@ test('renders one selected plate and smoothly follows it until the map is dragge
expect(toggle).toHaveAttribute('aria-pressed', 'true'); expect(toggle).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle); fireEvent.click(toggle);
await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false')); await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false'));
expect(clearLabels).toHaveBeenCalled(); expect(removeLabels).toHaveBeenCalled();
const selectedOnlyLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[]; const hiddenFloatingLabels = removeLabels.mock.calls[removeLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(selectedOnlyLabels).toHaveLength(1); expect(hiddenFloatingLabels).toHaveLength(1);
expect((selectedOnlyLabels[0].options.text as { content: string }).content).toBe('粤B67890'); expect((hiddenFloatingLabels[0].options.text as { content: string }).content).toBe('粤A12345');
}); });
test('renders every nearby plate with staggered positions at maximum zoom', async () => { test('renders every nearby plate with staggered positions at maximum zoom', async () => {
@@ -369,6 +407,7 @@ test('renders every nearby plate with staggered positions at maximum zoom', asyn
view.rerender(<FleetMap vehicles={[]} monitorMap={crowdedMap} selectedVin="LTEST000000000002" onSelect={() => undefined} />); view.rerender(<FleetMap vehicles={[]} monitorMap={crowdedMap} selectedVin="LTEST000000000002" onSelect={() => undefined} />);
await waitFor(() => expect(addLabels.mock.calls.length).toBeGreaterThan(labelRenderCount)); await waitFor(() => expect(addLabels.mock.calls.length).toBeGreaterThan(labelRenderCount));
const selectedDenseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[]; const selectedDenseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(selectedDenseMarkers).toHaveLength(1);
expect(selectedDenseMarkers.every((marker) => marker.options.icon == null)).toBe(true); expect(selectedDenseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined(); expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined();
}); });

View File

@@ -85,6 +85,24 @@ type PlateLabelPoint = {
latitude: number; latitude: number;
}; };
type LabelMarkerEntry = {
marker: AMapOverlay;
signature: string;
};
function massPointSignature(monitorMap: MonitorMapResponse | undefined, points: VehicleRealtimeRow[]) {
if (monitorMap) return [
`mode:${monitorMap.mode}`,
...monitorMap.clusters.map((cluster) => `c:${cluster.id}:${cluster.longitude}:${cluster.latitude}:${cluster.count}`),
...monitorMap.points.map((point) => `p:${point.vin}:${point.longitude}:${point.latitude}:${point.status}:${point.plate || ''}`)
].join('|');
return ['vehicles', ...points.map((point) => `p:${point.vin}:${point.longitude}:${point.latitude}:${vehicleStatus(point)}:${point.plate || ''}`)].join('|');
}
function labelMarkerSignature(point: PlateLabelPoint, selected: boolean, placement?: { direction: 'left' | 'right'; textOffset: [number, number] }) {
return `${point.longitude}:${point.latitude}:${point.plate || point.vin}:${selected ? 1 : 0}:${placement?.direction ?? 'right'}:${placement?.textOffset.join(',') ?? '8,0'}`;
}
function densePlatePlacements(points: PlateLabelPoint[]) { function densePlatePlacements(points: PlateLabelPoint[]) {
const buckets = new Map<string, PlateLabelPoint[]>(); const buckets = new Map<string, PlateLabelPoint[]>();
for (const point of points) { for (const point of points) {
@@ -128,6 +146,11 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const labelsRef = useRef<AMapLabelsLayer | null>(null); const labelsRef = useRef<AMapLabelsLayer | null>(null);
const denseLabelsRef = useRef<AMapLabelsLayer | null>(null); const denseLabelsRef = useRef<AMapLabelsLayer | null>(null);
const selectionRef = useRef<AMapOverlay | null>(null); const selectionRef = useRef<AMapOverlay | null>(null);
const labelMarkersRef = useRef(new Map<string, LabelMarkerEntry>());
const labelLayerModeRef = useRef<'normal' | 'dense' | ''>('');
const labelLayerStateRef = useRef('');
const massDataSignatureRef = useRef('__unset__');
const massStyleSignatureRef = useRef('__unset__');
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const onSelectVinRef = useRef(onSelectVin); const onSelectVinRef = useRef(onSelectVin);
const onViewportChangeRef = useRef(onViewportChange); const onViewportChangeRef = useRef(onViewportChange);
@@ -262,6 +285,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
} }
if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing); if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing);
massRef.current?.setMap(null); massRef.current?.setMap(null);
labelsRef.current?.clear();
denseLabelsRef.current?.clear();
labelsRef.current?.setMap(null); labelsRef.current?.setMap(null);
denseLabelsRef.current?.setMap(null); denseLabelsRef.current?.setMap(null);
selectionRef.current?.setMap?.(null); selectionRef.current?.setMap?.(null);
@@ -270,6 +295,11 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
labelsRef.current = null; labelsRef.current = null;
denseLabelsRef.current = null; denseLabelsRef.current = null;
selectionRef.current = null; selectionRef.current = null;
labelMarkersRef.current.clear();
labelLayerModeRef.current = '';
labelLayerStateRef.current = '';
massDataSignatureRef.current = '__unset__';
massStyleSignatureRef.current = '__unset__';
amapRef.current = null; amapRef.current = null;
mapRef.current = null; mapRef.current = null;
}; };
@@ -280,13 +310,19 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const AMap = amapRef.current; const AMap = amapRef.current;
if (!mass || !AMap) return; if (!mass || !AMap) return;
clustersRef.current = new Map((monitorMap?.clusters ?? []).map((cluster) => [cluster.id, cluster])); clustersRef.current = new Map((monitorMap?.clusters ?? []).map((cluster) => [cluster.id, cluster]));
const baseStyles = COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }));
const clusterCounts = [...new Set((monitorMap?.clusters ?? []).map((cluster) => cluster.count))].sort((a, b) => a - b); const clusterCounts = [...new Set((monitorMap?.clusters ?? []).map((cluster) => cluster.count))].sort((a, b) => a - b);
const clusterStyles = clusterCounts.map((count) => { const styleSignature = clusterCounts.join(',');
const visual = clusterVisual(count); if (massStyleSignatureRef.current !== styleSignature) {
return { url: visual.url, anchor: new AMap.Pixel(visual.diameter / 2, visual.diameter / 2), size: new AMap.Size(visual.diameter, visual.diameter) }; const baseStyles = COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }));
}); const clusterStyles = clusterCounts.map((count) => {
mass.setStyle?.([...baseStyles, ...clusterStyles]); const visual = clusterVisual(count);
return { url: visual.url, anchor: new AMap.Pixel(visual.diameter / 2, visual.diameter / 2), size: new AMap.Size(visual.diameter, visual.diameter) };
});
mass.setStyle?.([...baseStyles, ...clusterStyles]);
massStyleSignatureRef.current = styleSignature;
}
const dataSignature = massPointSignature(monitorMap, points);
if (massDataSignatureRef.current === dataSignature) return;
const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index])); const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index]));
const data: AMapMassPoint[] = monitorMap ? [ const data: AMapMassPoint[] = monitorMap ? [
...monitorMap.clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count}` })), ...monitorMap.clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count}` })),
@@ -295,6 +331,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
})); }));
mass.setData(data); mass.setData(data);
massDataSignatureRef.current = dataSignature;
}, [monitorMap, points, state]); }, [monitorMap, points, state]);
useEffect(() => { useEffect(() => {
@@ -303,13 +340,6 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const AMap = amapRef.current; const AMap = amapRef.current;
const map = mapRef.current; const map = mapRef.current;
if (!labels || !denseLabels || !AMap?.LabelMarker || !map) return; if (!labels || !denseLabels || !AMap?.LabelMarker || !map) return;
labels.clear();
denseLabels.clear();
if (!showLabels && !selectedVin) {
labels.setMap(null);
denseLabels.setMap(null);
return;
}
const mapLabelPoints = (monitorMap const mapLabelPoints = (monitorMap
? monitorMap.points ? monitorMap.points
: points.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) }))); : points.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) })));
@@ -318,13 +348,31 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
: mapLabelPoints; : mapLabelPoints;
const labelPoints = showLabels ? allLabelPoints : allLabelPoints.filter((point) => point.vin === selectedVin); const labelPoints = showLabels ? allLabelPoints : allLabelPoints.filter((point) => point.vin === selectedVin);
const showEveryPlate = mapZoom >= 19; const showEveryPlate = mapZoom >= 19;
const layerMode = showEveryPlate ? 'dense' : 'normal';
const activeLabels = showEveryPlate ? denseLabels : labels; const activeLabels = showEveryPlate ? denseLabels : labels;
labels.setMap(showEveryPlate ? null : map); if (labelLayerModeRef.current && labelLayerModeRef.current !== layerMode) {
denseLabels.setMap(showEveryPlate ? map : null); (labelLayerModeRef.current === 'dense' ? denseLabels : labels).clear();
labelMarkersRef.current.clear();
}
labelLayerModeRef.current = layerMode;
const layerState = `${layerMode}:${labelPoints.length > 0 ? 'visible' : 'hidden'}`;
if (labelLayerStateRef.current !== layerState) {
labels.setMap(layerMode === 'normal' && labelPoints.length ? map : null);
denseLabels.setMap(layerMode === 'dense' && labelPoints.length ? map : null);
labelLayerStateRef.current = layerState;
}
const densePlacements = showEveryPlate ? densePlatePlacements(labelPoints) : null; const densePlacements = showEveryPlate ? densePlatePlacements(labelPoints) : null;
const markers = labelPoints.map((point) => { const nextMarkers = new Map<string, LabelMarkerEntry>();
const markersToAdd: AMapOverlay[] = [];
for (const point of labelPoints) {
const placement = densePlacements?.get(point.vin); const placement = densePlacements?.get(point.vin);
return new AMap.LabelMarker!({ const signature = labelMarkerSignature(point, point.vin === selectedVin, placement);
const existing = labelMarkersRef.current.get(point.vin);
if (existing?.signature === signature) {
nextMarkers.set(point.vin, existing);
continue;
}
const marker = new AMap.LabelMarker!({
name: point.plate || point.vin, name: point.plate || point.vin,
position: wgs84ToGcj02(point.longitude, point.latitude), position: wgs84ToGcj02(point.longitude, point.latitude),
rank: point.vin === selectedVin ? 100 : 1, rank: point.vin === selectedVin ? 100 : 1,
@@ -350,8 +398,15 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
} }
} }
}); });
}); nextMarkers.set(point.vin, { marker, signature });
if (markers.length) activeLabels.add(markers); markersToAdd.push(marker);
}
const markersToRemove = [...labelMarkersRef.current.entries()]
.filter(([vin, entry]) => nextMarkers.get(vin) !== entry)
.map(([, entry]) => entry.marker);
if (markersToRemove.length) activeLabels.remove(markersToRemove);
if (markersToAdd.length) activeLabels.add(markersToAdd);
labelMarkersRef.current = nextMarkers;
}, [mapZoom, monitorMap, points, selectedTarget, selectedVin, showLabels, state]); }, [mapZoom, monitorMap, points, selectedTarget, selectedVin, showLabels, state]);
useEffect(() => { useEffect(() => {
@@ -381,9 +436,10 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
} else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) { } else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) {
mapRef.current.panTo?.(mapPosition, 650); mapRef.current.panTo?.(mapPosition, 650);
} }
const previousPositionKey = selectionPositionRef.current;
selectionPositionRef.current = positionKey; selectionPositionRef.current = positionKey;
if (selectionKeyRef.current === selectionKey && selectionRef.current) { if (selectionKeyRef.current === selectionKey && selectionRef.current) {
selectionRef.current.setPosition?.(mapPosition); if (previousPositionKey !== positionKey) selectionRef.current.setPosition?.(mapPosition);
return; return;
} }
selectionRef.current?.setMap?.(null); selectionRef.current?.setMap?.(null);

View File

@@ -2,6 +2,12 @@
This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready. This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready.
## 2026-07-16: incremental fleet-map refresh
The 15-second monitor refresh previously treated every new response object as a complete visual change. Even when only `asOf` changed, it regenerated MassMarks styles/data, cleared both label layers and constructed every plate marker again. With hundreds of visible vehicles this caused avoidable canvas work and periodic main-thread pressure.
FleetMap now fingerprints only fields that affect map rendering. An unchanged refresh performs no AMap data/style/label mutation. A moving vehicle still updates MassMarks once, but the LabelsLayer removes and replaces only that vehicle's changed marker; full label-layer replacement is reserved for crossing the normal/dense zoom boundary. The selected ripple marker also skips redundant position writes. This uses the official AMap JS API 2.0 [`LabelsLayer.remove`](https://lbs.amap.com/api/javascript-api-v2/guide/amap-massmarker/label-marker) and `LabelMarker` update model instead of reconstructing all overlays. Unit tests assert zero map mutations for an `asOf`-only refresh and exactly one label replacement for one moved vehicle.
## 2026-07-16: high-cardinality address cache lifecycle ## 2026-07-16: high-cardinality address cache lifecycle
The monitor list previously retained every manually resolved coordinate for 24 hours, and track replay inherited the global five-minute cache for every paused point. Long-running dispatch sessions could therefore accumulate address payloads across pages and arbitrary playback coordinates even after their components disappeared. The monitor list previously retained every manually resolved coordinate for 24 hours, and track replay inherited the global five-minute cache for every paused point. Long-running dispatch sessions could therefore accumulate address payloads across pages and arbitrary playback coordinates even after their components disappeared.