feat(monitor): match motion to report intervals

This commit is contained in:
lingniu
2026-07-16 09:55:10 +08:00
parent 3bbae099bc
commit e0e7e28762
9 changed files with 95 additions and 33 deletions

View File

@@ -36,6 +36,7 @@ export interface MonitorMapPoint {
socPercent: number;
totalMileageKm: number;
lastSeen: string;
reportIntervalMs?: number;
status: 'driving' | 'idle' | 'offline' | 'unknown';
}
@@ -545,6 +546,7 @@ export interface VehicleRealtimeRow {
socPercent: number;
totalMileageKm: number;
lastSeen: string;
reportIntervalMs?: number;
}
export interface HistoryLocationRow extends RealtimeLocationRow {

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, massPointFingerprint } from './FleetMap';
import { FleetMap, interpolateMassPoints, interpolateMassPointsByElapsed, massPointFingerprint, pointMoveDurationMs } from './FleetMap';
const setData = vi.fn<(data: AMapMassPoint[]) => void>();
const setStyle = vi.fn();
@@ -118,6 +118,7 @@ const pointMap: MonitorMapResponse = {
socPercent: 80,
totalMileageKm: 1234,
lastSeen: '2026-07-14T01:00:00Z',
reportIntervalMs: 10_000,
status: 'driving'
}, {
vin: 'LTEST000000000002',
@@ -130,6 +131,7 @@ const pointMap: MonitorMapResponse = {
socPercent: 72,
totalMileageKm: 2234,
lastSeen: '2026-07-14T01:00:00Z',
reportIntervalMs: 30_000,
status: 'idle'
}]
};
@@ -151,7 +153,7 @@ test('keeps large fleet redraw fingerprints constant-size and sensitive to point
expect(massPointFingerprint('points', [], manyPoints, [])).toBe(fingerprint);
});
test('interpolates existing mass points with easing while keeping new points stable', () => {
test('interpolates existing mass points at constant speed while keeping new points stable', () => {
const previous: AMapMassPoint[] = [{ id: 'moving', label: 'A', style: 0, lnglat: [10, 20] }];
const target: AMapMassPoint[] = [
{ id: 'moving', label: 'A', style: 2, lnglat: [14, 24] },
@@ -162,10 +164,34 @@ test('interpolates existing mass points with easing while keeping new points sta
{ ...target[0], lnglat: [10, 20] },
target[1]
]);
expect(interpolateMassPoints(previous, target, .5)[0]).toEqual({ ...target[0], lnglat: [13.5, 23.5] });
expect(interpolateMassPoints(previous, target, .5)[0]).toEqual({ ...target[0], lnglat: [12, 22] });
expect(interpolateMassPoints(previous, target, 1)).toEqual(target);
});
test('uses each protocol report interval with bounded fallbacks for point motion', () => {
expect(pointMoveDurationMs(10_000)).toBe(10_000);
expect(pointMoveDurationMs(30_000)).toBe(30_000);
expect(pointMoveDurationMs(60_000)).toBe(60_000);
expect(pointMoveDurationMs(undefined)).toBe(15_000);
expect(pointMoveDurationMs(100)).toBe(1_000);
expect(pointMoveDurationMs(300_000)).toBe(120_000);
});
test('advances each vehicle independently according to its report interval', () => {
const previous: 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]
]));
expect(frame[0].lnglat).toEqual([30, 30]);
expect(frame[1].lnglat).toEqual([10, 10]);
});
function amapMock(): AMapLike {
return {
Map: TestMap as unknown as AMapLike['Map'],
@@ -429,7 +455,7 @@ test('renders one selected plate and smoothly follows it until the map is dragge
/>
);
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14)), { timeout: 1_800 });
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 5_000);
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 10_000);
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const follow = screen.getByRole('button', { name: '跟随车辆' });

View File

@@ -19,8 +19,10 @@ import type { MonitorViewport } from '../hooks/useMonitorData';
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
const CLUSTER_VISUAL_CACHE_LIMIT = 256;
const POINT_MOVE_DURATION_MS = 5_000;
const POINT_MOVE_FRAME_MS = 50;
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 MAP_INTERACTION_PAN_DURATION_MS = 650;
const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
@@ -154,6 +156,7 @@ export function massPointFingerprint(
fingerprint.add(point.latitude);
fingerprint.add(point.status);
fingerprint.add(point.plate || '');
fingerprint.add(point.reportIntervalMs ?? '');
}
} else {
fingerprint.add(points.length);
@@ -163,6 +166,7 @@ export function massPointFingerprint(
fingerprint.add(point.latitude);
fingerprint.add(vehicleStatus(point));
fingerprint.add(point.plate || '');
fingerprint.add(point.reportIntervalMs ?? '');
}
}
return fingerprint.value();
@@ -172,25 +176,38 @@ function labelMarkerSignature(point: PlateLabelPoint, selected: boolean, placeme
return `${point.plate || point.vin}:${selected ? 1 : 0}:${placement?.direction ?? 'right'}:${placement?.textOffset.join(',') ?? '8,0'}`;
}
function interpolateMassPointsFrom(previousByID: Map<string, AMapMassPoint>, target: AMapMassPoint[], progress: number, eligibleIDs?: Set<string>) {
const bounded = Math.min(1, Math.max(0, progress));
const eased = 1 - (1 - bounded) ** 3;
function interpolateMassPointsFrom(previousByID: Map<string, AMapMassPoint>, target: AMapMassPoint[], progressFor: (id: string) => number, eligibleIDs?: Set<string>) {
return target.map((point) => {
if (eligibleIDs && !eligibleIDs.has(point.id)) return point;
const start = previousByID.get(point.id);
if (!start || start.lnglat[0] === point.lnglat[0] && start.lnglat[1] === point.lnglat[1]) return point;
const progress = Math.min(1, Math.max(0, progressFor(point.id)));
return {
...point,
lnglat: [
start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * eased,
start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * eased
start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * progress,
start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * progress
] as [number, number]
};
});
}
export function interpolateMassPoints(previous: AMapMassPoint[], target: AMapMassPoint[], progress: number) {
return interpolateMassPointsFrom(new Map(previous.map((point) => [point.id, point])), target, progress);
return interpolateMassPointsFrom(new Map(previous.map((point) => [point.id, point])), target, () => progress);
}
export function pointMoveDurationMs(reportIntervalMs?: number) {
if (!Number.isFinite(reportIntervalMs) || Number(reportIntervalMs) <= 0) return DEFAULT_POINT_MOVE_DURATION_MS;
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
);
}
function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) {
@@ -449,6 +466,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
] : fallbackPoints.map((vehicle) => ({
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
}));
const moveDurationByID = new Map((monitorMode ? mapPoints : fallbackPoints)
.map((point) => [point.vin, pointMoveDurationMs(point.reportIntervalMs)]));
massDataSignatureRef.current = dataSignature;
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
pointAnimationFrameRef.current = undefined;
@@ -465,6 +484,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
}
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;
@@ -478,12 +498,13 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
};
const animate = (timestamp: number) => {
startedAt ??= timestamp;
const progress = Math.min(1, (timestamp - startedAt) / POINT_MOVE_DURATION_MS);
if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || progress === 1) {
paint(interpolateMassPointsFrom(previousDataByID, data, progress, movingIDs));
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 (progress < 1) pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
if (!complete) pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
else {
pointAnimationFrameRef.current = undefined;
movingPointIDsRef.current.clear();
@@ -599,7 +620,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
else mapRef.current.panTo?.(mapPosition, MAP_INTERACTION_PAN_DURATION_MS);
centeredVinRef.current = selectedVin;
} else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) {
mapRef.current.panTo?.(mapPosition, POINT_MOVE_DURATION_MS);
mapRef.current.panTo?.(mapPosition, pointMoveDurationMs(target.reportIntervalMs));
}
const previousPositionKey = selectionPositionRef.current;
selectionPositionRef.current = positionKey;