diff --git a/frontend/src/components/StrategyView.tsx b/frontend/src/components/StrategyView.tsx index 3dee6a4..4904fd8 100644 --- a/frontend/src/components/StrategyView.tsx +++ b/frontend/src/components/StrategyView.tsx @@ -1,28 +1,9 @@ import type { EnrichedResult, Stint, PitStop } from '../types' import { compareFinishPosition } from '../utils' - -const COMPOUND_COLORS: Record = { - 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 = { - 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 (
@@ -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 (
-
- - {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 ( - - - {driver.name_acronym} - - - {dStints.map((stint, si) => { - const x = lapX(stint.lap_start) - const w = stintW(stint) - const fill = compoundColor(stint.compound) - return ( - - - {w > 18 && ( - - {compoundInitial(stint.compound)} - - )} - - ) - })} - - {dPits.map((pit, pi) => { - const x = lapX(pit.lap_number) - return ( - - ) - })} - - ) - })} - -
- -
- {usedCompounds.map((c) => ( - - - {c.charAt(0) + c.slice(1).toLowerCase()} - - ))} - {pit_stops.length > 0 && ( - - - Pit stop - - )} - - {totalLaps} laps - -
+
) } diff --git a/frontend/src/components/charts/TyreStintTimeline.tsx b/frontend/src/components/charts/TyreStintTimeline.tsx new file mode 100644 index 0000000..731574c --- /dev/null +++ b/frontend/src/components/charts/TyreStintTimeline.tsx @@ -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() + 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 ( +
+
No stint data to display.
+
+ ) + } + + return ( +
+
+ + {rows.map((row, i) => { + const rowY = i * ROW_H + return ( + + + {row.label} + + + {row.stints.map((stint, si) => ( + + {stintTitle(stint)} + + ))} + + ) + })} + + + + {ticks.map((lap) => ( + + + + {lap} + + + ))} + + +
+ +
+ {usedCompounds.map((compound) => ( + + + {compoundLabel(compound)} + + ))} + {safeTotal} laps +
+
+ ) +} diff --git a/frontend/src/styles/stint-timeline.css b/frontend/src/styles/stint-timeline.css new file mode 100644 index 0000000..38e9219 --- /dev/null +++ b/frontend/src/styles/stint-timeline.css @@ -0,0 +1,94 @@ +.stint-timeline { + --stint-left: 48px; + --stint-right: 12px; + --stint-row-h: 28px; + --stint-bar-h: 14px; + --stint-axis-h: 20px; +} + +.stint-timeline__scroll { + overflow-x: auto; +} + +.stint-timeline__svg { + width: 100%; + min-width: 280px; + max-width: 640px; + display: block; +} + +.stint-timeline__label { + font-family: var(--f-mono); + font-weight: 700; + font-size: 10px; +} + +.stint-timeline__bar { + stroke: none; +} + +.stint-timeline__bar.tyre-soft { fill: var(--tyre-soft); } +.stint-timeline__bar.tyre-medium { fill: var(--tyre-medium); } +.stint-timeline__bar.tyre-hard { fill: var(--tyre-hard); stroke: #555; stroke-width: 0.5; } +.stint-timeline__bar.tyre-inter { fill: var(--tyre-inter); } +.stint-timeline__bar.tyre-wet { fill: var(--tyre-wet); } +.stint-timeline__bar.tyre-unknown { fill: var(--surface-2); stroke: var(--border-2); stroke-width: 0.5; } + +.stint-timeline__bar--new { + stroke: var(--text); + stroke-width: 1; + stroke-dasharray: 2 1; +} + +.stint-timeline__axis-tick { + font-family: var(--f-mono); + font-size: 9px; + fill: var(--text-3); +} + +.stint-timeline__axis-line { + stroke: var(--border-2); + stroke-width: 1; +} + +.stint-timeline__legend { + display: flex; + gap: var(--s4); + flex-wrap: wrap; + margin-top: var(--s4); + font-size: 11px; + color: var(--text-3); + align-items: center; +} + +.stint-timeline__legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; +} + +.stint-timeline__legend-swatch.tyre-hard { + border: 1px solid #555; +} + +.stint-timeline__legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.stint-timeline__meta { + margin-left: auto; + font-family: var(--f-mono); + font-size: 10px; +} + +.stint-timeline__empty { + padding: var(--s5); + color: var(--text-3); + font-size: 13px; + text-align: center; + border: 1px dashed var(--border-2); + border-radius: var(--r2); +} diff --git a/frontend/src/test/tyre-stint-timeline.test.tsx b/frontend/src/test/tyre-stint-timeline.test.tsx new file mode 100644 index 0000000..b090c2e --- /dev/null +++ b/frontend/src/test/tyre-stint-timeline.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { TyreStintTimeline, type StintTimelineRow } from '../components/charts/TyreStintTimeline' +import { StrategyView } from '../components/StrategyView' +import type { EnrichedResult, Stint, PitStop } from '../types' + +const sampleRows: StintTimelineRow[] = [ + { + label: 'VER', + color: '#3671C6', + stints: [ + { compound: 'MEDIUM', lapStart: 1, lapEnd: 30 }, + { compound: 'SOFT', lapStart: 31, lapEnd: 78 }, + ], + }, + { + label: 'HAM', + color: '#E8002D', + stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }], + }, +] + +describe('TyreStintTimeline', () => { + it('renders one rect per stint', () => { + const { container } = render( + , + ) + expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3) + }) + + it('maps compounds to color classes', () => { + const { container } = render( + , + ) + const bars = container.querySelectorAll('.stint-timeline__bar') + expect(bars[0]).toHaveClass('tyre-medium') + expect(bars[1]).toHaveClass('tyre-soft') + expect(bars[2]).toHaveClass('tyre-soft') + }) + + it('shows native title with compound, lap range, and stint length', () => { + const { container } = render( + , + ) + const titles = [...container.querySelectorAll('title')].map((t) => t.textContent) + expect(titles).toContain('Medium · L1–30 · 30 laps') + expect(titles).toContain('Soft · L31–78 · 48 laps') + expect(titles).toContain('Soft · L1–18 · 18 laps') + }) + + it('renders lap-axis ticks every 10 laps', () => { + render() + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.getByText('10')).toBeInTheDocument() + expect(screen.getByText('20')).toBeInTheDocument() + expect(screen.getByText('70')).toBeInTheDocument() + }) + + it('shows legend only for used compounds', () => { + render() + expect(screen.getByTestId('legend-soft')).toBeInTheDocument() + expect(screen.getByTestId('legend-medium')).toBeInTheDocument() + expect(screen.queryByTestId('legend-hard')).not.toBeInTheDocument() + expect(screen.queryByTestId('legend-wet')).not.toBeInTheDocument() + expect(screen.getByText('Soft')).toBeInTheDocument() + expect(screen.getByText('Medium')).toBeInTheDocument() + }) + + it('renders empty state when rows are empty', () => { + render() + expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument() + expect(screen.getByText(/No stint data/i)).toBeInTheDocument() + }) +}) + +const results: EnrichedResult[] = [ + { + driver_number: 1, + position: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull Racing', + team_colour: '3671C6', + dnf: false, + dns: false, + dsq: false, + duration: null, + gap_to_leader: null, + number_of_laps: 78, + points: 25, + session_key: 9472, + meeting_key: 1229, + }, + { + driver_number: 44, + position: 2, + name_acronym: 'HAM', + full_name: 'Lewis Hamilton', + team_name: 'Ferrari', + team_colour: 'E8002D', + dnf: false, + dns: false, + dsq: false, + duration: null, + gap_to_leader: 5.1, + number_of_laps: 78, + points: 18, + session_key: 9472, + meeting_key: 1229, + }, +] + +const stints: Stint[] = [ + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + stint_number: 1, + compound: 'MEDIUM', + lap_start: 1, + lap_end: 30, + tyre_age_at_start: 0, + }, + { + session_key: 9472, + driver_number: 44, + meeting_key: 1229, + stint_number: 1, + compound: 'SOFT', + lap_start: 1, + lap_end: 18, + tyre_age_at_start: 0, + }, +] + +const pitStops: PitStop[] = [] + +describe('StrategyView integration', () => { + it('renders timeline when stints exist', () => { + const { container } = render( + , + ) + expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument() + expect(screen.getByTestId('stint-timeline')).toBeInTheDocument() + expect(screen.getByText('VER')).toBeInTheDocument() + expect(screen.getByText('HAM')).toBeInTheDocument() + expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(2) + }) +})