'use client'; import React, { useState, useEffect, useRef, useMemo } from 'react'; import type { Ride } from './app-cloud-data'; import { TelemetryPoint, CornerApexSummary, synthesizeTelemetryTrack, generateRideGpx, generateRideCsv, downloadFile, analyzeCorners, } from '../lib/telemetry-export'; 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; onClose: () => void; } const TILE_PROVIDERS = { osm: { key: 'osm', name: 'OpenStreetMap Streets', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors', maxZoom: 19, subdomains: 'abc', isDark: false, }, aws: { key: 'aws', name: 'Amazon Satellite', url: getAwsSatelliteTileUrl(), attribution: AWS_MAPS_ATTRIBUTION, maxZoom: 19, subdomains: '', isDark: false, }, dark: { key: 'dark', name: 'Tactical Dark (Labeled)', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors (Tactical Filter)', maxZoom: 19, subdomains: 'abc', isDark: true, }, vector: { key: 'vector', name: 'Tactical Vector Map', url: '', attribution: 'MotorSignal Forensic Engine', maxZoom: 20, subdomains: '', isDark: false, }, }; function createRiderIcon(L: any, heading = 0) { return L.divIcon({ className: 'rider-replay-marker', html: `
`, 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) { const [isPlaying, setIsPlaying] = useState(false); const [playbackSpeed, setPlaybackSpeed] = useState(1.0); const [currentIndex, setCurrentIndex] = useState(0); const [activeTab, setActiveTab] = useState<'map' | 'corners'>('map'); const [exportNotice, setExportNotice] = useState(null); // Leaflet map integration state & refs 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(() => { const controller = new AbortController(); 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}); return () => { active=false; controller.abort(); }; } void (async () => { try { const user = firebaseAuth?.currentUser; if (!user) throw Error('Sign in to load recorded telemetry.'); const token = await user.getIdToken(); if (!active) return; const response = await fetch(`/api/account/rides/${encodeURIComponent(ride.id)}/telemetry`, { headers:{Authorization:`Bearer ${token}`},cache:'no-store',signal:controller.signal, }); const data = await response.json(); if (!response.ok || !data.success) throw Error(data.error || 'Recorded telemetry could not be loaded.'); if (data.rideId !== ride.id) throw Error('Recorded telemetry returned a different ride.'); const recorded = normalizeTelemetryPoints(Array.isArray(data.points) ? data.points : []); if (!recorded.length) throw Error('No recorded GPS coordinates are available for this ride.'); if (active) setTelemetry({rideId:ride.id,points:recorded,source:data.source || 'recorded telemetry',error:null,loading:false}); } catch (error) { if (active) setTelemetry({rideId:ride.id,points:[],source:'',loading:false,error:error instanceof Error ? error.message : 'Recorded telemetry could not be loaded.'}); } })(); return () => { active=false; controller.abort(); }; }, [ride.id, ride.telemetryBundlePath, attachedPoints]); // Identity barrier prevents even one render of the previous ride after selection changes. const points = telemetry.rideId === ride.id ? telemetry.points : []; const isLoading = telemetry.rideId !== ride.id || telemetry.loading; const corners = useMemo(() => { return analyzeCorners(points); }, [points]); const animationFrameRef = useRef(null); const lastTimeRef = useRef(null); const indexRef = useRef(0); const playbackCarryRef = useRef(0); const lastPanTimeRef = useRef(0); const totalPoints = points.length; const currentPoint = points[currentIndex] || points[0]; // Keep a ref mirror of the cursor so the rAF loop resumes from the latest position // (including manual scrubs) without re-subscribing every index change. useEffect(() => { indexRef.current = currentIndex; }, [currentIndex]); // True when the recording has disconnected segments / GPS-loss gaps. Playback compresses // those gaps (advancePlaybackIndex), so the elapsed clock — which shows real recorded time — // will jump forward at a gap even though wall-clock playback does not pause. We surface this // explicitly rather than let the jump look like a glitch. const hasCompressedGaps = useMemo( () => points.some((p, i) => i > 0 && !areTelemetryPointsConnected(points[i - 1], p)), [points] ); // Manual seek (scrub, restart, corner jump, arrow keys): reconcile playback timing so the // next animation frame does not apply stale budget accumulated before the seek. const seekTo = (index: number) => { const maxIndex = Math.max(0, totalPoints - 1); const clamped = Math.max(0, Math.min(maxIndex, index)); playbackCarryRef.current = 0; lastTimeRef.current = null; indexRef.current = clamped; setCurrentIndex(clamped); const pt = points[clamped]; 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 }); } } } } } }; // Dynamically load Leaflet library and CSS with multi-CDN fallback useEffect(() => { if (typeof window === 'undefined') return; if ((window as any).L) { setLeafletReady(true); } const CDN_SOURCES = [ { js: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', css: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', }, { js: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js', css: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css', }, { js: 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js', css: 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css', }, ]; // Inject essential structural CSS immediately so Leaflet tiles and panes never collapse or misalign const styleId = 'leaflet-essential-structural-css'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` .leaflet-container { background-color: #0b1120 !important; outline: 0; position: relative; width: 100%; height: 100%; } .tactical-dark-tiles { filter: invert(100%) hue-rotate(180deg) brightness(85%) contrast(120%) !important; } .leaflet-pane, .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow, .leaflet-tile-container, .leaflet-pane > svg, .leaflet-pane > canvas, .leaflet-zoom-box, .leaflet-image-layer, .leaflet-layer { position: absolute; left: 0; top: 0; } .leaflet-tile { visibility: hidden; transition: opacity 0.2s ease-in; } .leaflet-tile-loaded { visibility: inherit; } .leaflet-zoom-animated { transform-origin: 0 0; } `; document.head.appendChild(style); } // Interval poller to immediately catch window.L as soon as any script finishes evaluation const checkInterval = setInterval(() => { if ((window as any).L) { setLeafletReady(true); clearInterval(checkInterval); } }, 50); const tryLoadCdn = (index: number) => { if (index >= CDN_SOURCES.length) { if (!(window as any).L) { console.warn('All Leaflet CDNs exhausted. Activating geographic map unavailable.'); setTileStatus('fallback'); } return; } const cdn = CDN_SOURCES[index]; // Load CSS const cssId = `leaflet-core-css-${index}`; if (!document.getElementById(cssId)) { const link = document.createElement('link'); link.id = cssId; link.rel = 'stylesheet'; link.href = cdn.css; link.crossOrigin = ''; document.head.appendChild(link); } // Load JS const scriptId = `leaflet-core-js-${index}`; let script = document.getElementById(scriptId) as HTMLScriptElement | null; if (!script) { script = document.createElement('script'); script.id = scriptId; script.src = cdn.js; script.async = true; script.crossOrigin = ''; script.addEventListener('load', () => { if ((window as any).L) { setLeafletReady(true); clearInterval(checkInterval); } else { tryLoadCdn(index + 1); } }); script.addEventListener('error', () => { console.warn(`Leaflet CDN source ${cdn.js} failed, trying next source...`); tryLoadCdn(index + 1); }); document.body.appendChild(script); } else if ((window as any).L) { setLeafletReady(true); clearInterval(checkInterval); } else { // Script already exists in DOM but window.L is not yet ready; attach listener script.addEventListener('load', () => { if ((window as any).L) { setLeafletReady(true); clearInterval(checkInterval); } }); } }; if (!(window as any).L) { tryLoadCdn(0); } // Safety watchdog: if not loaded within 5s, show map unavailable const watchdog = setTimeout(() => { if (!(window as any).L) { setTileStatus('fallback'); } }, 5000); return () => { clearInterval(checkInterval); clearTimeout(watchdog); }; }, []); // Initialize and configure Leaflet Map with Geographic Tile Layers useEffect(() => { if (!leafletReady || !points.length || typeof window === 'undefined') return; const L = (window as any).L; if (!L || !mapContainerRef.current) return; if (mapInstanceRef.current) { try { mapInstanceRef.current.remove(); } catch { // ignore } mapInstanceRef.current = null; } const container = mapContainerRef.current; if ((container as any)._leaflet_id) { delete (container as any)._leaflet_id; } const startLat = points[0].latitude; const startLon = points[0].longitude; const map = L.map(container, { center: [startLat, startLon], zoom: 14, zoomControl: true, attributionControl: true, fadeAnimation: true, zoomAnimation: true, }); mapInstanceRef.current = map; // Add Tile Layer with standard street/road labels if (tileProvider !== 'vector') { const currentProvider = TILE_PROVIDERS[tileProvider]; let loadedTiles = 0; let errorTiles = 0; const tileLayer = L.tileLayer(currentProvider.url, { attribution: currentProvider.attribution, 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', () => { loadedTiles++; setTileStatus('loaded'); }); tileLayer.on('tileerror', (e: any) => { errorTiles++; 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'); } }); tileLayerRef.current = tileLayer; } else { setTileStatus('fallback'); } // Feature group for route heatmap and overlays const routeGroup = L.featureGroup().addTo(map); routeLayerGroupRef.current = routeGroup; if (points.length >= 2) { // Group contiguous points with the same lean angle color into single polylines type SegmentStyle = { color: string; weight: number; opacity: number }; const getStyle = (lean: number): SegmentStyle => { const absLean = Math.abs(lean); if (absLean > 35) { return { color: '#ef4444', weight: 6.5, opacity: 1.0 }; } if (absLean > 20) { return { color: '#f59e0b', weight: 5.5, opacity: 0.95 }; } return { color: '#00f2fe', weight: 4.5, opacity: 0.9 }; }; let currentPts: [number, number][] = [[points[0].latitude, points[0].longitude]]; let currentStyle = getStyle(points[0].leanAngle); const flush = () => { if (currentPts.length > 1) routeGroup.addLayer(L.polyline(currentPts, { color:currentStyle.color,weight:currentStyle.weight,opacity:currentStyle.opacity, lineCap:'round',lineJoin:'round',smoothFactor:0, })); }; for (let i = 1; i < points.length; i++) { const p = points[i], previous = points[i-1]; const style = getStyle(p.leanAngle); if (!areTelemetryPointsConnected(previous,p)) { flush(); currentPts=[[p.latitude,p.longitude]]; currentStyle=style; continue; } currentPts.push([p.latitude,p.longitude]); if (style.color !== currentStyle.color) { flush(); currentPts=[[p.latitude,p.longitude]]; currentStyle=style; } } flush(); // Start Marker (High-visibility Green Pin with START label) const startIcon = L.divIcon({ className: 'route-start-pin', html: `
START
`, iconSize: [0, 0], iconAnchor: [0, 0], }); const startMarker = L.marker([points[0].latitude, points[0].longitude], { icon: startIcon, zIndexOffset: 500, }).bindTooltip('Route Start', { permanent: false, direction: 'top' }); routeGroup.addLayer(startMarker); // Finish Marker (High-visibility Violet Pin with FINISH label) const endPt = points[points.length - 1]; const finishIcon = L.divIcon({ className: 'route-finish-pin', html: `
FINISH
`, iconSize: [0, 0], iconAnchor: [0, 0], }); const finishMarker = L.marker([endPt.latitude, endPt.longitude], { icon: finishIcon, zIndexOffset: 500, }).bindTooltip('Route Finish', { permanent: false, direction: 'top' }); routeGroup.addLayer(finishMarker); // Corner Apex Markers corners.forEach((corner) => { const apexPt = points[corner.apexIndex]; if (apexPt) { const cornerMarker = L.circleMarker([apexPt.latitude, apexPt.longitude], { radius: 5, fillColor: corner.direction === 'Left' ? '#38bdf8' : '#34d399', color: '#ffffff', weight: 1.5, fillOpacity: 0.9, }).bindTooltip( `Turn ${corner.id} (${corner.direction}) • Apex Lean: ${corner.apexLeanAngle}° [Grade: ${corner.grade}]`, { direction: 'top' } ); cornerMarker.on('click', () => { seekTo(corner.apexIndex); }); routeGroup.addLayer(cornerMarker); } }); // 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 if (points[0]) { const riderIcon = createRiderIcon(L, points[0].heading || 0); const riderMarker = L.marker([points[0].latitude, points[0].longitude], { icon: riderIcon, zIndexOffset: 1000, }).addTo(map); 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(() => { map.invalidateSize(); }); const t1 = setTimeout(() => map.invalidateSize(), 100); const t2 = setTimeout(() => map.invalidateSize(), 300); const t3 = setTimeout(() => map.invalidateSize(), 600); const handleResize = () => { map.invalidateSize(); }; window.addEventListener('resize', handleResize); // ResizeObserver on the container to continuously sync Leaflet viewport during modal animation / orientation changes let resizeObserver: ResizeObserver | null = null; if (typeof ResizeObserver !== 'undefined' && container) { resizeObserver = new ResizeObserver(() => { map.invalidateSize(); }); resizeObserver.observe(container); } return () => { cancelAnimationFrame(rafId); clearTimeout(t1); clearTimeout(t2); 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; } riderMarkerRef.current = null; routeLayerGroupRef.current = null; tileLayerRef.current = null; }; }, [leafletReady, points, corners]); // Update Rider Beacon LatLng & Heading during scrub or state change useEffect(() => { if (!riderMarkerRef.current || !currentPoint || typeof window === 'undefined') return; const L = (window as any).L; if (!L) return; if (!isPlaying) { const pos = playbackCarryRef.current > 0 ? interpolateTelemetryPosition(points, indexRef.current, playbackCarryRef.current) : { latitude: currentPoint.latitude, longitude: currentPoint.longitude, heading: currentPoint.heading || 0, }; latestPositionRef.current = pos; updateRiderMarker( riderMarkerRef.current, pos.latitude, pos.longitude, pos.heading, L ); } }, [currentPoint, isPlaying, points]); // 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(() => { 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(() => { map?.invalidateSize?.({ pan: false }); }, 150); return () => { cancelAnimationFrame(raf); clearTimeout(timer); }; } }, [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); if (providerKey === 'vector') { setTileStatus('fallback'); if (tileLayerRef.current && mapInstanceRef.current) { try { mapInstanceRef.current.removeLayer(tileLayerRef.current); } catch { // ignore } tileLayerRef.current = null; } return; } setTileStatus('loading'); if (mapInstanceRef.current && typeof window !== 'undefined' && (window as any).L) { const L = (window as any).L; if (tileLayerRef.current) { try { mapInstanceRef.current.removeLayer(tileLayerRef.current); } catch { // ignore } } const provider = TILE_PROVIDERS[providerKey]; let loadedTiles = 0; let errorTiles = 0; const newLayer = L.tileLayer(provider.url, { attribution: provider.attribution, 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', () => { loadedTiles++; setTileStatus('loaded'); }); newLayer.on('tileerror', (e: any) => { errorTiles++; 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({ pan: false }); } }; // Handle Playback Loop — timestamp-driven so the rider marker stays synchronized with the // elapsed-time readout. Advances by real recorded time scaled by playbackSpeed; disconnected // gaps are compressed by advancePlaybackIndex so playback never stalls across dead time. useEffect(() => { if (!isPlaying) { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); lastTimeRef.current = null; return; } const animate = (timestamp: number) => { if (lastTimeRef.current == null) lastTimeRef.current = timestamp; const rawDelta = timestamp - lastTimeRef.current; const frameDelta = Math.max(0, Math.min(rawDelta, 200)); lastTimeRef.current = timestamp; // Virtual recorded-time budget to consume this frame, at the selected speed. const budget = frameDelta * playbackSpeed + playbackCarryRef.current; const { index, remainingMs } = advancePlaybackIndex(points, indexRef.current, budget); playbackCarryRef.current = index >= totalPoints - 1 ? 0 : remainingMs; // 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); // 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 } ); } } } if (index !== indexRef.current) { indexRef.current = index; setCurrentIndex(index); } if (index >= totalPoints - 1) { setIsPlaying(false); return; } animationFrameRef.current = requestAnimationFrame(animate); }; animationFrameRef.current = requestAnimationFrame(animate); return () => { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); lastTimeRef.current = null; }; }, [isPlaying, playbackSpeed, totalPoints, points]); // Keyboard navigation (Space to toggle play) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.code === 'Space' && totalPoints > 0) { e.preventDefault(); setIsPlaying((prev) => !prev); } else if (e.code === 'ArrowRight' && totalPoints > 0) { seekTo(indexRef.current + 20); } else if (e.code === 'ArrowLeft') { seekTo(indexRef.current - 20); } else if (e.code === 'Escape') { onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [totalPoints, onClose]); // Export Handlers const handleExportGpx = () => { if (!points.length) return; const gpx = generateRideGpx(ride, points); const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'ride'; const filename = `motorsignal_${ride.id}_${dateStr}.gpx`; downloadFile(gpx, filename, 'application/gpx+xml'); setExportNotice(`Exported GPX: ${filename}`); setTimeout(() => setExportNotice(null), 3500); }; const handleExportCsv = () => { if (!points.length) return; const csv = generateRideCsv(ride, points); const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'ride'; const filename = `motorsignal_20hz_${ride.id}_${dateStr}.csv`; downloadFile(csv, filename, 'text/csv;charset=utf-8;'); setExportNotice(`Exported 20 Hz CSV: ${filename}`); setTimeout(() => setExportNotice(null), 3500); }; const elapsedSeconds = currentPoint && points[0] ? Math.max(0,(Date.parse(currentPoint.timestamp)-Date.parse(points[0].timestamp))/1000) : 0; const totalSeconds = points.length > 1 ? Math.max(0,(Date.parse(points[points.length-1].timestamp)-Date.parse(points[0].timestamp))/1000) : 0; const formatTime = (secs: number) => { const m = Math.floor(secs / 60); const s = Math.floor(secs % 60); return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; const absLeanAngle = Math.abs(currentPoint?.leanAngle || 0); const leanDirection = (currentPoint?.leanAngle || 0) < 0 ? 'LEFT' : 'RIGHT'; return (
{/* Header Bar */}

TELEMETRY FORENSIC REPLAY

{ride.motorcycle || 'Motorcycle'} • {ride.distanceMiles.toFixed(2)} mi •{' '} {ride.startedAt ? ride.startedAt.toLocaleDateString() : 'Recorded Session'}

{/* Action buttons */}
{/* Feedback Alert Toast */} {exportNotice && (
{exportNotice}
)} {/* Main Content Area */}
{/* Cockpit HUD Gauges */}
{/* Speed Gauge */}
VEHICLE SPEED
{Math.round(currentPoint?.speed || 0)} mph
{/* Lean Angle Gauge */}
LEAN ANGLE 35 ? 'text-rose-400 font-bold' : absLeanAngle > 20 ? 'text-amber-400 font-bold' : 'text-slate-400'}> {leanDirection}
35 ? 'text-rose-400' : absLeanAngle > 20 ? 'text-amber-400' : 'text-emerald-400' }`} > {absLeanAngle.toFixed(1)}°
L 50°
35 ? 'bg-rose-500' : absLeanAngle > 20 ? 'bg-amber-400' : 'bg-brand-cyan' }`} style={{ left: (currentPoint?.leanAngle || 0) < 0 ? `${50 - (absLeanAngle / 50) * 50}%` : '50%', width: `${Math.min(50, (absLeanAngle / 50) * 50)}%`, }} >
50° R
{/* G-Force HUD */}
G-FORCE DYNAMICS
LONGITUDINAL = 0 ? 'text-brand-cyan' : 'text-rose-400' }`} > {(currentPoint?.longitudinalG || 0) >= 0 ? '+' : ''} {(currentPoint?.longitudinalG || 0).toFixed(2)} G
LATERAL {(currentPoint?.lateralG || 0).toFixed(2)} G
Heading: {Math.round(currentPoint?.heading || 0)}°
{/* Elevation / Progress */}
ALTITUDE & TIME
{Math.round(currentPoint?.altitude || 0)} ft MSL
{formatTime(elapsedSeconds)} / {formatTime(totalSeconds)}
{/* View Mode Tabs: Interactive Map vs Corner Apex Grading */}
{/* Heatmap Legend */}
Straight / Mild (≤20°) Corner (20°-35°) Apex Peak (>35°)
{/* View Tab 1: Map Replay */}
{(isLoading || telemetry.error || !points.length) && (
{isLoading ? 'Loading recorded telemetry…' : telemetry.error || 'No recorded GPS coordinates are available for this ride.'}
)} {!isLoading && points.length > 0 && !leafletReady && (
Geographic map is unavailable. Recorded telemetry can still be exported.
)} {/* Leaflet Map Target Div with explicit container sizing */}
0 ? 'opacity-100 z-10' : 'opacity-0 pointer-events-none z-0' }`} style={{ width: '100%', height: '100%' }} /> {/* Camera Follow Mode Controls (Follow / Re-center / Fit Route) */}
{/* Map Tile Layer Selector (Streets / Voyager / Dark / Vector) */}
{/* Keep metadata outside the map so it cannot cover Leaflet controls. */}
GPS: {currentPoint ? `${currentPoint.latitude.toFixed(5)}, ${currentPoint.longitude.toFixed(5)}` : 'Unavailable'} | {telemetry.source} {tileProvider === 'vector' || tileStatus === 'fallback' || !leafletReady ? 'Recorded geographic trace (no basemap)' : TILE_PROVIDERS[tileProvider].name} {(tileStatus === 'fallback' || !leafletReady) && tileProvider !== 'vector' && ( (Basemap unavailable) )}
{/* View Tab 2: Corner Apex Grading Table */}
{corners.length > 0 ? ( corners.map((corner) => ( { seekTo(corner.apexIndex); setActiveTab('map'); }} > )) ) : ( )}
Corner Direction Entry Spd Apex Spd Apex Lean Exit Spd Grade Action
Turn {corner.id} {corner.direction === 'Left' ? '↰ Left' : '↱ Right'} {corner.entrySpeed} mph {corner.apexSpeed} mph {corner.apexLeanAngle}° {corner.exitSpeed} mph {corner.grade}
No distinct corners detected over 16° lean threshold.
{/* Footer Playback Controls */}
{/* Timeline Scrubber */}
{formatTime(elapsedSeconds)} seekTo(Number(e.target.value))} className="flex-1 accent-brand-cyan cursor-pointer h-2 bg-slate-800 rounded-lg" data-testid="replay-timeline-slider" disabled={!points.length} /> {formatTime(totalSeconds)}
{/* Timeline is recorded time. Make the gap-compression behaviour explicit. */}
Recorded elapsed time {hasCompressedGaps && ( timer_off Recording has gaps — dead time is skipped during playback )}
{/* Transport Controls */}
{/* Playback Speed Multiplier */}
{[0.5, 1.0, 2.0, 5.0, 10.0].map((spd) => ( ))}
); }