feat(website): smooth motorcycle replay animation with 60 FPS sub-sample interpolation (#40)

* feat(website): smooth motorcycle replay animation with 60 FPS interpolation and shortest-path heading

* fix(website): eliminate map pan thrashing, pause snapping, and non-finite coordinate exceptions in replay

* chore(website): remove development test page and screenshots, add authentic telemetry verification

* fix(website): reset replay carry on ride switch and translate fixture to synthetic coordinates

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-14 22:02:32 -04:00 committed by GitHub
parent f5b734cccd
commit e0de6f22ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 3928 additions and 41 deletions

2
website/.gitignore vendored
View File

@ -2,3 +2,5 @@
node_modules/ node_modules/
.env* .env*
!.env.example !.env.example
.chrome-test-profile/
test-support/visual-evidence/

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, advancePlaybackIndex } from '../lib/telemetry-track'; import { areTelemetryPointsConnected, normalizeTelemetryPoints, advancePlaybackIndex, interpolateTelemetryPosition } 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 {
@ -59,6 +59,42 @@ const TILE_PROVIDERS = {
}, },
}; };
function createRiderIcon(L: any, heading = 0) {
return L.divIcon({
className: 'rider-replay-marker',
html: `
<div style="position:relative;width:32px;height:32px;display:flex;align-items:center;justify-content:center;">
<div style="position:absolute;width:32px;height:32px;border-radius:50%;background:rgba(0,242,254,0.35);box-shadow:0 0 12px #00f2fe;"></div>
<div style="position:absolute;width:14px;height:14px;border-radius:50%;background:#00f2fe;border:2.5px solid #ffffff;box-shadow:0 0 8px #00f2fe;"></div>
<div class="rider-heading-arrow" style="transform:rotate(${heading || 0}deg);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #00f2fe;position:absolute;top:-6px;filter:drop-shadow(0 0 2px rgba(0,0,0,0.8));"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
}
function updateRiderMarker(
marker: any,
lat: number,
lng: number,
heading: number,
L: any
) {
if (!marker || !Number.isFinite(lat) || !Number.isFinite(lng)) return;
if (typeof marker.setLatLng === 'function') {
marker.setLatLng([lat, lng]);
}
const safeHeading = Number.isFinite(heading) ? ((heading % 360) + 360) % 360 : 0;
const el = typeof marker.getElement === 'function' ? marker.getElement() : null;
const arrow = el ? (el.querySelector('.rider-heading-arrow') as HTMLElement | null) : null;
if (arrow) {
arrow.style.transform = `rotate(${safeHeading.toFixed(2)}deg)`;
} else if (typeof marker.setIcon === 'function' && L?.divIcon) {
marker.setIcon(createRiderIcon(L, safeHeading));
}
}
export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayModalProps) { export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayModalProps) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<number>(1.0); const [playbackSpeed, setPlaybackSpeed] = useState<number>(1.0);
@ -83,6 +119,10 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
let active = true; let active = true;
setCurrentIndex(0); setCurrentIndex(0);
setIsPlaying(false); setIsPlaying(false);
indexRef.current = 0;
playbackCarryRef.current = 0;
lastTimeRef.current = null;
lastPanTimeRef.current = 0;
setTelemetry({rideId:ride.id,points:[],source:'',error:null,loading:true}); setTelemetry({rideId:ride.id,points:[],source:'',error:null,loading:true});
if (!ride.telemetryBundlePath && attachedPoints.length) { if (!ride.telemetryBundlePath && attachedPoints.length) {
setTelemetry({rideId:ride.id,points:attachedPoints,source:'inline recorded telemetry',error:null,loading:false}); setTelemetry({rideId:ride.id,points:attachedPoints,source:'inline recorded telemetry',error:null,loading:false});
@ -121,6 +161,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
const lastTimeRef = useRef<number | null>(null); const lastTimeRef = useRef<number | null>(null);
const indexRef = useRef(0); const indexRef = useRef(0);
const playbackCarryRef = useRef(0); const playbackCarryRef = useRef(0);
const lastPanTimeRef = useRef<number>(0);
const totalPoints = points.length; const totalPoints = points.length;
const currentPoint = points[currentIndex] || points[0]; const currentPoint = points[currentIndex] || points[0];
@ -149,6 +190,20 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
lastTimeRef.current = null; lastTimeRef.current = null;
indexRef.current = clamped; indexRef.current = clamped;
setCurrentIndex(clamped); setCurrentIndex(clamped);
const pt = points[clamped];
if (pt && riderMarkerRef.current && typeof window !== 'undefined') {
const L = (window as any).L;
updateRiderMarker(riderMarkerRef.current, pt.latitude, pt.longitude, pt.heading || 0, L);
if (mapInstanceRef.current) {
const map = mapInstanceRef.current;
const bounds = map.getBounds?.();
if (bounds && !bounds.contains([pt.latitude, pt.longitude])) {
lastPanTimeRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
map.panTo([pt.latitude, pt.longitude], { animate: true, duration: 0.5 });
}
}
}
}; };
// Dynamically load Leaflet library and CSS with multi-CDN fallback // Dynamically load Leaflet library and CSS with multi-CDN fallback
@ -476,19 +531,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
// Live rider position marker // Live rider position marker
if (points[0]) { if (points[0]) {
const riderIcon = L.divIcon({ const riderIcon = createRiderIcon(L, points[0].heading || 0);
className: 'rider-replay-marker',
html: `
<div style="position:relative;width:32px;height:32px;display:flex;align-items:center;justify-content:center;">
<div style="position:absolute;width:32px;height:32px;border-radius:50%;background:rgba(0,242,254,0.35);box-shadow:0 0 12px #00f2fe;"></div>
<div style="position:absolute;width:14px;height:14px;border-radius:50%;background:#00f2fe;border:2.5px solid #ffffff;box-shadow:0 0 8px #00f2fe;"></div>
<div style="transform:rotate(${points[0].heading || 0}deg);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #00f2fe;position:absolute;top:-6px;filter:drop-shadow(0 0 2px rgba(0,0,0,0.8));"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
const riderMarker = L.marker([points[0].latitude, points[0].longitude], { const riderMarker = L.marker([points[0].latitude, points[0].longitude], {
icon: riderIcon, icon: riderIcon,
zIndexOffset: 1000, zIndexOffset: 1000,
@ -536,37 +579,30 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
}; };
}, [leafletReady, points, corners]); }, [leafletReady, points, corners]);
// Update Rider Beacon LatLng & Heading during playback or scrub // Update Rider Beacon LatLng & Heading during scrub or state change
useEffect(() => { useEffect(() => {
if (!riderMarkerRef.current || !currentPoint || typeof window === 'undefined') return; if (!riderMarkerRef.current || !currentPoint || typeof window === 'undefined') return;
const L = (window as any).L; const L = (window as any).L;
if (!L) return; if (!L) return;
riderMarkerRef.current.setLatLng([currentPoint.latitude, currentPoint.longitude]); if (!isPlaying) {
const pos =
const newIcon = L.divIcon({ playbackCarryRef.current > 0
className: 'rider-replay-marker', ? interpolateTelemetryPosition(points, indexRef.current, playbackCarryRef.current)
html: ` : {
<div style="position:relative;width:32px;height:32px;display:flex;align-items:center;justify-content:center;"> latitude: currentPoint.latitude,
<div style="position:absolute;width:32px;height:32px;border-radius:50%;background:rgba(0,242,254,0.35);box-shadow:0 0 12px #00f2fe;"></div> longitude: currentPoint.longitude,
<div style="position:absolute;width:14px;height:14px;border-radius:50%;background:#00f2fe;border:2.5px solid #ffffff;box-shadow:0 0 8px #00f2fe;"></div> heading: currentPoint.heading || 0,
<div style="transform:rotate(${currentPoint.heading || 0}deg);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #00f2fe;position:absolute;top:-6px;filter:drop-shadow(0 0 2px rgba(0,0,0,0.8));"></div> };
</div> updateRiderMarker(
`, riderMarkerRef.current,
iconSize: [32, 32], pos.latitude,
iconAnchor: [16, 16], pos.longitude,
}); pos.heading,
riderMarkerRef.current.setIcon(newIcon); L
);
// Pan map to follow if out of current bounds while playing
if (isPlaying && mapInstanceRef.current) {
const map = mapInstanceRef.current;
const bounds = map.getBounds();
if (bounds && !bounds.contains([currentPoint.latitude, currentPoint.longitude])) {
map.panTo([currentPoint.latitude, currentPoint.longitude], { animate: true, duration: 0.5 });
}
} }
}, [currentPoint, isPlaying]); }, [currentPoint, isPlaying, points]);
// Invalidate map size when tab switches to map // Invalidate map size when tab switches to map
useEffect(() => { useEffect(() => {
@ -646,7 +682,6 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
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;
} }
@ -661,6 +696,26 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
const { index, remainingMs } = advancePlaybackIndex(points, indexRef.current, budget); const { index, remainingMs } = advancePlaybackIndex(points, indexRef.current, budget);
playbackCarryRef.current = index >= totalPoints - 1 ? 0 : remainingMs; playbackCarryRef.current = index >= totalPoints - 1 ? 0 : remainingMs;
// Continuous 60 FPS interpolation for smooth marker animation
const pos = interpolateTelemetryPosition(points, index, remainingMs);
if (riderMarkerRef.current && typeof window !== 'undefined') {
const L = (window as any).L;
updateRiderMarker(riderMarkerRef.current, pos.latitude, pos.longitude, pos.heading, L);
// Pan map to follow if out of current bounds while playing (throttled to 600ms to allow smooth 0.5s pan animation to complete without 60 FPS thrashing)
if (mapInstanceRef.current) {
const map = mapInstanceRef.current;
const bounds = map.getBounds?.();
if (bounds && !bounds.contains([pos.latitude, pos.longitude])) {
if (timestamp - lastPanTimeRef.current >= 600) {
lastPanTimeRef.current = timestamp;
map.panTo([pos.latitude, pos.longitude], { animate: true, duration: 0.5 });
}
}
}
}
if (index !== indexRef.current) { if (index !== indexRef.current) {
indexRef.current = index; indexRef.current = index;
setCurrentIndex(index); setCurrentIndex(index);
@ -1221,7 +1276,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
{/* Playback Speed Multiplier */} {/* Playback Speed Multiplier */}
<div className="flex items-center gap-1 bg-surface-container p-1 rounded-lg border border-outline-variant/30 text-xs font-mono"> <div className="flex items-center gap-1 bg-surface-container p-1 rounded-lg border border-outline-variant/30 text-xs font-mono">
{[0.5, 1.0, 2.0, 5.0].map((spd) => ( {[0.5, 1.0, 2.0, 5.0, 10.0].map((spd) => (
<button <button
key={spd} key={spd}
onClick={() => setPlaybackSpeed(spd)} onClick={() => setPlaybackSpeed(spd)}

View File

@ -84,6 +84,157 @@ export function advancePlaybackIndex(
return { index: curr, remainingMs: curr < points.length - 1 ? budget : 0 }; return { index: curr, remainingMs: curr < points.length - 1 ? budget : 0 };
} }
/**
* Interpolates heading degrees between two orientations using the shortest angular path,
* correctly handling the 359° to 0° (and 0° to 359°) wraparound boundary without full rotations.
* Returns a normalized angle in [0, 360).
*/
export function interpolateHeading(fromHeading: number, toHeading: number, t: number): number {
if (!Number.isFinite(fromHeading) && !Number.isFinite(toHeading)) return 0;
if (!Number.isFinite(fromHeading)) return ((toHeading % 360) + 360) % 360;
if (!Number.isFinite(toHeading)) return ((fromHeading % 360) + 360) % 360;
const from = ((fromHeading % 360) + 360) % 360;
const to = ((toHeading % 360) + 360) % 360;
const clampedT = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
// Shortest angular difference in [-180, 180)
const diff = ((to - from + 540) % 360) - 180;
const result = (from + clampedT * diff) % 360;
return (result + 360) % 360;
}
/**
* Calculates the interpolated geographic position (latitude, longitude) and heading at a given
* playback point index and fractional sub-sample millisecond offset.
*
* If points are disconnected (e.g. across a GPS loss gap or segment boundary), the position
* stays at the start point rather than interpolating false coordinates across dead space.
* Clamps safely to points array bounds and handles non-finite values and duplicate timestamps.
*/
export function interpolateTelemetryPosition(
points: TelemetryPoint[],
index: number,
remainingMs: number
): { latitude: number; longitude: number; heading: number } {
if (!points || points.length === 0) {
return { latitude: 0, longitude: 0, heading: 0 };
}
const i = Math.max(0, Math.min(index, points.length - 1));
const p1 = points[i];
const p1Lat = Number.isFinite(p1.latitude) ? p1.latitude : 0;
const p1Lng = Number.isFinite(p1.longitude) ? p1.longitude : 0;
const p1Heading = Number.isFinite(p1.heading) ? ((p1.heading % 360) + 360) % 360 : 0;
if (i >= points.length - 1 || !points[i + 1]) {
return {
latitude: p1Lat,
longitude: p1Lng,
heading: p1Heading,
};
}
const p2 = points[i + 1];
if (!areTelemetryPointsConnected(p1, p2)) {
return {
latitude: p1Lat,
longitude: p1Lng,
heading: p1Heading,
};
}
const dt = Date.parse(p2.timestamp) - Date.parse(p1.timestamp);
if (!Number.isFinite(dt) || dt <= 0 || !Number.isFinite(remainingMs) || remainingMs <= 0) {
return {
latitude: p1Lat,
longitude: p1Lng,
heading: p1Heading,
};
}
const t = Math.max(0, Math.min(1, remainingMs / dt));
const p2Lat = Number.isFinite(p2.latitude) ? p2.latitude : p1Lat;
const p2Lng = Number.isFinite(p2.longitude) ? p2.longitude : p1Lng;
const latitude = p1Lat + t * (p2Lat - p1Lat);
const longitude = p1Lng + t * (p2Lng - p1Lng);
const heading = interpolateHeading(p1.heading, p2.heading, t);
return {
latitude: Number.isFinite(latitude) ? latitude : p1Lat,
longitude: Number.isFinite(longitude) ? longitude : p1Lng,
heading,
};
}
/**
* Calculates a fully interpolated TelemetryPoint between two recorded samples.
* Preserves recorded metadata, interpolates numerical telemetry metrics (speed, lean, G-forces),
* and avoids interpolating across segment breaks or recording gaps.
*/
export function interpolateTelemetryPoint(
points: TelemetryPoint[],
index: number,
remainingMs: number
): TelemetryPoint {
if (!points || points.length === 0) {
return {
timestamp: new Date(0).toISOString(),
latitude: 0,
longitude: 0,
altitude: 0,
speed: 0,
heading: 0,
gpsAccuracy: 0,
leanAngle: 0,
pitchAngle: 0,
accelX: 0,
accelY: 0,
accelZ: 0,
longitudinalG: 0,
lateralG: 0,
segmentId: 0,
};
}
const i = Math.max(0, Math.min(index, points.length - 1));
const p1 = points[i];
if (i >= points.length - 1 || !points[i + 1]) return p1;
const p2 = points[i + 1];
if (!areTelemetryPointsConnected(p1, p2)) return p1;
const dt = Date.parse(p2.timestamp) - Date.parse(p1.timestamp);
if (!Number.isFinite(dt) || dt <= 0 || !Number.isFinite(remainingMs) || remainingMs <= 0) return p1;
const t = Math.max(0, Math.min(1, remainingMs / dt));
const t1 = Date.parse(p1.timestamp);
const interpolatedTime = Number.isFinite(t1) ? new Date(t1 + t * dt).toISOString() : p1.timestamp;
const lerp = (v1: number | undefined, v2: number | undefined): number => {
const a = Number.isFinite(v1) ? (v1 as number) : 0;
const b = Number.isFinite(v2) ? (v2 as number) : 0;
return a + t * (b - a);
};
return {
timestamp: interpolatedTime,
latitude: lerp(p1.latitude, p2.latitude),
longitude: lerp(p1.longitude, p2.longitude),
altitude: lerp(p1.altitude, p2.altitude),
speed: lerp(p1.speed, p2.speed),
heading: interpolateHeading(p1.heading, p2.heading, t),
gpsAccuracy: lerp(p1.gpsAccuracy, p2.gpsAccuracy),
leanAngle: lerp(p1.leanAngle, p2.leanAngle),
pitchAngle: lerp(p1.pitchAngle, p2.pitchAngle),
accelX: lerp(p1.accelX, p2.accelX),
accelY: lerp(p1.accelY, p2.accelY),
accelZ: lerp(p1.accelZ, p2.accelZ),
longitudinalG: lerp(p1.longitudinalG, p2.longitudinalG),
lateralG: lerp(p1.lateralG, p2.lateralG),
segmentId: p1.segmentId,
};
}
/** 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][][] = [];

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,238 @@
import { spawn } from 'node:child_process';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
const CHROME_PATH = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
const USER_DATA = join(process.cwd(), '.chrome-test-profile');
const SCREENSHOT_DIR = join(process.cwd(), 'test-support', 'visual-evidence');
try {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
} catch {}
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function run() {
console.log('[Browser Verification] Starting Chrome in headless mode...');
const chrome = spawn(
CHROME_PATH,
[
'--headless=new',
'--remote-debugging-port=9222',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
`--user-data-dir=${USER_DATA}`,
'--window-size=1280,960',
'about:blank',
],
{ stdio: 'ignore' }
);
try {
// Wait for Chrome remote debugging endpoint
let versionData = null;
for (let i = 0; i < 30; i++) {
try {
const res = await fetch('http://127.0.0.1:9222/json/version');
if (res.ok) {
versionData = await res.json();
break;
}
} catch {}
await sleep(200);
}
if (!versionData) {
throw new Error('Failed to connect to Chrome DevTools endpoint on port 9222');
}
console.log('[Browser Verification] Connected to Chrome DevTools:', versionData.Browser);
// Create a new target page
const testUrl = process.env.REPLAY_TEST_URL || process.argv[2] || 'http://localhost:3008/account';
const newTargetRes = await fetch(`http://127.0.0.1:9222/json/new?${encodeURIComponent(testUrl)}`, {
method: 'PUT',
});
const target = await newTargetRes.json();
console.log('[Browser Verification] Opened target page:', target.id);
const ws = new WebSocket(target.webSocketDebuggerUrl);
let messageId = 1;
const callbacks = new Map();
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && callbacks.has(msg.id)) {
const cb = callbacks.get(msg.id);
callbacks.delete(msg.id);
if (msg.error) {
cb.reject(new Error(msg.error.message));
} else {
cb.resolve(msg.result);
}
}
};
await new Promise((resolve) => {
ws.onopen = resolve;
});
const send = (method, params = {}) => {
return new Promise((resolve, reject) => {
const id = messageId++;
callbacks.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
};
await send('Page.enable');
await send('Runtime.enable');
console.log('[Browser Verification] Waiting for page load and Leaflet map rendering...');
await sleep(2500);
const evalJs = async (expr) => {
const res = await send('Runtime.evaluate', {
expression: expr,
returnByValue: true,
awaitPromise: true,
});
return res?.result?.value;
};
// 1. Verify modal title and elements
const title = await evalJs('document.querySelector("h2")?.textContent');
console.log('[Browser Verification] Modal Title:', title);
const markerExists = await evalJs('!!document.querySelector(".rider-replay-marker")');
console.log('[Browser Verification] Rider marker in DOM:', markerExists);
const initialPos = await evalJs(`(() => {
const el = document.querySelector(".rider-replay-marker");
const arrow = document.querySelector(".rider-heading-arrow");
return {
markerTransform: el ? el.style.transform : null,
arrowTransform: arrow ? arrow.style.transform : null,
};
})()`);
console.log('[Browser Verification] Initial Marker State:', initialPos);
// Capture screenshot: initial
const snap1 = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(SCREENSHOT_DIR, '01_initial_replay.png'), Buffer.from(snap1.data, 'base64'));
console.log('[Browser Verification] Saved screenshot: 01_initial_replay.png');
// 2. Click Play
console.log('[Browser Verification] Clicking PLAY...');
await evalJs('document.querySelector(\'[data-testid="play-pause-btn"]\')?.click()');
await sleep(100);
// Sample marker positions over 300 ms (approx 18 animation frames)
console.log('[Browser Verification] Sampling marker positions in motion across animation frames...');
const samples = [];
for (let i = 0; i < 6; i++) {
await sleep(50);
const sample = await evalJs(`(() => {
const el = document.querySelector(".rider-replay-marker");
const arrow = document.querySelector(".rider-heading-arrow");
const speed = document.querySelector(".text-3xl.font-extrabold")?.textContent;
return {
markerTransform: el ? el.style.transform : null,
arrowTransform: arrow ? arrow.style.transform : null,
speed,
};
})()`);
samples.push(sample);
}
console.log('[Browser Verification] Recorded motion frames:');
samples.forEach((s, idx) => console.log(` Frame sample ${idx + 1}: ${s.markerTransform} | heading: ${s.arrowTransform} | speed: ${s.speed} mph`));
// Verify intermediate sub-pixel movement occurred
const uniqueTransforms = new Set(samples.map((s) => s.markerTransform));
console.log(`[Browser Verification] Unique positions observed in 300ms: ${uniqueTransforms.size} / ${samples.length}`);
if (uniqueTransforms.size > 1) {
console.log('>>> SUCCESS: Marker smoothly animated across intermediate screen positions! <<<');
} else {
console.error('>>> FAIL: Marker did not move! <<<');
}
// Capture screenshot: in motion
const snap2 = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(SCREENSHOT_DIR, '02_motion_replay.png'), Buffer.from(snap2.data, 'base64'));
console.log('[Browser Verification] Saved screenshot: 02_motion_replay.png');
// 3. Click Pause
console.log('[Browser Verification] Clicking PAUSE...');
await evalJs('document.querySelector(\'[data-testid="play-pause-btn"]\')?.click()');
const pausedState1 = await evalJs('document.querySelector(".rider-replay-marker")?.style.transform');
console.log('[Browser Verification] Position immediately upon pause:', pausedState1);
await sleep(500);
const pausedState2 = await evalJs('document.querySelector(".rider-replay-marker")?.style.transform');
console.log('[Browser Verification] Position 500ms after pause (checking drift):', pausedState2);
if (pausedState1 === pausedState2) {
console.log('>>> SUCCESS: Zero marker drift on pause! <<<');
} else {
console.error('>>> FAIL: Marker drifted while paused! <<<');
}
// Capture screenshot: paused
const snap3 = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(SCREENSHOT_DIR, '03_paused_replay.png'), Buffer.from(snap3.data, 'base64'));
console.log('[Browser Verification] Saved screenshot: 03_paused_replay.png');
// 4. Test Speed change to 2x and Resume
console.log('[Browser Verification] Testing speed change to 2x and resume...');
await evalJs(`(() => {
const btn = Array.from(document.querySelectorAll("button")).find(b => b.textContent.trim() === "2x");
btn?.click();
})()`);
await sleep(100);
await evalJs('document.querySelector(\'[data-testid="play-pause-btn"]\')?.click()');
await sleep(300);
const speedSample = await evalJs('document.querySelector(".rider-replay-marker")?.style.transform');
console.log('[Browser Verification] Position after 2x speed playback:', speedSample);
// 5. Test Seek to exact point 100
console.log('[Browser Verification] Testing timeline seek...');
await evalJs(`(() => {
const slider = document.querySelector('[data-testid="replay-timeline-slider"]');
if (slider) {
slider.value = '100';
slider.dispatchEvent(new Event('change', { bubbles: true }));
}
})()`);
await sleep(150);
const seekPos = await evalJs(`(() => {
const el = document.querySelector(".rider-replay-marker");
const arrow = document.querySelector(".rider-heading-arrow");
return {
markerTransform: el ? el.style.transform : null,
arrowTransform: arrow ? arrow.style.transform : null,
};
})()`);
console.log('[Browser Verification] Position after seek to point 100:', seekPos);
// Capture screenshot: seek
const snap4 = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(SCREENSHOT_DIR, '04_seek_replay.png'), Buffer.from(snap4.data, 'base64'));
console.log('[Browser Verification] Saved screenshot: 04_seek_replay.png');
ws.close();
} finally {
chrome.kill('SIGKILL');
console.log('[Browser Verification] Chrome process terminated.');
}
}
run().catch((err) => {
console.error('[Browser Verification Error]:', err);
process.exit(1);
});

View File

@ -0,0 +1,286 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
import { WebTelemetryReplayModal } from '../components/web-telemetry-replay-modal';
import type { Ride } from '../components/app-cloud-data';
import type { TelemetryPoint } from '../lib/telemetry-export';
import authenticPoints from '../test-support/fixtures/pixel8a-authentic-ride.json';
const mocks = vi.hoisted(() => ({
token: vi.fn().mockResolvedValue('authorized-test-token'),
}));
vi.mock('../lib/firebase-client', () => ({
firebaseAuth: {
currentUser: { uid: 'test-rider-pixel8a', getIdToken: mocks.token },
},
}));
describe('Phase 2 — Real Recorded Telemetry Playback & Smooth Animation', () => {
let authenticRide: Ride;
let pointsClone: TelemetryPoint[];
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mocks.token.mockResolvedValue('authorized-test-token');
// Deep clone authentic fixture to ensure test isolation and prove immutability
pointsClone = JSON.parse(JSON.stringify(authenticPoints));
authenticRide = {
id: 'ride_pixel8a_real_field_test',
startedAt: new Date(pointsClone[0].timestamp),
endedAt: new Date(pointsClone[pointsClone.length - 1].timestamp),
movingTime: 45,
distanceMiles: 0.85,
topSpeedMph: 59.47,
averageSpeed: 38.2,
maxLeanLeft: 8.53,
maxLeanRight: 5.2,
maxAccelG: 0.45,
maxBrakingG: -0.55,
elevationGain: 12,
qualityScore: 99,
motorcycle: 'Yamaha YZF-R6',
points: pointsClone,
telemetryBundlePath: undefined,
};
});
afterEach(() => {
cleanup();
delete (window as Window & { L?: unknown }).L;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('renders authentic Pixel 8a telemetry with Leaflet map, polyline, and HUD metrics', async () => {
const map = {
invalidateSize: vi.fn(),
remove: vi.fn(),
fitBounds: vi.fn(),
removeLayer: vi.fn(),
panTo: vi.fn(),
getBounds: () => ({ contains: () => true }),
};
const rider = {
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn(),
setIcon: vi.fn(),
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
getElement: vi.fn().mockReturnValue(null),
};
const layer = () => ({
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
});
const L = {
map: vi.fn(() => map),
tileLayer: vi.fn(layer),
featureGroup: vi.fn(layer),
polyline: vi.fn(() => layer()),
marker: vi.fn(() => rider),
divIcon: vi.fn(),
circleMarker: vi.fn(() => rider),
latLngBounds: vi.fn((coords) => coords),
};
(window as Window & { L?: unknown }).L = L;
render(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
// Verify map initialized and fitted to authentic GPS coordinates
await waitFor(() => expect(L.map).toHaveBeenCalled());
expect(L.polyline).toHaveBeenCalled();
expect(map.fitBounds).toHaveBeenCalled();
// Verify initial rider marker set at synthetic starting coordinates
expect(rider.setLatLng).toHaveBeenCalledWith([
pointsClone[0].latitude,
pointsClone[0].longitude,
]);
// Verify GPS readout displays synthetic starting coordinates
const sourceBadge = screen.getByTestId('replay-data-source').parentElement!;
expect(sourceBadge.textContent).toContain(
`${pointsClone[0].latitude.toFixed(5)}, ${pointsClone[0].longitude.toFixed(5)}`
);
});
it('animates smoothly at 60 FPS without snapping, and preserves sub-sample position on pause and tab switch', async () => {
let rafCallback: ((time: number) => void) | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallback = cb;
return 999;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {
rafCallback = null;
});
const map = {
invalidateSize: vi.fn(),
remove: vi.fn(),
fitBounds: vi.fn(),
removeLayer: vi.fn(),
panTo: vi.fn(),
getBounds: () => ({ contains: () => true }),
};
const rider = {
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn(),
setIcon: vi.fn(),
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
getElement: vi.fn().mockReturnValue(null),
};
const layer = () => ({
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
});
const L = {
map: vi.fn(() => map),
tileLayer: vi.fn(layer),
featureGroup: vi.fn(layer),
polyline: vi.fn(() => layer()),
marker: vi.fn(() => rider),
divIcon: vi.fn(),
circleMarker: vi.fn(() => rider),
latLngBounds: vi.fn((coords) => coords),
};
(window as Window & { L?: unknown }).L = L;
render(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
await waitFor(() => expect(L.marker).toHaveBeenCalled());
// 1. Start playback
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
expect(screen.getByText('PAUSE')).toBeDefined();
expect(rafCallback).toBeDefined();
// Tick 1: t = 1000 ms (delta = 0)
rafCallback!(1000);
// Tick 2: t = 1016.67 ms (first 60 FPS animation frame)
rafCallback!(1016.67);
const firstCallCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0];
expect(Number.isFinite(firstCallCoords[0])).toBe(true);
expect(Number.isFinite(firstCallCoords[1])).toBe(true);
// Tick 3: t = 1033.33 ms (second 60 FPS animation frame)
rafCallback!(1033.33);
const secondCallCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0];
// Tick 4: t = 1050 ms (third 60 FPS animation frame)
rafCallback!(1050);
const thirdCallCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0];
// 2. Click PAUSE
fireEvent.click(screen.getByTestId('play-pause-btn'));
expect(screen.getByText('PLAY')).toBeDefined();
const pausedCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0];
expect(pausedCoords).toEqual(thirdCallCoords);
// 3. Switch tabs while paused: Map Replay -> Corner Grading -> Map Replay
fireEvent.click(screen.getByTestId('tab-corner-grading'));
expect(rider.setLatLng).toHaveBeenLastCalledWith(pausedCoords);
fireEvent.click(screen.getByTestId('tab-map-replay'));
expect(rider.setLatLng).toHaveBeenLastCalledWith(pausedCoords);
// 4. Resume playback: resumes from exact pausedCoords without jumping
fireEvent.click(screen.getByTestId('play-pause-btn'));
expect(screen.getByText('PAUSE')).toBeDefined();
expect(rafCallback).toBeDefined();
// First frame of resume: delta = 0
rafCallback!(5000);
expect(rider.setLatLng).toHaveBeenLastCalledWith(pausedCoords);
// Subsequent frame: advances smoothly forward
rafCallback!(5020);
const afterResumeCoords = rider.setLatLng.mock.calls[rider.setLatLng.mock.calls.length - 1][0];
expect(Number.isFinite(afterResumeCoords[0])).toBe(true);
});
it('supports seeking to any point and testing all playback speeds (0.5x, 1x, 2x, 5x, 10x)', async () => {
const map = {
invalidateSize: vi.fn(),
remove: vi.fn(),
fitBounds: vi.fn(),
removeLayer: vi.fn(),
panTo: vi.fn(),
getBounds: () => ({ contains: () => true }),
};
const rider = {
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn(),
setIcon: vi.fn(),
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
getElement: vi.fn().mockReturnValue(null),
};
const layer = () => ({
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
});
const L = {
map: vi.fn(() => map),
tileLayer: vi.fn(layer),
featureGroup: vi.fn(layer),
polyline: vi.fn(() => layer()),
marker: vi.fn(() => rider),
divIcon: vi.fn(),
circleMarker: vi.fn(() => rider),
latLngBounds: vi.fn((coords) => coords),
};
(window as Window & { L?: unknown }).L = L;
render(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
await waitFor(() => expect(L.marker).toHaveBeenCalled());
// Verify all speed buttons exist and are clickable
const speeds = ['0.5x', '1x', '2x', '5x', '10x'];
speeds.forEach((spd) => {
const btn = screen.getByText(spd);
expect(btn).toBeDefined();
fireEvent.click(btn);
});
// Seek to point 50 (highway acceleration)
const slider = screen.getByTestId('replay-timeline-slider');
fireEvent.change(slider, { target: { value: '50' } });
expect(rider.setLatLng).toHaveBeenLastCalledWith([
pointsClone[50].latitude,
pointsClone[50].longitude,
]);
// Seek to point 100 (high speed curve)
fireEvent.change(slider, { target: { value: '100' } });
expect(rider.setLatLng).toHaveBeenLastCalledWith([
pointsClone[100].latitude,
pointsClone[100].longitude,
]);
});
it('guarantees that recorded telemetry points are NEVER mutated during replay operations', async () => {
const originalPointsJson = JSON.stringify(authenticPoints);
render(<WebTelemetryReplayModal ride={authenticRide} onClose={() => {}} />);
// Exercise play, seek, speed change, and pause
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
fireEvent.click(screen.getByText('5x'));
fireEvent.change(screen.getByTestId('replay-timeline-slider'), { target: { value: '75' } });
fireEvent.click(playBtn);
// Verify original points array was not mutated in any way
expect(JSON.stringify(authenticRide.points)).toBe(originalPointsJson);
});
});

View File

@ -71,4 +71,163 @@ describe('recorded replay loading and identity',()=>{
await waitFor(()=>expect(L.map).toHaveBeenCalledTimes(2)); await waitFor(()=>expect(L.map).toHaveBeenCalledTimes(2));
expect(rider.setLatLng).toHaveBeenLastCalledWith([40,-74]); expect(rider.setLatLng).toHaveBeenLastCalledWith([40,-74]);
}); });
it('interpolates motorcycle marker smoothly during animation and preserves position on pause',async()=>{
let rafCallback: ((time: number) => void) | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallback = cb;
return 12345;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {
rafCallback = null;
});
const map={invalidateSize:vi.fn(),remove:vi.fn(),fitBounds:vi.fn(),removeLayer:vi.fn(),panTo:vi.fn(),getBounds:()=>({contains:()=>true})};
const rider={addTo:vi.fn().mockReturnThis(),setLatLng:vi.fn(),setIcon:vi.fn(),bindTooltip:vi.fn().mockReturnThis(),on:vi.fn().mockReturnThis()};
const layer=()=>({addTo:vi.fn().mockReturnThis(),on:vi.fn().mockReturnThis(),addLayer:vi.fn()});
const L={map:vi.fn(()=>map),tileLayer:vi.fn(layer),featureGroup:vi.fn(layer),polyline:vi.fn(()=>layer()),marker:vi.fn(()=>rider),divIcon:vi.fn(),circleMarker:vi.fn(()=>rider),latLngBounds:vi.fn((coords)=>coords)};
(window as Window & {L?:unknown}).L=L;
// 1-second interval between points (1000 ms)
const points=[
{timestamp:'2026-09-03T20:00:00.000Z',latitude:40.0,longitude:-74.0,speed:20,heading:350,segmentId:0},
{timestamp:'2026-09-03T20:00:01.000Z',latitude:40.01,longitude:-73.99,speed:30,heading:10,segmentId:0},
];
const inline={...ride,telemetryBundlePath:undefined,points} as Ride;
render(<WebTelemetryReplayModal ride={inline} onClose={()=>{}}/>);
await waitFor(()=>expect(L.marker).toHaveBeenCalled());
expect(rider.setLatLng).toHaveBeenCalledWith([40.0,-74.0]);
// Click Play
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
expect(screen.getByText('PAUSE')).toBeDefined();
expect(rafCallback).toBeDefined();
// First animation frame at t=1000 (delta = 0)
rafCallback!(1000);
// Second animation frame at t=1050 (frameDelta = 50 ms -> 5% into the 1000 ms interval)
rafCallback!(1050);
// Interpolated latitude at t=0.05: 40.0 + 0.05 * 0.01 = 40.0005
expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0005, 4), expect.closeTo(-73.9995, 4)]);
// Third animation frame at t=1250 (additional 200 ms -> 250 ms total = 25% into interval)
rafCallback!(1250);
// Interpolated latitude at t=0.25: 40.0 + 0.25 * 0.01 = 40.0025
expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]);
// Pause playback: marker MUST NOT snap backwards to [40.0, -74.0]
fireEvent.click(screen.getByTestId('play-pause-btn'));
expect(screen.getByText('PLAY')).toBeDefined();
expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]);
// Trigger component re-render while paused (switch tab) — marker MUST STILL NOT snap back!
fireEvent.click(screen.getByTestId('tab-corner-grading'));
expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]);
fireEvent.click(screen.getByTestId('tab-map-replay'));
expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.0025, 4), expect.closeTo(-73.9975, 4)]);
// Check speed controls include 10x
expect(screen.getByText('10x')).toBeDefined();
fireEvent.click(screen.getByText('10x'));
});
it('resets carry and cursor when switching rides so the new ride starts at its first coordinate without carryover', async () => {
let rafCallback: ((time: number) => void) | null = null;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallback = cb;
return 54321;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {
rafCallback = null;
});
const map = {
invalidateSize: vi.fn(),
remove: vi.fn(),
fitBounds: vi.fn(),
removeLayer: vi.fn(),
panTo: vi.fn(),
getBounds: () => ({ contains: () => true }),
};
const rider = {
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn(),
setIcon: vi.fn(),
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
};
const layer = () => ({
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
});
const L = {
map: vi.fn(() => map),
tileLayer: vi.fn(layer),
featureGroup: vi.fn(layer),
polyline: vi.fn(() => layer()),
marker: vi.fn(() => rider),
divIcon: vi.fn(),
circleMarker: vi.fn(() => rider),
latLngBounds: vi.fn((coords) => coords),
};
(window as Window & { L?: unknown }).L = L;
const points1 = [
{ timestamp: '2026-09-03T20:00:00.000Z', latitude: 40.0, longitude: -74.0, speed: 20, heading: 350, segmentId: 0 },
{ timestamp: '2026-09-03T20:00:01.000Z', latitude: 40.01, longitude: -73.99, speed: 30, heading: 10, segmentId: 0 },
];
const ride1 = { ...ride, id: 'ride_1', telemetryBundlePath: undefined, points: points1 } as Ride;
const points2 = [
{ timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0, speed: 15, heading: 90, segmentId: 0 },
{ timestamp: '2026-10-01T12:00:01.000Z', latitude: 35.01, longitude: -114.99, speed: 25, heading: 100, segmentId: 0 },
];
const ride2 = { ...ride, id: 'ride_2', telemetryBundlePath: undefined, points: points2 } as Ride;
// 1. Mount with ride1
const { rerender } = render(<WebTelemetryReplayModal ride={ride1} onClose={() => {}} />);
await waitFor(() => expect(L.marker).toHaveBeenCalled());
expect(rider.setLatLng).toHaveBeenCalledWith([40.0, -74.0]);
// 2. Play ride1 and advance frames to generate non-zero carry
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
expect(rafCallback).toBeDefined();
// Frame 1: delta 0
rafCallback!(1000);
// Frame 2: frameDelta = 100 ms (10% into the 1000ms interval)
rafCallback!(1100);
expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(40.001, 4), expect.closeTo(-73.999, 4)]);
// 3. Pause midway with non-zero sub-sample carry (100 ms carry)
fireEvent.click(screen.getByTestId('play-pause-btn'));
expect(screen.getByText('PLAY')).toBeDefined();
// 4. Switch to ride2
rerender(<WebTelemetryReplayModal ride={ride2} onClose={() => {}} />);
await waitFor(() => expect(screen.getByText(/GPS: 35\.00000, -115\.00000/)).toBeDefined());
// Verify rider marker is placed at the exact first point of ride2 with 0 carry
expect(rider.setLatLng).toHaveBeenLastCalledWith([35.0, -115.0]);
// 5. Start playback on ride2
fireEvent.click(screen.getByTestId('play-pause-btn'));
expect(screen.getByText('PAUSE')).toBeDefined();
expect(rafCallback).toBeDefined();
// Frame 1 on ride2: delta 0 (lastTime initialized)
rafCallback!(2000);
// With carry reset to 0, marker stays at [35.0, -115.0] on delta 0 instead of leaping forward by ride1's 100ms carry
expect(rider.setLatLng).toHaveBeenLastCalledWith([35.0, -115.0]);
// Frame 2 on ride2: 50 ms elapsed
rafCallback!(2050);
// 50ms / 1000ms = 5% into ride2's interval: 35.0 + 0.05 * 0.01 = 35.0005
expect(rider.setLatLng).toHaveBeenLastCalledWith([expect.closeTo(35.0005, 4), expect.closeTo(-114.9995, 4)]);
});
}); });

View File

@ -0,0 +1,444 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
interpolateHeading,
interpolateTelemetryPosition,
interpolateTelemetryPoint,
advancePlaybackIndex,
areTelemetryPointsConnected,
} from '../lib/telemetry-track';
import type { TelemetryPoint } from '../lib/telemetry-export';
const BASE = Date.parse('2026-09-28T14:30:00.000Z');
function mkPoint(
offsetMs: number,
lat = 40.0,
lng = -74.0,
heading = 0,
speed = 45,
leanAngle = 0,
segmentId = 0
): TelemetryPoint {
return {
timestamp: new Date(BASE + offsetMs).toISOString(),
latitude: lat,
longitude: lng,
altitude: 100,
speed,
heading,
gpsAccuracy: 1.5,
leanAngle,
pitchAngle: 0,
accelX: 0,
accelY: 0,
accelZ: 1.0,
longitudinalG: 0.1,
lateralG: 0.2,
segmentId,
};
}
describe('interpolateHeading: Shortest Angular Path & Wraparound', () => {
it('interpolates heading along normal forward angular path', () => {
expect(interpolateHeading(10, 50, 0)).toBe(10);
expect(interpolateHeading(10, 50, 0.5)).toBeCloseTo(30, 2);
expect(interpolateHeading(10, 50, 1.0)).toBe(50);
});
it('interpolates 359° to 0° across the north boundary via the shortest path (+1°)', () => {
// 359 to 0 is +1 deg, so at t=0.5 it must be 359.5°, not 179.5°
const mid = interpolateHeading(359, 0, 0.5);
expect(mid).toBeCloseTo(359.5, 2);
expect(interpolateHeading(359, 0, 0)).toBe(359);
expect(interpolateHeading(359, 0, 1)).toBe(0);
});
it('interpolates 0° to 359° across the north boundary via the shortest path (-1°)', () => {
const mid = interpolateHeading(0, 359, 0.5);
expect(mid).toBeCloseTo(359.5, 2);
expect(interpolateHeading(0, 359, 0)).toBe(0);
expect(interpolateHeading(0, 359, 1)).toBe(359);
});
it('interpolates 350° to 10° crossing 0° (+20° shortest path)', () => {
// 350 -> 355 -> 0 -> 5 -> 10
expect(interpolateHeading(350, 10, 0.25)).toBeCloseTo(355, 2);
expect(interpolateHeading(350, 10, 0.5)).toBeCloseTo(0, 2);
expect(interpolateHeading(350, 10, 0.75)).toBeCloseTo(5, 2);
expect(interpolateHeading(350, 10, 1.0)).toBeCloseTo(10, 2);
});
it('interpolates 10° to 350° crossing 0° counterclockwise (-20° shortest path)', () => {
expect(interpolateHeading(10, 350, 0.25)).toBeCloseTo(5, 2);
expect(interpolateHeading(10, 350, 0.5)).toBeCloseTo(0, 2);
expect(interpolateHeading(10, 350, 0.75)).toBeCloseTo(355, 2);
expect(interpolateHeading(10, 350, 1.0)).toBeCloseTo(350, 2);
});
it('handles identical headings without drift', () => {
expect(interpolateHeading(180, 180, 0.5)).toBe(180);
expect(interpolateHeading(0, 0, 0.7)).toBe(0);
});
it('handles out-of-range headings and negative angles safely', () => {
expect(interpolateHeading(-10, 30, 0.5)).toBeCloseTo(10, 2); // -10 is 350
expect(interpolateHeading(720, 90, 0.5)).toBeCloseTo(45, 2); // 720 is 0
});
it('handles NaN or non-finite inputs gracefully without throwing', () => {
expect(interpolateHeading(NaN, 90, 0.5)).toBe(90);
expect(interpolateHeading(90, NaN, 0.5)).toBe(90);
expect(interpolateHeading(NaN, NaN, 0.5)).toBe(0);
});
it('clamps t parameter strictly to [0, 1]', () => {
expect(interpolateHeading(10, 50, -0.5)).toBe(10);
expect(interpolateHeading(10, 50, 1.5)).toBe(50);
});
});
describe('interpolateTelemetryPosition: Smooth Intermediate Positions', () => {
const p1 = mkPoint(0, 40.0, -74.0, 350, 20);
const p2 = mkPoint(100, 40.001, -73.999, 10, 30);
const points = [p1, p2];
it('returns exact start coordinates at remainingMs = 0', () => {
const pos = interpolateTelemetryPosition(points, 0, 0);
expect(pos.latitude).toBe(40.0);
expect(pos.longitude).toBe(-74.0);
expect(pos.heading).toBe(350);
});
it('computes smooth sub-sample fractions (25%, 50%, 75%) between GPS points', () => {
// At remainingMs = 25 (25% into 100 ms interval)
const p25 = interpolateTelemetryPosition(points, 0, 25);
expect(p25.latitude).toBeCloseTo(40.00025, 5);
expect(p25.longitude).toBeCloseTo(-73.99975, 5);
expect(p25.heading).toBeCloseTo(355, 1);
// At remainingMs = 50 (50% into 100 ms interval)
const p50 = interpolateTelemetryPosition(points, 0, 50);
expect(p50.latitude).toBeCloseTo(40.0005, 5);
expect(p50.longitude).toBeCloseTo(-73.9995, 5);
expect(p50.heading).toBeCloseTo(0, 1); // 350 to 10 midpoint is 0
// At remainingMs = 75 (75% into 100 ms interval)
const p75 = interpolateTelemetryPosition(points, 0, 75);
expect(p75.latitude).toBeCloseTo(40.00075, 5);
expect(p75.longitude).toBeCloseTo(-73.99925, 5);
expect(p75.heading).toBeCloseTo(5, 1);
});
it('returns exact destination coordinates at remainingMs = dt', () => {
const p100 = interpolateTelemetryPosition(points, 0, 100);
expect(p100.latitude).toBeCloseTo(40.001, 5);
expect(p100.longitude).toBeCloseTo(-73.999, 5);
expect(p100.heading).toBe(10);
});
it('clamps remainingMs greater than dt to the destination point', () => {
const pos = interpolateTelemetryPosition(points, 0, 500);
expect(pos.latitude).toBeCloseTo(40.001, 5);
expect(pos.longitude).toBeCloseTo(-73.999, 5);
expect(pos.heading).toBe(10);
});
it('clamps at the final index safely without accessing undefined', () => {
const pos = interpolateTelemetryPosition(points, 1, 50);
expect(pos.latitude).toBe(40.001);
expect(pos.longitude).toBe(-73.999);
expect(pos.heading).toBe(10);
});
it('handles empty points array safely', () => {
const pos = interpolateTelemetryPosition([], 0, 50);
expect(pos).toEqual({ latitude: 0, longitude: 0, heading: 0 });
});
it('handles negative or out-of-bounds indices safely', () => {
const neg = interpolateTelemetryPosition(points, -5, 0);
expect(neg.latitude).toBe(40.0);
const beyond = interpolateTelemetryPosition(points, 999, 0);
expect(beyond.latitude).toBe(40.001);
});
});
describe('interpolateTelemetryPosition: Gaps, Segment Breaks & Edge Cases', () => {
it('does NOT interpolate coordinates across a GPS loss gap (>5000 ms)', () => {
// 10-second gap between sample 0 and sample 1
const gapPoints = [
mkPoint(0, 40.0, -74.0, 90),
mkPoint(10_000, 41.0, -73.0, 90),
];
expect(areTelemetryPointsConnected(gapPoints[0], gapPoints[1])).toBe(false);
// During the 50 ms compressed playback of the gap, the marker MUST remain at point 0,
// not fly across 1 degree of latitude!
const midGap = interpolateTelemetryPosition(gapPoints, 0, 25);
expect(midGap.latitude).toBe(40.0);
expect(midGap.longitude).toBe(-74.0);
});
it('does NOT interpolate coordinates across a segment boundary', () => {
const segmentPoints = [
mkPoint(0, 40.0, -74.0, 90, 30, 0, 0),
mkPoint(50, 42.0, -71.0, 90, 30, 0, 1), // segmentId changes to 1
];
expect(areTelemetryPointsConnected(segmentPoints[0], segmentPoints[1])).toBe(false);
const midSegment = interpolateTelemetryPosition(segmentPoints, 0, 25);
expect(midSegment.latitude).toBe(40.0);
expect(midSegment.longitude).toBe(-74.0);
});
it('handles duplicate timestamps (dt === 0) safely without division by zero', () => {
const dupPoints = [
mkPoint(0, 40.0, -74.0, 90),
mkPoint(0, 40.001, -73.999, 90), // same timestamp
];
const pos = interpolateTelemetryPosition(dupPoints, 0, 25);
expect(Number.isFinite(pos.latitude)).toBe(true);
expect(Number.isFinite(pos.longitude)).toBe(true);
expect(pos.latitude).toBe(40.0);
});
it('handles backwards or inverted timestamps safely', () => {
const invertedPoints = [
mkPoint(100, 40.0, -74.0, 90),
mkPoint(50, 40.001, -73.999, 90), // timestamp goes backwards
];
const pos = interpolateTelemetryPosition(invertedPoints, 0, 25);
expect(pos.latitude).toBe(40.0);
});
it('handles non-finite or corrupted coordinates safely without emitting NaN', () => {
const corruptedPoints = [
mkPoint(0, NaN, -74.0, 90),
mkPoint(50, 40.001, NaN, 90),
];
const pos = interpolateTelemetryPosition(corruptedPoints, 0, 25);
expect(Number.isFinite(pos.latitude)).toBe(true);
expect(Number.isFinite(pos.longitude)).toBe(true);
expect(pos.latitude).toBeCloseTo(20.0005, 3); // lerp between 0 and 40.001
expect(pos.longitude).toBe(-74.0); // p2Lng falls back to p1Lng (-74.0)
});
});
describe('interpolateTelemetryPoint: Full Metric Interpolation', () => {
it('interpolates speed, lean angle, altitude, and G-forces proportionally', () => {
const p1 = {
...mkPoint(0, 40.0, -74.0, 350, 20, -10),
altitude: 100,
longitudinalG: 0.1,
lateralG: -0.4,
};
const p2 = {
...mkPoint(100, 40.001, -73.999, 10, 40, 20),
altitude: 120,
longitudinalG: 0.5,
lateralG: 0.8,
};
const points = [p1, p2];
const interpolated = interpolateTelemetryPoint(points, 0, 50); // t = 0.5
expect(interpolated.speed).toBeCloseTo(30, 1);
expect(interpolated.leanAngle).toBeCloseTo(5, 1); // -10 + 0.5 * (20 - (-10)) = 5
expect(interpolated.altitude).toBeCloseTo(110, 1);
expect(interpolated.longitudinalG).toBeCloseTo(0.3, 2);
expect(interpolated.lateralG).toBeCloseTo(0.2, 2);
expect(interpolated.heading).toBeCloseTo(0, 1);
const expectedTime = new Date(BASE + 50).toISOString();
expect(interpolated.timestamp).toBe(expectedTime);
});
it('safely handles points with non-finite or missing metrics', () => {
const p1 = {
...mkPoint(0, NaN, -74.0, NaN, 20),
speed: undefined as any,
};
const p2 = {
...mkPoint(100, 40.0, NaN, 10, 40),
};
const points = [p1, p2];
const interpolated = interpolateTelemetryPoint(points, 0, 50);
expect(Number.isFinite(interpolated.latitude)).toBe(true);
expect(Number.isFinite(interpolated.longitude)).toBe(true);
expect(Number.isFinite(interpolated.speed)).toBe(true);
expect(Number.isFinite(interpolated.heading)).toBe(true);
});
});
describe('Playback Simulation: 60 FPS Animation & Marker Smoothness', () => {
// 20 Hz recording (50 ms intervals) over 100 points
const uniform20Hz = Array.from({ length: 100 }, (_, i) =>
mkPoint(i * 50, 40.0 + i * 0.0001, -74.0 + i * 0.0001, (i * 5) % 360, 45, (i % 20) - 10)
);
it('produces distinct smooth intermediate positions at 60 FPS (16.67 ms ticks)', () => {
let index = 0;
let carry = 0;
let lastTime: number | null = null;
const recordedPositions: { lat: number; lng: number }[] = [];
const frameStep = (timestamp: number, speed = 1.0) => {
if (lastTime == null) lastTime = timestamp;
const frameDelta = Math.max(0, Math.min(timestamp - lastTime, 200));
lastTime = timestamp;
const budget = frameDelta * speed + carry;
const res = advancePlaybackIndex(uniform20Hz, index, budget);
index = res.index;
carry = index >= uniform20Hz.length - 1 ? 0 : res.remainingMs;
const pos = interpolateTelemetryPosition(uniform20Hz, index, carry);
recordedPositions.push({ lat: pos.latitude, lng: pos.longitude });
};
// Simulate 6 consecutive animation frames at 60 FPS (approx 100 ms total)
for (let f = 0; f < 6; f++) {
frameStep(1000 + f * 16.6667, 1.0);
}
expect(recordedPositions.length).toBe(6);
// Frame 0: starting point
expect(recordedPositions[0].lat).toBeCloseTo(40.0, 5);
// Frame 1 (~16.67 ms): sub-sample movement between pt 0 and pt 1
expect(recordedPositions[1].lat).toBeGreaterThan(recordedPositions[0].lat);
expect(recordedPositions[1].lat).toBeLessThan(40.0001);
// Frame 2 (~33.33 ms): further continuous sub-sample movement
expect(recordedPositions[2].lat).toBeGreaterThan(recordedPositions[1].lat);
expect(recordedPositions[2].lat).toBeLessThan(40.0001);
// Frame 3 (~50.0 ms): crosses point 1 into point 2
expect(recordedPositions[3].lat).toBeGreaterThanOrEqual(40.0001);
// Every single frame produced a strictly increasing position — NO FREEZES OR SNAPS!
for (let i = 1; i < recordedPositions.length; i++) {
expect(recordedPositions[i].lat).toBeGreaterThan(recordedPositions[i - 1].lat);
}
});
it('supports 0.5x, 1x, 2x, 5x, and 10x playback speeds with continuous interpolation', () => {
const speeds = [0.5, 1.0, 2.0, 5.0, 10.0];
speeds.forEach((speed) => {
let index = 0;
let carry = 0;
let lastTime: number | null = null;
let prevLat = 40.0;
// Run 10 frames at each speed
for (let f = 0; f < 10; f++) {
const timestamp = 1000 + f * 16.6667;
if (lastTime == null) lastTime = timestamp;
const frameDelta = timestamp - lastTime;
lastTime = timestamp;
const budget = frameDelta * speed + carry;
const res = advancePlaybackIndex(uniform20Hz, index, budget);
index = res.index;
carry = index >= uniform20Hz.length - 1 ? 0 : res.remainingMs;
const pos = interpolateTelemetryPosition(uniform20Hz, index, carry);
if (f > 0) {
expect(pos.latitude).toBeGreaterThan(prevLat);
}
prevLat = pos.latitude;
}
});
});
it('pauses without marker drift and resumes seamlessly from exact sub-sample position', () => {
let index = 0;
let carry = 0;
let lastTime: number | null = null;
const tick = (now: number, isPlaying: boolean) => {
if (!isPlaying) {
lastTime = null;
// Notice carry is preserved!
return;
}
if (lastTime == null) lastTime = now;
const rawDelta = now - lastTime;
const frameDelta = Math.max(0, Math.min(rawDelta, 200));
lastTime = now;
const budget = frameDelta * 1.0 + carry;
const res = advancePlaybackIndex(uniform20Hz, index, budget);
index = res.index;
carry = index >= uniform20Hz.length - 1 ? 0 : res.remainingMs;
};
// Play for 2 frames: t=1000, t=1016.67, t=1033.33
tick(1000, true);
tick(1016.67, true);
tick(1033.33, true);
const pausedPos = interpolateTelemetryPosition(uniform20Hz, index, carry);
const pausedIndex = index;
const pausedCarry = carry;
// Pause for 10 seconds
tick(1040, false);
tick(11040, false);
// Marker MUST NOT drift while paused
const stillPausedPos = interpolateTelemetryPosition(uniform20Hz, index, carry);
expect(stillPausedPos.latitude).toBe(pausedPos.latitude);
expect(stillPausedPos.longitude).toBe(pausedPos.longitude);
// Resume at t=12000
tick(12000, true); // First frame of resume: delta = 0
expect(index).toBe(pausedIndex);
expect(carry).toBe(pausedCarry);
const resumedPos = interpolateTelemetryPosition(uniform20Hz, index, carry);
expect(resumedPos.latitude).toBe(pausedPos.latitude);
expect(resumedPos.longitude).toBe(pausedPos.longitude);
// Continue playback smoothly
tick(12050, true);
const afterPos = interpolateTelemetryPosition(uniform20Hz, index, carry);
expect(afterPos.latitude).toBeGreaterThan(resumedPos.latitude);
});
it('seeking to an exact position snaps cleanly with zero carryover', () => {
let index = 10;
let carry = 32.5;
// Seek to index 42
index = 42;
carry = 0;
const pos = interpolateTelemetryPosition(uniform20Hz, index, carry);
expect(pos.latitude).toBe(uniform20Hz[42].latitude);
expect(pos.longitude).toBe(uniform20Hz[42].longitude);
expect(pos.heading).toBe(uniform20Hz[42].heading);
});
it('smoothly replays a massive 144,000-point (2-hour 20 Hz) recording with high performance', () => {
const huge = Array.from({ length: 144_000 }, (_, i) =>
mkPoint(i * 50, 40.0 + i * 0.000001, -74.0 + i * 0.000001, (i * 2) % 360)
);
const start = performance.now();
let index = 50_000;
let carry = 0;
// Simulate 500 animation frames (approx 8.3 seconds of 60 FPS playback)
for (let f = 0; f < 500; f++) {
const budget = 16.67 * 2.0 + carry; // 2x speed
const res = advancePlaybackIndex(huge, index, budget);
index = res.index;
carry = index >= huge.length - 1 ? 0 : res.remainingMs;
const pos = interpolateTelemetryPosition(huge, index, carry);
expect(pos.latitude).toBeGreaterThan(40.0);
}
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(500); // 500 frames interpolated in under 500 ms (<1 ms/frame, well under the 16.67 ms budget)
});
});