diff --git a/frontend/src/components/StrategyView.tsx b/frontend/src/components/StrategyView.tsx index 4904fd8..ba46783 100644 --- a/frontend/src/components/StrategyView.tsx +++ b/frontend/src/components/StrategyView.tsx @@ -12,7 +12,7 @@ interface Props { hasStints: boolean } -export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) { +export function StrategyView({ results, stints, pit_stops, hasStints }: Props) { if (!hasStints) { return (
@@ -88,6 +88,10 @@ export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints lapEnd: s.lap_end, isNew: s.tyre_age_at_start === 0, })), + pitStops: pit_stops + .filter((p) => p.driver_number === driver.driver_number) + .map((p) => p.lap_number) + .sort((a, b) => a - b), })) return ( diff --git a/frontend/src/components/charts/TyreStintTimeline.tsx b/frontend/src/components/charts/TyreStintTimeline.tsx index 731574c..1727a22 100644 --- a/frontend/src/components/charts/TyreStintTimeline.tsx +++ b/frontend/src/components/charts/TyreStintTimeline.tsx @@ -12,6 +12,8 @@ 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 { @@ -57,6 +59,14 @@ 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) { @@ -130,6 +140,21 @@ export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) { {stintTitle(stint)} ))} + + {(row.pitStops ?? []).map((lapNumber, pi) => ( + + {pitMarkerTitle(row.label, lapNumber)} + + ))} ) })} diff --git a/frontend/src/styles/stint-timeline.css b/frontend/src/styles/stint-timeline.css index 38e9219..8198770 100644 --- a/frontend/src/styles/stint-timeline.css +++ b/frontend/src/styles/stint-timeline.css @@ -40,6 +40,13 @@ stroke-dasharray: 2 1; } +.stint-timeline__pit-marker { + stroke: var(--text); + stroke-width: 1.5; + opacity: 0.7; + pointer-events: stroke; +} + .stint-timeline__axis-tick { font-family: var(--f-mono); font-size: 9px; diff --git a/frontend/src/test/StrategyView.test.tsx b/frontend/src/test/StrategyView.test.tsx index 03b35f1..7cf4fb8 100644 --- a/frontend/src/test/StrategyView.test.tsx +++ b/frontend/src/test/StrategyView.test.tsx @@ -106,6 +106,17 @@ describe('StrategyView — stints available', () => { ) expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument() }) + + it('maps pit_stops into timeline pit markers for the matching driver', () => { + const { container } = render( + + ) + const markers = container.querySelectorAll('[data-testid="pit-marker"]') + expect(markers).toHaveLength(1) + expect(markers[0]).toHaveAttribute('data-lap', '19') + const titles = [...container.querySelectorAll('title')].map((t) => t.textContent) + expect(titles).toContain('HAM pit stop · L19') + }) }) describe('StrategyView — stints missing', () => { diff --git a/frontend/src/test/tyre-stint-timeline.test.tsx b/frontend/src/test/tyre-stint-timeline.test.tsx index b090c2e..5eebc24 100644 --- a/frontend/src/test/tyre-stint-timeline.test.tsx +++ b/frontend/src/test/tyre-stint-timeline.test.tsx @@ -71,6 +71,61 @@ describe('TyreStintTimeline', () => { expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument() expect(screen.getByText(/No stint data/i)).toBeInTheDocument() }) + + it('renders one pit marker per stop at the correct lap position', () => { + const rowsWithPits: StintTimelineRow[] = [ + { + label: 'HAM', + color: '#E8002D', + stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }], + pitStops: [19], + }, + { + label: 'VER', + color: '#3671C6', + stints: [ + { compound: 'MEDIUM', lapStart: 1, lapEnd: 30 }, + { compound: 'SOFT', lapStart: 31, lapEnd: 78 }, + ], + pitStops: [31, 52], + }, + ] + const { container } = render( + , + ) + const markers = container.querySelectorAll('[data-testid="pit-marker"]') + expect(markers).toHaveLength(3) + expect(markers[0]).toHaveAttribute('data-lap', '19') + expect(markers[1]).toHaveAttribute('data-lap', '31') + expect(markers[2]).toHaveAttribute('data-lap', '52') + expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3) + }) + + it('positions pit markers using lap_number and includes driver in tooltip', () => { + const rows: StintTimelineRow[] = [ + { + label: 'HAM', + color: '#E8002D', + stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }], + pitStops: [19], + }, + ] + const { container } = render() + const marker = container.querySelector('[data-testid="pit-marker"]') as SVGLineElement + expect(marker).toBeTruthy() + // lap 19 → x = 48 + (18/78) * 580 ≈ 181.85 + expect(Number(marker.getAttribute('x1'))).toBeCloseTo(181.85, 1) + const titles = [...container.querySelectorAll('title')].map((t) => t.textContent) + expect(titles).toContain('HAM pit stop · L19') + }) + + it('leaves rows without pit data unchanged', () => { + const { container } = render( + , + ) + expect(container.querySelectorAll('[data-testid="pit-marker"]')).toHaveLength(0) + expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3) + }) }) const results: EnrichedResult[] = [