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 type { MonitorMapResponse } from '../../api/types';
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 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', () => {
const previous: AMapMassPoint[] = [
const points: AMapMassPoint[] = [
{ id: 'JT808', label: 'A', 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 frame = interpolateMassPointsByElapsed(previous, target, 10_000, new Map([
['JT808', 10_000],
['GB32960', 30_000]
]));
const pointArray = points;
const pointObjects = [...points];
advancePointMotions([
{ 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(frame[1].lnglat).toEqual([10, 10]);
expect(points[0].lnglat).toEqual([30, 30]);
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 {
@@ -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));
}, { timeout: 1_800 });
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(removeLabels).toHaveBeenCalledTimes(removeCalls);
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 MIN_POINT_MOVE_DURATION_MS = 1_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 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))));
}
export function interpolateMassPointsByElapsed(previous: AMapMassPoint[], target: AMapMassPoint[], elapsedMs: number, durationByID: Map<string, number>, eligibleIDs?: Set<string>) {
return interpolateMassPointsFrom(
new Map(previous.map((point) => [point.id, point])),
target,
(id) => elapsedMs / (durationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS),
eligibleIDs
);
export type MutablePointMotion = {
point: AMapMassPoint;
start: [number, number];
target: [number, number];
durationMs: number;
};
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[]) {
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);
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 }) {
@@ -273,6 +289,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const massStyleSignatureRef = useRef('__unset__');
const renderedMassDataRef = useRef<AMapMassPoint[]>([]);
const pointAnimationFrameRef = useRef<number | undefined>(undefined);
const pointAnimationTimerRef = useRef<number | undefined>(undefined);
const movingPointIDsRef = useRef(new Set<string>());
const onSelectRef = useRef(onSelect);
const onSelectVinRef = useRef(onSelectVin);
@@ -409,7 +426,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
window.clearTimeout(resizeTimer);
window.clearTimeout(viewportTimerRef.current);
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
window.clearTimeout(pointAnimationTimerRef.current);
pointAnimationFrameRef.current = undefined;
pointAnimationTimerRef.current = undefined;
movingPointIDsRef.current.clear();
if (selectMassPoint) massInstance?.off?.('click', selectMassPoint);
if (notifyViewport) {
@@ -470,43 +489,58 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
.map((point) => [point.vin, pointMoveDurationMs(point.reportIntervalMs)]));
massDataSignatureRef.current = dataSignature;
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
window.clearTimeout(pointAnimationTimerRef.current);
pointAnimationFrameRef.current = undefined;
pointAnimationTimerRef.current = undefined;
const previousData = renderedMassDataRef.current;
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 movingIDs = new Set([...movingPointIDs(previousData, data)].filter((id) => vehiclePointIDs.has(id)));
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);
renderedMassDataRef.current = data;
movingPointIDsRef.current.clear();
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 lastPaintAt = -Infinity;
const longestMoveDuration = Math.max(...[...movingIDs].map((id) => moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS));
const paint = (frameData: AMapMassPoint[]) => {
mass.setData(frameData);
renderedMassDataRef.current = frameData;
for (const point of frameData) {
if (!movingIDs.has(point.id)) continue;
const label = labelMarkersRef.current.get(point.id);
label?.marker.setPosition?.(point.lnglat);
if (label) label.position = point.lnglat;
if (selectedVinRef.current === point.id) selectionRef.current?.setPosition?.(point.lnglat);
const frameMs = pointMoveFrameMs(data.length, motions.length);
const paint = (elapsed: number) => {
advancePointMotions(motions, elapsed);
mass.setData(data);
renderedMassDataRef.current = data;
for (const motion of motions) {
const label = labelMarkersRef.current.get(motion.point.id);
label?.marker.setPosition?.(motion.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) => {
pointAnimationFrameRef.current = undefined;
startedAt ??= timestamp;
const elapsed = timestamp - startedAt;
const complete = elapsed >= longestMoveDuration;
if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || complete) {
paint(interpolateMassPointsFrom(previousDataByID, data, (id) => elapsed / (moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS), movingIDs));
lastPaintAt = timestamp;
}
if (!complete) pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
const complete = elapsed >= longestMoveDuration || document.visibilityState === 'hidden';
paint(complete ? longestMoveDuration : elapsed);
if (!complete) schedule();
else {
pointAnimationFrameRef.current = undefined;
movingPointIDsRef.current.clear();
}
};