Compare commits

..

1 Commits

Author SHA1 Message Date
AmanTahiliani
0977861806 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 <cursoragent@cursor.com>
2026-07-03 23:48:50 -04:00
16 changed files with 599 additions and 472 deletions

View File

@@ -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<DeltaSeries>): number {
return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0)
}
function yExtent(drivers: ReadonlyArray<DriverDeltaResult>): { 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<SVGSVGElement>(null)
const [hoverLap, setHoverLap] = useState<number | null>(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<SVGRectElement> | React.MouseEvent<SVGRectElement>) => {
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 (
<div className="delta-graph" data-testid="delta-time-graph-empty">
<p className="delta-graph-empty">No lap data to compare.</p>
</div>
)
}
if (drivers.length === 0) {
return (
<div className="delta-graph" data-testid="delta-time-graph-empty">
<p className="delta-graph-empty">Select at least two drivers to compare.</p>
</div>
)
}
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 (
<div className="delta-graph" data-testid="delta-time-graph">
<svg
ref={svgRef}
className="delta-graph-svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Cumulative delta time chart"
>
{yTicks.map((tick) => (
<g key={`y-${tick}`}>
<line
x1={PL}
x2={W - PR}
y1={toY(tick)}
y2={toY(tick)}
className={Math.abs(tick) < 1e-9 ? 'delta-graph-zero-line' : 'delta-graph-grid-line'}
data-testid={Math.abs(tick) < 1e-9 ? 'delta-zero-line' : undefined}
/>
<text
x={PL - 6}
y={toY(tick) + 3}
textAnchor="end"
className="delta-graph-axis-label"
>
{formatDeltaSeconds(tick)}
</text>
</g>
))}
{lapTickNumbers.map((lap) => {
const x = toX(lap - 1)
return (
<g key={`lap-${lap}`}>
<line
x1={x}
x2={x}
y1={H - PB}
y2={H - PB + 4}
className="delta-graph-grid-line"
/>
<text
x={x}
y={H - PB + 16}
textAnchor="middle"
className="delta-graph-axis-label"
>
{lap}
</text>
</g>
)
})}
{drivers.map((driver) => {
const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) =>
`${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`,
)
return (
<g key={driver.label} data-testid={`delta-line-${driver.label}`}>
{segments.map((points, i) => (
<polyline
key={`${driver.label}-${i}`}
points={points}
className="delta-graph-driver-line"
stroke={driver.color}
/>
))}
</g>
)
})}
{hoverX !== null && (
<>
<line
x1={hoverX}
x2={hoverX}
y1={PT}
y2={H - PB}
className="delta-graph-crosshair"
data-testid="delta-crosshair"
/>
{tooltipRows.length > 0 && (
<g className="delta-graph-tooltip" data-testid="delta-tooltip">
<rect
x={tooltipX}
y={tooltipY}
width={tooltipW}
height={tooltipH}
rx={4}
className="delta-graph-tooltip-bg"
/>
<text
x={tooltipX + 8}
y={tooltipY + 14}
className="delta-graph-tooltip-title"
>
Lap {hoverLap! + 1}
</text>
{tooltipRows.map((row, i) => (
<text
key={row.label}
x={tooltipX + 8}
y={tooltipY + 28 + i * 14}
className="delta-graph-tooltip-row"
fill={row.color}
>
{row.label} {formatDeltaSeconds(row.delta)}
</text>
))}
</g>
)}
</>
)}
<rect
x={PL}
y={PT}
width={plotW}
height={plotH}
fill="transparent"
className="delta-graph-hover-layer"
onPointerMove={handlePointerMove}
onMouseMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onMouseLeave={handlePointerLeave}
/>
</svg>
</div>
)
}

View File

@@ -1,108 +0,0 @@
import { useMemo, useRef, useState } from 'react'
import { Pause, Play } from 'lucide-react'
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types'
import { radioCaptureKey, radioClipUrl } from '../../lib/radio'
import { teamColor } from '../../utils'
import '../../styles/team-radio.css'
interface Props {
captures?: LiveRadioCapture[]
driverInfo?: Record<string, LiveDriverInfo>
session?: LiveSessionMeta
}
function relativeTime(utc: string): string {
const timestamp = new Date(utc).getTime()
if (!utc || Number.isNaN(timestamp)) return '--'
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000))
if (diffSeconds < 60) return `${diffSeconds}s ago`
const minutes = Math.floor(diffSeconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string {
return info?.Tla || racingNumber
}
export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) {
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingKey, setPlayingKey] = useState<string | null>(null)
const newestFirst = useMemo(() => [...captures].reverse(), [captures])
const toggleCapture = (capture: LiveRadioCapture) => {
const key = radioCaptureKey(capture)
const audio = audioRef.current
const url = radioClipUrl(session, capture)
if (!audio || !url) return
if (playingKey === key) {
audio.pause()
setPlayingKey(null)
return
}
audio.pause()
audio.src = url
setPlayingKey(key)
const play = audio.play()
if (play) {
play.catch(() => setPlayingKey(null))
}
}
return (
<section className="team-radio panel-glass" data-testid="team-radio-ticker">
<div className="sec-header sticky-header">
<span className="sec-title">Team Radio</span>
{captures.length > 0 && <span className="sec-meta">{captures.length} clips</span>}
</div>
<audio
ref={audioRef}
className="team-radio-audio"
onEnded={() => setPlayingKey(null)}
/>
{newestFirst.length === 0 ? (
<div className="missing-notice">No team radio clips in the current live snapshot.</div>
) : (
<div className="team-radio-list">
{newestFirst.map((capture) => {
const key = radioCaptureKey(capture)
const info = driverInfo[capture.RacingNumber]
const label = driverLabel(info, capture.RacingNumber)
const isPlaying = playingKey === key
const url = radioClipUrl(session, capture)
return (
<div className="team-radio-row" key={key}>
<button
className="team-radio-play"
type="button"
aria-label={`${isPlaying ? 'Pause' : 'Play'} ${label} radio`}
disabled={!url}
onClick={() => toggleCapture(capture)}
>
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<span
className="team-radio-driver"
style={{ borderColor: teamColor(info?.TeamColour) }}
title={info?.TeamName || undefined}
>
{label}
</span>
<span className="team-radio-time">{relativeTime(capture.Utc)}</span>
</div>
)
})}
</div>
)}
</section>
)
}

112
frontend/src/lib/delta.ts Normal file
View File

@@ -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 | null>): 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<DeltaSeries>,
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<DeltaSeries>,
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<number | null>,
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
}

View File

@@ -1,22 +0,0 @@
import type { LiveRadioCapture, LiveSessionMeta } from '../types'
export const LIVE_TIMING_STATIC_BASE = 'https://livetiming.formula1.com/static/'
export function radioClipUrl(
session: Pick<LiveSessionMeta, 'Path'> | null | undefined,
capture: Pick<LiveRadioCapture, 'Path'> | null | undefined,
): string {
const sessionPath = session?.Path?.trim()
const capturePath = capture?.Path?.trim()
if (!sessionPath || !capturePath) return ''
if (/^https?:\/\//i.test(capturePath)) return capturePath
const base = LIVE_TIMING_STATIC_BASE.replace(/\/+$/, '')
const normalizedSession = sessionPath.replace(/^\/+|\/+$/g, '')
const normalizedCapture = capturePath.replace(/^\/+/g, '')
return `${base}/${normalizedSession}/${normalizedCapture}`
}
export function radioCaptureKey(capture: LiveRadioCapture): string {
return `${capture.Utc}-${capture.RacingNumber}-${capture.Path}`
}

View File

@@ -21,7 +21,6 @@ import { TimingTower } from '../components/live/TimingTower'
import { BattleChips } from '../components/live/BattleChips' import { BattleChips } from '../components/live/BattleChips'
import { PinnedDrivers } from '../components/live/PinnedDrivers' import { PinnedDrivers } from '../components/live/PinnedDrivers'
import { RaceControlFeed } from '../components/live/RaceControlFeed' import { RaceControlFeed } from '../components/live/RaceControlFeed'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { TrackMap } from '../components/live/TrackMap' import { TrackMap } from '../components/live/TrackMap'
import { Radio } from 'lucide-react' import { Radio } from 'lucide-react'
@@ -212,11 +211,6 @@ export function LiveTimingPage() {
/> />
</div> </div>
<div className="live-rc-col"> <div className="live-rc-col">
<TeamRadioTicker
captures={snapshot.TeamRadio ?? []}
driverInfo={snapshot.DriverInfo}
session={snapshot.Session}
/>
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} /> <RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
</div> </div>
</div> </div>

View File

@@ -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;
}

View File

@@ -1,82 +0,0 @@
.team-radio {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.team-radio-audio {
display: none;
}
.team-radio-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 260px;
overflow-y: auto;
padding-right: 2px;
}
.team-radio-row {
display: grid;
grid-template-columns: 28px minmax(44px, 56px) minmax(68px, 1fr);
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.team-radio-row:last-child {
border-bottom: 0;
}
.team-radio-play {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
color: var(--text-1);
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.team-radio-play:hover:not(:disabled),
.team-radio-play:focus-visible:not(:disabled) {
border-color: rgba(255, 255, 255, 0.36);
background: rgba(255, 255, 255, 0.12);
}
.team-radio-play:disabled {
color: var(--text-3);
cursor: not-allowed;
opacity: 0.55;
}
.team-radio-driver {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 24px;
border-left: 3px solid var(--text-3);
color: var(--text-1);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
}
.team-radio-time {
min-width: 0;
color: var(--text-2);
font-family: var(--font-mono);
font-size: 12px;
text-align: right;
white-space: nowrap;
}

View File

@@ -201,7 +201,6 @@ describe('TimingTower', () => {
CircuitName: 'Silverstone', CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying', SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying', SessionName: 'Sprint Qualifying',
Path: '',
}} }}
/>, />,
) )

View File

@@ -1,87 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
import { radioClipUrl } from '../lib/radio'
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../types'
const session: LiveSessionMeta = {
MeetingName: 'British Grand Prix',
CircuitName: 'Silverstone',
SessionType: 'Race',
SessionName: 'Race',
Path: '/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/',
}
const driverInfo: Record<string, LiveDriverInfo> = {
'4': {
RacingNumber: '4',
BroadcastName: 'L NORRIS',
Tla: 'NOR',
TeamName: 'McLaren',
TeamColour: 'ff8000',
FirstName: 'Lando',
LastName: 'Norris',
},
'16': {
RacingNumber: '16',
BroadcastName: 'C LECLERC',
Tla: 'LEC',
TeamName: 'Ferrari',
TeamColour: 'e8002d',
FirstName: 'Charles',
LastName: 'Leclerc',
},
}
const captures: LiveRadioCapture[] = [
{ Utc: '2026-07-05T14:05:00Z', RacingNumber: '16', Path: '/TeamRadio/LEC-1.mp3' },
{ Utc: '2026-07-05T14:07:00Z', RacingNumber: '4', Path: 'TeamRadio/NOR-1.mp3' },
]
afterEach(() => {
vi.restoreAllMocks()
})
describe('radioClipUrl', () => {
it('builds static CDN URLs from the session path and capture path', () => {
expect(radioClipUrl(session, captures[0])).toBe(
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/LEC-1.mp3',
)
})
it('returns an empty URL without path data', () => {
expect(radioClipUrl({ ...session, Path: '' }, captures[0])).toBe('')
expect(radioClipUrl(session, { ...captures[0], Path: '' })).toBe('')
})
})
describe('TeamRadioTicker', () => {
it('renders captures newest first with driver labels', () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-05T14:10:00Z'))
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
const rows = screen.getAllByRole('button')
expect(rows).toHaveLength(2)
expect(screen.getByText('NOR')).toBeInTheDocument()
expect(screen.getByText('LEC')).toBeInTheDocument()
expect(screen.getAllByText(/m ago/).map((node) => node.textContent)).toEqual(['3m ago', '5m ago'])
})
it('uses one audio element and toggles play/pause per clip', () => {
const play = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue()
const pause = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
fireEvent.click(screen.getByLabelText('Play NOR radio'))
expect(play).toHaveBeenCalledTimes(1)
expect(document.querySelectorAll('audio')).toHaveLength(1)
expect(document.querySelector('audio')?.getAttribute('src')).toBe(
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/NOR-1.mp3',
)
fireEvent.click(screen.getByLabelText('Pause NOR radio'))
expect(pause).toHaveBeenCalled()
})
})

View File

@@ -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(<DeltaTimeGraph series={[]} />)
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(<DeltaTimeGraph series={[reference]} />)
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(<DeltaTimeGraph series={[reference, challenger]} />)
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(<DeltaTimeGraph series={[reference, challenger]} />)
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()
})
})

View File

@@ -100,8 +100,7 @@ const snapshot: LiveStreamData = {
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 }, { Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
], ],
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false }, Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' }, Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
TeamRadio: [],
TrackStatus: '1', TrackStatus: '1',
CurrentLap: 21, CurrentLap: 21,
TotalLaps: 78, TotalLaps: 78,
@@ -221,7 +220,7 @@ describe('track status mapping', () => {
describe('live qualifying display', () => { describe('live qualifying display', () => {
it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => { it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => {
const display = liveSessionDisplay( const display = liveSessionDisplay(
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' }, { MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' },
rows(22), rows(22),
) )
expect(display.phaseLabel).toBe('SQ1') expect(display.phaseLabel).toBe('SQ1')
@@ -233,7 +232,7 @@ describe('live qualifying display', () => {
it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => { it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => {
const display = liveSessionDisplay( const display = liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' }, { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
rows(20), rows(20),
) )
expect(display.phaseLabel).toBe('Q1') expect(display.phaseLabel).toBe('Q1')
@@ -242,7 +241,7 @@ describe('live qualifying display', () => {
it('moves phase 2 cutoff after P10 once five cars are knocked out', () => { it('moves phase 2 cutoff after P10 once five cars are knocked out', () => {
const display = liveSessionDisplay( const display = liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' }, { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
rows(20, 5), rows(20, 5),
) )
expect(display.phaseLabel).toBe('Q2') expect(display.phaseLabel).toBe('Q2')
@@ -252,13 +251,13 @@ describe('live qualifying display', () => {
it('shows no cutoff for race sessions or Q3', () => { it('shows no cutoff for race sessions or Q3', () => {
expect( expect(
liveSessionDisplay( liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' }, { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
rows(20), rows(20),
).cutoffPosition, ).cutoffPosition,
).toBeNull() ).toBeNull()
expect( expect(
liveSessionDisplay( liveSessionDisplay(
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' }, { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
rows(10), rows(10),
).cutoffPosition, ).cutoffPosition,
).toBeNull() ).toBeNull()

View File

@@ -261,7 +261,6 @@ export interface LiveSessionMeta {
CircuitName: string CircuitName: string
SessionType: string SessionType: string
SessionName: string SessionName: string
Path: string
} }
export interface LiveStintData { export interface LiveStintData {
@@ -270,12 +269,6 @@ export interface LiveStintData {
Laps: number Laps: number
} }
export interface LiveRadioCapture {
Utc: string
RacingNumber: string
Path: string
}
export interface LiveStreamData { export interface LiveStreamData {
Drivers: Record<string, LiveDriverData> Drivers: Record<string, LiveDriverData>
DriverInfo: Record<string, LiveDriverInfo> DriverInfo: Record<string, LiveDriverInfo>
@@ -284,7 +277,6 @@ export interface LiveStreamData {
RCMessages: LiveRCMessage[] RCMessages: LiveRCMessage[]
Weather: LiveWeatherData Weather: LiveWeatherData
Session: LiveSessionMeta Session: LiveSessionMeta
TeamRadio: LiveRadioCapture[]
TrackStatus: string TrackStatus: string
CurrentLap: number CurrentLap: number
TotalLaps: number TotalLaps: number

View File

@@ -5,7 +5,6 @@ import (
"compress/flate" "compress/flate"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"testing" "testing"
"time" "time"
@@ -212,16 +211,12 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
state.ProcessTopic("SessionInfo", json.RawMessage(`{ state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}}, "Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
"Name": "Race", "Name": "Race",
"Type": "Race", "Type": "Race"
"Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/"
}`)) }`))
s := state.Snapshot().Session s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" { if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
t.Fatalf("session = %+v", s) t.Fatalf("session = %+v", s)
} }
if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" {
t.Fatalf("session path = %q", s.Path)
}
} }
func TestProcessTopicDriverList(t *testing.T) { func TestProcessTopicDriverList(t *testing.T) {
@@ -290,68 +285,6 @@ func TestProcessTopicRaceControlMessages(t *testing.T) {
} }
} }
func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) {
state := live.NewState()
snapshot := json.RawMessage(`{
"Captures": [
{"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"},
{"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"}
]
}`)
if !state.ProcessTopic("TeamRadio", snapshot) {
t.Fatal("TeamRadio snapshot should update state")
}
patch := json.RawMessage(`{
"Captures": {
"2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"}
}
}`)
if !state.ProcessTopic("TeamRadio", patch) {
t.Fatal("TeamRadio keyed patch should update state")
}
radio := state.Snapshot().TeamRadio
if len(radio) != 3 {
t.Fatalf("radio captures = %d, want 3", len(radio))
}
if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" {
t.Fatalf("radio order = %+v", radio)
}
}
func TestProcessTopicTeamRadioMalformed(t *testing.T) {
state := live.NewState()
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) {
t.Fatal("incomplete TeamRadio capture should not update state")
}
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) {
t.Fatal("unexpected TeamRadio captures shape should not update state")
}
if len(state.Snapshot().TeamRadio) != 0 {
t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio)
}
}
func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) {
state := live.NewState()
for i := 0; i < 25; i++ {
payload := json.RawMessage([]byte(fmt.Sprintf(`{
"Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}]
}`, i, i, i)))
state.ProcessTopic("TeamRadio", payload)
}
radio := state.Snapshot().TeamRadio
if len(radio) != 20 {
t.Fatalf("radio captures = %d, want 20", len(radio))
}
if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" {
t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19])
}
}
func TestProcessTopicWeatherData(t *testing.T) { func TestProcessTopicWeatherData(t *testing.T) {
state := live.NewState() state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{ state.ProcessTopic("WeatherData", json.RawMessage(`{

View File

@@ -105,7 +105,6 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
"RaceControlMessages", "RaceControlMessages",
"WeatherData", "WeatherData",
"SessionInfo", "SessionInfo",
"TeamRadio",
"CurrentTyres", "CurrentTyres",
"TimingAppData", "TimingAppData",
"TimingStats", "TimingStats",
@@ -197,7 +196,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
return err return err
} }
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`) subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg) err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil { if err != nil {
return err return err

View File

@@ -8,8 +8,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"sort"
"strings" "strings"
"time" "time"
) )
@@ -25,7 +23,6 @@ type State struct {
RCMessages []LiveRCMessage RCMessages []LiveRCMessage
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
TeamRadio []LiveRadioCapture
TrackStatus string TrackStatus string
CurrentLap int CurrentLap int
TotalLaps int TotalLaps int
@@ -37,7 +34,6 @@ type State struct {
} }
const signalRRecordSeparator = byte(0x1e) const signalRRecordSeparator = byte(0x1e)
const maxTeamRadioCaptures = 20
// NewState returns an empty live timing accumulator. // NewState returns an empty live timing accumulator.
func NewState() *State { func NewState() *State {
@@ -75,8 +71,6 @@ func (s *State) Snapshot() LiveStreamData {
} }
cpyRC := make([]LiveRCMessage, len(s.RCMessages)) cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages) copy(cpyRC, s.RCMessages)
cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
copy(cpyRadio, s.TeamRadio)
cpyStints := make(map[string][]LiveStintData, len(s.Stints)) cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints { for k, v := range s.Stints {
st := make([]LiveStintData, len(v)) st := make([]LiveStintData, len(v))
@@ -92,7 +86,6 @@ func (s *State) Snapshot() LiveStreamData {
RCMessages: cpyRC, RCMessages: cpyRC,
Weather: s.Weather, Weather: s.Weather,
Session: s.Session, Session: s.Session,
TeamRadio: cpyRadio,
TrackStatus: s.TrackStatus, TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap, CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps, TotalLaps: s.TotalLaps,
@@ -342,7 +335,6 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} `json:"Meeting"` } `json:"Meeting"`
Name string `json:"Name"` Name string `json:"Name"`
Type string `json:"Type"` Type string `json:"Type"`
Path string `json:"Path"`
} }
if json.Unmarshal(data, &si) == nil { if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" { if si.Meeting.Name != "" {
@@ -357,13 +349,8 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
if si.Type != "" { if si.Type != "" {
s.Session.SessionType = si.Type s.Session.SessionType = si.Type
} }
if si.Path != "" {
s.Session.Path = si.Path
}
updated = true updated = true
} }
case "TeamRadio":
updated = s.updateTeamRadio(data)
case "CurrentTyres": case "CurrentTyres":
var ct map[string]json.RawMessage var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil { if json.Unmarshal(data, &ct) == nil {
@@ -496,62 +483,6 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
return out, err == nil return out, err == nil
} }
func (s *State) updateTeamRadio(data json.RawMessage) bool {
var payload struct {
Captures json.RawMessage `json:"Captures"`
}
if err := json.Unmarshal(data, &payload); err != nil {
log.Printf("live: skipping malformed TeamRadio payload: %v", err)
return false
}
if len(payload.Captures) == 0 || string(payload.Captures) == "null" {
log.Printf("live: skipping TeamRadio payload without Captures")
return false
}
captures := indexedRawValues(payload.Captures)
if len(captures) == 0 {
log.Printf("live: skipping TeamRadio payload with unexpected Captures shape")
return false
}
updated := false
for _, captureRaw := range captures {
var capture LiveRadioCapture
if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil {
log.Printf("live: skipping malformed TeamRadio capture: %v", err)
continue
}
if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" {
log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path)
continue
}
if s.hasTeamRadioCapture(capture) {
continue
}
s.TeamRadio = append(s.TeamRadio, capture)
updated = true
}
if updated {
sort.SliceStable(s.TeamRadio, func(i, j int) bool {
return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc
})
if len(s.TeamRadio) > maxTeamRadioCaptures {
s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...)
}
}
return updated
}
func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool {
for _, existing := range s.TeamRadio {
if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path {
return true
}
}
return false
}
func (s *State) updatePositions(data json.RawMessage) bool { func (s *State) updatePositions(data json.RawMessage) bool {
var payload struct { var payload struct {
Position json.RawMessage `json:"Position"` Position json.RawMessage `json:"Position"`
@@ -868,9 +799,6 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
fmt.Sscanf(k, "%d", &i) fmt.Sscanf(k, "%d", &i)
values = append(values, indexedRaw{Index: i, Raw: v}) values = append(values, indexedRaw{Index: i, Raw: v})
} }
sort.Slice(values, func(i, j int) bool {
return values[i].Index < values[j].Index
})
return values return values
} }

View File

@@ -85,7 +85,6 @@ type LiveSessionMeta struct {
CircuitName string CircuitName string
SessionType string SessionType string
SessionName string SessionName string
Path string
} }
// LivePositionData is the latest raw F1 GPS position for one driver. // LivePositionData is the latest raw F1 GPS position for one driver.
@@ -145,13 +144,6 @@ type LiveStintData struct {
Laps int Laps int
} }
// LiveRadioCapture is one team radio audio clip from the live timing feed.
type LiveRadioCapture struct {
Utc string
RacingNumber string
Path string
}
// LiveStreamData is an immutable snapshot of all live timing state. // LiveStreamData is an immutable snapshot of all live timing state.
type LiveStreamData struct { type LiveStreamData struct {
Drivers map[string]LiveDriverData Drivers map[string]LiveDriverData
@@ -161,7 +153,6 @@ type LiveStreamData struct {
RCMessages []LiveRCMessage RCMessages []LiveRCMessage
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
TeamRadio []LiveRadioCapture
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int CurrentLap int
TotalLaps int TotalLaps int