feat(website): smooth continuous replay camera follow and mobile tile loading (#42)

* feat(website): smooth continuous replay camera follow and mobile tile loading

- Smooth camera following: compute frame-rate-independent camera translation with 32px dead-zone and velocity clamping to keep motorcycle centered without camera oscillation or outrunning tile loading.
- Mobile tile rasterization: configure tile layers with updateWhenIdle: false, keepBuffer: 8, and updateInterval: 100 to eliminate Leaflet mobile pan tile freezing and black voids.
- Interactive follow controls: gracefully suspend auto-follow on user drag/zoom; add 'Following'/'Re-center' toggle and 'Fit Route' controls.
- Tile error recovery: single retry with backoff for transient tile errors before reporting fallback status.
- Add comprehensive unit tests in telemetry-camera-follow.test.ts and component behavioral tests in replay-map-camera-follow.test.tsx.

* fix(website): isolate tab recentering from sample advancement and suspend follow on manual zoom

- P1: Stop camera recentering at every telemetry sample boundary. In web-telemetry-replay-modal.tsx, isolate the map recentering and size invalidation effect to run solely on activeTab changes rather than on currentPoint index advancements. Track latest interpolated position via latestPositionRef, allowing the smooth camera follow engine to exclusively drive camera updates during playback without raw snapping.
- P2: Suspend follow mode upon user-initiated zoom gestures (pinch, wheel, double click via zoomstart) alongside dragstart. Guard programmatic zoom operations (initial fitBounds and fitRoute) using isProgrammaticZoomRef so initial map setup does not inadvertently disable follow mode.
- Add regression coverage in replay-map-camera-follow.test.tsx verifying sample boundary crossings within the dead-zone do not trigger raw panTo snapping, and verifying manual zoom suspends following until explicit Re-center.

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-15 01:56:09 -04:00 committed by GitHub
parent e0de6f22ba
commit ac76257210
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 828 additions and 27 deletions

View File

@ -14,6 +14,7 @@ import {
import { firebaseAuth } from '../lib/firebase-client'; import { firebaseAuth } from '../lib/firebase-client';
import { areTelemetryPointsConnected, normalizeTelemetryPoints, advancePlaybackIndex, interpolateTelemetryPosition } 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';
import { computeCameraFollowStep } from '../lib/telemetry-camera-follow';
interface WebTelemetryReplayModalProps { interface WebTelemetryReplayModalProps {
ride: Ride; ride: Ride;
@ -106,12 +107,16 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
const [leafletReady, setLeafletReady] = useState(() => typeof window !== 'undefined' && !!(window as any).L); const [leafletReady, setLeafletReady] = useState(() => typeof window !== 'undefined' && !!(window as any).L);
const [tileProvider, setTileProvider] = useState<'osm' | 'aws' | 'dark' | 'vector'>('osm'); const [tileProvider, setTileProvider] = useState<'osm' | 'aws' | 'dark' | 'vector'>('osm');
const [tileStatus, setTileStatus] = useState<'loading' | 'loaded' | 'fallback'>('loading'); const [tileStatus, setTileStatus] = useState<'loading' | 'loaded' | 'fallback'>('loading');
const [isFollowing, setIsFollowing] = useState(true);
const [telemetry, setTelemetry] = useState<{rideId:string;points:TelemetryPoint[];source:string;error:string|null;loading:boolean}>({rideId:ride.id,points:[],source:'',error:null,loading:true}); const [telemetry, setTelemetry] = useState<{rideId:string;points:TelemetryPoint[];source:string;error:string|null;loading:boolean}>({rideId:ride.id,points:[],source:'',error:null,loading:true});
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapInstanceRef = useRef<any>(null); const mapInstanceRef = useRef<any>(null);
const routeLayerGroupRef = useRef<any>(null); const routeLayerGroupRef = useRef<any>(null);
const riderMarkerRef = useRef<any>(null); const riderMarkerRef = useRef<any>(null);
const tileLayerRef = useRef<any>(null); const tileLayerRef = useRef<any>(null);
const isFollowingRef = useRef(true);
const isProgrammaticZoomRef = useRef(false);
const latestPositionRef = useRef<{ latitude: number; longitude: number; heading: number } | null>(null);
const attachedPoints = useMemo(() => synthesizeTelemetryTrack(ride), [ride]); const attachedPoints = useMemo(() => synthesizeTelemetryTrack(ride), [ride]);
useEffect(() => { useEffect(() => {
@ -119,10 +124,14 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
let active = true; let active = true;
setCurrentIndex(0); setCurrentIndex(0);
setIsPlaying(false); setIsPlaying(false);
setIsFollowing(true);
indexRef.current = 0; indexRef.current = 0;
playbackCarryRef.current = 0; playbackCarryRef.current = 0;
lastTimeRef.current = null; lastTimeRef.current = null;
lastPanTimeRef.current = 0; lastPanTimeRef.current = 0;
isFollowingRef.current = true;
isProgrammaticZoomRef.current = false;
latestPositionRef.current = null;
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});
@ -192,15 +201,25 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
setCurrentIndex(clamped); setCurrentIndex(clamped);
const pt = points[clamped]; const pt = points[clamped];
if (pt && riderMarkerRef.current && typeof window !== 'undefined') { if (pt) {
const L = (window as any).L; latestPositionRef.current = {
updateRiderMarker(riderMarkerRef.current, pt.latitude, pt.longitude, pt.heading || 0, L); latitude: pt.latitude,
if (mapInstanceRef.current) { longitude: pt.longitude,
const map = mapInstanceRef.current; heading: pt.heading || 0,
const bounds = map.getBounds?.(); };
if (bounds && !bounds.contains([pt.latitude, pt.longitude])) { if (riderMarkerRef.current && typeof window !== 'undefined') {
lastPanTimeRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); const L = (window as any).L;
map.panTo([pt.latitude, pt.longitude], { animate: true, duration: 0.5 }); updateRiderMarker(riderMarkerRef.current, pt.latitude, pt.longitude, pt.heading || 0, L);
if (mapInstanceRef.current) {
const map = mapInstanceRef.current;
if (isFollowingRef.current) {
map.panTo([pt.latitude, pt.longitude], { animate: false });
} else {
const bounds = map.getBounds?.();
if (bounds && !bounds.contains([pt.latitude, pt.longitude])) {
map.panTo([pt.latitude, pt.longitude], { animate: false });
}
}
} }
} }
} }
@ -236,7 +255,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
style.id = styleId; style.id = styleId;
style.textContent = ` style.textContent = `
.leaflet-container { .leaflet-container {
background-color: transparent !important; background-color: #0b1120 !important;
outline: 0; outline: 0;
position: relative; position: relative;
width: 100%; width: 100%;
@ -254,6 +273,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
} }
.leaflet-tile { .leaflet-tile {
visibility: hidden; visibility: hidden;
transition: opacity 0.2s ease-in;
} }
.leaflet-tile-loaded { .leaflet-tile-loaded {
visibility: inherit; visibility: inherit;
@ -395,6 +415,10 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
maxZoom: currentProvider.maxZoom, maxZoom: currentProvider.maxZoom,
subdomains: currentProvider.subdomains || 'abc', subdomains: currentProvider.subdomains || 'abc',
className: currentProvider.isDark ? 'tactical-dark-tiles' : undefined, className: currentProvider.isDark ? 'tactical-dark-tiles' : undefined,
updateWhenIdle: false,
keepBuffer: 8,
updateInterval: 100,
crossOrigin: true,
}).addTo(map); }).addTo(map);
tileLayer.on('tileload', () => { tileLayer.on('tileload', () => {
@ -402,9 +426,20 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
setTileStatus('loaded'); setTileStatus('loaded');
}); });
tileLayer.on('tileerror', () => { tileLayer.on('tileerror', (e: any) => {
errorTiles++; errorTiles++;
if (errorTiles >= 4 && loadedTiles === 0) { const tile = e?.tile;
if (tile && !tile._msRetry) {
tile._msRetry = true;
setTimeout(() => {
if (tileLayerRef.current && tile.src) {
const src = tile.src;
tile.src = '';
tile.src = src;
}
}, 1200);
}
if (errorTiles >= 8 && loadedTiles === 0) {
console.warn(`Tile provider ${tileProvider} failed to load tiles; keeping recorded geographic layers.`); console.warn(`Tile provider ${tileProvider} failed to load tiles; keeping recorded geographic layers.`);
setTileStatus('fallback'); setTileStatus('fallback');
} }
@ -526,7 +561,11 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
// Fit map bounds with padding // Fit map bounds with padding
const latLngs = points.map((p) => [p.latitude, p.longitude]); const latLngs = points.map((p) => [p.latitude, p.longitude]);
isProgrammaticZoomRef.current = true;
map.fitBounds(L.latLngBounds(latLngs), { padding: [35, 35] }); map.fitBounds(L.latLngBounds(latLngs), { padding: [35, 35] });
setTimeout(() => {
isProgrammaticZoomRef.current = false;
}, 250);
} }
// Live rider position marker // Live rider position marker
@ -539,6 +578,21 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
riderMarkerRef.current = riderMarker; riderMarkerRef.current = riderMarker;
} }
// Detect user-initiated map gestures (drag and manual zoom) to gracefully suspend auto-follow
const handleDragStart = () => {
isFollowingRef.current = false;
setIsFollowing(false);
};
const handleZoomStart = () => {
if (isProgrammaticZoomRef.current) return;
isFollowingRef.current = false;
setIsFollowing(false);
};
map.on?.('dragstart', handleDragStart);
map.on?.('zoomstart', handleZoomStart);
// Invalidate size across layout phases to guarantee full tile rasterization // Invalidate size across layout phases to guarantee full tile rasterization
map.invalidateSize(); map.invalidateSize();
const rafId = requestAnimationFrame(() => { const rafId = requestAnimationFrame(() => {
@ -569,6 +623,8 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
clearTimeout(t3); clearTimeout(t3);
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize);
if (resizeObserver) resizeObserver.disconnect(); if (resizeObserver) resizeObserver.disconnect();
map.off?.('dragstart', handleDragStart);
map.off?.('zoomstart', handleZoomStart);
if (mapInstanceRef.current) { if (mapInstanceRef.current) {
mapInstanceRef.current.remove(); mapInstanceRef.current.remove();
mapInstanceRef.current = null; mapInstanceRef.current = null;
@ -594,6 +650,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
longitude: currentPoint.longitude, longitude: currentPoint.longitude,
heading: currentPoint.heading || 0, heading: currentPoint.heading || 0,
}; };
latestPositionRef.current = pos;
updateRiderMarker( updateRiderMarker(
riderMarkerRef.current, riderMarkerRef.current,
pos.latitude, pos.latitude,
@ -604,14 +661,23 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
} }
}, [currentPoint, isPlaying, points]); }, [currentPoint, isPlaying, points]);
// Invalidate map size when tab switches to map // Invalidate map size and maintain follow alignment when tab switches to map.
// Note: Runs ONLY when activeTab changes, never on telemetry index advance / currentPoint changes,
// allowing the smooth camera follow engine to exclusively drive camera movement during playback.
useEffect(() => { useEffect(() => {
if (activeTab === 'map' && mapInstanceRef.current) { if (activeTab === 'map' && mapInstanceRef.current) {
const map = mapInstanceRef.current;
const raf = requestAnimationFrame(() => { const raf = requestAnimationFrame(() => {
mapInstanceRef.current?.invalidateSize(); map?.invalidateSize?.({ pan: false });
if (isFollowingRef.current) {
const target = latestPositionRef.current || (currentPoint ? { latitude: currentPoint.latitude, longitude: currentPoint.longitude } : null);
if (target) {
map?.panTo?.([target.latitude, target.longitude], { animate: false });
}
}
}); });
const timer = setTimeout(() => { const timer = setTimeout(() => {
mapInstanceRef.current?.invalidateSize(); map?.invalidateSize?.({ pan: false });
}, 150); }, 150);
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
@ -620,6 +686,40 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
} }
}, [activeTab]); }, [activeTab]);
// Toggle camera auto-follow or re-center on current motorcycle position
const handleToggleFollow = () => {
if (!isFollowing) {
isFollowingRef.current = true;
setIsFollowing(true);
if (mapInstanceRef.current) {
const target = latestPositionRef.current || (currentPoint ? { latitude: currentPoint.latitude, longitude: currentPoint.longitude } : null);
if (target) {
mapInstanceRef.current.panTo([target.latitude, target.longitude], { animate: false });
}
}
} else {
isFollowingRef.current = false;
setIsFollowing(false);
}
};
// Fit entire recorded route within viewport and suspend follow
const handleFitRoute = () => {
if (mapInstanceRef.current && points.length) {
isFollowingRef.current = false;
setIsFollowing(false);
isProgrammaticZoomRef.current = true;
const L = typeof window !== 'undefined' ? (window as any).L : null;
if (L?.latLngBounds) {
const latLngs = points.map((p) => [p.latitude, p.longitude]);
mapInstanceRef.current.fitBounds(L.latLngBounds(latLngs), { padding: [35, 35] });
setTimeout(() => {
isProgrammaticZoomRef.current = false;
}, 250);
}
}
};
// Handle switching map tile provider layers // Handle switching map tile provider layers
const handleSelectTileProvider = (providerKey: 'osm' | 'aws' | 'dark' | 'vector') => { const handleSelectTileProvider = (providerKey: 'osm' | 'aws' | 'dark' | 'vector') => {
setTileProvider(providerKey); setTileProvider(providerKey);
@ -655,6 +755,10 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
maxZoom: provider.maxZoom, maxZoom: provider.maxZoom,
subdomains: provider.subdomains || 'abc', subdomains: provider.subdomains || 'abc',
className: provider.isDark ? 'tactical-dark-tiles' : undefined, className: provider.isDark ? 'tactical-dark-tiles' : undefined,
updateWhenIdle: false,
keepBuffer: 8,
updateInterval: 100,
crossOrigin: true,
}).addTo(mapInstanceRef.current); }).addTo(mapInstanceRef.current);
newLayer.on('tileload', () => { newLayer.on('tileload', () => {
@ -662,16 +766,27 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
setTileStatus('loaded'); setTileStatus('loaded');
}); });
newLayer.on('tileerror', () => { newLayer.on('tileerror', (e: any) => {
errorTiles++; errorTiles++;
if (errorTiles >= 4 && loadedTiles === 0) { const tile = e?.tile;
if (tile && !tile._msRetry) {
tile._msRetry = true;
setTimeout(() => {
if (tileLayerRef.current && tile.src) {
const src = tile.src;
tile.src = '';
tile.src = src;
}
}, 1200);
}
if (errorTiles >= 8 && loadedTiles === 0) {
console.warn(`Tile provider ${providerKey} failed, keeping recorded geographic layers.`); console.warn(`Tile provider ${providerKey} failed, keeping recorded geographic layers.`);
setTileStatus('fallback'); setTileStatus('fallback');
} }
}); });
tileLayerRef.current = newLayer; tileLayerRef.current = newLayer;
mapInstanceRef.current.invalidateSize(); mapInstanceRef.current.invalidateSize({ pan: false });
} }
}; };
@ -698,20 +813,24 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
// Continuous 60 FPS interpolation for smooth marker animation // Continuous 60 FPS interpolation for smooth marker animation
const pos = interpolateTelemetryPosition(points, index, remainingMs); const pos = interpolateTelemetryPosition(points, index, remainingMs);
latestPositionRef.current = pos;
if (riderMarkerRef.current && typeof window !== 'undefined') { if (riderMarkerRef.current && typeof window !== 'undefined') {
const L = (window as any).L; const L = (window as any).L;
updateRiderMarker(riderMarkerRef.current, pos.latitude, pos.longitude, pos.heading, 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) // Smooth camera follow without abrupt recentering jumps or oscillation
if (mapInstanceRef.current) { if (mapInstanceRef.current && isFollowingRef.current) {
const map = mapInstanceRef.current; const follow = computeCameraFollowStep(
const bounds = map.getBounds?.(); mapInstanceRef.current,
if (bounds && !bounds.contains([pos.latitude, pos.longitude])) { { lat: pos.latitude, lng: pos.longitude },
if (timestamp - lastPanTimeRef.current >= 600) { frameDelta
lastPanTimeRef.current = timestamp; );
map.panTo([pos.latitude, pos.longitude], { animate: true, duration: 0.5 }); if (follow.shouldPan) {
} mapInstanceRef.current.panTo(
[follow.targetLatLng.lat, follow.targetLatLng.lng],
{ animate: false }
);
} }
} }
} }
@ -1033,6 +1152,34 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
/> />
{/* Camera Follow Mode Controls (Follow / Re-center / Fit Route) */}
<div className="absolute top-3 left-3 z-[400] flex items-center gap-1 font-mono text-[10px]">
<button
type="button"
onClick={handleToggleFollow}
data-testid="follow-toggle-btn"
className={`px-2.5 py-1 rounded-lg backdrop-blur-md border transition cursor-pointer flex items-center gap-1.5 shadow ${
isFollowing
? 'bg-brand-cyan/20 border-brand-cyan text-brand-cyan font-bold'
: 'bg-black/80 border-amber-400/60 text-amber-300 hover:text-white hover:bg-slate-800'
}`}
title={isFollowing ? 'Auto-follow active: keeping motorcycle in view' : 'Auto-follow suspended: click to re-center on motorcycle'}
>
<span className={`w-1.5 h-1.5 rounded-full ${isFollowing ? 'bg-brand-cyan animate-pulse' : 'bg-amber-400'}`}></span>
<span>{isFollowing ? 'Following' : 'Re-center'}</span>
</button>
<button
type="button"
onClick={handleFitRoute}
data-testid="fit-route-btn"
className="px-2 py-1 rounded-lg bg-black/80 backdrop-blur-md border border-slate-700 text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
title="Fit full route in viewport"
>
<span>Fit Route</span>
</button>
</div>
{/* Map Tile Layer Selector (Streets / Voyager / Dark / Vector) */} {/* Map Tile Layer Selector (Streets / Voyager / Dark / Vector) */}
<div className="absolute top-3 right-3 z-[400] flex items-center gap-1 bg-black/80 backdrop-blur-md p-1 rounded-lg border border-slate-700 font-mono text-[10px]"> <div className="absolute top-3 right-3 z-[400] flex items-center gap-1 bg-black/80 backdrop-blur-md p-1 rounded-lg border border-slate-700 font-mono text-[10px]">
<button <button

View File

@ -0,0 +1,192 @@
/**
* Telemetry Camera Follow Engine
*
* Provides smooth, continuous camera tracking for motorcycle telemetry replay.
* Features:
* - Dead zone threshold: small central radius where minor vehicle movements / GPS jitter
* do not cause camera movement, preventing motion sickness and camera oscillation.
* - Smooth exponential damping (lerp): camera smoothly accelerates, follows at vehicle speed,
* and decelerates to a gentle stop without overshoot.
* - Tile-protection clamping: limits maximum camera velocity per frame to prevent the camera
* from outrunning tile rasterization on mobile networks.
* - Leaflet integration: works directly with container pixel coordinates and falls back
* cleanly to LatLng interpolation when container projection is unavailable.
*/
export interface CameraFollowOptions {
/** Dead zone radius in screen pixels around map center (default: 32px) */
deadZoneRadiusPx?: number;
/** Maximum camera pan displacement in pixels per frame to prevent outrunning tiles (default: 12px) */
maxStepPxPerFrame?: number;
/** Damping coefficient for exponential smoothing (default: 4.5) */
dampingLambda?: number;
/** Minimum pixel movement threshold to avoid sub-pixel jitter (default: 0.5px) */
minPanThresholdPx?: number;
}
export interface CameraFollowResult {
/** Whether the map camera should be panned this frame */
shouldPan: boolean;
/** The calculated target center coordinates */
targetLatLng: { lat: number; lng: number };
/** Distance from motorcycle marker to map center in pixels */
distanceFromCenterPx: number;
/** Excess distance beyond dead zone */
excessPx: number;
/** Calculated screen pan delta in pixels */
panDeltaPx: { x: number; y: number };
}
/**
* Computes the next smooth camera pan step towards the motorcycle marker position.
*
* @param map Leaflet map instance (or mock)
* @param markerLatLng Current interpolated motorcycle coordinates { lat, lng }
* @param frameDeltaMs Time elapsed since previous animation frame in milliseconds
* @param options Tunable camera parameters
*/
export function computeCameraFollowStep(
map: any,
markerLatLng: { lat: number; lng: number },
frameDeltaMs = 16.67,
options: CameraFollowOptions = {}
): CameraFollowResult {
if (!map || !Number.isFinite(markerLatLng?.lat) || !Number.isFinite(markerLatLng?.lng)) {
return {
shouldPan: false,
targetLatLng: { lat: markerLatLng?.lat || 0, lng: markerLatLng?.lng || 0 },
distanceFromCenterPx: 0,
excessPx: 0,
panDeltaPx: { x: 0, y: 0 },
};
}
const deadZone = options.deadZoneRadiusPx ?? 32;
const maxStep = options.maxStepPxPerFrame ?? 12;
const lambda = options.dampingLambda ?? 4.5;
const minThreshold = options.minPanThresholdPx ?? 0.5;
// Retrieve current map center
let currentCenter = { lat: markerLatLng.lat, lng: markerLatLng.lng };
if (typeof map.getCenter === 'function') {
const c = map.getCenter();
if (c && Number.isFinite(c.lat) && Number.isFinite(c.lng)) {
currentCenter = { lat: c.lat, lng: c.lng };
}
}
// Frame-rate independent exponential smoothing factor: 1 - e^(-lambda * dt)
const dt = Math.max(0.001, Math.min(frameDeltaMs, 100)) / 1000;
const smoothFactor = 1 - Math.exp(-lambda * dt);
// Preferred Path: Screen pixel projection using Leaflet container points
if (
typeof map.latLngToContainerPoint === 'function' &&
typeof map.containerPointToLatLng === 'function'
) {
try {
const size = typeof map.getSize === 'function' ? map.getSize() : { x: 600, y: 400 };
const centerPx = { x: (size?.x || 600) / 2, y: (size?.y || 400) / 2 };
const markerPx = map.latLngToContainerPoint([markerLatLng.lat, markerLatLng.lng]);
if (
markerPx &&
Number.isFinite(markerPx.x) &&
Number.isFinite(markerPx.y)
) {
const dx = markerPx.x - centerPx.x;
const dy = markerPx.y - centerPx.y;
const dist = Math.hypot(dx, dy);
// Within dead zone: camera remains stationary to avoid micro-jitter
if (dist <= deadZone) {
return {
shouldPan: false,
targetLatLng: currentCenter,
distanceFromCenterPx: dist,
excessPx: 0,
panDeltaPx: { x: 0, y: 0 },
};
}
const excess = dist - deadZone;
let stepPx = excess * smoothFactor;
// Tile protection clamp: prevents camera from outrunning tile download / rendering
if (stepPx > maxStep) {
stepPx = maxStep;
}
if (stepPx < minThreshold) {
return {
shouldPan: false,
targetLatLng: currentCenter,
distanceFromCenterPx: dist,
excessPx: excess,
panDeltaPx: { x: 0, y: 0 },
};
}
const unitX = dx / dist;
const unitY = dy / dist;
const panX = unitX * stepPx;
const panY = unitY * stepPx;
const newCenterPx = {
x: centerPx.x + panX,
y: centerPx.y + panY,
};
const targetLatLngRaw = map.containerPointToLatLng(newCenterPx);
if (
targetLatLngRaw &&
Number.isFinite(targetLatLngRaw.lat) &&
Number.isFinite(targetLatLngRaw.lng)
) {
return {
shouldPan: true,
targetLatLng: { lat: targetLatLngRaw.lat, lng: targetLatLngRaw.lng },
distanceFromCenterPx: dist,
excessPx: excess,
panDeltaPx: { x: panX, y: panY },
};
}
}
} catch {
// Fall through to LatLng degree interpolation if container conversion fails
}
}
// Fallback Path: Direct LatLng degree interpolation (e.g. test environments without container geometry)
const dLat = markerLatLng.lat - currentCenter.lat;
const dLng = markerLatLng.lng - currentCenter.lng;
const distDeg = Math.hypot(dLat, dLng);
// Approximate dead zone in degrees (~0.0003 deg is ~30m at mid-latitudes)
const deadZoneDeg = 0.0003;
if (distDeg <= deadZoneDeg) {
return {
shouldPan: false,
targetLatLng: currentCenter,
distanceFromCenterPx: 0,
excessPx: 0,
panDeltaPx: { x: 0, y: 0 },
};
}
const excessDeg = distDeg - deadZoneDeg;
const stepDeg = excessDeg * smoothFactor;
const unitLat = dLat / distDeg;
const unitLng = dLng / distDeg;
return {
shouldPan: true,
targetLatLng: {
lat: currentCenter.lat + unitLat * stepDeg,
lng: currentCenter.lng + unitLng * stepDeg,
},
distanceFromCenterPx: distDeg * 100000,
excessPx: excessDeg * 100000,
panDeltaPx: { x: unitLng * stepDeg * 100000, y: unitLat * stepDeg * 100000 },
};
}

View File

@ -0,0 +1,353 @@
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, 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';
const mocks = vi.hoisted(() => ({
token: vi.fn().mockResolvedValue('authorized-test-token'),
}));
vi.mock('../lib/firebase-client', () => ({
firebaseAuth: {
currentUser: { uid: 'test-rider', getIdToken: mocks.token },
},
}));
describe('Replay Map Camera Following & Tile Loading', () => {
let sampleRide: Ride;
let rafCallback: ((time: number) => void) | null = null;
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mocks.token.mockResolvedValue('authorized-test-token');
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallback = cb;
return 1001;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {
rafCallback = null;
});
const points = [
{ timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0, speed: 30, heading: 90, segmentId: 0 },
{ timestamp: '2026-10-01T12:00:01.000Z', latitude: 35.0, longitude: -114.99, speed: 35, heading: 90, segmentId: 0 },
{ timestamp: '2026-10-01T12:00:02.000Z', latitude: 35.0, longitude: -114.98, speed: 40, heading: 90, segmentId: 0 },
];
sampleRide = {
id: 'ride_camera_test',
startedAt: new Date('2026-10-01T12:00:00.000Z'),
endedAt: new Date('2026-10-01T12:00:02.000Z'),
movingTime: 2,
distanceMiles: 1.0,
topSpeedMph: 40,
averageSpeed: 35,
maxLeanLeft: 10,
maxLeanRight: 10,
maxAccelG: 0.3,
maxBrakingG: -0.4,
elevationGain: 5,
qualityScore: 98,
points,
telemetryBundlePath: undefined,
} as Ride;
});
afterEach(() => {
cleanup();
delete (window as Window & { L?: unknown }).L;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const setupLeafletMock = () => {
const listeners: Record<string, ((...args: any[]) => void)[]> = {};
const map = {
invalidateSize: vi.fn(),
remove: vi.fn(),
fitBounds: vi.fn(() => {
(listeners['zoomstart'] || []).forEach((cb) => cb());
(listeners['zoomend'] || []).forEach((cb) => cb());
}),
removeLayer: vi.fn(),
panTo: vi.fn(),
getBounds: () => ({ contains: () => true }),
getCenter: vi.fn(() => ({ lat: 35.0, lng: -115.0 })),
getSize: vi.fn(() => ({ x: 600, y: 400 })),
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => {
// Linear screen projection: 1 deg = 5000 px, center at (300, 200)
return {
x: 300 + (lng - (-115.0)) * 5000,
y: 200 - (lat - 35.0) * 5000,
};
}),
containerPointToLatLng: vi.fn((pt: { x: number; y: number }) => {
return {
lat: 35.0 - (pt.y - 200) / 5000,
lng: -115.0 + (pt.x - 300) / 5000,
};
}),
on: vi.fn((event: string, cb: (...args: any[]) => void) => {
listeners[event] = listeners[event] || [];
listeners[event].push(cb);
return map;
}),
off: vi.fn((event: string, cb: (...args: any[]) => void) => {
if (listeners[event]) {
listeners[event] = listeners[event].filter((h) => h !== cb);
}
return map;
}),
_trigger: (event: string, ...args: any[]) => {
(listeners[event] || []).forEach((cb) => cb(...args));
},
};
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),
};
let tileOptionsCapture: any = null;
const tileLayerFn = vi.fn((_url?: string, options?: any) => {
tileOptionsCapture = options;
return {
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
};
});
const genericLayer = () => ({
addTo: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
addLayer: vi.fn(),
});
const L = {
map: vi.fn(() => map),
tileLayer: tileLayerFn,
featureGroup: vi.fn(genericLayer),
polyline: vi.fn(genericLayer),
marker: vi.fn(() => rider),
divIcon: vi.fn(),
circleMarker: vi.fn(() => rider),
latLngBounds: vi.fn((coords) => coords),
};
(window as Window & { L?: unknown }).L = L;
return { map, rider, L, getTileOptions: () => tileOptionsCapture, triggerMapEvent: map._trigger };
};
it('configures tileLayer with mobile continuous loading options to prevent black map tiles', async () => {
const { getTileOptions } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('telemetry-leaflet-map')).toBeDefined());
const options = getTileOptions();
expect(options).toBeDefined();
// Verify critical mobile options
expect(options.updateWhenIdle).toBe(false);
expect(options.keepBuffer).toBeGreaterThanOrEqual(6);
expect(options.updateInterval).toBeLessThanOrEqual(150);
expect(options.crossOrigin).toBe(true);
});
it('smoothly follows motorcycle marker with camera during playback and stops upon pause', async () => {
const { map, rider } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
// Auto-follow button shows active "Following" status by default
const followBtn = screen.getByTestId('follow-toggle-btn');
expect(followBtn.textContent).toContain('Following');
// Click play
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
expect(screen.getByText('PAUSE')).toBeDefined();
expect(rafCallback).toBeDefined();
// Frame 1: delta 0
rafCallback!(1000);
// Frame 2: 50ms advance (rider moves east to -114.9995 deg -> 2.5 px offset, within dead zone)
rafCallback!(1050);
expect(rider.setLatLng).toHaveBeenCalled();
// Frames 3-6: step rAF frames forward (frameDelta clamped to max 200ms per frame)
// 200ms + 200ms + 200ms + 200ms = 850ms total
rafCallback!(1250);
rafCallback!(1450);
rafCallback!(1650);
rafCallback!(1850);
// Marker is now ~85% of interval (42.5 px east), exceeding the 32px dead zone
// Verify map.panTo was called with { animate: false } to smoothly shift the camera
expect(map.panTo).toHaveBeenCalledWith(
expect.arrayContaining([expect.any(Number), expect.any(Number)]),
{ animate: false }
);
// Click pause
fireEvent.click(playBtn);
expect(screen.getByText('PLAY')).toBeDefined();
const panCallsBeforePause = map.panTo.mock.calls.length;
// After pause, rafCallback is cleared and no further camera pan calls occur
expect(rafCallback).toBeNull();
expect(map.panTo.mock.calls.length).toBe(panCallsBeforePause);
});
it('suspends follow mode upon user map drag and resumes upon clicking Re-center', async () => {
const { map, triggerMapEvent } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
const followBtn = screen.getByTestId('follow-toggle-btn');
expect(followBtn.textContent).toContain('Following');
// User manually drags the map (Leaflet dragstart event)
fireEvent(window, new Event('resize')); // test safety
// Wrap state change in waitFor / act
await waitFor(() => {
triggerMapEvent('dragstart');
expect(followBtn.textContent).toContain('Re-center');
});
// Start playback while follow is suspended
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
const panCallsWhileSuspended = map.panTo.mock.calls.length;
rafCallback!(1000);
rafCallback!(1800); // large movement
// Camera did not pan while follow was suspended
expect(map.panTo.mock.calls.length).toBe(panCallsWhileSuspended);
// User clicks Re-center
fireEvent.click(followBtn);
// Verify follow mode resumes and camera immediately re-centers on current position
expect(followBtn.textContent).toContain('Following');
expect(map.panTo).toHaveBeenCalledWith([35, -114.998], { animate: false });
});
it('provides Fit Route button that fits bounds and suspends follow mode', async () => {
const { map } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('fit-route-btn')).toBeDefined());
const fitBtn = screen.getByTestId('fit-route-btn');
const followBtn = screen.getByTestId('follow-toggle-btn');
expect(followBtn.textContent).toContain('Following');
fireEvent.click(fitBtn);
expect(map.fitBounds).toHaveBeenCalled();
expect(followBtn.textContent).toContain('Re-center');
});
it('recenters map camera cleanly when scrubbing timeline slider', async () => {
const { map, rider } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('replay-timeline-slider')).toBeDefined());
const slider = screen.getByTestId('replay-timeline-slider');
fireEvent.change(slider, { target: { value: '2' } });
expect(rider.setLatLng).toHaveBeenLastCalledWith([sampleRide.points![2].latitude, sampleRide.points![2].longitude]);
expect(map.panTo).toHaveBeenCalledWith([sampleRide.points![2].latitude, sampleRide.points![2].longitude], { animate: false });
});
it('suspends follow mode upon manual zoom (zoomstart) without being disabled by programmatic fitBounds', async () => {
const { map, triggerMapEvent } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={sampleRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
const followBtn = screen.getByTestId('follow-toggle-btn');
// Programmatic zoom during mount (fitBounds) did not disable follow mode
expect(followBtn.textContent).toContain('Following');
// User performs manual pinch, wheel, or double-click zoom (emitting Leaflet zoomstart)
await waitFor(() => {
triggerMapEvent('zoomstart');
expect(followBtn.textContent).toContain('Re-center');
});
// Start playback while follow is suspended
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
const panCallsWhileSuspended = map.panTo.mock.calls.length;
// Step forward frames with movement that would otherwise exceed dead zone
act(() => {
rafCallback!(1000);
rafCallback!(1800);
});
// Animation loop does NOT pan or fight user while follow is suspended
expect(map.panTo.mock.calls.length).toBe(panCallsWhileSuspended);
// Explicit user click on Re-center resumes following and immediately aligns camera
fireEvent.click(followBtn);
expect(followBtn.textContent).toContain('Following');
expect(map.panTo).toHaveBeenCalledWith([35, -114.998], { animate: false });
});
it('crosses multiple telemetry sample boundaries without triggering raw panTo snapping', async () => {
// 4 points spaced closely so all points are within the 32px dead zone from origin
// In our test projection: 1 deg = 5000 px, so 0.001 deg = 5 px.
const closePoints = [
{ timestamp: '2026-10-01T12:00:00.000Z', latitude: 35.0, longitude: -115.0000, speed: 20, heading: 90, segmentId: 0 },
{ timestamp: '2026-10-01T12:00:00.050Z', latitude: 35.0, longitude: -114.9998, speed: 20, heading: 90, segmentId: 0 }, // 1 px offset
{ timestamp: '2026-10-01T12:00:00.100Z', latitude: 35.0, longitude: -114.9996, speed: 20, heading: 90, segmentId: 0 }, // 2 px offset
{ timestamp: '2026-10-01T12:00:00.150Z', latitude: 35.0, longitude: -114.9994, speed: 20, heading: 90, segmentId: 0 }, // 3 px offset
];
const testRide = {
...sampleRide,
id: 'ride_sample_boundaries_test',
endedAt: new Date('2026-10-01T12:00:00.150Z'),
points: closePoints,
} as Ride;
const { map } = setupLeafletMock();
render(<WebTelemetryReplayModal ride={testRide} onClose={() => {}} />);
await waitFor(() => expect(screen.getByTestId('follow-toggle-btn')).toBeDefined());
const playBtn = screen.getByTestId('play-pause-btn');
fireEvent.click(playBtn);
expect(rafCallback).toBeDefined();
// Clear initial setup calls
map.panTo.mockClear();
// Frame 1: t=0ms, index = 0
act(() => {
rafCallback!(1000);
// Frame 2: t=60ms (crosses sample boundary from point 0 to point 1 at 50ms)
rafCallback!(1060);
// Frame 3: t=120ms (crosses sample boundary from point 1 to point 2 at 100ms)
rafCallback!(1120);
});
// Because rider moved by at most 2px (well within 32px dead-zone) across 2 sample boundaries,
// neither the dead-zone follow engine nor the tab effect should have called map.panTo.
expect(map.panTo).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { computeCameraFollowStep } from '../lib/telemetry-camera-follow';
describe('telemetry-camera-follow engine', () => {
const createMockMap = (center = { lat: 35.0, lng: -115.0 }, size = { x: 600, y: 400 }) => {
return {
getCenter: vi.fn(() => ({ ...center })),
getSize: vi.fn(() => ({ ...size })),
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => {
// Linear scale for testing: 1 deg = 1000 px, centered at (300, 200)
const x = (size.x / 2) + (lng - center.lng) * 1000;
const y = (size.y / 2) - (lat - center.lat) * 1000;
return { x, y };
}),
containerPointToLatLng: vi.fn((pt: { x: number; y: number }) => {
const lng = center.lng + (pt.x - (size.x / 2)) / 1000;
const lat = center.lat - (pt.y - (size.y / 2)) / 1000;
return { lat, lng };
}),
};
};
it('keeps camera stationary when motorcycle marker is within dead zone radius', () => {
const map = createMockMap({ lat: 35.0, lng: -115.0 });
// Marker offset by only 10 pixels (dead zone is 32px)
// 10 px / 1000 = 0.01 deg
const markerLatLng = { lat: 35.005, lng: -115.005 }; // hypot is ~7 px
const result = computeCameraFollowStep(map, markerLatLng, 16.67, { deadZoneRadiusPx: 32 });
expect(result.shouldPan).toBe(false);
expect(result.excessPx).toBe(0);
expect(result.targetLatLng.lat).toBe(35.0);
expect(result.targetLatLng.lng).toBe(-115.0);
});
it('smoothly follows towards marker when outside dead zone', () => {
const map = createMockMap({ lat: 35.0, lng: -115.0 });
// Marker offset to the right by 100 pixels (dx = 100, dy = 0)
// lng = -115.0 + 100 / 1000 = -114.90
const markerLatLng = { lat: 35.0, lng: -114.90 };
const result = computeCameraFollowStep(map, markerLatLng, 16.67, {
deadZoneRadiusPx: 30,
maxStepPxPerFrame: 20,
dampingLambda: 4.5,
});
expect(result.shouldPan).toBe(true);
expect(result.distanceFromCenterPx).toBeCloseTo(100, 1);
expect(result.excessPx).toBeCloseTo(70, 1);
expect(result.panDeltaPx.x).toBeGreaterThan(0);
expect(result.panDeltaPx.y).toBeCloseTo(0, 4);
// Target longitude should move east (towards -114.90)
expect(result.targetLatLng.lng).toBeGreaterThan(-115.0);
expect(result.targetLatLng.lng).toBeLessThan(-114.90);
expect(result.targetLatLng.lat).toBeCloseTo(35.0, 5);
});
it('strictly clamps maximum pan step per frame to prevent outrunning tile loading', () => {
const map = createMockMap({ lat: 35.0, lng: -115.0 });
// Marker very far away (500 pixels away)
const markerLatLng = { lat: 35.0, lng: -114.50 };
const maxStep = 10;
const result = computeCameraFollowStep(map, markerLatLng, 100, {
deadZoneRadiusPx: 30,
maxStepPxPerFrame: maxStep,
dampingLambda: 10, // high damping
});
expect(result.shouldPan).toBe(true);
const stepLength = Math.hypot(result.panDeltaPx.x, result.panDeltaPx.y);
expect(stepLength).toBeCloseTo(maxStep, 2);
});
it('adjusts smoothly to higher playback speeds and larger frame deltas', () => {
const map = createMockMap({ lat: 35.0, lng: -115.0 });
const markerLatLng = { lat: 35.0, lng: -114.90 }; // 100px away
const stepShort = computeCameraFollowStep(map, markerLatLng, 16.67, { maxStepPxPerFrame: 50 });
const stepLong = computeCameraFollowStep(map, markerLatLng, 50, { maxStepPxPerFrame: 50 });
expect(stepLong.panDeltaPx.x).toBeGreaterThan(stepShort.panDeltaPx.x);
});
it('falls back safely to LatLng interpolation when container projection methods are missing', () => {
const mapWithoutProjection = {
getCenter: () => ({ lat: 35.0, lng: -115.0 }),
};
const markerLatLng = { lat: 35.01, lng: -115.01 }; // outside ~0.0003 deg dead zone
const result = computeCameraFollowStep(mapWithoutProjection, markerLatLng, 16.67);
expect(result.shouldPan).toBe(true);
expect(result.targetLatLng.lat).toBeGreaterThan(35.0);
expect(result.targetLatLng.lng).toBeLessThan(-115.0);
});
it('handles null, undefined, and non-finite coordinates gracefully without throwing', () => {
const map = createMockMap();
const resultNull = computeCameraFollowStep(null, null as any);
expect(resultNull.shouldPan).toBe(false);
const resultNaN = computeCameraFollowStep(map, { lat: NaN, lng: 10 });
expect(resultNaN.shouldPan).toBe(false);
});
});