Compare commits
7 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6fa531f2 | ||
|
|
ad379e0f07 | ||
|
|
cc4337be88 | ||
|
|
addfd6d24d | ||
|
|
b5d070e116 | ||
|
|
233eefaf12 | ||
|
|
51b0238b09 |
BIN
docs/phase-1/screenshots/command-center-hero-between.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/phase-1/screenshots/live-event-tyre-radio-mocked.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/phase-1/screenshots/race-hub-compare-telemetry-delta.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/phase-1/screenshots/race-hub-strategy-timeline.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchLapsComparison, fetchTelemetry } from '../api'
|
import { fetchLapsComparison, fetchTelemetry } from '../api'
|
||||||
import {
|
import {
|
||||||
@@ -57,16 +57,23 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
|
|||||||
[results, drivers],
|
[results, drivers],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const previousSessionKey = useRef(sessionKey)
|
||||||
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
|
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
|
||||||
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
|
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (previousSessionKey.current !== sessionKey) {
|
||||||
|
previousSessionKey.current = sessionKey
|
||||||
|
setDriverA(initialPair?.[0] ?? null)
|
||||||
|
setDriverB(initialPair?.[1] ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (driverA != null && driverB != null) return
|
if (driverA != null && driverB != null) return
|
||||||
const pair = defaultCompareDriverNumbers(results, drivers)
|
if (!initialPair) return
|
||||||
if (!pair) return
|
setDriverA(initialPair[0])
|
||||||
setDriverA(pair[0])
|
setDriverB(initialPair[1])
|
||||||
setDriverB(pair[1])
|
}, [sessionKey, initialPair, driverA, driverB])
|
||||||
}, [results, drivers, driverA, driverB])
|
|
||||||
|
|
||||||
const pair = useMemo((): [number, number] | null => {
|
const pair = useMemo((): [number, number] | null => {
|
||||||
if (driverA == null || driverB == null || driverA === driverB) return null
|
if (driverA == null || driverB == null || driverA === driverB) return null
|
||||||
@@ -222,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { CompareView } from '../components/CompareView'
|
import { CompareView } from '../components/CompareView'
|
||||||
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
|
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
|
||||||
@@ -158,13 +158,89 @@ const comparison: LapsComparisonResponse = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCompareView() {
|
const nextSessionResults: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
driver_number: 16,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'LEC',
|
||||||
|
full_name: 'Charles Leclerc',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'E8002D',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: null,
|
||||||
|
gap_to_leader: null,
|
||||||
|
number_of_laps: 57,
|
||||||
|
points: 25,
|
||||||
|
session_key: 9550,
|
||||||
|
meeting_key: 1234,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 55,
|
||||||
|
position: 2,
|
||||||
|
name_acronym: 'SAI',
|
||||||
|
full_name: 'Carlos Sainz',
|
||||||
|
team_name: 'Williams',
|
||||||
|
team_colour: '64C4FF',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: null,
|
||||||
|
gap_to_leader: 3.2,
|
||||||
|
number_of_laps: 57,
|
||||||
|
points: 18,
|
||||||
|
session_key: 9550,
|
||||||
|
meeting_key: 1234,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const nextSessionDrivers: Driver[] = [
|
||||||
|
{
|
||||||
|
driver_number: 16,
|
||||||
|
name_acronym: 'LEC',
|
||||||
|
full_name: 'Charles Leclerc',
|
||||||
|
first_name: 'Charles',
|
||||||
|
last_name: 'Leclerc',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'E8002D',
|
||||||
|
headshot_url: '',
|
||||||
|
broadcast_name: 'C LECLERC',
|
||||||
|
session_key: 9550,
|
||||||
|
meeting_key: 1234,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 55,
|
||||||
|
name_acronym: 'SAI',
|
||||||
|
full_name: 'Carlos Sainz',
|
||||||
|
first_name: 'Carlos',
|
||||||
|
last_name: 'Sainz',
|
||||||
|
team_name: 'Williams',
|
||||||
|
team_colour: '64C4FF',
|
||||||
|
headshot_url: '',
|
||||||
|
broadcast_name: 'C SAINZ',
|
||||||
|
session_key: 9550,
|
||||||
|
meeting_key: 1234,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderCompareView(
|
||||||
|
props: {
|
||||||
|
sessionKey?: number
|
||||||
|
results?: EnrichedResult[]
|
||||||
|
drivers?: Driver[]
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
})
|
})
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<CompareView sessionKey={9472} results={results} drivers={drivers} />
|
<CompareView
|
||||||
|
sessionKey={props.sessionKey ?? 9472}
|
||||||
|
results={props.results ?? results}
|
||||||
|
drivers={props.drivers ?? drivers}
|
||||||
|
/>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -305,6 +381,28 @@ describe('CompareView', () => {
|
|||||||
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resets the selected pair when the mounted session changes', async () => {
|
||||||
|
const { rerender } = renderCompareView()
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('compare-picker-a'), { target: { value: '44' } })
|
||||||
|
expect(screen.getByTestId('compare-picker-a')).toHaveValue('44')
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||||
|
<CompareView
|
||||||
|
sessionKey={9550}
|
||||||
|
results={nextSessionResults}
|
||||||
|
drivers={nextSessionDrivers}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('compare-picker-a')).toHaveValue('16')
|
||||||
|
expect(screen.getByTestId('compare-picker-b')).toHaveValue('55')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('renders telemetry and pace sections with mocked queries', async () => {
|
it('renders telemetry and pace sections with mocked queries', async () => {
|
||||||
renderCompareView()
|
renderCompareView()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 129 KiB |