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) */}