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:
parent
4eb2075c7a
commit
ae1980b4ed
|
|
@ -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<number | null>(null);
|
||||
const lastTimeRef = useRef<number | null>(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(() => {
|
||||
|
|
|
|||
|
|
@ -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][][] = [];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue