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,
|
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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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][][] = [];
|
||||||
|
|
|
||||||
|
|
@ -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