1446 lines
60 KiB
TypeScript
1446 lines
60 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, 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: '© <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: '© <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,
|
|
},
|
|
};
|
|
|
|
function createRiderIcon(L: any, heading = 0) {
|
|
return 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 class="rider-heading-arrow" style="transform:rotate(${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],
|
|
});
|
|
}
|
|
|
|
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<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 [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<HTMLDivElement | null>(null);
|
|
const mapInstanceRef = useRef<any>(null);
|
|
const routeLayerGroupRef = useRef<any>(null);
|
|
const riderMarkerRef = useRef<any>(null);
|
|
const tileLayerRef = useRef<any>(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<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 lastPanTimeRef = useRef<number>(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: `
|
|
<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', () => {
|
|
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 (
|
|
<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 (≤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 */}
|
|
<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%' }}
|
|
/>
|
|
|
|
{/* Camera Follow Mode Controls (Follow / Re-center / Fit Route) */}
|
|
<div className="absolute top-3 left-3 z-[400] flex items-center gap-1 font-mono text-[10px]">
|
|
<button
|
|
type="button"
|
|
onClick={handleToggleFollow}
|
|
data-testid="follow-toggle-btn"
|
|
className={`px-2.5 py-1 rounded-lg backdrop-blur-md border transition cursor-pointer flex items-center gap-1.5 shadow ${
|
|
isFollowing
|
|
? 'bg-brand-cyan/20 border-brand-cyan text-brand-cyan font-bold'
|
|
: 'bg-black/80 border-amber-400/60 text-amber-300 hover:text-white hover:bg-slate-800'
|
|
}`}
|
|
title={isFollowing ? 'Auto-follow active: keeping motorcycle in view' : 'Auto-follow suspended: click to re-center on motorcycle'}
|
|
>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${isFollowing ? 'bg-brand-cyan animate-pulse' : 'bg-amber-400'}`}></span>
|
|
<span>{isFollowing ? 'Following' : 'Re-center'}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleFitRoute}
|
|
data-testid="fit-route-btn"
|
|
className="px-2 py-1 rounded-lg bg-black/80 backdrop-blur-md border border-slate-700 text-slate-300 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
|
title="Fit full route in viewport"
|
|
>
|
|
<span>Fit Route</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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={() => {
|
|
seekTo(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();
|
|
seekTo(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) => 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}
|
|
/>
|
|
<span className="text-xs font-mono text-slate-400 w-12">{formatTime(totalSeconds)}</span>
|
|
</div>
|
|
|
|
{/* Timeline is recorded time. Make the gap-compression behaviour explicit. */}
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-slate-500">
|
|
<span>Recorded elapsed time</span>
|
|
{hasCompressedGaps && (
|
|
<span className="text-amber-400/80 flex items-center gap-1">
|
|
<span className="material-symbols-outlined text-[12px]">timer_off</span>
|
|
Recording has gaps — dead time is skipped during playback
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Transport Controls */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => seekTo(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, 10.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>
|
|
);
|
|
}
|