perf(monitor): skip metadata-only map work

This commit is contained in:
lingniu
2026-07-16 05:16:51 +08:00
parent 379f1ce941
commit b21f9a60cd
3 changed files with 75 additions and 21 deletions

View File

@@ -248,6 +248,33 @@ test('skips unchanged refresh redraws and replaces only a moved vehicle label',
expect((replacementLabels[0].options.text as { content: string }).content).toBe('粤A12345');
});
test('does not traverse unchanged point arrays for an asOf-only refresh', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
let pointReads = 0;
const observedPoints = new Proxy(pointMap.points, {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) pointReads += 1;
return Reflect.get(target, property, receiver);
}
});
const observedMap = { ...pointMap, points: observedPoints };
const view = render(<FleetMap vehicles={[]} monitorMap={observedMap} onSelect={() => undefined} />);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
pointReads = 0;
await act(async () => {
view.rerender(<FleetMap
vehicles={[]}
monitorMap={{ ...observedMap, asOf: '2026-07-14T01:00:15Z' }}
onSelect={() => undefined}
/>);
await Promise.resolve();
});
expect(pointReads).toBe(0);
});
test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => {
const [west, south] = wgs84ToGcj02(113, 22);
const [east, north] = wgs84ToGcj02(114, 24);

View File

@@ -90,11 +90,18 @@ type LabelMarkerEntry = {
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 || ''}`)
const NO_VEHICLE_POINTS: VehicleRealtimeRow[] = [];
function massPointSignature(
mode: MonitorMapResponse['mode'] | undefined,
clusters: MonitorMapResponse['clusters'] | undefined,
mapPoints: MonitorMapResponse['points'] | undefined,
points: VehicleRealtimeRow[]
) {
if (mode) return [
`mode:${mode}`,
...(clusters ?? []).map((cluster) => `c:${cluster.id}:${cluster.longitude}:${cluster.latitude}:${cluster.count}`),
...(mapPoints ?? []).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('|');
}
@@ -166,11 +173,15 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const [followSelected, setFollowSelected] = useState(true);
const [mapZoom, setMapZoom] = useState(5);
const points = useMemo(() => vehicles.filter((vehicle) => isValidAMapCoordinate(vehicle.longitude, vehicle.latitude)), [vehicles]);
const monitorMode = monitorMap?.mode;
const monitorPoints = monitorMap?.points;
const monitorClusters = monitorMap?.clusters;
const fallbackPoints = monitorMode ? NO_VEHICLE_POINTS : points;
const selectedTarget = useMemo(() => selectedVin
? monitorMap?.points.find((item) => item.vin === selectedVin) ?? points.find((item) => item.vin === selectedVin)
: undefined, [monitorMap, points, selectedVin]);
const renderedPointCount = monitorMap ? monitorMap.points.length : points.length;
const renderedClusterCount = monitorMap?.clusters.length ?? 0;
? monitorPoints?.find((item) => item.vin === selectedVin) ?? points.find((item) => item.vin === selectedVin)
: undefined, [monitorPoints, points, selectedVin]);
const renderedPointCount = monitorPoints?.length ?? fallbackPoints.length;
const renderedClusterCount = monitorClusters?.length ?? 0;
const mapComposition = monitorMap && renderedClusterCount > 0
? `${renderedClusterCount} 个聚合 · ${renderedPointCount} 个车辆点 · ${monitorMap.total}`
: `${renderedPointCount} 个有效点位`;
@@ -309,8 +320,10 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const mass = massRef.current;
const AMap = amapRef.current;
if (!mass || !AMap) return;
clustersRef.current = new Map((monitorMap?.clusters ?? []).map((cluster) => [cluster.id, cluster]));
const clusterCounts = [...new Set((monitorMap?.clusters ?? []).map((cluster) => cluster.count))].sort((a, b) => a - b);
const clusters = monitorClusters ?? [];
const mapPoints = monitorPoints ?? [];
clustersRef.current = new Map(clusters.map((cluster) => [cluster.id, cluster]));
const clusterCounts = [...new Set(clusters.map((cluster) => cluster.count))].sort((a, b) => a - b);
const styleSignature = clusterCounts.join(',');
if (massStyleSignatureRef.current !== styleSignature) {
const baseStyles = COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }));
@@ -321,18 +334,18 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
mass.setStyle?.([...baseStyles, ...clusterStyles]);
massStyleSignatureRef.current = styleSignature;
}
const dataSignature = massPointSignature(monitorMap, points);
const dataSignature = massPointSignature(monitorMode, monitorClusters, monitorPoints, fallbackPoints);
if (massDataSignatureRef.current === dataSignature) return;
const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index]));
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.points.map((point) => ({ lnglat: wgs84ToGcj02(point.longitude, point.latitude), style: point.status === 'driving' ? 2 : point.status === 'idle' ? 0 : point.status === 'offline' ? 1 : 3, id: point.vin, label: point.plate || point.vin }))
] : points.map((vehicle) => ({
const data: AMapMassPoint[] = monitorMode ? [
...clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count}` })),
...mapPoints.map((point) => ({ lnglat: wgs84ToGcj02(point.longitude, point.latitude), style: point.status === 'driving' ? 2 : point.status === 'idle' ? 0 : point.status === 'offline' ? 1 : 3, id: point.vin, label: point.plate || point.vin }))
] : fallbackPoints.map((vehicle) => ({
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
}));
mass.setData(data);
massDataSignatureRef.current = dataSignature;
}, [monitorMap, points, state]);
}, [fallbackPoints, monitorClusters, monitorMode, monitorPoints, state]);
useEffect(() => {
const labels = labelsRef.current;
@@ -340,9 +353,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const AMap = amapRef.current;
const map = mapRef.current;
if (!labels || !denseLabels || !AMap?.LabelMarker || !map) return;
const mapLabelPoints = (monitorMap
? monitorMap.points
: points.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) })));
const mapLabelPoints = (monitorPoints
? monitorPoints
: fallbackPoints.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) })));
const allLabelPoints = selectedTarget && !mapLabelPoints.some((point) => point.vin === selectedTarget.vin)
? [...mapLabelPoints, selectedTarget]
: mapLabelPoints;
@@ -407,7 +420,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
if (markersToRemove.length) activeLabels.remove(markersToRemove);
if (markersToAdd.length) activeLabels.add(markersToAdd);
labelMarkersRef.current = nextMarkers;
}, [mapZoom, monitorMap, points, selectedTarget, selectedVin, showLabels, state]);
}, [fallbackPoints, mapZoom, monitorPoints, selectedTarget, selectedVin, showLabels, state]);
useEffect(() => {
if (!selectedVin || !mapRef.current || !amapRef.current) {

View File

@@ -2,6 +2,20 @@
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: render-scoped fleet refresh
The incremental map update already avoided AMap mutations when a 15-second response changed only its `asOf` timestamp, but the effects still depended on the complete response object. Each metadata-only refresh therefore traversed all visible points to rebuild signatures, cluster counts and label diffs before discovering that there was nothing to draw. The cost was hidden from mutation-count tests and could still create a periodic main-thread spike in a dense viewport.
FleetMap now binds rendering work to the response fields that can change pixels: `mode`, `points` and `clusters`. TanStack Query's JSON structural sharing preserves those nested references when their content is unchanged, and React compares effect dependencies with `Object.is`; an `asOf`-only root-object replacement therefore no longer enters either the MassMarks or LabelsLayer pipeline. Fallback vehicle points are also detached while a monitor-map payload is active, so unrelated list-prop identity changes cannot wake the map renderer. Real point-array changes still run the existing incremental marker replacement path.
This follows React's guidance to remove unnecessary object dependencies and TanStack Query's structural-sharing contract:
- <https://react.dev/reference/react/useEffect>
- <https://react.dev/learn/removing-effect-dependencies>
- <https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults>
A proxy-backed regression test counts numeric array-index reads and proves that a metadata-only refresh performs zero point traversal. The existing movement regression still proves that replacing one vehicle point refreshes MassMarks once and replaces exactly one plate label.
## 2026-07-16: search-scope route error recovery
The route boundary correctly isolated render failures, but its error state outlived search-parameter navigation inside the same pathname. If one alert, history or mileage scope triggered a render exception, correcting the filter or following a same-module link changed the URL while leaving the previous fallback permanently mounted. The operator had to reload the entire application or leave the module even when the next scope was valid.