From ac762572103a73e574b773800913c9a221f31983 Mon Sep 17 00:00:00 2001 From: Billie Ponder Date: Thu, 15 Oct 2026 01:56:09 -0400 Subject: [PATCH] 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 --- .../components/web-telemetry-replay-modal.tsx | 201 ++++++++-- website/lib/telemetry-camera-follow.ts | 192 ++++++++++ .../tests/replay-map-camera-follow.test.tsx | 353 ++++++++++++++++++ website/tests/telemetry-camera-follow.test.ts | 109 ++++++ 4 files changed, 828 insertions(+), 27 deletions(-) create mode 100644 website/lib/telemetry-camera-follow.ts create mode 100644 website/tests/replay-map-camera-follow.test.tsx create mode 100644 website/tests/telemetry-camera-follow.test.ts diff --git a/website/components/web-telemetry-replay-modal.tsx b/website/components/web-telemetry-replay-modal.tsx index c16d9d7..1224b69 100644 --- a/website/components/web-telemetry-replay-modal.tsx +++ b/website/components/web-telemetry-replay-modal.tsx @@ -14,6 +14,7 @@ import { import { firebaseAuth } from '../lib/firebase-client'; import { areTelemetryPointsConnected, normalizeTelemetryPoints, advancePlaybackIndex, interpolateTelemetryPosition } from '../lib/telemetry-track'; import { getAwsSatelliteTileUrl, AWS_MAPS_ATTRIBUTION } from '../lib/aws-maps'; +import { computeCameraFollowStep } from '../lib/telemetry-camera-follow'; interface WebTelemetryReplayModalProps { ride: Ride; @@ -106,12 +107,16 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod const [leafletReady, setLeafletReady] = useState(() => typeof window !== 'undefined' && !!(window as any).L); const [tileProvider, setTileProvider] = useState<'osm' | 'aws' | 'dark' | 'vector'>('osm'); 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 mapContainerRef = useRef(null); const mapInstanceRef = useRef(null); const routeLayerGroupRef = useRef(null); const riderMarkerRef = useRef(null); const tileLayerRef = useRef(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]); useEffect(() => { @@ -119,10 +124,14 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod let active = true; setCurrentIndex(0); setIsPlaying(false); + setIsFollowing(true); indexRef.current = 0; playbackCarryRef.current = 0; lastTimeRef.current = null; lastPanTimeRef.current = 0; + isFollowingRef.current = true; + isProgrammaticZoomRef.current = false; + latestPositionRef.current = null; setTelemetry({rideId:ride.id,points:[],source:'',error:null,loading:true}); if (!ride.telemetryBundlePath && attachedPoints.length) { 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); 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 }); + if (pt) { + latestPositionRef.current = { + latitude: pt.latitude, + longitude: pt.longitude, + heading: pt.heading || 0, + }; + if (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; + 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.textContent = ` .leaflet-container { - background-color: transparent !important; + background-color: #0b1120 !important; outline: 0; position: relative; width: 100%; @@ -254,6 +273,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod } .leaflet-tile { visibility: hidden; + transition: opacity 0.2s ease-in; } .leaflet-tile-loaded { visibility: inherit; @@ -395,6 +415,10 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod maxZoom: currentProvider.maxZoom, subdomains: currentProvider.subdomains || 'abc', className: currentProvider.isDark ? 'tactical-dark-tiles' : undefined, + updateWhenIdle: false, + keepBuffer: 8, + updateInterval: 100, + crossOrigin: true, }).addTo(map); tileLayer.on('tileload', () => { @@ -402,9 +426,20 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod setTileStatus('loaded'); }); - tileLayer.on('tileerror', () => { + tileLayer.on('tileerror', (e: any) => { 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.`); setTileStatus('fallback'); } @@ -526,7 +561,11 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod // Fit map bounds with padding const latLngs = points.map((p) => [p.latitude, p.longitude]); + isProgrammaticZoomRef.current = true; map.fitBounds(L.latLngBounds(latLngs), { padding: [35, 35] }); + setTimeout(() => { + isProgrammaticZoomRef.current = false; + }, 250); } // Live rider position marker @@ -539,6 +578,21 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod 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 map.invalidateSize(); const rafId = requestAnimationFrame(() => { @@ -569,6 +623,8 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod clearTimeout(t3); window.removeEventListener('resize', handleResize); if (resizeObserver) resizeObserver.disconnect(); + map.off?.('dragstart', handleDragStart); + map.off?.('zoomstart', handleZoomStart); if (mapInstanceRef.current) { mapInstanceRef.current.remove(); mapInstanceRef.current = null; @@ -594,6 +650,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod longitude: currentPoint.longitude, heading: currentPoint.heading || 0, }; + latestPositionRef.current = pos; updateRiderMarker( riderMarkerRef.current, pos.latitude, @@ -604,14 +661,23 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod } }, [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(() => { if (activeTab === 'map' && mapInstanceRef.current) { + const map = mapInstanceRef.current; 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(() => { - mapInstanceRef.current?.invalidateSize(); + map?.invalidateSize?.({ pan: false }); }, 150); return () => { cancelAnimationFrame(raf); @@ -620,6 +686,40 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod } }, [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 const handleSelectTileProvider = (providerKey: 'osm' | 'aws' | 'dark' | 'vector') => { setTileProvider(providerKey); @@ -655,6 +755,10 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod maxZoom: provider.maxZoom, subdomains: provider.subdomains || 'abc', className: provider.isDark ? 'tactical-dark-tiles' : undefined, + updateWhenIdle: false, + keepBuffer: 8, + updateInterval: 100, + crossOrigin: true, }).addTo(mapInstanceRef.current); newLayer.on('tileload', () => { @@ -662,16 +766,27 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod setTileStatus('loaded'); }); - newLayer.on('tileerror', () => { + newLayer.on('tileerror', (e: any) => { 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.`); setTileStatus('fallback'); } }); 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 const pos = interpolateTelemetryPosition(points, index, remainingMs); + latestPositionRef.current = pos; 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 }); - } + // Smooth camera follow without abrupt recentering jumps or oscillation + if (mapInstanceRef.current && isFollowingRef.current) { + const follow = computeCameraFollowStep( + mapInstanceRef.current, + { lat: pos.latitude, lng: pos.longitude }, + frameDelta + ); + 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%' }} /> + {/* Camera Follow Mode Controls (Follow / Re-center / Fit Route) */} +
+ + + +
+ {/* Map Tile Layer Selector (Streets / Voyager / Dark / Vector) */}