mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Wire Race Hub pit_stops into TyreStintTimeline via optional per-row pitStops laps; render vertical markers at stint boundaries with driver/lap tooltips. Matches pre-extraction positioning ((lap-1)/totalLaps). Co-authored-by: Cursor <cursoragent@cursor.com>
208 lines
6.2 KiB
TypeScript
208 lines
6.2 KiB
TypeScript
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[]
|
||
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
|
||
pitStops?: number[]
|
||
}
|
||
|
||
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 pitMarkerX(lapNumber: number, totalLaps: number): number {
|
||
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
|
||
}
|
||
|
||
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
|
||
return `${driverLabel} pit stop · L${lapNumber}`
|
||
}
|
||
|
||
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>
|
||
))}
|
||
|
||
{(row.pitStops ?? []).map((lapNumber, pi) => (
|
||
<line
|
||
key={`pit-${pi}`}
|
||
x1={pitMarkerX(lapNumber, safeTotal)}
|
||
x2={pitMarkerX(lapNumber, safeTotal)}
|
||
y1={BAR_Y - 3}
|
||
y2={BAR_Y + BAR_H + 3}
|
||
className="stint-timeline__pit-marker"
|
||
data-testid="pit-marker"
|
||
data-lap={lapNumber}
|
||
>
|
||
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
|
||
</line>
|
||
))}
|
||
</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>
|
||
)
|
||
}
|