import { useMemo, useRef, useState } from 'react' import '../../styles/telemetry-trace.css' export interface TelemetryTraceSample { speed: number throttle: number brake: number } export interface TelemetryTraceSeries { label: string color: string samples: TelemetryTraceSample[] } export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake' export interface TelemetryTraceChartProps { series: TelemetryTraceSeries[] channels?: TelemetryTraceChannel[] /** Height of each channel panel in SVG units (viewBox space). */ height?: number } const VIEW_WIDTH = 800 const PANEL_GAP = 18 const PAD_TOP = 6 const PAD_BOTTOM = 6 const CHANNEL_LABELS: Record = { speed: 'Speed', throttle: 'Throttle', brake: 'Brake', } const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)) function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number { const raw = sample[channel] // Throttle/brake are percentages; clamp so out-of-range API values can't // draw outside the panel. Speed is clamped to >= 0. if (channel === 'speed') return Math.max(0, raw) return clamp(raw, 0, 100) } function formatValue(value: number, channel: TelemetryTraceChannel): string { if (channel === 'speed') return `${Math.round(value)} km/h` return `${Math.round(value)}%` } export function TelemetryTraceChart({ series, channels = ['speed', 'throttle', 'brake'], height = 110, }: TelemetryTraceChartProps) { const svgRef = useRef(null) const [hoverIndex, setHoverIndex] = useState(null) // Series are index-aligned; mismatched lengths are clamped to the shortest // series so every drawn x has a value for every driver. const sampleCount = useMemo( () => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))), [series], ) const speedMax = useMemo(() => { let max = 0 for (const s of series) { for (let i = 0; i < sampleCount; i++) { max = Math.max(max, channelValue(s.samples[i], 'speed')) } } return max > 0 ? max : 1 }, [series, sampleCount]) if (series.length === 0 || sampleCount === 0 || channels.length === 0) { return
No telemetry data
} // Clamp a stale hover index in case the series prop shrank between renders. const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1) const panelHeight = height const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100) const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => { const usable = panelHeight - PAD_TOP - PAD_BOTTOM const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel) return panelTop + PAD_TOP + (1 - frac) * usable } const handleMouseMove = (e: React.MouseEvent) => { const rect = svgRef.current?.getBoundingClientRect() if (!rect || rect.width === 0) { setHoverIndex(0) return } const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1) setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1)) } return (
{series.map((s) => ( ))}
setHoverIndex(null)} > {channels.map((channel, panelIdx) => { const panelTop = panelIdx * (panelHeight + PANEL_GAP) return ( {CHANNEL_LABELS[channel]} {channel === 'speed' ? `${Math.round(speedMax)} km/h` : '100%'} {series.map((s) => { const points = Array.from({ length: sampleCount }, (_, i) => { const x = xAt(i) const y = yAt(s.samples[i][channel], channel, panelTop) return `${x.toFixed(2)},${y.toFixed(2)}` }).join(' ') return ( ) })} ) })} {hover !== null && ( )} {hover !== null && (
Sample {hover} {series.map((s) => ( ))}
)}
) }