perf(monitor): reuse point animation buffers
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user