diff --git a/website/.gitignore b/website/.gitignore index 3854550..3b1394a 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -2,3 +2,5 @@ node_modules/ .env* !.env.example +.chrome-test-profile/ +test-support/visual-evidence/ diff --git a/website/components/web-telemetry-replay-modal.tsx b/website/components/web-telemetry-replay-modal.tsx index e736856..c16d9d7 100644 --- a/website/components/web-telemetry-replay-modal.tsx +++ b/website/components/web-telemetry-replay-modal.tsx @@ -12,7 +12,7 @@ import { analyzeCorners, } from '../lib/telemetry-export'; import { firebaseAuth } from '../lib/firebase-client'; -import { areTelemetryPointsConnected, normalizeTelemetryPoints, advancePlaybackIndex } from '../lib/telemetry-track'; +import { areTelemetryPointsConnected, normalizeTelemetryPoints, advancePlaybackIndex, interpolateTelemetryPosition } from '../lib/telemetry-track'; import { getAwsSatelliteTileUrl, AWS_MAPS_ATTRIBUTION } from '../lib/aws-maps'; interface WebTelemetryReplayModalProps { @@ -59,6 +59,42 @@ const TILE_PROVIDERS = { }, }; +function createRiderIcon(L: any, heading = 0) { + return L.divIcon({ + className: 'rider-replay-marker', + html: ` +
+
+
+
+
+ `, + iconSize: [32, 32], + iconAnchor: [16, 16], + }); +} + +function updateRiderMarker( + marker: any, + lat: number, + lng: number, + heading: number, + L: any +) { + if (!marker || !Number.isFinite(lat) || !Number.isFinite(lng)) return; + if (typeof marker.setLatLng === 'function') { + marker.setLatLng([lat, lng]); + } + const safeHeading = Number.isFinite(heading) ? ((heading % 360) + 360) % 360 : 0; + const el = typeof marker.getElement === 'function' ? marker.getElement() : null; + const arrow = el ? (el.querySelector('.rider-heading-arrow') as HTMLElement | null) : null; + if (arrow) { + arrow.style.transform = `rotate(${safeHeading.toFixed(2)}deg)`; + } else if (typeof marker.setIcon === 'function' && L?.divIcon) { + marker.setIcon(createRiderIcon(L, safeHeading)); + } +} + export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayModalProps) { const [isPlaying, setIsPlaying] = useState(false); const [playbackSpeed, setPlaybackSpeed] = useState(1.0); @@ -83,6 +119,10 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod let active = true; setCurrentIndex(0); setIsPlaying(false); + indexRef.current = 0; + playbackCarryRef.current = 0; + lastTimeRef.current = null; + lastPanTimeRef.current = 0; 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}); @@ -121,6 +161,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod const lastTimeRef = useRef(null); const indexRef = useRef(0); const playbackCarryRef = useRef(0); + const lastPanTimeRef = useRef(0); const totalPoints = points.length; const currentPoint = points[currentIndex] || points[0]; @@ -149,6 +190,20 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod lastTimeRef.current = null; indexRef.current = clamped; setCurrentIndex(clamped); + + const pt = points[clamped]; + if (pt && 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; + const bounds = map.getBounds?.(); + if (bounds && !bounds.contains([pt.latitude, pt.longitude])) { + lastPanTimeRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); + map.panTo([pt.latitude, pt.longitude], { animate: true, duration: 0.5 }); + } + } + } }; // Dynamically load Leaflet library and CSS with multi-CDN fallback @@ -476,19 +531,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod // Live rider position marker if (points[0]) { - const riderIcon = L.divIcon({ - className: 'rider-replay-marker', - html: ` -
-
-
-
-
- `, - iconSize: [32, 32], - iconAnchor: [16, 16], - }); - + const riderIcon = createRiderIcon(L, points[0].heading || 0); const riderMarker = L.marker([points[0].latitude, points[0].longitude], { icon: riderIcon, zIndexOffset: 1000, @@ -536,37 +579,30 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod }; }, [leafletReady, points, corners]); - // Update Rider Beacon LatLng & Heading during playback or scrub + // 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; - riderMarkerRef.current.setLatLng([currentPoint.latitude, currentPoint.longitude]); - - const newIcon = L.divIcon({ - className: 'rider-replay-marker', - html: ` -
-
-
-
-
- `, - 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 }); - } + if (!isPlaying) { + const pos = + playbackCarryRef.current > 0 + ? interpolateTelemetryPosition(points, indexRef.current, playbackCarryRef.current) + : { + latitude: currentPoint.latitude, + longitude: currentPoint.longitude, + heading: currentPoint.heading || 0, + }; + updateRiderMarker( + riderMarkerRef.current, + pos.latitude, + pos.longitude, + pos.heading, + L + ); } - }, [currentPoint, isPlaying]); + }, [currentPoint, isPlaying, points]); // Invalidate map size when tab switches to map useEffect(() => { @@ -646,7 +682,6 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod if (!isPlaying) { if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current); lastTimeRef.current = null; - playbackCarryRef.current = 0; return; } @@ -661,6 +696,26 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod 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); + + if (riderMarkerRef.current && typeof window !== 'undefined') { + const L = (window as any).L; + updateRiderMarker(riderMarkerRef.current, pos.latitude, pos.longitude, pos.heading, L); + + // Pan map to follow if out of current bounds while playing (throttled to 600ms to allow smooth 0.5s pan animation to complete without 60 FPS thrashing) + if (mapInstanceRef.current) { + const map = mapInstanceRef.current; + const bounds = map.getBounds?.(); + if (bounds && !bounds.contains([pos.latitude, pos.longitude])) { + if (timestamp - lastPanTimeRef.current >= 600) { + lastPanTimeRef.current = timestamp; + map.panTo([pos.latitude, pos.longitude], { animate: true, duration: 0.5 }); + } + } + } + } + if (index !== indexRef.current) { indexRef.current = index; setCurrentIndex(index); @@ -1221,7 +1276,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod {/* Playback Speed Multiplier */}
- {[0.5, 1.0, 2.0, 5.0].map((spd) => ( + {[0.5, 1.0, 2.0, 5.0, 10.0].map((spd) => (