MotorSignal-App/website/components/web-telemetry-replay-modal.tsx

1211 lines
52 KiB
TypeScript

'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 } from '../lib/telemetry-track';
import { getAwsSatelliteTileUrl, AWS_MAPS_ATTRIBUTION } from '../lib/aws-maps';
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: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> 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: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> 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,
},
};
export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayModalProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<number>(1.0);
const [currentIndex, setCurrentIndex] = useState(0);
const [activeTab, setActiveTab] = useState<'map' | 'corners'>('map');
const [exportNotice, setExportNotice] = useState<string | null>(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 [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 mapInstanceRef = useRef<any>(null);
const routeLayerGroupRef = useRef<any>(null);
const riderMarkerRef = useRef<any>(null);
const tileLayerRef = useRef<any>(null);
const attachedPoints = useMemo(() => synthesizeTelemetryTrack(ride), [ride]);
useEffect(() => {
const controller = new AbortController();
let active = true;
setCurrentIndex(0);
setIsPlaying(false);
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<CornerApexSummary[]>(() => {
return analyzeCorners(points);
}, [points]);
const animationFrameRef = useRef<number | null>(null);
const lastTimeRef = useRef<number | null>(null);
const indexRef = useRef(0);
const playbackCarryRef = 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]);
// 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: transparent !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;
}
.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,
}).addTo(map);
tileLayer.on('tileload', () => {
loadedTiles++;
setTileStatus('loaded');
});
tileLayer.on('tileerror', () => {
errorTiles++;
if (errorTiles >= 4 && 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: `
<div style="position:relative;display:flex;flex-direction:column;align-items:center;transform:translate(-50%, -100%);pointer-events:none;">
<div style="background:#10b981;color:#ffffff;font-size:9px;font-family:monospace;font-weight:900;padding:2px 6px;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.6);border:1.5px solid #ffffff;white-space:nowrap;letter-spacing:0.5px;">
START
</div>
<div style="width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid #10b981;"></div>
<div style="width:6px;height:6px;border-radius:50%;background:#ffffff;box-shadow:0 0 4px #10b981;margin-top:-2px;"></div>
</div>
`,
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: `
<div style="position:relative;display:flex;flex-direction:column;align-items:center;transform:translate(-50%, -100%);pointer-events:none;">
<div style="background:#8b5cf6;color:#ffffff;font-size:9px;font-family:monospace;font-weight:900;padding:2px 6px;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,0.6);border:1.5px solid #ffffff;white-space:nowrap;letter-spacing:0.5px;">
FINISH
</div>
<div style="width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:6px solid #8b5cf6;"></div>
<div style="width:6px;height:6px;border-radius:50%;background:#ffffff;box-shadow:0 0 4px #8b5cf6;margin-top:-2px;"></div>
</div>
`,
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', () => {
setCurrentIndex(corner.apexIndex);
});
routeGroup.addLayer(cornerMarker);
}
});
// Fit map bounds with padding
const latLngs = points.map((p) => [p.latitude, p.longitude]);
map.fitBounds(L.latLngBounds(latLngs), { padding: [35, 35] });
}
// Live rider position marker
if (points[0]) {
const riderIcon = L.divIcon({
className: 'rider-replay-marker',
html: `
<div style="position:relative;width:32px;height:32px;display:flex;align-items:center;justify-content:center;">
<div style="position:absolute;width:32px;height:32px;border-radius:50%;background:rgba(0,242,254,0.35);box-shadow:0 0 12px #00f2fe;"></div>
<div style="position:absolute;width:14px;height:14px;border-radius:50%;background:#00f2fe;border:2.5px solid #ffffff;box-shadow:0 0 8px #00f2fe;"></div>
<div style="transform:rotate(${points[0].heading || 0}deg);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #00f2fe;position:absolute;top:-6px;filter:drop-shadow(0 0 2px rgba(0,0,0,0.8));"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
const riderMarker = L.marker([points[0].latitude, points[0].longitude], {
icon: riderIcon,
zIndexOffset: 1000,
}).addTo(map);
riderMarkerRef.current = riderMarker;
}
// 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();
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 playback or scrub
useEffect(() => {
if (!riderMarkerRef.current || !currentPoint || typeof window === 'undefined') return;
const L = (window as any).L;
if (!L) return;
riderMarkerRef.current.setLatLng([currentPoint.latitude, currentPoint.longitude]);
const newIcon = L.divIcon({
className: 'rider-replay-marker',
html: `
<div style="position:relative;width:32px;height:32px;display:flex;align-items:center;justify-content:center;">
<div style="position:absolute;width:32px;height:32px;border-radius:50%;background:rgba(0,242,254,0.35);box-shadow:0 0 12px #00f2fe;"></div>
<div style="position:absolute;width:14px;height:14px;border-radius:50%;background:#00f2fe;border:2.5px solid #ffffff;box-shadow:0 0 8px #00f2fe;"></div>
<div style="transform:rotate(${currentPoint.heading || 0}deg);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:10px solid #00f2fe;position:absolute;top:-6px;filter:drop-shadow(0 0 2px rgba(0,0,0,0.8));"></div>
</div>
`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
riderMarkerRef.current.setIcon(newIcon);
// Pan map to follow if out of current bounds while playing
if (isPlaying && mapInstanceRef.current) {
const map = mapInstanceRef.current;
const bounds = map.getBounds();
if (bounds && !bounds.contains([currentPoint.latitude, currentPoint.longitude])) {
map.panTo([currentPoint.latitude, currentPoint.longitude], { animate: true, duration: 0.5 });
}
}
}, [currentPoint, isPlaying]);
// Invalidate map size when tab switches to map
useEffect(() => {
if (activeTab === 'map' && mapInstanceRef.current) {
const raf = requestAnimationFrame(() => {
mapInstanceRef.current?.invalidateSize();
});
const timer = setTimeout(() => {
mapInstanceRef.current?.invalidateSize();
}, 150);
return () => {
cancelAnimationFrame(raf);
clearTimeout(timer);
};
}
}, [activeTab]);
// 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,
}).addTo(mapInstanceRef.current);
newLayer.on('tileload', () => {
loadedTiles++;
setTileStatus('loaded');
});
newLayer.on('tileerror', () => {
errorTiles++;
if (errorTiles >= 4 && loadedTiles === 0) {
console.warn(`Tile provider ${providerKey} failed, keeping recorded geographic layers.`);
setTileStatus('fallback');
}
});
tileLayerRef.current = newLayer;
mapInstanceRef.current.invalidateSize();
}
};
// 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;
playbackCarryRef.current = 0;
return;
}
const animate = (timestamp: number) => {
if (lastTimeRef.current == null) lastTimeRef.current = timestamp;
const frameDelta = timestamp - lastTimeRef.current;
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;
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);
};
}, [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) {
setCurrentIndex((prev) => Math.min(totalPoints - 1, prev + 20));
} else if (e.code === 'ArrowLeft') {
setCurrentIndex((prev) => Math.max(0, prev - 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 (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/85 backdrop-blur-md animate-fadeIn"
>
<div className="glass-card rounded-2xl border border-brand-cyan/40 max-w-5xl w-full max-h-[92vh] flex flex-col bg-slate-950 text-white shadow-2xl overflow-hidden">
{/* Header Bar */}
<div className="flex flex-wrap items-center justify-between p-4 sm:p-5 border-b border-outline-variant/30 gap-3 bg-surface-container-low/70">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-brand-cyan animate-pulse"></span>
<h2 className="text-lg sm:text-xl font-black font-mono tracking-wide text-white">
TELEMETRY FORENSIC REPLAY
</h2>
</div>
<p className="text-xs font-mono text-slate-400">
{ride.motorcycle || 'Motorcycle'} • {ride.distanceMiles.toFixed(2)} mi •{' '}
{ride.startedAt ? ride.startedAt.toLocaleDateString() : 'Recorded Session'}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
onClick={handleExportGpx}
data-testid="export-gpx-btn"
disabled={!points.length}
className="px-3 py-1.5 rounded-lg bg-surface-container-high hover:bg-brand-cyan hover:text-black border border-outline-variant/50 text-xs font-mono font-semibold transition flex items-center gap-1.5 cursor-pointer"
title="Download GPX 1.1 format for Garmin, Strava, Google Earth"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Export GPX</span>
</button>
<button
onClick={handleExportCsv}
data-testid="export-csv-btn"
disabled={!points.length}
className="px-3 py-1.5 rounded-lg bg-surface-container-high hover:bg-emerald-400 hover:text-black border border-outline-variant/50 text-xs font-mono font-semibold transition flex items-center gap-1.5 cursor-pointer"
title="Download full 20 Hz telemetry CSV (speed, lean, 3-axis G)"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>20 Hz CSV</span>
</button>
<button
onClick={onClose}
data-testid="close-replay-modal"
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-surface-container-high transition cursor-pointer"
aria-label="Close replay modal"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Feedback Alert Toast */}
{exportNotice && (
<div className="bg-brand-cyan/20 border-b border-brand-cyan/50 text-brand-cyan text-xs font-mono px-4 py-2 flex items-center gap-2 animate-fadeIn">
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan"></span>
<span>{exportNotice}</span>
</div>
)}
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Cockpit HUD Gauges */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 font-mono">
{/* Speed Gauge */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<span className="text-[10px] text-slate-400">VEHICLE SPEED</span>
<div className="my-1">
<span className="text-3xl font-extrabold text-brand-cyan">
{Math.round(currentPoint?.speed || 0)}
</span>
<span className="text-xs text-slate-400 ml-1">mph</span>
</div>
<div className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div
className="bg-brand-cyan h-full transition-all duration-75"
style={{ width: `${Math.min(100, ((currentPoint?.speed || 0) / (ride.topSpeedMph || 100)) * 100)}%` }}
></div>
</div>
</div>
{/* Lean Angle Gauge */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<div className="flex items-center justify-between text-[10px]">
<span className="text-slate-400">LEAN ANGLE</span>
<span className={absLeanAngle > 35 ? 'text-rose-400 font-bold' : absLeanAngle > 20 ? 'text-amber-400 font-bold' : 'text-slate-400'}>
{leanDirection}
</span>
</div>
<div className="my-1 flex items-baseline gap-1">
<span
className={`text-3xl font-extrabold ${
absLeanAngle > 35 ? 'text-rose-400' : absLeanAngle > 20 ? 'text-amber-400' : 'text-emerald-400'
}`}
>
{absLeanAngle.toFixed(1)}°
</span>
</div>
<div className="flex items-center gap-1 text-[9px] text-slate-500">
<span>L 50°</span>
<div className="flex-1 bg-slate-800 h-1.5 rounded-full relative overflow-hidden">
<div
className={`absolute top-0 bottom-0 transition-all duration-75 ${
absLeanAngle > 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)}%`,
}}
></div>
</div>
<span>50° R</span>
</div>
</div>
{/* G-Force HUD */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<span className="text-[10px] text-slate-400">G-FORCE DYNAMICS</span>
<div className="my-1 flex items-center justify-between">
<div>
<span className="text-[10px] text-slate-500 block">LONGITUDINAL</span>
<span
className={`text-lg font-bold ${
(currentPoint?.longitudinalG || 0) >= 0 ? 'text-brand-cyan' : 'text-rose-400'
}`}
>
{(currentPoint?.longitudinalG || 0) >= 0 ? '+' : ''}
{(currentPoint?.longitudinalG || 0).toFixed(2)} G
</span>
</div>
<div>
<span className="text-[10px] text-slate-500 block">LATERAL</span>
<span className="text-lg font-bold text-amber-400">
{(currentPoint?.lateralG || 0).toFixed(2)} G
</span>
</div>
</div>
<div className="text-[9px] text-slate-400">
Heading: {Math.round(currentPoint?.heading || 0)}°
</div>
</div>
{/* Elevation / Progress */}
<div className="bg-surface-container-low/80 p-3 rounded-xl border border-outline-variant/30 flex flex-col justify-between">
<span className="text-[10px] text-slate-400">ALTITUDE & TIME</span>
<div className="my-1">
<span className="text-2xl font-extrabold text-white">
{Math.round(currentPoint?.altitude || 0)}
</span>
<span className="text-xs text-slate-400 ml-1">ft MSL</span>
</div>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>{formatTime(elapsedSeconds)}</span>
<span className="text-slate-600">/</span>
<span>{formatTime(totalSeconds)}</span>
</div>
</div>
</div>
{/* View Mode Tabs: Interactive Map vs Corner Apex Grading */}
<div className="flex items-center justify-between border-b border-outline-variant/30 pb-2">
<div className="flex items-center gap-2">
<button
onClick={() => setActiveTab('map')}
data-testid="tab-map-replay"
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-bold transition cursor-pointer ${
activeTab === 'map'
? 'bg-brand-cyan text-black shadow-md'
: 'bg-surface-container text-slate-300 hover:text-white'
}`}
>
Route Heatmap & Trace
</button>
<button
onClick={() => setActiveTab('corners')}
data-testid="tab-corner-grading"
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-bold transition flex items-center gap-1.5 cursor-pointer ${
activeTab === 'corners'
? 'bg-brand-cyan text-black shadow-md'
: 'bg-surface-container text-slate-300 hover:text-white'
}`}
>
<span>Corner Apex Grading</span>
<span className="px-1.5 py-0.2 rounded-full bg-slate-900/60 text-[10px]">
{corners.length}
</span>
</button>
</div>
{/* Heatmap Legend */}
<div className="hidden sm:flex items-center gap-3 text-[11px] font-mono">
<span className="flex items-center gap-1 text-slate-400">
<span className="w-2.5 h-2.5 rounded-full bg-brand-cyan"></span>
Straight / Mild (&le;20°)
</span>
<span className="flex items-center gap-1 text-slate-400">
<span className="w-2.5 h-2.5 rounded-full bg-amber-400"></span>
Corner (20°-35°)
</span>
<span className="flex items-center gap-1 text-slate-400">
<span className="w-2.5 h-2.5 rounded-full bg-rose-500"></span>
Apex Peak (&gt;35°)
</span>
</div>
</div>
{/* View Tab 1: Map Replay */}
<div
className={`relative rounded-xl overflow-hidden border border-outline-variant/40 bg-slate-950 h-80 sm:h-96 w-full ${
activeTab === 'map' ? 'block' : 'hidden'
}`}
>
{(isLoading || telemetry.error || !points.length) && (
<div role="status" className="absolute inset-0 z-[500] flex items-center justify-center bg-slate-950 p-6 text-center font-mono text-sm text-slate-300">
{isLoading ? 'Loading recorded telemetry…' : telemetry.error || 'No recorded GPS coordinates are available for this ride.'}
</div>
)}
{!isLoading && points.length > 0 && !leafletReady && (
<div role="status" className="absolute inset-0 z-[500] flex items-center justify-center bg-slate-950 p-6 text-sm">
Geographic map is unavailable. Recorded telemetry can still be exported.
</div>
)}
{/* Leaflet Map Target Div with explicit container sizing */}
<div
ref={mapContainerRef}
data-testid="telemetry-leaflet-map"
className={`w-full h-full min-h-[320px] sm:min-h-[384px] transition-opacity duration-300 ${
leafletReady && points.length > 0
? 'opacity-100 z-10'
: 'opacity-0 pointer-events-none z-0'
}`}
style={{ width: '100%', height: '100%' }}
/>
{/* 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]">
<button
type="button"
onClick={() => handleSelectTileProvider('osm')}
data-testid="tile-osm-btn"
className={`px-2 py-1 rounded transition cursor-pointer ${
tileProvider === 'osm'
? 'bg-brand-cyan text-black font-bold shadow'
: 'text-slate-300 hover:text-white'
}`}
title="OpenStreetMap Standard (High-resolution road & street names)"
>
<span className="hidden sm:inline">OSM Streets</span>
<span className="sm:hidden">OSM</span>
</button>
<button
type="button"
onClick={() => handleSelectTileProvider('aws')}
data-testid="tile-aws-btn"
className={`px-2 py-1 rounded transition cursor-pointer ${
tileProvider === 'aws'
? 'bg-brand-cyan text-black font-bold shadow'
: 'text-slate-300 hover:text-white'
}`}
title="Amazon Location Service (Satellite Imagery)"
>
<span className="hidden sm:inline">Amazon Satellite</span>
<span className="sm:hidden">Satellite</span>
</button>
<button
type="button"
onClick={() => handleSelectTileProvider('dark')}
data-testid="tile-dark-btn"
className={`px-2 py-1 rounded transition cursor-pointer ${
tileProvider === 'dark'
? 'bg-brand-cyan text-black font-bold shadow'
: 'text-slate-300 hover:text-white'
}`}
title="Tactical Dark (Labeled streets & highways)"
>
<span className="hidden sm:inline">Tactical Dark</span>
<span className="sm:hidden">Dark</span>
</button>
<button
type="button"
onClick={() => handleSelectTileProvider('vector')}
data-testid="tile-vector-btn"
className={`px-2 py-1 rounded transition cursor-pointer ${
tileProvider === 'vector'
? 'bg-brand-cyan text-black font-bold shadow'
: 'text-slate-300 hover:text-white'
}`}
title="High-Contrast Tactical Vector Map"
>
<span className="hidden sm:inline">Vector Map</span>
<span className="sm:hidden">Vector</span>
</button>
</div>
</div>
{/* Keep metadata outside the map so it cannot cover Leaflet controls. */}
<div
data-testid="replay-gps-badge"
className={`w-full min-w-0 flex-wrap bg-black/80 backdrop-blur-md px-3 py-2 rounded-lg border border-slate-700 text-[11px] font-mono text-slate-300 items-center gap-x-2 gap-y-1 ${activeTab === 'map' ? 'flex' : 'hidden'}`}
>
<span className="w-1.5 h-1.5 shrink-0 rounded-full bg-brand-cyan animate-pulse"></span>
<span className="whitespace-nowrap">GPS: {currentPoint ? `${currentPoint.latitude.toFixed(5)}, ${currentPoint.longitude.toFixed(5)}` : 'Unavailable'}</span>
<span className="text-slate-500">|</span>
<span data-testid="replay-data-source">{telemetry.source}</span>
<span className="text-emerald-400 font-semibold">
{tileProvider === 'vector' || tileStatus === 'fallback' || !leafletReady
? 'Recorded geographic trace (no basemap)'
: TILE_PROVIDERS[tileProvider].name}
</span>
{(tileStatus === 'fallback' || !leafletReady) && tileProvider !== 'vector' && (
<span className="text-amber-400 text-[9px]">(Basemap unavailable)</span>
)}
</div>
{/* View Tab 2: Corner Apex Grading Table */}
<div
className={`rounded-xl border border-outline-variant/40 overflow-hidden bg-surface-container-low/60 ${
activeTab === 'corners' ? 'block' : 'hidden'
}`}
>
<div className="overflow-x-auto">
<table className="w-full text-left font-mono text-xs">
<thead className="bg-surface-container-high/80 text-slate-400 uppercase text-[10px] tracking-wider border-b border-outline-variant/30">
<tr>
<th className="py-2.5 px-3">Corner</th>
<th className="py-2.5 px-3">Direction</th>
<th className="py-2.5 px-3">Entry Spd</th>
<th className="py-2.5 px-3">Apex Spd</th>
<th className="py-2.5 px-3">Apex Lean</th>
<th className="py-2.5 px-3">Exit Spd</th>
<th className="py-2.5 px-3">Grade</th>
<th className="py-2.5 px-3 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-outline-variant/20">
{corners.length > 0 ? (
corners.map((corner) => (
<tr
key={corner.id}
className="hover:bg-brand-cardAlt/50 transition cursor-pointer"
onClick={() => {
setCurrentIndex(corner.apexIndex);
setActiveTab('map');
}}
>
<td className="py-2.5 px-3 font-bold text-white">Turn {corner.id}</td>
<td className="py-2.5 px-3">
<span
className={`px-1.5 py-0.5 rounded text-[10px] ${
corner.direction === 'Left'
? 'bg-blue-500/20 text-blue-400'
: 'bg-emerald-500/20 text-emerald-400'
}`}
>
{corner.direction === 'Left' ? '↰ Left' : '↱ Right'}
</span>
</td>
<td className="py-2.5 px-3 text-slate-300">{corner.entrySpeed} mph</td>
<td className="py-2.5 px-3 font-semibold text-brand-cyan">
{corner.apexSpeed} mph
</td>
<td className="py-2.5 px-3 font-bold text-amber-400">
{corner.apexLeanAngle}°
</td>
<td className="py-2.5 px-3 text-slate-300">{corner.exitSpeed} mph</td>
<td className="py-2.5 px-3">
<span
className={`px-2 py-0.5 rounded font-bold text-[11px] ${
corner.grade === 'A+' || corner.grade === 'A'
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: corner.grade === 'B'
? 'bg-brand-cyan/20 text-brand-cyan border border-brand-cyan/30'
: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
}`}
>
{corner.grade}
</span>
</td>
<td className="py-2.5 px-3 text-right">
<button
onClick={(e) => {
e.stopPropagation();
setCurrentIndex(corner.apexIndex);
setActiveTab('map');
}}
className="px-2 py-1 rounded bg-surface-container-high hover:bg-brand-cyan hover:text-black text-[10px] transition cursor-pointer"
>
Seek Apex
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="py-8 text-center text-slate-400">
No distinct corners detected over 16° lean threshold.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Footer Playback Controls */}
<div className="p-4 sm:p-5 border-t border-outline-variant/30 bg-surface-container-low/70 space-y-3">
{/* Timeline Scrubber */}
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-slate-400 w-12 text-right">
{formatTime(elapsedSeconds)}
</span>
<input
type="range"
min={0}
max={Math.max(1, totalPoints - 1)}
value={currentIndex}
onChange={(e) => setCurrentIndex(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}
/>
<span className="text-xs font-mono text-slate-400 w-12">{formatTime(totalSeconds)}</span>
</div>
{/* Transport Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentIndex(0)}
title="Restart from beginning"
className="p-2 rounded-lg bg-surface-container hover:bg-slate-700 text-slate-300 hover:text-white transition cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.334 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z" />
</svg>
</button>
<button
onClick={() => setIsPlaying((prev) => !prev)}
data-testid="play-pause-btn"
disabled={!points.length}
className="px-5 py-2 rounded-xl bg-gradient-to-r from-brand-cyan to-blue-600 hover:opacity-95 text-black font-extrabold font-mono text-sm shadow-[0_0_16px_rgba(0,242,254,0.3)] transition flex items-center gap-2 cursor-pointer"
>
{isPlaying ? (
<>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
<span>PAUSE</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3l14 9-14 9V3z" />
</svg>
<span>PLAY</span>
</>
)}
</button>
</div>
{/* Playback Speed Multiplier */}
<div className="flex items-center gap-1 bg-surface-container p-1 rounded-lg border border-outline-variant/30 text-xs font-mono">
{[0.5, 1.0, 2.0, 5.0].map((spd) => (
<button
key={spd}
onClick={() => setPlaybackSpeed(spd)}
className={`px-2 py-1 rounded transition cursor-pointer ${
playbackSpeed === spd
? 'bg-brand-cyan text-black font-bold'
: 'text-slate-400 hover:text-white'
}`}
>
{spd}x
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}