From b21f9a60cd62140c604c2b3bd7f2c6a2774a3bca Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 05:16:51 +0800 Subject: [PATCH] perf(monitor): skip metadata-only map work --- .../apps/web/src/v2/map/FleetMap.test.tsx | 27 +++++++++ .../apps/web/src/v2/map/FleetMap.tsx | 55 ++++++++++++------- .../docs/frontend-production-readiness.md | 14 +++++ 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index 512aab28..78a77c04 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -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( undefined} />); + await waitFor(() => expect(addLabels).toHaveBeenCalled()); + pointReads = 0; + + await act(async () => { + view.rerender( 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); diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index d9f5955b..4849e926 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -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) { diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index bf0e4d5b..8a6d9f4d 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -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: + +- +- +- + +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.