From 09778618069b552348152397f69f099ce4d56fc1 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:48:50 -0400 Subject: [PATCH] feat(frontend): add DeltaTimeGraph cumulative delta chart (#17) Introduce a reusable SVG chart primitive for lap-by-lap cumulative time delta vs a reference driver, with pure math in lib/delta.ts, dedicated styles, null-lap gap handling, and Vitest coverage for math and rendering. Co-authored-by: Cursor --- .../src/components/charts/DeltaTimeGraph.tsx | 282 ++++++++++++++++++ frontend/src/lib/delta.ts | 112 +++++++ frontend/src/styles/delta-graph.css | 76 +++++ frontend/src/test/delta-time-graph.test.tsx | 121 ++++++++ 4 files changed, 591 insertions(+) create mode 100644 frontend/src/components/charts/DeltaTimeGraph.tsx create mode 100644 frontend/src/lib/delta.ts create mode 100644 frontend/src/styles/delta-graph.css create mode 100644 frontend/src/test/delta-time-graph.test.tsx diff --git a/frontend/src/components/charts/DeltaTimeGraph.tsx b/frontend/src/components/charts/DeltaTimeGraph.tsx new file mode 100644 index 0000000..c4e840f --- /dev/null +++ b/frontend/src/components/charts/DeltaTimeGraph.tsx @@ -0,0 +1,282 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { + computeCumulativeDeltas, + deltaPolylineSegments, + formatDeltaSeconds, + type DeltaSeries, + type DriverDeltaResult, +} from '../../lib/delta' +import '../../styles/delta-graph.css' + +export type { DeltaSeries } + +export interface DeltaTimeGraphProps { + series: DeltaSeries[] + referenceLabel?: string +} + +const W = 640 +const H = 220 +const PL = 44 +const PR = 16 +const PT = 12 +const PB = 28 + +function lapCount(series: ReadonlyArray): number { + return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0) +} + +function yExtent(drivers: ReadonlyArray): { min: number; max: number } { + let min = 0 + let max = 0 + for (const driver of drivers) { + for (const delta of driver.deltas) { + if (delta === null) continue + min = Math.min(min, delta) + max = Math.max(max, delta) + } + } + if (min === max) { + const pad = 1 + return { min: min - pad, max: max + pad } + } + const span = max - min + const pad = span * 0.08 + return { min: min - pad, max: max + pad } +} + +function niceYTicks(min: number, max: number): number[] { + const span = max - min + if (span <= 0) return [0] + const rough = span / 4 + const magnitude = Math.pow(10, Math.floor(Math.log10(rough))) + const step = Math.ceil(rough / magnitude) * magnitude + const ticks: number[] = [] + const start = Math.ceil(min / step) * step + for (let v = start; v <= max + step * 0.01; v += step) { + ticks.push(Number(v.toFixed(6))) + } + if (!ticks.some((t) => Math.abs(t) < step * 0.01)) { + ticks.push(0) + ticks.sort((a, b) => a - b) + } + return ticks +} + +export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) { + const svgRef = useRef(null) + const [hoverLap, setHoverLap] = useState(null) + + const drivers = useMemo( + () => computeCumulativeDeltas(series, referenceLabel), + [series, referenceLabel], + ) + + const laps = useMemo(() => lapCount(series), [series]) + const plotW = W - PL - PR + const plotH = H - PT - PB + const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers]) + const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax]) + + const toX = useCallback( + (lapIndex: number) => { + if (laps <= 1) return PL + plotW / 2 + return PL + (lapIndex / (laps - 1)) * plotW + }, + [laps, plotW], + ) + + const toY = useCallback( + (delta: number) => { + const span = yMax - yMin || 1 + return PT + ((delta - yMin) / span) * plotH + }, + [yMin, yMax, plotH], + ) + + const lapTickNumbers = useMemo(() => { + const ticks: number[] = [] + for (let lap = 5; lap <= laps; lap += 5) { + ticks.push(lap) + } + return ticks + }, [laps]) + + const handlePointerMove = useCallback( + (e: React.PointerEvent | React.MouseEvent) => { + if (!svgRef.current || laps === 0) return + const rect = svgRef.current.getBoundingClientRect() + if (rect.width <= 0) return + const x = ((e.clientX - rect.left) / rect.width) * W + const frac = Math.max(0, Math.min(1, (x - PL) / plotW)) + const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1)) + if (!Number.isFinite(lapIndex)) return + setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex))) + }, + [laps, plotW], + ) + + const handlePointerLeave = useCallback(() => setHoverLap(null), []) + + if (series.length === 0 || laps === 0) { + return ( +
+

No lap data to compare.

+
+ ) + } + + if (drivers.length === 0) { + return ( +
+

Select at least two drivers to compare.

+
+ ) + } + + const hoverX = hoverLap !== null ? toX(hoverLap) : null + const tooltipRows = hoverLap !== null + ? drivers + .map((d) => { + const delta = d.deltas[hoverLap] + if (delta == null) return null + return { label: d.label, color: d.color, delta } + }) + .filter((row): row is { label: string; color: string; delta: number } => row !== null) + : [] + + const tooltipH = 18 + tooltipRows.length * 14 + const tooltipW = 120 + const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0 + const tooltipY = PT + + return ( +
+ + {yTicks.map((tick) => ( + + + + {formatDeltaSeconds(tick)} + + + ))} + + {lapTickNumbers.map((lap) => { + const x = toX(lap - 1) + return ( + + + + {lap} + + + ) + })} + + {drivers.map((driver) => { + const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) => + `${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`, + ) + return ( + + {segments.map((points, i) => ( + + ))} + + ) + })} + + {hoverX !== null && ( + <> + + {tooltipRows.length > 0 && ( + + + + Lap {hoverLap! + 1} + + {tooltipRows.map((row, i) => ( + + {row.label} {formatDeltaSeconds(row.delta)} + + ))} + + )} + + )} + + + +
+ ) +} diff --git a/frontend/src/lib/delta.ts b/frontend/src/lib/delta.ts new file mode 100644 index 0000000..eb8fc64 --- /dev/null +++ b/frontend/src/lib/delta.ts @@ -0,0 +1,112 @@ +// Cumulative lap-time delta math for driver comparison charts. +// Pure functions only — no React — so everything is unit-testable. + +export interface DeltaSeries { + label: string + color: string + /** Lap duration in seconds; null = no time (pit/out lap). */ + lapTimes: (number | null)[] +} + +export interface DriverDeltaResult { + label: string + color: string + /** Cumulative delta vs reference (seconds); null when that lap has no time. */ + deltas: (number | null)[] +} + +/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */ +export function formatDeltaSeconds(delta: number): string { + const sign = delta >= 0 ? '+' : '' + return `${sign}${delta.toFixed(1)}s` +} + +function buildCumulative(lapTimes: ReadonlyArray): number[] { + const cumulative: number[] = [] + let running = 0 + for (const lap of lapTimes) { + if (lap !== null) { + running += lap + } + cumulative.push(running) + } + return cumulative +} + +function resolveReference( + series: ReadonlyArray, + referenceLabel?: string, +): DeltaSeries | null { + if (series.length === 0) return null + if (referenceLabel) { + return series.find((s) => s.label === referenceLabel) ?? series[0] + } + return series[0] +} + +/** + * Compute per-lap cumulative time delta for each non-reference driver. + * Positive = behind reference; negative = ahead. + * Null laps carry cumulative forward but emit null in deltas (skip when plotting). + */ +export function computeCumulativeDeltas( + series: ReadonlyArray, + referenceLabel?: string, +): DriverDeltaResult[] { + const reference = resolveReference(series, referenceLabel) + if (!reference) return [] + + const refCumulative = buildCumulative(reference.lapTimes) + + return series + .filter((s) => s.label !== reference.label) + .map((driver) => { + const driverCumulative = buildCumulative(driver.lapTimes) + const lapCount = Math.max(driver.lapTimes.length, refCumulative.length) + const deltas: (number | null)[] = [] + + for (let i = 0; i < lapCount; i++) { + if (driver.lapTimes[i] === null) { + deltas.push(null) + continue + } + const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0 + const driverValue = + driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0 + deltas.push(driverValue - refValue) + } + + return { + label: driver.label, + color: driver.color, + deltas, + } + }) +} + +/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */ +export function deltaPolylineSegments( + deltas: ReadonlyArray, + toPoint: (lapIndex: number, delta: number) => string, +): string[] { + const segments: string[] = [] + let current: string[] = [] + + for (let i = 0; i < deltas.length; i++) { + const value = deltas[i] + if (value === null) { + if (current.length > 0) { + segments.push(current.join(' ')) + current = [] + } + continue + } + current.push(toPoint(i, value)) + } + + if (current.length > 0) { + segments.push(current.join(' ')) + } + + return segments +} diff --git a/frontend/src/styles/delta-graph.css b/frontend/src/styles/delta-graph.css new file mode 100644 index 0000000..e3fafd5 --- /dev/null +++ b/frontend/src/styles/delta-graph.css @@ -0,0 +1,76 @@ +.delta-graph { + width: 100%; + min-width: 280px; + max-width: 640px; +} + +.delta-graph-svg { + display: block; + width: 100%; +} + +.delta-graph-empty { + padding: 1.5rem 1rem; + text-align: center; + color: var(--text-3); + font-size: 0.875rem; +} + +.delta-graph-zero-line { + stroke: var(--text-3); + stroke-width: 1; + stroke-dasharray: 4 3; +} + +.delta-graph-grid-line { + stroke: var(--border); + stroke-width: 0.5; +} + +.delta-graph-axis-label { + fill: var(--text-3); + font-family: var(--f-mono); + font-size: 9px; +} + +.delta-graph-driver-line { + fill: none; + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.delta-graph-crosshair { + stroke: var(--text-2); + stroke-width: 1; + stroke-dasharray: 3 3; + pointer-events: none; +} + +.delta-graph-hover-layer { + cursor: crosshair; + touch-action: none; +} + +.delta-graph-tooltip { + pointer-events: none; +} + +.delta-graph-tooltip-bg { + fill: var(--bg-elevated, var(--bg)); + stroke: var(--border); + stroke-width: 1; +} + +.delta-graph-tooltip-title { + fill: var(--text-1); + font-family: var(--f-mono); + font-size: 10px; + font-weight: 600; +} + +.delta-graph-tooltip-row { + fill: var(--text-2); + font-family: var(--f-mono); + font-size: 9px; +} diff --git a/frontend/src/test/delta-time-graph.test.tsx b/frontend/src/test/delta-time-graph.test.tsx new file mode 100644 index 0000000..291d136 --- /dev/null +++ b/frontend/src/test/delta-time-graph.test.tsx @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { DeltaTimeGraph } from '../components/charts/DeltaTimeGraph' +import { + computeCumulativeDeltas, + deltaPolylineSegments, + formatDeltaSeconds, + type DeltaSeries, +} from '../lib/delta' + +const reference: DeltaSeries = { + label: 'VER', + color: '#3671C6', + lapTimes: [90, 91, 92], +} + +const challenger: DeltaSeries = { + label: 'HAM', + color: '#E8002D', + lapTimes: [89, 92, 90], +} + +describe('formatDeltaSeconds', () => { + it('formats signed deltas with one decimal and s suffix', () => { + expect(formatDeltaSeconds(2.5)).toBe('+2.5s') + expect(formatDeltaSeconds(-1.2)).toBe('-1.2s') + expect(formatDeltaSeconds(0)).toBe('+0.0s') + }) +}) + +describe('computeCumulativeDeltas', () => { + it('computes cumulative delta vs the first series by default', () => { + const result = computeCumulativeDeltas([reference, challenger]) + expect(result).toHaveLength(1) + expect(result[0].label).toBe('HAM') + // Lap 1: 89-90=-1, Lap 2: (89+92)-(90+91)=0, Lap 3: (89+92+90)-(90+91+92)=-2 + expect(result[0].deltas[0]).toBeCloseTo(-1) + expect(result[0].deltas[1]).toBeCloseTo(0) + expect(result[0].deltas[2]).toBeCloseTo(-2) + }) + + it('respects an explicit reference label', () => { + const result = computeCumulativeDeltas([reference, challenger], 'HAM') + expect(result).toHaveLength(1) + expect(result[0].label).toBe('VER') + expect(result[0].deltas[0]).toBeCloseTo(1) + expect(result[0].deltas[2]).toBeCloseTo(2) + }) + + it('emits null for missing lap times while carrying cumulative forward', () => { + const withNull: DeltaSeries = { + label: 'NOR', + color: '#FF8000', + lapTimes: [88, null, 93], + } + const result = computeCumulativeDeltas([reference, withNull]) + expect(result[0].deltas[0]).toBeCloseTo(-2) + expect(result[0].deltas[1]).toBeNull() + // After null: NOR cum=181, VER cum=273 → delta -92 + expect(result[0].deltas[2]).toBeCloseTo(-92) + }) + + it('returns an empty array when only one series is provided', () => { + expect(computeCumulativeDeltas([reference])).toEqual([]) + }) +}) + +describe('deltaPolylineSegments', () => { + it('splits polylines at null laps', () => { + const segments = deltaPolylineSegments( + [1, null, 2], + (lap, delta) => `${lap},${delta}`, + ) + expect(segments).toEqual(['0,1', '2,2']) + }) +}) + +describe('DeltaTimeGraph', () => { + it('renders an empty state with no series', () => { + render() + expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument() + expect(screen.getByText(/No lap data/i)).toBeInTheDocument() + }) + + it('renders an empty state with only the reference driver', () => { + render() + expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument() + expect(screen.getByText(/at least two drivers/i)).toBeInTheDocument() + }) + + it('renders a zero line and one polyline per non-reference driver', () => { + const { container } = render() + expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument() + expect(container.querySelector('[data-testid="delta-zero-line"]')).toBeInTheDocument() + expect(screen.getByTestId('delta-line-HAM')).toBeInTheDocument() + expect(container.querySelectorAll('.delta-graph-driver-line')).toHaveLength(1) + expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument() + }) + + it('shows a crosshair tooltip on hover', () => { + vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + width: 640, + height: 220, + right: 640, + bottom: 220, + toJSON: () => ({}), + }) + const { container } = render() + const hoverLayer = container.querySelector('.delta-graph-hover-layer') + expect(hoverLayer).toBeTruthy() + fireEvent.mouseMove(hoverLayer!, { clientX: 44, clientY: 100 }) + expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument() + expect(screen.getByTestId('delta-tooltip')).toBeInTheDocument() + expect(screen.getByText(/Lap 1/)).toBeInTheDocument() + vi.restoreAllMocks() + }) +})