From d140a9782b69132f760cd1a4bf2512d24c260e95 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 02:25:04 +0800 Subject: [PATCH] perf(web): reconcile fleet map incrementally --- .../apps/web/src/integrations/amap.ts | 1 + .../apps/web/src/v2/map/FleetMap.test.tsx | 47 ++++++++- .../apps/web/src/v2/map/FleetMap.tsx | 96 +++++++++++++++---- .../docs/frontend-production-readiness.md | 6 ++ 4 files changed, 126 insertions(+), 24 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/integrations/amap.ts b/vehicle-data-platform/apps/web/src/integrations/amap.ts index 4115c3ef..2cf7c89d 100644 --- a/vehicle-data-platform/apps/web/src/integrations/amap.ts +++ b/vehicle-data-platform/apps/web/src/integrations/amap.ts @@ -39,6 +39,7 @@ export type AMapMassMarks = { export type AMapLabelsLayer = { setMap: (map: AMapMap | null) => void; add: (markers: AMapOverlay | AMapOverlay[]) => void; + remove: (markers: AMapOverlay | AMapOverlay[]) => void; clear: () => void; }; 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 cf96436f..512aab28 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 @@ -7,6 +7,7 @@ import { FleetMap } from './FleetMap'; const setData = vi.fn<(data: AMapMassPoint[]) => void>(); const setStyle = vi.fn(); const addLabels = vi.fn(); +const removeLabels = vi.fn(); const clearLabels = vi.fn(); const setLabelsMap = vi.fn(); const markerSetMap = vi.fn(); @@ -55,6 +56,7 @@ class TestLabelsLayer { labelLayerOptions.push(options); } add = addLabels; + remove = removeLabels; clear = clearLabels; setMap = setLabelsMap; } @@ -144,6 +146,7 @@ afterEach(() => { setData.mockReset(); setStyle.mockReset(); addLabels.mockReset(); + removeLabels.mockReset(); clearLabels.mockReset(); setLabelsMap.mockReset(); markerSetMap.mockReset(); @@ -210,6 +213,41 @@ test('detaches AMap listeners and destroys the map on unmount', async () => { 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( 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( 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( 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 () => { const [west, south] = wgs84ToGcj02(113, 22); 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'); fireEvent.click(toggle); await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false')); - expect(clearLabels).toHaveBeenCalled(); - const selectedOnlyLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[]; - expect(selectedOnlyLabels).toHaveLength(1); - expect((selectedOnlyLabels[0].options.text as { content: string }).content).toBe('粤B67890'); + expect(removeLabels).toHaveBeenCalled(); + const hiddenFloatingLabels = removeLabels.mock.calls[removeLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[]; + expect(hiddenFloatingLabels).toHaveLength(1); + expect((hiddenFloatingLabels[0].options.text as { content: string }).content).toBe('粤A12345'); }); 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( undefined} />); await waitFor(() => expect(addLabels.mock.calls.length).toBeGreaterThan(labelRenderCount)); 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.find((marker) => marker.options.rank === 100)).toBeDefined(); }); 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 891fa7e5..d9f5955b 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -85,6 +85,24 @@ type PlateLabelPoint = { 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[]) { const buckets = new Map(); for (const point of points) { @@ -128,6 +146,11 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect const labelsRef = useRef(null); const denseLabelsRef = useRef(null); const selectionRef = useRef(null); + const labelMarkersRef = useRef(new Map()); + const labelLayerModeRef = useRef<'normal' | 'dense' | ''>(''); + const labelLayerStateRef = useRef(''); + const massDataSignatureRef = useRef('__unset__'); + const massStyleSignatureRef = useRef('__unset__'); const onSelectRef = useRef(onSelect); const onSelectVinRef = useRef(onSelectVin); const onViewportChangeRef = useRef(onViewportChange); @@ -262,6 +285,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect } if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing); massRef.current?.setMap(null); + labelsRef.current?.clear(); + denseLabelsRef.current?.clear(); labelsRef.current?.setMap(null); denseLabelsRef.current?.setMap(null); selectionRef.current?.setMap?.(null); @@ -270,6 +295,11 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect labelsRef.current = null; denseLabelsRef.current = null; selectionRef.current = null; + labelMarkersRef.current.clear(); + labelLayerModeRef.current = ''; + labelLayerStateRef.current = ''; + massDataSignatureRef.current = '__unset__'; + massStyleSignatureRef.current = '__unset__'; amapRef.current = null; mapRef.current = null; }; @@ -280,13 +310,19 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect const AMap = amapRef.current; if (!mass || !AMap) return; 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 clusterStyles = clusterCounts.map((count) => { - 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]); + 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) })); + const clusterStyles = clusterCounts.map((count) => { + 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 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} 辆` })), @@ -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 })); mass.setData(data); + massDataSignatureRef.current = dataSignature; }, [monitorMap, points, state]); useEffect(() => { @@ -303,13 +340,6 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect const AMap = amapRef.current; const map = mapRef.current; if (!labels || !denseLabels || !AMap?.LabelMarker || !map) return; - labels.clear(); - denseLabels.clear(); - if (!showLabels && !selectedVin) { - labels.setMap(null); - denseLabels.setMap(null); - return; - } const mapLabelPoints = (monitorMap ? monitorMap.points : points.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) }))); @@ -318,13 +348,31 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect : mapLabelPoints; const labelPoints = showLabels ? allLabelPoints : allLabelPoints.filter((point) => point.vin === selectedVin); const showEveryPlate = mapZoom >= 19; + const layerMode = showEveryPlate ? 'dense' : 'normal'; const activeLabels = showEveryPlate ? denseLabels : labels; - labels.setMap(showEveryPlate ? null : map); - denseLabels.setMap(showEveryPlate ? map : null); + if (labelLayerModeRef.current && labelLayerModeRef.current !== layerMode) { + (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 markers = labelPoints.map((point) => { + const nextMarkers = new Map(); + const markersToAdd: AMapOverlay[] = []; + for (const point of labelPoints) { 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, position: wgs84ToGcj02(point.longitude, point.latitude), rank: point.vin === selectedVin ? 100 : 1, @@ -350,8 +398,15 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect } } }); - }); - if (markers.length) activeLabels.add(markers); + nextMarkers.set(point.vin, { marker, signature }); + 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]); useEffect(() => { @@ -381,9 +436,10 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect } else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) { mapRef.current.panTo?.(mapPosition, 650); } + const previousPositionKey = selectionPositionRef.current; selectionPositionRef.current = positionKey; if (selectionKeyRef.current === selectionKey && selectionRef.current) { - selectionRef.current.setPosition?.(mapPosition); + if (previousPositionKey !== positionKey) selectionRef.current.setPosition?.(mapPosition); return; } selectionRef.current?.setMap?.(null); diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index 996c03d0..08a57939 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -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. +## 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 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.