diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index 20110666..e4cfef24 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -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); diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index 95c3a7b0..74d9cd69 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -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(); @@ -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, eligibleIDs?: Set) { - 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(); + 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([]); const pointAnimationFrameRef = useRef(undefined); + const pointAnimationTimerRef = useRef(undefined); const movingPointIDsRef = useRef(new Set()); 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(); } };