682 lines
29 KiB
TypeScript
682 lines
29 KiB
TypeScript
'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<number>(1.0);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [activeTab, setActiveTab] = useState<'map' | 'corners'>('map');
|
|
const [exportNotice, setExportNotice] = useState<string | null>(null);
|
|
|
|
// Generate or load telemetry points
|
|
const points = useMemo<TelemetryPoint[]>(() => {
|
|
return synthesizeTelemetryTrack(ride);
|
|
}, [ride]);
|
|
|
|
const corners = useMemo<CornerApexSummary[]>(() => {
|
|
return analyzeCorners(points);
|
|
}, [points]);
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const animationFrameRef = useRef<number | null>(null);
|
|
const lastTimeRef = useRef<number | null>(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 (
|
|
<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"
|
|
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"
|
|
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 (≤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 (>35°)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* View Tab 1: Map Replay */}
|
|
{activeTab === 'map' && (
|
|
<div className="relative rounded-xl overflow-hidden border border-outline-variant/40 bg-slate-950 h-80 sm:h-96">
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={800}
|
|
height={400}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
|
|
{/* In-canvas Mini Overlay Tag */}
|
|
<div className="absolute top-3 left-3 bg-black/80 backdrop-blur-md px-3 py-1 rounded-lg border border-slate-700 text-[10px] font-mono text-slate-300">
|
|
GPS: {currentPoint?.latitude.toFixed(5)}, {currentPoint?.longitude.toFixed(5)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* View Tab 2: Corner Apex Grading Table */}
|
|
{activeTab === 'corners' && (
|
|
<div className="rounded-xl border border-outline-variant/40 overflow-hidden bg-surface-container-low/60">
|
|
<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"
|
|
/>
|
|
<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"
|
|
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>
|
|
);
|
|
}
|