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

@@ -49,6 +49,7 @@ type MonitorMapPoint struct {
SOCPercent float64 `json:"socPercent"` SOCPercent float64 `json:"socPercent"`
TotalMileageKm float64 `json:"totalMileageKm"` TotalMileageKm float64 `json:"totalMileageKm"`
LastSeen string `json:"lastSeen"` LastSeen string `json:"lastSeen"`
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
Status string `json:"status"` Status string `json:"status"`
} }
@@ -1024,6 +1025,7 @@ type VehicleRealtimeRow struct {
SOCPercent float64 `json:"socPercent"` SOCPercent float64 `json:"socPercent"`
TotalMileageKm float64 `json:"totalMileageKm"` TotalMileageKm float64 `json:"totalMileageKm"`
LastSeen string `json:"lastSeen"` LastSeen string `json:"lastSeen"`
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
} }
type HistoryLocationRow struct { type HistoryLocationRow struct {

View File

@@ -394,6 +394,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
} }
groupSQL := `FROM vehicle_realtime_location l ` + groupSQL := `FROM vehicle_realtime_location l ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` + `LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = l.vin AND s.protocol = l.protocol ` +
`WHERE ` + strings.Join(where, " AND ") + ` ` + `WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY l.vin, b.plate, b.phone, b.oem ` + `GROUP BY l.vin, b.plate, b.phone, b.oem ` +
havingSQL havingSQL
@@ -414,7 +415,8 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen ` + `COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
groupSQL + groupSQL +
`ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`, `ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`,
Args: args, Args: args,

View File

@@ -401,7 +401,8 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
var onlineProtocols string var onlineProtocols string
var online int var online int
var longitude, latitude, speed, soc, mileage string var longitude, latitude, speed, soc, mileage string
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen); err != nil { var reportIntervalMs int64
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil {
return Page[VehicleRealtimeRow]{}, err return Page[VehicleRealtimeRow]{}, err
} }
row.Protocols = splitCSV(protocols) row.Protocols = splitCSV(protocols)
@@ -413,6 +414,9 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
row.SpeedKmh = parseFloatString(speed) row.SpeedKmh = parseFloatString(speed)
row.SOCPercent = parseFloatString(soc) row.SOCPercent = parseFloatString(soc)
row.TotalMileageKm = parseFloatString(mileage) row.TotalMileageKm = parseFloatString(mileage)
if reportIntervalMs > 0 {
row.ReportIntervalMs = &reportIntervalMs
}
items = append(items, row) items = append(items, row)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {

View File

@@ -200,7 +200,7 @@ func TestBuildRealtimeLocationSQL(t *testing.T) {
func TestBuildVehicleRealtimeSQL(t *testing.T) { func TestBuildVehicleRealtimeSQL(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}} query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}}
built := buildVehicleRealtimeSQL(query) built := buildVehicleRealtimeSQL(query)
for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "GROUP BY l.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "vehicle_realtime_count"} { for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "vehicle_realtime_snapshot", "access_report_interval_ms", "GROUP BY l.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "vehicle_realtime_count"} {
if !strings.Contains(built.Text+built.CountText, want) { if !strings.Contains(built.Text+built.CountText, want) {
t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText) t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText)
} }

View File

@@ -606,6 +606,7 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
VIN: vehicle.VIN, Plate: vehicle.Plate, Protocol: vehicle.PrimaryProtocol, Protocols: vehicle.Protocols, VIN: vehicle.VIN, Plate: vehicle.Plate, Protocol: vehicle.PrimaryProtocol, Protocols: vehicle.Protocols,
Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh, Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh,
SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen, SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen,
ReportIntervalMs: vehicle.ReportIntervalMs,
Status: monitorVehicleStatus(vehicle), Status: monitorVehicleStatus(vehicle),
}) })
} }

View File

@@ -312,8 +312,9 @@ func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) {
} }
func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) { func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) {
reportIntervalMs := int64(30000)
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{ vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
{VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130}, {VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs},
{VIN: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135}, {VIN: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135},
{VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500}, {VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500},
}, Total: 3, Limit: 3} }, Total: 3, Limit: 3}
@@ -333,6 +334,9 @@ func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testin
if detail.Mode != "points" || len(detail.Points) != 3 || len(detail.Clusters) != 0 { if detail.Mode != "points" || len(detail.Points) != 3 || len(detail.Clusters) != 0 {
t.Fatalf("zoom 11 must release controlled data as exact points: %+v", detail) t.Fatalf("zoom 11 must release controlled data as exact points: %+v", detail)
} }
if detail.Points[0].ReportIntervalMs == nil || *detail.Points[0].ReportIntervalMs != reportIntervalMs {
t.Fatalf("map point must preserve the primary protocol report interval: %+v", detail.Points[0])
}
} }
func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) { func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) {

View File

@@ -36,6 +36,7 @@ export interface MonitorMapPoint {
socPercent: number; socPercent: number;
totalMileageKm: number; totalMileageKm: number;
lastSeen: string; lastSeen: string;
reportIntervalMs?: number;
status: 'driving' | 'idle' | 'offline' | 'unknown'; status: 'driving' | 'idle' | 'offline' | 'unknown';
} }
@@ -545,6 +546,7 @@ export interface VehicleRealtimeRow {
socPercent: number; socPercent: number;
totalMileageKm: number; totalMileageKm: number;
lastSeen: string; lastSeen: string;
reportIntervalMs?: number;
} }
export interface HistoryLocationRow extends RealtimeLocationRow { 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 { 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, massPointFingerprint } from './FleetMap'; import { FleetMap, interpolateMassPoints, interpolateMassPointsByElapsed, massPointFingerprint, pointMoveDurationMs } from './FleetMap';
const setData = vi.fn<(data: AMapMassPoint[]) => void>(); const setData = vi.fn<(data: AMapMassPoint[]) => void>();
const setStyle = vi.fn(); const setStyle = vi.fn();
@@ -118,6 +118,7 @@ const pointMap: MonitorMapResponse = {
socPercent: 80, socPercent: 80,
totalMileageKm: 1234, totalMileageKm: 1234,
lastSeen: '2026-07-14T01:00:00Z', lastSeen: '2026-07-14T01:00:00Z',
reportIntervalMs: 10_000,
status: 'driving' status: 'driving'
}, { }, {
vin: 'LTEST000000000002', vin: 'LTEST000000000002',
@@ -130,6 +131,7 @@ const pointMap: MonitorMapResponse = {
socPercent: 72, socPercent: 72,
totalMileageKm: 2234, totalMileageKm: 2234,
lastSeen: '2026-07-14T01:00:00Z', lastSeen: '2026-07-14T01:00:00Z',
reportIntervalMs: 30_000,
status: 'idle' status: 'idle'
}] }]
}; };
@@ -151,7 +153,7 @@ test('keeps large fleet redraw fingerprints constant-size and sensitive to point
expect(massPointFingerprint('points', [], manyPoints, [])).toBe(fingerprint); 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 previous: AMapMassPoint[] = [{ id: 'moving', label: 'A', style: 0, lnglat: [10, 20] }];
const target: AMapMassPoint[] = [ const target: AMapMassPoint[] = [
{ id: 'moving', label: 'A', style: 2, lnglat: [14, 24] }, { 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[0], lnglat: [10, 20] },
target[1] 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); 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 { function amapMock(): AMapLike {
return { return {
Map: TestMap as unknown as AMapLike['Map'], 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 }); 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); expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const follow = screen.getByRole('button', { name: '跟随车辆' }); 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 COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
const CLUSTER_VISUAL_CACHE_LIMIT = 256; const CLUSTER_VISUAL_CACHE_LIMIT = 256;
const POINT_MOVE_DURATION_MS = 5_000; const DEFAULT_POINT_MOVE_DURATION_MS = 15_000;
const POINT_MOVE_FRAME_MS = 50; 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 MAP_INTERACTION_PAN_DURATION_MS = 650;
const clusterVisualCache = new Map<number, { diameter: number; url: string }>(); const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
@@ -154,6 +156,7 @@ export function massPointFingerprint(
fingerprint.add(point.latitude); fingerprint.add(point.latitude);
fingerprint.add(point.status); fingerprint.add(point.status);
fingerprint.add(point.plate || ''); fingerprint.add(point.plate || '');
fingerprint.add(point.reportIntervalMs ?? '');
} }
} else { } else {
fingerprint.add(points.length); fingerprint.add(points.length);
@@ -163,6 +166,7 @@ export function massPointFingerprint(
fingerprint.add(point.latitude); fingerprint.add(point.latitude);
fingerprint.add(vehicleStatus(point)); fingerprint.add(vehicleStatus(point));
fingerprint.add(point.plate || ''); fingerprint.add(point.plate || '');
fingerprint.add(point.reportIntervalMs ?? '');
} }
} }
return fingerprint.value(); 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'}`; 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>) { function interpolateMassPointsFrom(previousByID: Map<string, AMapMassPoint>, target: AMapMassPoint[], progressFor: (id: string) => number, eligibleIDs?: Set<string>) {
const bounded = Math.min(1, Math.max(0, progress));
const eased = 1 - (1 - bounded) ** 3;
return target.map((point) => { return target.map((point) => {
if (eligibleIDs && !eligibleIDs.has(point.id)) return point; if (eligibleIDs && !eligibleIDs.has(point.id)) return point;
const start = previousByID.get(point.id); const start = previousByID.get(point.id);
if (!start || start.lnglat[0] === point.lnglat[0] && start.lnglat[1] === point.lnglat[1]) return point; 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 { return {
...point, ...point,
lnglat: [ lnglat: [
start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * eased, start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * progress,
start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * eased start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * progress
] as [number, number] ] as [number, number]
}; };
}); });
} }
export function interpolateMassPoints(previous: AMapMassPoint[], target: AMapMassPoint[], progress: 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[]) { function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) {
@@ -449,6 +466,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
] : fallbackPoints.map((vehicle) => ({ ] : fallbackPoints.map((vehicle) => ({
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin 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; massDataSignatureRef.current = dataSignature;
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
pointAnimationFrameRef.current = undefined; pointAnimationFrameRef.current = undefined;
@@ -465,6 +484,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
} }
let startedAt: number | undefined; let startedAt: number | undefined;
let lastPaintAt = -Infinity; let lastPaintAt = -Infinity;
const longestMoveDuration = Math.max(...[...movingIDs].map((id) => moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS));
const paint = (frameData: AMapMassPoint[]) => { const paint = (frameData: AMapMassPoint[]) => {
mass.setData(frameData); mass.setData(frameData);
renderedMassDataRef.current = frameData; renderedMassDataRef.current = frameData;
@@ -478,12 +498,13 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
}; };
const animate = (timestamp: number) => { const animate = (timestamp: number) => {
startedAt ??= timestamp; startedAt ??= timestamp;
const progress = Math.min(1, (timestamp - startedAt) / POINT_MOVE_DURATION_MS); const elapsed = timestamp - startedAt;
if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || progress === 1) { const complete = elapsed >= longestMoveDuration;
paint(interpolateMassPointsFrom(previousDataByID, data, progress, movingIDs)); 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; lastPaintAt = timestamp;
} }
if (progress < 1) pointAnimationFrameRef.current = window.requestAnimationFrame(animate); if (!complete) pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
else { else {
pointAnimationFrameRef.current = undefined; pointAnimationFrameRef.current = undefined;
movingPointIDsRef.current.clear(); 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); else mapRef.current.panTo?.(mapPosition, MAP_INTERACTION_PAN_DURATION_MS);
centeredVinRef.current = selectedVin; centeredVinRef.current = selectedVin;
} else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) { } 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; const previousPositionKey = selectionPositionRef.current;
selectionPositionRef.current = positionKey; selectionPositionRef.current = positionKey;