fix(website): synchronize telemetry replay playback with recorded time (Phase 4)

The replay loop advanced the cursor by one sample every 50 ms / speed, assuming a uniform
20 Hz stream. Recorded telemetry is not always uniform (segment breaks and GPS-loss gaps up
to 5 s), so the rider marker drifted out of sync with the elapsed-time readout — which is
derived from real timestamps — and "1x" did not match real time across gaps.

Make playback timestamp-driven:
- Add advancePlaybackIndex(points, fromIndex, budgetMs): a pure helper that consumes a
  budget of virtual recorded time, advancing by the real delta between samples so the
  marker tracks the elapsed-time clock. Disconnected transitions (per
  areTelemetryPointsConnected) are compressed to a single 50 ms step so playback never
  stalls across multi-second dead time. No coordinates are altered.
- Drive the rAF loop with frameDelta * playbackSpeed plus carry-over, resuming from an
  index ref so manual scrubs are honored.

Verified sound without changes: SHA-256-checked R2/inline telemetry load, CSV/GPX parse and
export integrity, loading/error/empty overlays, and tile-provider fallback.

Tests: advancePlaybackIndex covers uniform cadence, carry-over, gap compression, end-clamp,
and empty input. Existing replay/export/corner tests remain green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Developer 2026-10-14 12:36:07 -04:00
parent 4eb2075c7a
commit ae1980b4ed
3 changed files with 132 additions and 16 deletions

View File

@ -12,7 +12,7 @@ import {
analyzeCorners, analyzeCorners,
} from '../lib/telemetry-export'; } from '../lib/telemetry-export';
import { firebaseAuth } from '../lib/firebase-client'; 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'; import { getAwsSatelliteTileUrl, AWS_MAPS_ATTRIBUTION } from '../lib/aws-maps';
interface WebTelemetryReplayModalProps { interface WebTelemetryReplayModalProps {
@ -119,10 +119,18 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
const lastTimeRef = useRef<number | null>(null); const lastTimeRef = useRef<number | null>(null);
const indexRef = useRef(0);
const playbackCarryRef = useRef(0);
const totalPoints = points.length; const totalPoints = points.length;
const currentPoint = points[currentIndex] || points[0]; 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 // Dynamically load Leaflet library and CSS with multi-CDN fallback
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; 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(() => { useEffect(() => {
if (!isPlaying) { if (!isPlaying) {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
lastTimeRef.current = null; lastTimeRef.current = null;
playbackCarryRef.current = 0;
return; return;
} }
const animate = (timestamp: number) => { const animate = (timestamp: number) => {
if (!lastTimeRef.current) lastTimeRef.current = timestamp; if (lastTimeRef.current == null) lastTimeRef.current = timestamp;
const delta = timestamp - lastTimeRef.current; const frameDelta = timestamp - lastTimeRef.current;
lastTimeRef.current = timestamp;
// 20 Hz step interval is 50 ms. With playback multiplier: // Virtual recorded-time budget to consume this frame, at the selected speed.
const stepInterval = 50 / playbackSpeed; const budget = frameDelta * playbackSpeed + playbackCarryRef.current;
const { index, remainingMs } = advancePlaybackIndex(points, indexRef.current, budget);
playbackCarryRef.current = index >= totalPoints - 1 ? 0 : remainingMs;
if (delta >= stepInterval) { if (index !== indexRef.current) {
lastTimeRef.current = timestamp; indexRef.current = index;
setCurrentIndex((prev) => { setCurrentIndex(index);
if (prev >= totalPoints - 1) { }
setIsPlaying(false); if (index >= totalPoints - 1) {
return prev; setIsPlaying(false);
} return;
return prev + 1;
});
} }
animationFrameRef.current = requestAnimationFrame(animate); animationFrameRef.current = requestAnimationFrame(animate);
@ -645,7 +657,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
return () => { return () => {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
}; };
}, [isPlaying, playbackSpeed, totalPoints]); }, [isPlaying, playbackSpeed, totalPoints, points]);
// Keyboard navigation (Space to toggle play) // Keyboard navigation (Space to toggle play)
useEffect(() => { useEffect(() => {

View File

@ -44,6 +44,41 @@ export function areTelemetryPointsConnected(a: TelemetryPoint, b: TelemetryPoint
return a.segmentId === b.segmentId && delta >= 0 && delta <= 5000; 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. */ /** Leaflet's geographic API is [latitude, longitude], never normalized screen pixels. */
export function geographicSegments(points: TelemetryPoint[]): [number, number][][] { export function geographicSegments(points: TelemetryPoint[]): [number, number][][] {
const segments: [number,number][][] = []; const segments: [number,number][][] = [];

View File

@ -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 });
});
});