feat(frontend): add reusable TyreStintTimeline component (#16)

Extract stint timeline SVG from StrategyView into a data-source-agnostic
chart primitive with compound legend, lap-axis ticks, and hover titles.
StrategyView maps race-hub stint data into the new props contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-03 23:48:09 -04:00
parent 75ca5f4deb
commit c5a7ad51eb
4 changed files with 445 additions and 157 deletions

View File

@@ -1,28 +1,9 @@
import type { EnrichedResult, Stint, PitStop } from '../types'
import { compareFinishPosition } from '../utils'
const COMPOUND_COLORS: Record<string, string> = {
SOFT: '#e8002d',
MEDIUM: '#ffd600',
HARD: '#e8e8e4',
INTERMEDIATE: '#39b54a',
WET: '#0067ff',
}
function compoundColor(c: string): string {
return COMPOUND_COLORS[c.toUpperCase()] ?? '#666'
}
function compoundInitial(c: string): string {
const abbr: Record<string, string> = {
SOFT: 'S',
MEDIUM: 'M',
HARD: 'H',
INTERMEDIATE: 'I',
WET: 'W',
}
return abbr[c.toUpperCase()] ?? c[0] ?? '?'
}
import {
TyreStintTimeline,
type StintTimelineRow,
} from './charts/TyreStintTimeline'
interface Props {
results: EnrichedResult[]
@@ -31,7 +12,7 @@ interface Props {
hasStints: boolean
}
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
if (!hasStints) {
return (
<div>
@@ -89,147 +70,29 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
const cmp = compareFinishPosition(a.position, b.position)
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
})
const totalLaps = Math.max(
...stints.map((s) => s.lap_end),
...results.map((r) => r.number_of_laps),
1
1,
)
const SVG_W = 640
const LEFT = 48
const RIGHT = 12
const ROW_H = 28
const BAR_H = 14
const BAR_Y = 7
const BAR_W = SVG_W - LEFT - RIGHT
const SVG_H = sortedDrivers.length * ROW_H + 8
const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W
const stintW = (s: Stint) =>
Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W)
const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter(
(c) => c in COMPOUND_COLORS
)
const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({
label: driver.name_acronym || String(driver.driver_number),
color: driver.team_colour ? `#${driver.team_colour}` : '#888',
stints: stints
.filter((s) => s.driver_number === driver.driver_number)
.map((s) => ({
compound: s.compound,
lapStart: s.lap_start,
lapEnd: s.lap_end,
isNew: s.tyre_age_at_start === 0,
})),
}))
return (
<div data-testid="strategy-chart">
<div className="scroll-x">
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
style={{ width: '100%', minWidth: 280, maxWidth: SVG_W, display: 'block' }}
role="img"
aria-label="Race strategy stint chart"
>
{sortedDrivers.map((driver, i) => {
const rowY = i * ROW_H
const color = driver.team_colour ? `#${driver.team_colour}` : '#888'
const dStints = stints.filter((s) => s.driver_number === driver.driver_number)
const dPits = pit_stops.filter((p) => p.driver_number === driver.driver_number)
return (
<g key={driver.driver_number} transform={`translate(0,${rowY})`}>
<text
x={LEFT - 5}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="end"
fill={color}
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={10}
>
{driver.name_acronym}
</text>
{dStints.map((stint, si) => {
const x = lapX(stint.lap_start)
const w = stintW(stint)
const fill = compoundColor(stint.compound)
return (
<g key={si}>
<rect x={x} y={BAR_Y} width={w} height={BAR_H} fill={fill} rx={1.5} />
{w > 18 && (
<text
x={x + w / 2}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="middle"
fill="#111"
fontFamily="var(--f-mono)"
fontWeight={700}
fontSize={8}
>
{compoundInitial(stint.compound)}
</text>
)}
</g>
)
})}
{dPits.map((pit, pi) => {
const x = lapX(pit.lap_number)
return (
<line
key={pi}
x1={x}
x2={x}
y1={BAR_Y - 3}
y2={BAR_Y + BAR_H + 3}
stroke="var(--text)"
strokeWidth={1.5}
opacity={0.7}
/>
)
})}
</g>
)
})}
</svg>
</div>
<div
style={{
display: 'flex',
gap: 'var(--s4)',
flexWrap: 'wrap',
marginTop: 'var(--s4)',
fontSize: 11,
color: 'var(--text-3)',
alignItems: 'center',
}}
>
{usedCompounds.map((c) => (
<span key={c} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
style={{
width: 10,
height: 10,
background: COMPOUND_COLORS[c],
borderRadius: 2,
display: 'inline-block',
border: c === 'HARD' ? '1px solid #555' : undefined,
}}
/>
{c.charAt(0) + c.slice(1).toLowerCase()}
</span>
))}
{pit_stops.length > 0 && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginLeft: 4 }}>
<span
style={{
width: 1,
height: 12,
background: 'var(--text)',
display: 'inline-block',
opacity: 0.7,
}}
/>
Pit stop
</span>
)}
<span style={{ marginLeft: 'auto', fontFamily: 'var(--f-mono)', fontSize: 10 }}>
{totalLaps} laps
</span>
</div>
<TyreStintTimeline rows={timelineRows} totalLaps={totalLaps} />
</div>
)
}

View File

@@ -0,0 +1,182 @@
import { compoundClass } from '../../lib/live'
import '../../styles/stint-timeline.css'
export interface StintTimelineStint {
compound: string
lapStart: number
lapEnd: number
isNew?: boolean
}
export interface StintTimelineRow {
label: string
color: string
stints: StintTimelineStint[]
}
interface TyreStintTimelineProps {
rows: StintTimelineRow[]
totalLaps: number
}
const SVG_W = 640
const LEFT = 48
const RIGHT = 12
const ROW_H = 28
const BAR_H = 14
const BAR_Y = 7
const AXIS_H = 20
const BAR_W = SVG_W - LEFT - RIGHT
const COMPOUND_ORDER = ['SOFT', 'MEDIUM', 'HARD', 'INTERMEDIATE', 'WET'] as const
function compoundLabel(compound: string): string {
const upper = compound.toUpperCase()
if (upper === 'INTERMEDIATE') return 'Intermediate'
return upper.charAt(0) + upper.slice(1).toLowerCase()
}
function stintLength(stint: StintTimelineStint): number {
return stint.lapEnd - stint.lapStart + 1
}
function stintTitle(stint: StintTimelineStint): string {
const length = stintLength(stint)
return `${compoundLabel(stint.compound)} · L${stint.lapStart}${stint.lapEnd} · ${length} lap${length === 1 ? '' : 's'}`
}
function lapX(lap: number, totalLaps: number): number {
return LEFT + (lap / totalLaps) * BAR_W
}
function stintBarX(stint: StintTimelineStint, totalLaps: number): number {
return LEFT + ((stint.lapStart - 1) / totalLaps) * BAR_W
}
function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
}
function axisTicks(totalLaps: number): number[] {
const ticks: number[] = []
for (let lap = 0; lap <= totalLaps; lap += 10) {
ticks.push(lap)
}
return ticks
}
function collectUsedCompounds(rows: StintTimelineRow[]): string[] {
const seen = new Set<string>()
for (const row of rows) {
for (const stint of row.stints) {
seen.add(stint.compound.toUpperCase())
}
}
const ordered = COMPOUND_ORDER.filter((c) => seen.has(c))
const extras = [...seen]
.filter((c) => !COMPOUND_ORDER.includes(c as (typeof COMPOUND_ORDER)[number]))
.sort()
return [...ordered, ...extras]
}
export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
const safeTotal = Math.max(totalLaps, 1)
const usedCompounds = collectUsedCompounds(rows)
const ticks = axisTicks(safeTotal)
const chartH = rows.length * ROW_H
const svgH = chartH + AXIS_H + 4
if (rows.length === 0) {
return (
<div className="stint-timeline" data-testid="stint-timeline-empty">
<div className="stint-timeline__empty">No stint data to display.</div>
</div>
)
}
return (
<div className="stint-timeline" data-testid="stint-timeline">
<div className="stint-timeline__scroll">
<svg
viewBox={`0 0 ${SVG_W} ${svgH}`}
className="stint-timeline__svg"
role="img"
aria-label="Tyre stint timeline"
>
{rows.map((row, i) => {
const rowY = i * ROW_H
return (
<g key={`${row.label}-${i}`} transform={`translate(0,${rowY})`}>
<text
x={LEFT - 5}
y={BAR_Y + BAR_H / 2 + 4}
textAnchor="end"
fill={row.color}
className="stint-timeline__label"
>
{row.label}
</text>
{row.stints.map((stint, si) => (
<rect
key={si}
x={stintBarX(stint, safeTotal)}
y={BAR_Y}
width={stintBarW(stint, safeTotal)}
height={BAR_H}
rx={3}
className={`stint-timeline__bar ${compoundClass(stint.compound)}${stint.isNew ? ' stint-timeline__bar--new' : ''}`}
>
<title>{stintTitle(stint)}</title>
</rect>
))}
</g>
)
})}
<g transform={`translate(0,${chartH})`}>
<line
x1={LEFT}
x2={LEFT + BAR_W}
y1={0}
y2={0}
className="stint-timeline__axis-line"
/>
{ticks.map((lap) => (
<g key={lap}>
<line
x1={lapX(lap, safeTotal)}
x2={lapX(lap, safeTotal)}
y1={0}
y2={4}
className="stint-timeline__axis-line"
/>
<text
x={lapX(lap, safeTotal)}
y={14}
textAnchor="middle"
className="stint-timeline__axis-tick"
>
{lap}
</text>
</g>
))}
</g>
</svg>
</div>
<div className="stint-timeline__legend">
{usedCompounds.map((compound) => (
<span key={compound} className="stint-timeline__legend-item">
<span
className={`stint-timeline__legend-swatch ${compoundClass(compound)}`}
data-testid={`legend-${compound.toLowerCase()}`}
/>
{compoundLabel(compound)}
</span>
))}
<span className="stint-timeline__meta">{safeTotal} laps</span>
</div>
</div>
)
}