'use client'; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Ride } from './app-cloud-data'; import { TelemetryPoint, CornerApexSummary, synthesizeTelemetryTrack, generateRideGpx, generateRideCsv, downloadFile, analyzeCorners, } from '../lib/telemetry-export'; interface WebTelemetryReplayModalProps { ride: Ride; onClose: () => void; } 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); // Generate or load telemetry points const points = useMemo(() => { return synthesizeTelemetryTrack(ride); }, [ride]); const corners = useMemo(() => { return analyzeCorners(points); }, [points]); const canvasRef = useRef(null); const animationFrameRef = useRef(null); const lastTimeRef = useRef(null); const totalPoints = points.length; const currentPoint = points[currentIndex] || points[0]; // Bounding box for canvas projection const bounds = useMemo(() => { if (points.length === 0) return { minLat: 0, maxLat: 1, minLon: 0, maxLon: 1 }; let minLat = points[0].latitude; let maxLat = points[0].latitude; let minLon = points[0].longitude; let maxLon = points[0].longitude; for (const p of points) { if (p.latitude < minLat) minLat = p.latitude; if (p.latitude > maxLat) maxLat = p.latitude; if (p.longitude < minLon) minLon = p.longitude; if (p.longitude > maxLon) maxLon = p.longitude; } // Add padding (10%) const latPad = (maxLat - minLat) * 0.1 || 0.001; const lonPad = (maxLon - minLon) * 0.1 || 0.001; return { minLat: minLat - latPad, maxLat: maxLat + latPad, minLon: minLon - lonPad, maxLon: maxLon + lonPad, }; }, [points]); // Project lat/lon to canvas coordinates const project = useCallback( (lat: number, lon: number, width: number, height: number) => { const x = ((lon - bounds.minLon) / (bounds.maxLon - bounds.minLon)) * width; // Invert Y since latitude increases northward const y = height - ((lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * height; return { x, y }; }, [bounds] ); // Draw the route on canvas useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const width = canvas.width; const height = canvas.height; // Clear background ctx.fillStyle = '#070a0f'; ctx.fillRect(0, 0, width, height); // Subtle tactical grid ctx.strokeStyle = 'rgba(255, 255, 255, 0.04)'; ctx.lineWidth = 1; const gridSize = 40; for (let x = 0; x < width; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } for (let y = 0; y < height; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } if (points.length < 2) return; // Draw segmented polyline with lean angle heatmap colors for (let i = 0; i < points.length - 1; i++) { const p1 = points[i]; const p2 = points[i + 1]; const pt1 = project(p1.latitude, p1.longitude, width, height); const pt2 = project(p2.latitude, p2.longitude, width, height); const absLean = Math.abs(p1.leanAngle); // Multi-color lean angle heatmap: // Green/Cyan <= 20° | Yellow/Amber 20°-35° | Red/Crimson > 35° let strokeColor = '#00f2fe'; // Cyan (straight / mild) let lineWidth = 3.5; if (absLean > 35) { strokeColor = '#ef4444'; // Red (Aggressive Apex) lineWidth = 5.0; } else if (absLean > 20) { strokeColor = '#f59e0b'; // Amber (Moderate Cornering) lineWidth = 4.0; } // Past path vs upcoming path opacity ctx.beginPath(); ctx.moveTo(pt1.x, pt1.y); ctx.lineTo(pt2.x, pt2.y); ctx.strokeStyle = i <= currentIndex ? strokeColor : 'rgba(100, 116, 139, 0.35)'; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); } // Start marker const startPt = project(points[0].latitude, points[0].longitude, width, height); ctx.beginPath(); ctx.arc(startPt.x, startPt.y, 6, 0, Math.PI * 2); ctx.fillStyle = '#10b981'; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.stroke(); // Finish marker const endPt = project(points[points.length - 1].latitude, points[points.length - 1].longitude, width, height); ctx.beginPath(); ctx.arc(endPt.x, endPt.y, 6, 0, Math.PI * 2); ctx.fillStyle = '#8b5cf6'; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.stroke(); // Current rider position & heading beacon if (currentPoint) { const riderPt = project(currentPoint.latitude, currentPoint.longitude, width, height); // Pulsing outer halo ctx.beginPath(); ctx.arc(riderPt.x, riderPt.y, 14, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0, 242, 254, 0.25)'; ctx.fill(); // Heading arrow / dot ctx.save(); ctx.translate(riderPt.x, riderPt.y); ctx.rotate((currentPoint.heading * Math.PI) / 180); // Arrow path ctx.beginPath(); ctx.moveTo(0, -10); ctx.lineTo(6, 8); ctx.lineTo(0, 4); ctx.lineTo(-6, 8); ctx.closePath(); ctx.fillStyle = '#00f2fe'; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore(); } }, [points, currentIndex, project, currentPoint]); // Handle Playback Loop useEffect(() => { if (!isPlaying) { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); lastTimeRef.current = null; return; } const animate = (timestamp: number) => { if (!lastTimeRef.current) lastTimeRef.current = timestamp; const delta = timestamp - lastTimeRef.current; // 20 Hz step interval is 50 ms. With playback multiplier: const stepInterval = 50 / playbackSpeed; if (delta >= stepInterval) { lastTimeRef.current = timestamp; setCurrentIndex((prev) => { if (prev >= totalPoints - 1) { setIsPlaying(false); return prev; } return prev + 1; }); } animationFrameRef.current = requestAnimationFrame(animate); }; animationFrameRef.current = requestAnimationFrame(animate); return () => { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); }; }, [isPlaying, playbackSpeed, totalPoints]); // Keyboard navigation (Space to toggle play) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault(); setIsPlaying((prev) => !prev); } else if (e.code === 'ArrowRight') { 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 = () => { 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 = () => { 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 = totalPoints > 0 ? (currentIndex / totalPoints) * (ride.movingTime || 60) : 0; const totalSeconds = ride.movingTime || 60; 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 */} {activeTab === 'map' && (
{/* In-canvas Mini Overlay Tag */}
GPS: {currentPoint?.latitude.toFixed(5)}, {currentPoint?.longitude.toFixed(5)}
)} {/* View Tab 2: Corner Apex Grading Table */} {activeTab === 'corners' && (
{corners.length > 0 ? ( corners.map((corner) => ( { setCurrentIndex(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)} 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" /> {formatTime(totalSeconds)}
{/* Transport Controls */}
{/* Playback Speed Multiplier */}
{[0.5, 1.0, 2.0, 5.0].map((spd) => ( ))}
); }