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:
parent
f5b734cccd
commit
e0de6f22ba
|
|
@ -2,3 +2,5 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.chrome-test-profile/
|
||||||
|
test-support/visual-evidence/
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue