mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Compare commits
2 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6fa531f2 | ||
|
|
ad379e0f07 |
@@ -229,7 +229,9 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="compare-section-title">Race pace</div>
|
<div className="compare-section-title">Race pace</div>
|
||||||
<div className="compare-section-meta">
|
<div className="compare-section-meta">
|
||||||
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}
|
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
|
||||||
|
only where the reference lap is valid; gaps appear when the reference has no lap
|
||||||
|
time.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SectionState
|
<SectionState
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface Props {
|
|||||||
hasStints: boolean
|
hasStints: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
|
export function StrategyView({ results, stints, pit_stops: _pitStops, hasStints }: Props) {
|
||||||
if (!hasStints) {
|
if (!hasStints) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -88,10 +88,6 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
|
|||||||
lapEnd: s.lap_end,
|
lapEnd: s.lap_end,
|
||||||
isNew: s.tyre_age_at_start === 0,
|
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 (
|
return (
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ export interface StintTimelineRow {
|
|||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
stints: StintTimelineStint[]
|
stints: StintTimelineStint[]
|
||||||
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
|
|
||||||
pitStops?: number[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TyreStintTimelineProps {
|
interface TyreStintTimelineProps {
|
||||||
@@ -59,14 +57,6 @@ function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
|
|||||||
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
|
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[] {
|
function axisTicks(totalLaps: number): number[] {
|
||||||
const ticks: number[] = []
|
const ticks: number[] = []
|
||||||
for (let lap = 0; lap <= totalLaps; lap += 10) {
|
for (let lap = 0; lap <= totalLaps; lap += 10) {
|
||||||
@@ -140,21 +130,6 @@ export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
|
|||||||
<title>{stintTitle(stint)}</title>
|
<title>{stintTitle(stint)}</title>
|
||||||
</rect>
|
</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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -21,15 +21,31 @@ export function formatDeltaSeconds(delta: number): string {
|
|||||||
return `${sign}${delta.toFixed(1)}s`
|
return `${sign}${delta.toFixed(1)}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCumulative(lapTimes: ReadonlyArray<number | null>): number[] {
|
/**
|
||||||
|
* Cumulative lap time aligned to reference-valid laps only.
|
||||||
|
* Laps where the reference is null are skipped for every series so later deltas
|
||||||
|
* do not compare against a frozen baseline while challengers keep accumulating.
|
||||||
|
*/
|
||||||
|
function buildAlignedCumulative(
|
||||||
|
lapTimes: ReadonlyArray<number | null>,
|
||||||
|
referenceLapTimes: ReadonlyArray<number | null>,
|
||||||
|
): number[] {
|
||||||
const cumulative: number[] = []
|
const cumulative: number[] = []
|
||||||
let running = 0
|
let running = 0
|
||||||
for (const lap of lapTimes) {
|
const length = Math.max(lapTimes.length, referenceLapTimes.length)
|
||||||
if (lap !== null) {
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
if (referenceLapTimes[i] == null) {
|
||||||
|
cumulative.push(running)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const lap = lapTimes[i]
|
||||||
|
if (lap != null) {
|
||||||
running += lap
|
running += lap
|
||||||
}
|
}
|
||||||
cumulative.push(running)
|
cumulative.push(running)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cumulative
|
return cumulative
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +63,8 @@ function resolveReference(
|
|||||||
/**
|
/**
|
||||||
* Compute per-lap cumulative time delta for each non-reference driver.
|
* Compute per-lap cumulative time delta for each non-reference driver.
|
||||||
* Positive = behind reference; negative = ahead.
|
* Positive = behind reference; negative = ahead.
|
||||||
* Null laps carry cumulative forward but emit null in deltas (skip when plotting).
|
* Deltas are only emitted where the reference lap is valid; reference-null laps
|
||||||
|
* gap every series. Challenger-null laps gap only that driver's line.
|
||||||
*/
|
*/
|
||||||
export function computeCumulativeDeltas(
|
export function computeCumulativeDeltas(
|
||||||
series: ReadonlyArray<DeltaSeries>,
|
series: ReadonlyArray<DeltaSeries>,
|
||||||
@@ -56,24 +73,22 @@ export function computeCumulativeDeltas(
|
|||||||
const reference = resolveReference(series, referenceLabel)
|
const reference = resolveReference(series, referenceLabel)
|
||||||
if (!reference) return []
|
if (!reference) return []
|
||||||
|
|
||||||
const refCumulative = buildCumulative(reference.lapTimes)
|
const refLapTimes = reference.lapTimes
|
||||||
|
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
|
||||||
|
|
||||||
return series
|
return series
|
||||||
.filter((s) => s.label !== reference.label)
|
.filter((s) => s.label !== reference.label)
|
||||||
.map((driver) => {
|
.map((driver) => {
|
||||||
const driverCumulative = buildCumulative(driver.lapTimes)
|
const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
|
||||||
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
|
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
|
||||||
const deltas: (number | null)[] = []
|
const deltas: (number | null)[] = []
|
||||||
|
|
||||||
for (let i = 0; i < lapCount; i++) {
|
for (let i = 0; i < lapCount; i++) {
|
||||||
if (driver.lapTimes[i] === null) {
|
if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
|
||||||
deltas.push(null)
|
deltas.push(null)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0
|
deltas.push(driverCumulative[i] - refCumulative[i])
|
||||||
const driverValue =
|
|
||||||
driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
|
|
||||||
deltas.push(driverValue - refValue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -40,13 +40,6 @@
|
|||||||
stroke-dasharray: 2 1;
|
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 {
|
.stint-timeline__axis-tick {
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
@@ -106,17 +106,6 @@ describe('StrategyView — stints available', () => {
|
|||||||
)
|
)
|
||||||
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps pit_stops into timeline pit markers for the matching driver', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
|
|
||||||
)
|
|
||||||
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', () => {
|
describe('StrategyView — stints missing', () => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe('computeCumulativeDeltas', () => {
|
|||||||
expect(result[0].deltas[2]).toBeCloseTo(2)
|
expect(result[0].deltas[2]).toBeCloseTo(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits null for missing lap times while carrying cumulative forward', () => {
|
it('emits null for challenger missing lap times while carrying cumulative forward', () => {
|
||||||
const withNull: DeltaSeries = {
|
const withNull: DeltaSeries = {
|
||||||
label: 'NOR',
|
label: 'NOR',
|
||||||
color: '#FF8000',
|
color: '#FF8000',
|
||||||
@@ -60,6 +60,41 @@ describe('computeCumulativeDeltas', () => {
|
|||||||
expect(result[0].deltas[2]).toBeCloseTo(-92)
|
expect(result[0].deltas[2]).toBeCloseTo(-92)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('gaps all drivers when the reference lap is null and resumes without that window', () => {
|
||||||
|
const refWithNull: DeltaSeries = {
|
||||||
|
label: 'VER',
|
||||||
|
color: '#3671C6',
|
||||||
|
lapTimes: [90, null, 92],
|
||||||
|
}
|
||||||
|
const validChallenger: DeltaSeries = {
|
||||||
|
label: 'HAM',
|
||||||
|
color: '#E8002D',
|
||||||
|
lapTimes: [89, 91, 90],
|
||||||
|
}
|
||||||
|
const result = computeCumulativeDeltas([refWithNull, validChallenger])
|
||||||
|
expect(result[0].deltas[0]).toBeCloseTo(-1)
|
||||||
|
expect(result[0].deltas[1]).toBeNull()
|
||||||
|
// Lap 3 excludes the reference-null window for both: (89+90) - (90+92) = -3
|
||||||
|
expect(result[0].deltas[2]).toBeCloseTo(-3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gaps challenger laps beyond a shorter reference series', () => {
|
||||||
|
const shortReference: DeltaSeries = {
|
||||||
|
label: 'VER',
|
||||||
|
color: '#3671C6',
|
||||||
|
lapTimes: [90, 91],
|
||||||
|
}
|
||||||
|
const longerChallenger: DeltaSeries = {
|
||||||
|
label: 'HAM',
|
||||||
|
color: '#E8002D',
|
||||||
|
lapTimes: [89, 92, 90],
|
||||||
|
}
|
||||||
|
const result = computeCumulativeDeltas([shortReference, longerChallenger])
|
||||||
|
expect(result[0].deltas[0]).toBeCloseTo(-1)
|
||||||
|
expect(result[0].deltas[1]).toBeCloseTo(0)
|
||||||
|
expect(result[0].deltas[2]).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('returns an empty array when only one series is provided', () => {
|
it('returns an empty array when only one series is provided', () => {
|
||||||
expect(computeCumulativeDeltas([reference])).toEqual([])
|
expect(computeCumulativeDeltas([reference])).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -97,6 +132,19 @@ describe('DeltaTimeGraph', () => {
|
|||||||
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('splits polylines at reference-null laps', () => {
|
||||||
|
const refWithNull: DeltaSeries = {
|
||||||
|
label: 'VER',
|
||||||
|
color: '#3671C6',
|
||||||
|
lapTimes: [90, null, 92],
|
||||||
|
}
|
||||||
|
const { container } = render(
|
||||||
|
<DeltaTimeGraph series={[refWithNull, challenger]} />,
|
||||||
|
)
|
||||||
|
const lines = container.querySelectorAll('.delta-graph-driver-line')
|
||||||
|
expect(lines.length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('shows a crosshair tooltip on hover', () => {
|
it('shows a crosshair tooltip on hover', () => {
|
||||||
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -118,4 +166,31 @@ describe('DeltaTimeGraph', () => {
|
|||||||
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
|
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('omits tooltip rows on reference-null laps', () => {
|
||||||
|
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 640,
|
||||||
|
height: 220,
|
||||||
|
right: 640,
|
||||||
|
bottom: 220,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
const refWithNull: DeltaSeries = {
|
||||||
|
label: 'VER',
|
||||||
|
color: '#3671C6',
|
||||||
|
lapTimes: [90, null, 92],
|
||||||
|
}
|
||||||
|
const { container } = render(
|
||||||
|
<DeltaTimeGraph series={[refWithNull, challenger]} />,
|
||||||
|
)
|
||||||
|
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
|
||||||
|
fireEvent.mouseMove(hoverLayer!, { clientX: 352, clientY: 100 })
|
||||||
|
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('delta-tooltip')).not.toBeInTheDocument()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,61 +71,6 @@ describe('TyreStintTimeline', () => {
|
|||||||
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
|
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
|
||||||
expect(screen.getByText(/No stint data/i)).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(
|
|
||||||
<TyreStintTimeline rows={rowsWithPits} totalLaps={78} />,
|
|
||||||
)
|
|
||||||
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(<TyreStintTimeline rows={rows} totalLaps={78} />)
|
|
||||||
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(
|
|
||||||
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
|
|
||||||
)
|
|
||||||
expect(container.querySelectorAll('[data-testid="pit-marker"]')).toHaveLength(0)
|
|
||||||
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const results: EnrichedResult[] = [
|
const results: EnrichedResult[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user