From eacd5f26e96d97cee4eea916c732d4735751df21 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:59:39 -0400 Subject: [PATCH] feat(#15): Telemetry trace chart component Implemented by claude via .agents/dev dispatch. --- .../components/charts/TelemetryTraceChart.tsx | 194 ++++++++++++++++++ frontend/src/styles/telemetry-trace.css | 101 +++++++++ .../src/test/telemetry-trace-chart.test.tsx | 118 +++++++++++ 3 files changed, 413 insertions(+) create mode 100644 frontend/src/components/charts/TelemetryTraceChart.tsx create mode 100644 frontend/src/styles/telemetry-trace.css create mode 100644 frontend/src/test/telemetry-trace-chart.test.tsx diff --git a/frontend/src/components/charts/TelemetryTraceChart.tsx b/frontend/src/components/charts/TelemetryTraceChart.tsx new file mode 100644 index 0000000..90d446f --- /dev/null +++ b/frontend/src/components/charts/TelemetryTraceChart.tsx @@ -0,0 +1,194 @@ +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) => ( + + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/styles/telemetry-trace.css b/frontend/src/styles/telemetry-trace.css new file mode 100644 index 0000000..87b316f --- /dev/null +++ b/frontend/src/styles/telemetry-trace.css @@ -0,0 +1,101 @@ +.telemetry-trace { + display: flex; + flex-direction: column; + gap: 8px; +} + +.telemetry-trace-empty { + padding: 24px; + text-align: center; + color: var(--text-3); + font-size: 13px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.telemetry-trace-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + font-size: 12px; + color: var(--text-2); +} + +.telemetry-trace-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.telemetry-trace-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex: none; +} + +.telemetry-trace-svg { + display: block; + width: 100%; + height: auto; +} + +.telemetry-trace-panel-bg { + fill: var(--surface); + stroke: var(--border); + stroke-width: 1; + rx: 4; +} + +.telemetry-trace-panel-title { + fill: var(--text-2); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.telemetry-trace-axis-max { + fill: var(--text-3); + font-size: 10px; +} + +.telemetry-trace-crosshair { + stroke: var(--text-2); + stroke-width: 1; + stroke-dasharray: 3 3; + pointer-events: none; +} + +.telemetry-trace-readout { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-2); + background: var(--surface-h); + border: 1px solid var(--border); + border-radius: 6px; +} + +.telemetry-trace-readout-index { + color: var(--text-3); +} + +.telemetry-trace-readout-driver { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.telemetry-trace-readout-label { + color: var(--text); + font-weight: 600; +} + +.telemetry-trace-readout-value { + font-variant-numeric: tabular-nums; +} diff --git a/frontend/src/test/telemetry-trace-chart.test.tsx b/frontend/src/test/telemetry-trace-chart.test.tsx new file mode 100644 index 0000000..1281cb7 --- /dev/null +++ b/frontend/src/test/telemetry-trace-chart.test.tsx @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { + TelemetryTraceChart, + type TelemetryTraceSeries, +} from '../components/charts/TelemetryTraceChart' + +function makeSeries(label: string, color: string, speeds: number[]): TelemetryTraceSeries { + return { + label, + color, + samples: speeds.map((speed, i) => ({ + speed, + throttle: (i * 25) % 101, + brake: i % 2 === 0 ? 100 : 0, + })), + } +} + +const VER = makeSeries('VER', '#2563eb', [280, 310, 190, 90, 240]) +const LEC = makeSeries('LEC', '#dc2626', [275, 305, 200, 95, 235]) + +describe('TelemetryTraceChart', () => { + it('renders one polyline per series per channel', () => { + const { container } = render() + const lines = container.querySelectorAll('polyline.telemetry-trace-line') + expect(lines).toHaveLength(2 * 3) + for (const channel of ['speed', 'throttle', 'brake']) { + expect( + container.querySelectorAll(`polyline.telemetry-trace-line[data-channel="${channel}"]`), + ).toHaveLength(2) + } + expect( + container.querySelectorAll('polyline.telemetry-trace-line[data-series="VER"]'), + ).toHaveLength(3) + }) + + it('respects the channels prop', () => { + const { container } = render() + expect(container.querySelectorAll('polyline.telemetry-trace-line')).toHaveLength(1) + expect(container.querySelectorAll('.telemetry-trace-panel')).toHaveLength(1) + }) + + it('renders an empty state for no series', () => { + render() + expect(screen.getByText('No telemetry data')).toBeInTheDocument() + }) + + it('renders an empty state when a series has no samples', () => { + render() + expect(screen.getByText('No telemetry data')).toBeInTheDocument() + }) + + it('survives a single sample and mismatched series lengths', () => { + const short = makeSeries('VER', '#2563eb', [300]) + const long = makeSeries('LEC', '#dc2626', [280, 290, 300]) + const { container } = render() + // Clamped to the shortest series: every polyline has exactly one point. + const lines = container.querySelectorAll('polyline.telemetry-trace-line') + expect(lines).toHaveLength(6) + for (const line of lines) { + expect(line.getAttribute('points')!.trim().split(' ')).toHaveLength(1) + } + }) + + it('clamps throttle values to 0-100 within the panel', () => { + const wild: TelemetryTraceSeries = { + label: 'VER', + color: '#2563eb', + samples: [ + { speed: 100, throttle: 150, brake: 0 }, + { speed: 100, throttle: -20, brake: 0 }, + { speed: 100, throttle: 50, brake: 0 }, + ], + } + const { container } = render( + , + ) + const line = container.querySelector('polyline.telemetry-trace-line[data-channel="throttle"]')! + const ys = line + .getAttribute('points')! + .split(' ') + .map((p) => Number(p.split(',')[1])) + // Panel occupies y 0..110 with 6px padding; clamped values pin to the edges. + expect(ys[0]).toBeCloseTo(6, 1) // 150% -> 100% -> panel top + expect(ys[1]).toBeCloseTo(104, 1) // -20% -> 0% -> panel bottom + expect(ys[2]).toBeCloseTo(55, 1) // 50% -> middle + }) + + it('shows the speed axis max in km/h', () => { + render() + expect(screen.getByText('310 km/h')).toBeInTheDocument() + }) + + it('lists every series in the legend with a color swatch', () => { + const { container } = render() + const legend = container.querySelector('.telemetry-trace-legend')! + expect(legend.textContent).toContain('VER') + expect(legend.textContent).toContain('LEC') + expect(legend.querySelectorAll('.telemetry-trace-swatch')).toHaveLength(2) + }) + + it('shows a crosshair and per-driver readout on hover, hides on leave', () => { + const { container } = render() + const svg = container.querySelector('svg.telemetry-trace-svg')! + expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument() + + fireEvent.mouseMove(svg, { clientX: 0, clientY: 10 }) + expect(screen.getByTestId('telemetry-trace-crosshair')).toBeInTheDocument() + const readout = screen.getByTestId('telemetry-trace-readout') + expect(readout.textContent).toContain('VER') + expect(readout.textContent).toContain('LEC') + expect(readout.textContent).toContain('km/h') + + fireEvent.mouseLeave(svg) + expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument() + }) +})