diff --git a/website/components/web-telemetry-replay-modal.tsx b/website/components/web-telemetry-replay-modal.tsx index d97e489..9349b81 100644 --- a/website/components/web-telemetry-replay-modal.tsx +++ b/website/components/web-telemetry-replay-modal.tsx @@ -12,7 +12,7 @@ import { analyzeCorners, } from '../lib/telemetry-export'; import { firebaseAuth } from '../lib/firebase-client'; -import { areTelemetryPointsConnected, normalizeTelemetryPoints } from '../lib/telemetry-track'; +import { areTelemetryPointsConnected, normalizeTelemetryPoints, advancePlaybackIndex } from '../lib/telemetry-track'; import { getAwsSatelliteTileUrl, AWS_MAPS_ATTRIBUTION } from '../lib/aws-maps'; interface WebTelemetryReplayModalProps { @@ -119,10 +119,18 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod const animationFrameRef = useRef(null); const lastTimeRef = useRef(null); + const indexRef = useRef(0); + const playbackCarryRef = useRef(0); const totalPoints = points.length; const currentPoint = points[currentIndex] || points[0]; + // Keep a ref mirror of the cursor so the rAF loop resumes from the latest position + // (including manual scrubs) without re-subscribing every index change. + useEffect(() => { + indexRef.current = currentIndex; + }, [currentIndex]); + // Dynamically load Leaflet library and CSS with multi-CDN fallback useEffect(() => { if (typeof window === 'undefined') return; @@ -611,30 +619,34 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod } }; - // Handle Playback Loop + // Handle Playback Loop — timestamp-driven so the rider marker stays synchronized with the + // elapsed-time readout. Advances by real recorded time scaled by playbackSpeed; disconnected + // gaps are compressed by advancePlaybackIndex so playback never stalls across dead time. useEffect(() => { if (!isPlaying) { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); lastTimeRef.current = null; + playbackCarryRef.current = 0; return; } const animate = (timestamp: number) => { - if (!lastTimeRef.current) lastTimeRef.current = timestamp; - const delta = timestamp - lastTimeRef.current; + if (lastTimeRef.current == null) lastTimeRef.current = timestamp; + const frameDelta = timestamp - lastTimeRef.current; + lastTimeRef.current = timestamp; - // 20 Hz step interval is 50 ms. With playback multiplier: - const stepInterval = 50 / playbackSpeed; + // Virtual recorded-time budget to consume this frame, at the selected speed. + const budget = frameDelta * playbackSpeed + playbackCarryRef.current; + const { index, remainingMs } = advancePlaybackIndex(points, indexRef.current, budget); + playbackCarryRef.current = index >= totalPoints - 1 ? 0 : remainingMs; - if (delta >= stepInterval) { - lastTimeRef.current = timestamp; - setCurrentIndex((prev) => { - if (prev >= totalPoints - 1) { - setIsPlaying(false); - return prev; - } - return prev + 1; - }); + if (index !== indexRef.current) { + indexRef.current = index; + setCurrentIndex(index); + } + if (index >= totalPoints - 1) { + setIsPlaying(false); + return; } animationFrameRef.current = requestAnimationFrame(animate); @@ -645,7 +657,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod return () => { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); }; - }, [isPlaying, playbackSpeed, totalPoints]); + }, [isPlaying, playbackSpeed, totalPoints, points]); // Keyboard navigation (Space to toggle play) useEffect(() => { diff --git a/website/lib/telemetry-track.ts b/website/lib/telemetry-track.ts index f91a149..aff3c4b 100644 --- a/website/lib/telemetry-track.ts +++ b/website/lib/telemetry-track.ts @@ -44,6 +44,41 @@ export function areTelemetryPointsConnected(a: TelemetryPoint, b: TelemetryPoint return a.segmentId === b.segmentId && delta >= 0 && delta <= 5000; } +/** + * Advances a replay cursor by a budget of virtual milliseconds, honoring the real time + * between recorded samples so the rider marker stays synchronized with the elapsed-time + * readout (which is derived from timestamps). Playback is therefore wall-clock accurate at + * 1x and correctly scaled at other speeds, even when samples are non-uniform. + * + * Disconnected transitions (segment breaks or GPS loss, per areTelemetryPointsConnected) + * are compressed to a single nominal 20 Hz step (50 ms) so playback never stalls across + * dead time instead of waiting out a multi-second gap. No coordinates are altered. + * + * Returns the new index and the leftover budget to carry into the next frame. + */ +export function advancePlaybackIndex( + points: TelemetryPoint[], + fromIndex: number, + budgetMs: number +): { index: number; remainingMs: number } { + if (points.length === 0) return { index: 0, remainingMs: 0 }; + let i = Math.max(0, Math.min(fromIndex, points.length - 1)); + let budget = Math.max(0, budgetMs); + while (i < points.length - 1) { + const connected = areTelemetryPointsConnected(points[i], points[i + 1]); + const dt = connected + ? Math.max(0, Date.parse(points[i + 1].timestamp) - Date.parse(points[i].timestamp)) + : 50; + if (budget >= dt) { + budget -= dt; + i++; + } else { + break; + } + } + return { index: i, remainingMs: i < points.length - 1 ? budget : 0 }; +} + /** Leaflet's geographic API is [latitude, longitude], never normalized screen pixels. */ export function geographicSegments(points: TelemetryPoint[]): [number, number][][] { const segments: [number,number][][] = []; diff --git a/website/tests/telemetry-playback-sync.test.ts b/website/tests/telemetry-playback-sync.test.ts new file mode 100644 index 0000000..dc19adf --- /dev/null +++ b/website/tests/telemetry-playback-sync.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { advancePlaybackIndex } from '../lib/telemetry-track'; +import type { TelemetryPoint } from '../lib/telemetry-export'; + +const BASE = Date.parse('2026-09-28T14:30:00.000Z'); + +// Minimal TelemetryPoint builder — advancePlaybackIndex only reads timestamp + segmentId. +function mk(offsetMs: number, segmentId = 0): TelemetryPoint { + return { + timestamp: new Date(BASE + offsetMs).toISOString(), + latitude: 40, + longitude: -74, + altitude: 0, + speed: 0, + heading: 0, + gpsAccuracy: 0, + leanAngle: 0, + pitchAngle: 0, + accelX: 0, + accelY: 0, + accelZ: 0, + longitudinalG: 0, + lateralG: 0, + segmentId, + }; +} + +describe('advancePlaybackIndex (replay playback synchronization)', () => { + const uniform = Array.from({ length: 10 }, (_, i) => mk(i * 50)); // 20 Hz, 50 ms apart + + it('advances in step with real recorded time (1x)', () => { + // 200 ms of budget over 50 ms samples = 4 steps. + const r = advancePlaybackIndex(uniform, 0, 200); + expect(r.index).toBe(4); + expect(r.remainingMs).toBe(0); + }); + + it('carries leftover budget when it does not cover a full step', () => { + // 120 ms consumes two 50 ms steps (100 ms), leaving 20 ms. + const r = advancePlaybackIndex(uniform, 0, 120); + expect(r.index).toBe(2); + expect(r.remainingMs).toBe(20); + }); + + it('does not advance when the budget is below one step', () => { + const r = advancePlaybackIndex(uniform, 3, 10); + expect(r.index).toBe(3); + expect(r.remainingMs).toBe(10); + }); + + it('compresses a multi-second gap to a single 50 ms step so playback never stalls', () => { + // Samples 0..2 uniform; sample 3 is 10 s later (disconnected). A 60 ms budget from + // index 2 must still cross the gap (treated as a 50 ms step), not wait 10 s. + const gapped = [mk(0), mk(50), mk(100), mk(10_100)]; + const r = advancePlaybackIndex(gapped, 2, 60); + expect(r.index).toBe(3); + expect(r.remainingMs).toBe(0); // reached the end + }); + + it('clamps at the final index with no remaining budget', () => { + const r = advancePlaybackIndex(uniform, 8, 100_000); + expect(r.index).toBe(9); + expect(r.remainingMs).toBe(0); + }); + + it('is a no-op on empty input', () => { + expect(advancePlaybackIndex([], 0, 1000)).toEqual({ index: 0, remainingMs: 0 }); + }); +});