Compare commits

..

1 Commits

Author SHA1 Message Date
AmanTahiliani
6c72c12821 feat(live): add team radio ticker
Subscribe to TeamRadio on the F1 live feed, parse defensive capture snapshots and patches, and relay capped captures through live snapshots with the SessionInfo static path. Add the frontend CDN URL helper and ticker using one shared audio element.
2026-07-03 23:56:19 -04:00
16 changed files with 472 additions and 599 deletions

View File

@@ -1,282 +0,0 @@
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

@@ -0,0 +1,108 @@
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>
)
}

View File

@@ -1,112 +0,0 @@
// 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
}

22
frontend/src/lib/radio.ts Normal file
View File

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

View File

@@ -1,76 +0,0 @@
.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

@@ -0,0 +1,82 @@
.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,6 +201,7 @@ describe('TimingTower', () => {
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
Path: '',
}}
/>,
)

View File

@@ -0,0 +1,87 @@
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

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"compress/flate"
"encoding/base64"
"encoding/json"
"fmt"
"testing"
"time"
@@ -211,12 +212,16 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
"Name": "Race",
"Type": "Race"
"Type": "Race",
"Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/"
}`))
s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
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) {
@@ -285,6 +290,68 @@ 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) {
state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{

View File

@@ -105,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
"RaceControlMessages",
"WeatherData",
"SessionInfo",
"TeamRadio",
"CurrentTyres",
"TimingAppData",
"TimingStats",
@@ -196,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
return err
}
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}`)
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}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err

View File

@@ -8,6 +8,8 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"sort"
"strings"
"time"
)
@@ -23,6 +25,7 @@ type State struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TeamRadio []LiveRadioCapture
TrackStatus string
CurrentLap int
TotalLaps int
@@ -34,6 +37,7 @@ type State struct {
}
const signalRRecordSeparator = byte(0x1e)
const maxTeamRadioCaptures = 20
// NewState returns an empty live timing accumulator.
func NewState() *State {
@@ -71,6 +75,8 @@ func (s *State) Snapshot() LiveStreamData {
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages)
cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
copy(cpyRadio, s.TeamRadio)
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints {
st := make([]LiveStintData, len(v))
@@ -86,6 +92,7 @@ func (s *State) Snapshot() LiveStreamData {
RCMessages: cpyRC,
Weather: s.Weather,
Session: s.Session,
TeamRadio: cpyRadio,
TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps,
@@ -335,6 +342,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
Path string `json:"Path"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
@@ -349,8 +357,13 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
if si.Type != "" {
s.Session.SessionType = si.Type
}
if si.Path != "" {
s.Session.Path = si.Path
}
updated = true
}
case "TeamRadio":
updated = s.updateTeamRadio(data)
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
@@ -483,6 +496,62 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
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 {
var payload struct {
Position json.RawMessage `json:"Position"`
@@ -799,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
fmt.Sscanf(k, "%d", &i)
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
}

View File

@@ -85,6 +85,7 @@ type LiveSessionMeta struct {
CircuitName string
SessionType string
SessionName string
Path string
}
// LivePositionData is the latest raw F1 GPS position for one driver.
@@ -144,6 +145,13 @@ type LiveStintData struct {
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.
type LiveStreamData struct {
Drivers map[string]LiveDriverData
@@ -153,6 +161,7 @@ type LiveStreamData struct {
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TeamRadio []LiveRadioCapture
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int