perf(monitor): reuse point animation buffers

This commit is contained in:
lingniu
2026-07-16 10:27:36 +08:00
parent 59f220885b
commit 77f12be63e
2 changed files with 84 additions and 38 deletions

View File

@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { MonitorMapResponse } from '../../api/types'; import type { MonitorMapResponse } from '../../api/types';
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap'; import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
import { FleetMap, interpolateMassPoints, interpolateMassPointsByElapsed, massPointFingerprint, pointMoveDurationMs } from './FleetMap'; import { advancePointMotions, FleetMap, interpolateMassPoints, massPointFingerprint, pointMoveDurationMs, pointMoveFrameMs } from './FleetMap';
const setData = vi.fn<(data: AMapMassPoint[]) => void>(); const setData = vi.fn<(data: AMapMassPoint[]) => void>();
const setStyle = vi.fn(); const setStyle = vi.fn();
@@ -178,18 +178,28 @@ test('uses each protocol report interval with bounded fallbacks for point motion
}); });
test('advances each vehicle independently according to its report interval', () => { test('advances each vehicle independently according to its report interval', () => {
const previous: AMapMassPoint[] = [ const points: AMapMassPoint[] = [
{ id: 'JT808', label: 'A', style: 0, lnglat: [0, 0] }, { id: 'JT808', label: 'A', style: 0, lnglat: [0, 0] },
{ id: 'GB32960', label: 'B', style: 0, lnglat: [0, 0] } { id: 'GB32960', label: 'B', style: 0, lnglat: [0, 0] }
]; ];
const target: AMapMassPoint[] = previous.map((point) => ({ ...point, lnglat: [30, 30] })); const pointArray = points;
const frame = interpolateMassPointsByElapsed(previous, target, 10_000, new Map([ const pointObjects = [...points];
['JT808', 10_000], advancePointMotions([
['GB32960', 30_000] { point: points[0], start: [0, 0], target: [30, 30], durationMs: 10_000 },
])); { point: points[1], start: [0, 0], target: [30, 30], durationMs: 30_000 }
], 10_000);
expect(frame[0].lnglat).toEqual([30, 30]); expect(points[0].lnglat).toEqual([30, 30]);
expect(frame[1].lnglat).toEqual([10, 10]); expect(points[1].lnglat).toEqual([10, 10]);
expect(points).toBe(pointArray);
expect(points[0]).toBe(pointObjects[0]);
expect(points[1]).toBe(pointObjects[1]);
});
test('reduces animation frame frequency as fleet redraw pressure grows', () => {
expect(pointMoveFrameMs(1, 1)).toBe(100);
expect(pointMoveFrameMs(200, 10)).toBeLessThan(pointMoveFrameMs(800, 120));
expect(pointMoveFrameMs(2_000, 1_000)).toBe(220);
}); });
function amapMock(): AMapLike { function amapMock(): AMapLike {
@@ -339,6 +349,8 @@ test('skips unchanged refresh redraws and smoothly moves points without replacin
expect(latest?.[0].lnglat).toEqual(wgs84ToGcj02(113.27, 23.13)); expect(latest?.[0].lnglat).toEqual(wgs84ToGcj02(113.27, 23.13));
}, { timeout: 1_800 }); }, { timeout: 1_800 });
expect(setData.mock.calls.length).toBeGreaterThan(dataCalls + 1); expect(setData.mock.calls.length).toBeGreaterThan(dataCalls + 1);
const animationFrameBuffers = setData.mock.calls.slice(dataCalls).map(([frameData]) => frameData);
expect(new Set(animationFrameBuffers).size).toBe(1);
expect(setStyle).toHaveBeenCalledTimes(styleCalls); expect(setStyle).toHaveBeenCalledTimes(styleCalls);
expect(removeLabels).toHaveBeenCalledTimes(removeCalls); expect(removeLabels).toHaveBeenCalledTimes(removeCalls);
expect(addLabels).toHaveBeenCalledTimes(addCalls); expect(addLabels).toHaveBeenCalledTimes(addCalls);

View File

@@ -22,7 +22,9 @@ const CLUSTER_VISUAL_CACHE_LIMIT = 256;
const DEFAULT_POINT_MOVE_DURATION_MS = 15_000; const DEFAULT_POINT_MOVE_DURATION_MS = 15_000;
const MIN_POINT_MOVE_DURATION_MS = 1_000; const MIN_POINT_MOVE_DURATION_MS = 1_000;
const MAX_POINT_MOVE_DURATION_MS = 120_000; const MAX_POINT_MOVE_DURATION_MS = 120_000;
const POINT_MOVE_FRAME_MS = 80; const MIN_POINT_MOVE_FRAME_MS = 100;
const MAX_POINT_MOVE_FRAME_MS = 220;
const POINT_MOVE_EPSILON = 0.000003;
const MAP_INTERACTION_PAN_DURATION_MS = 650; const MAP_INTERACTION_PAN_DURATION_MS = 650;
const clusterVisualCache = new Map<number, { diameter: number; url: string }>(); const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
@@ -201,21 +203,35 @@ export function pointMoveDurationMs(reportIntervalMs?: number) {
return Math.min(MAX_POINT_MOVE_DURATION_MS, Math.max(MIN_POINT_MOVE_DURATION_MS, Math.round(Number(reportIntervalMs)))); return Math.min(MAX_POINT_MOVE_DURATION_MS, Math.max(MIN_POINT_MOVE_DURATION_MS, Math.round(Number(reportIntervalMs))));
} }
export function interpolateMassPointsByElapsed(previous: AMapMassPoint[], target: AMapMassPoint[], elapsedMs: number, durationByID: Map<string, number>, eligibleIDs?: Set<string>) { export type MutablePointMotion = {
return interpolateMassPointsFrom( point: AMapMassPoint;
new Map(previous.map((point) => [point.id, point])), start: [number, number];
target, target: [number, number];
(id) => elapsedMs / (durationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS), durationMs: number;
eligibleIDs };
);
export function advancePointMotions(motions: MutablePointMotion[], elapsedMs: number) {
for (const motion of motions) {
const progress = Math.min(1, Math.max(0, elapsedMs / motion.durationMs));
motion.point.lnglat[0] = motion.start[0] + (motion.target[0] - motion.start[0]) * progress;
motion.point.lnglat[1] = motion.start[1] + (motion.target[1] - motion.start[1]) * progress;
}
}
export function pointMoveFrameMs(totalPoints: number, movingPoints: number) {
if (totalPoints <= 2) return MIN_POINT_MOVE_FRAME_MS;
const pressure = Math.max(totalPoints / 800, movingPoints / 120);
return Math.round(Math.min(MAX_POINT_MOVE_FRAME_MS, MIN_POINT_MOVE_FRAME_MS + pressure * 120));
} }
function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) { function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) {
const previousByID = new Map(previous.map((point) => [point.id, point.lnglat])); const previousByID = new Map(previous.map((point) => [point.id, point.lnglat]));
return new Set(target.flatMap((point) => { const moving = new Set<string>();
for (const point of target) {
const start = previousByID.get(point.id); const start = previousByID.get(point.id);
return start && (start[0] !== point.lnglat[0] || start[1] !== point.lnglat[1]) ? [point.id] : []; if (start && (Math.abs(start[0] - point.lnglat[0]) >= POINT_MOVE_EPSILON || Math.abs(start[1] - point.lnglat[1]) >= POINT_MOVE_EPSILON)) moving.add(point.id);
})); }
return moving;
} }
function selectionStatus(target: PlateLabelPoint & { status?: string; online?: boolean; speedKmh?: number }) { function selectionStatus(target: PlateLabelPoint & { status?: string; online?: boolean; speedKmh?: number }) {
@@ -273,6 +289,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const massStyleSignatureRef = useRef('__unset__'); const massStyleSignatureRef = useRef('__unset__');
const renderedMassDataRef = useRef<AMapMassPoint[]>([]); const renderedMassDataRef = useRef<AMapMassPoint[]>([]);
const pointAnimationFrameRef = useRef<number | undefined>(undefined); const pointAnimationFrameRef = useRef<number | undefined>(undefined);
const pointAnimationTimerRef = useRef<number | undefined>(undefined);
const movingPointIDsRef = useRef(new Set<string>()); const movingPointIDsRef = useRef(new Set<string>());
const onSelectRef = useRef(onSelect); const onSelectRef = useRef(onSelect);
const onSelectVinRef = useRef(onSelectVin); const onSelectVinRef = useRef(onSelectVin);
@@ -409,7 +426,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
window.clearTimeout(resizeTimer); window.clearTimeout(resizeTimer);
window.clearTimeout(viewportTimerRef.current); window.clearTimeout(viewportTimerRef.current);
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
window.clearTimeout(pointAnimationTimerRef.current);
pointAnimationFrameRef.current = undefined; pointAnimationFrameRef.current = undefined;
pointAnimationTimerRef.current = undefined;
movingPointIDsRef.current.clear(); movingPointIDsRef.current.clear();
if (selectMassPoint) massInstance?.off?.('click', selectMassPoint); if (selectMassPoint) massInstance?.off?.('click', selectMassPoint);
if (notifyViewport) { if (notifyViewport) {
@@ -470,43 +489,58 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
.map((point) => [point.vin, pointMoveDurationMs(point.reportIntervalMs)])); .map((point) => [point.vin, pointMoveDurationMs(point.reportIntervalMs)]));
massDataSignatureRef.current = dataSignature; massDataSignatureRef.current = dataSignature;
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
window.clearTimeout(pointAnimationTimerRef.current);
pointAnimationFrameRef.current = undefined; pointAnimationFrameRef.current = undefined;
pointAnimationTimerRef.current = undefined;
const previousData = renderedMassDataRef.current; const previousData = renderedMassDataRef.current;
const previousDataByID = new Map(previousData.map((point) => [point.id, point])); const previousDataByID = new Map(previousData.map((point) => [point.id, point]));
const vehiclePointIDs = new Set(monitorMode ? mapPoints.map((point) => point.vin) : fallbackPoints.map((point) => point.vin)); const vehiclePointIDs = new Set(monitorMode ? mapPoints.map((point) => point.vin) : fallbackPoints.map((point) => point.vin));
const movingIDs = new Set([...movingPointIDs(previousData, data)].filter((id) => vehiclePointIDs.has(id))); const movingIDs = new Set([...movingPointIDs(previousData, data)].filter((id) => vehiclePointIDs.has(id)));
movingPointIDsRef.current = movingIDs; movingPointIDsRef.current = movingIDs;
if (!movingIDs.size || previousData.length === 0 || typeof window.requestAnimationFrame !== 'function') { if (!movingIDs.size || previousData.length === 0 || typeof window.requestAnimationFrame !== 'function' || document.visibilityState === 'hidden') {
mass.setData(data); mass.setData(data);
renderedMassDataRef.current = data; renderedMassDataRef.current = data;
movingPointIDsRef.current.clear(); movingPointIDsRef.current.clear();
return; return;
} }
const dataByID = new Map(data.map((point) => [point.id, point]));
const motions: MutablePointMotion[] = [];
for (const id of movingIDs) {
const point = dataByID.get(id);
const previous = previousDataByID.get(id);
if (!point || !previous) continue;
const target: [number, number] = [point.lnglat[0], point.lnglat[1]];
point.lnglat = [previous.lnglat[0], previous.lnglat[1]];
motions.push({ point, start: [previous.lnglat[0], previous.lnglat[1]], target, durationMs: moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS });
}
let startedAt: number | undefined; let startedAt: number | undefined;
let lastPaintAt = -Infinity;
const longestMoveDuration = Math.max(...[...movingIDs].map((id) => moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS)); const longestMoveDuration = Math.max(...[...movingIDs].map((id) => moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS));
const paint = (frameData: AMapMassPoint[]) => { const frameMs = pointMoveFrameMs(data.length, motions.length);
mass.setData(frameData); const paint = (elapsed: number) => {
renderedMassDataRef.current = frameData; advancePointMotions(motions, elapsed);
for (const point of frameData) { mass.setData(data);
if (!movingIDs.has(point.id)) continue; renderedMassDataRef.current = data;
const label = labelMarkersRef.current.get(point.id); for (const motion of motions) {
label?.marker.setPosition?.(point.lnglat); const label = labelMarkersRef.current.get(motion.point.id);
if (label) label.position = point.lnglat; label?.marker.setPosition?.(motion.point.lnglat);
if (selectedVinRef.current === point.id) selectionRef.current?.setPosition?.(point.lnglat); if (label) label.position = motion.point.lnglat;
if (selectedVinRef.current === motion.point.id) selectionRef.current?.setPosition?.(motion.point.lnglat);
} }
}; };
const schedule = () => {
pointAnimationTimerRef.current = window.setTimeout(() => {
pointAnimationTimerRef.current = undefined;
pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
}, frameMs);
};
const animate = (timestamp: number) => { const animate = (timestamp: number) => {
pointAnimationFrameRef.current = undefined;
startedAt ??= timestamp; startedAt ??= timestamp;
const elapsed = timestamp - startedAt; const elapsed = timestamp - startedAt;
const complete = elapsed >= longestMoveDuration; const complete = elapsed >= longestMoveDuration || document.visibilityState === 'hidden';
if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || complete) { paint(complete ? longestMoveDuration : elapsed);
paint(interpolateMassPointsFrom(previousDataByID, data, (id) => elapsed / (moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS), movingIDs)); if (!complete) schedule();
lastPaintAt = timestamp;
}
if (!complete) pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
else { else {
pointAnimationFrameRef.current = undefined;
movingPointIDsRef.current.clear(); movingPointIDsRef.current.clear();
} }
}; };