mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Merge pull request #41 from AmanTahiliani/feat/issue-10-telemetry-compare-overlay-speed-throttle
Telemetry compare overlay (speed/throttle/brake + delta) (#10)
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
ArticleContent,
|
||||
CarDataSample,
|
||||
ChampionshipHub,
|
||||
LapsComparisonResponse,
|
||||
LiveStateResponse,
|
||||
LiveSessionMeta,
|
||||
Meeting,
|
||||
@@ -126,3 +128,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
|
||||
body: JSON.stringify({ url: articleUrl }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchTelemetry(
|
||||
sessionKey: number,
|
||||
driverNumber: number,
|
||||
): Promise<CarDataSample[]> {
|
||||
const res = await fetch(
|
||||
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export async function fetchLapsComparison(
|
||||
sessionKey: number,
|
||||
drivers?: number[],
|
||||
): Promise<LapsComparisonResponse> {
|
||||
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||
if (drivers?.length) {
|
||||
params.set('drivers', drivers.join(','))
|
||||
}
|
||||
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
244
frontend/src/components/CompareView.tsx
Normal file
244
frontend/src/components/CompareView.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLapsComparison, fetchTelemetry } from '../api'
|
||||
import {
|
||||
buildBestLapTraceSeries,
|
||||
compareDriverOptions,
|
||||
comparisonToDeltaSeries,
|
||||
defaultCompareDriverNumbers,
|
||||
formatPitLapsCaption,
|
||||
} from '../lib/compare'
|
||||
import type { Driver, EnrichedResult } from '../types'
|
||||
import { teamColor } from '../utils'
|
||||
import { DriverCell } from './DriverCell'
|
||||
import { TelemetryTraceChart } from './charts/TelemetryTraceChart'
|
||||
import { DeltaTimeGraph } from './charts/DeltaTimeGraph'
|
||||
import '../styles/compare-view.css'
|
||||
|
||||
interface Props {
|
||||
sessionKey: number
|
||||
results: EnrichedResult[]
|
||||
drivers: Driver[]
|
||||
}
|
||||
|
||||
function SectionState({
|
||||
loading,
|
||||
error,
|
||||
empty,
|
||||
emptyMessage,
|
||||
children,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
empty: boolean
|
||||
emptyMessage: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="loading-state">loading…</div>
|
||||
}
|
||||
if (error) {
|
||||
return <div className="error-box">{error.message}</div>
|
||||
}
|
||||
if (empty) {
|
||||
return <div className="missing-notice">{emptyMessage}</div>
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function CompareView({ sessionKey, results, drivers }: Props) {
|
||||
const driverOptions = useMemo(
|
||||
() => compareDriverOptions(drivers, results),
|
||||
[drivers, results],
|
||||
)
|
||||
|
||||
const initialPair = useMemo(
|
||||
() => defaultCompareDriverNumbers(results, drivers),
|
||||
[results, drivers],
|
||||
)
|
||||
|
||||
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
|
||||
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (driverA != null && driverB != null) return
|
||||
const pair = defaultCompareDriverNumbers(results, drivers)
|
||||
if (!pair) return
|
||||
setDriverA(pair[0])
|
||||
setDriverB(pair[1])
|
||||
}, [results, drivers, driverA, driverB])
|
||||
|
||||
const pair = useMemo((): [number, number] | null => {
|
||||
if (driverA == null || driverB == null || driverA === driverB) return null
|
||||
return [driverA, driverB]
|
||||
}, [driverA, driverB])
|
||||
|
||||
const comparisonQuery = useQuery({
|
||||
queryKey: ['laps-comparison', sessionKey, pair?.[0], pair?.[1]],
|
||||
queryFn: () => fetchLapsComparison(sessionKey, pair!),
|
||||
enabled: pair != null,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const telemetryAQuery = useQuery({
|
||||
queryKey: ['telemetry', sessionKey, pair?.[0]],
|
||||
queryFn: () => fetchTelemetry(sessionKey, pair![0]),
|
||||
enabled: pair != null,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const telemetryBQuery = useQuery({
|
||||
queryKey: ['telemetry', sessionKey, pair?.[1]],
|
||||
queryFn: () => fetchTelemetry(sessionKey, pair![1]),
|
||||
enabled: pair != null,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const comparison = comparisonQuery.data
|
||||
const referenceLabel = useMemo(() => {
|
||||
if (!pair) return undefined
|
||||
const meta = comparison?.drivers.find((d) => d.driver_number === pair[0])
|
||||
const session = drivers.find((d) => d.driver_number === pair[0])
|
||||
return meta?.name_acronym || session?.name_acronym
|
||||
}, [pair, comparison, drivers])
|
||||
|
||||
const deltaSeries = useMemo(() => {
|
||||
if (!comparison || !pair) return []
|
||||
return comparisonToDeltaSeries(comparison, pair, drivers)
|
||||
}, [comparison, pair, drivers])
|
||||
|
||||
const pitCaption = useMemo(() => {
|
||||
if (!comparison || !pair) return null
|
||||
return formatPitLapsCaption(comparison.pit_laps, pair, comparison, drivers)
|
||||
}, [comparison, pair, drivers])
|
||||
|
||||
const traceSeries = useMemo(() => {
|
||||
if (!pair || !comparison) return []
|
||||
const out = []
|
||||
|
||||
for (const dn of pair) {
|
||||
const comp = comparison.drivers.find((d) => d.driver_number === dn)
|
||||
const session = drivers.find((d) => d.driver_number === dn)
|
||||
const label = comp?.name_acronym || session?.name_acronym || `#${dn}`
|
||||
const color = teamColor(comp?.team_colour || session?.team_colour)
|
||||
|
||||
const carData =
|
||||
dn === pair[0] ? (telemetryAQuery.data ?? []) : (telemetryBQuery.data ?? [])
|
||||
const series = buildBestLapTraceSeries(carData, comp?.laps ?? [], label, color)
|
||||
if (series) out.push(series)
|
||||
}
|
||||
|
||||
return out
|
||||
}, [pair, comparison, drivers, telemetryAQuery.data, telemetryBQuery.data])
|
||||
|
||||
const telemetryLoading = telemetryAQuery.isLoading || telemetryBQuery.isLoading
|
||||
const telemetryError = telemetryAQuery.error ?? telemetryBQuery.error
|
||||
|
||||
if (driverOptions.length < 2) {
|
||||
return (
|
||||
<div className="missing-notice" data-testid="compare-view-empty">
|
||||
Need at least two drivers in this session to compare.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const driverAInfo = driverOptions.find((d) => d.driver_number === driverA)
|
||||
const driverBInfo = driverOptions.find((d) => d.driver_number === driverB)
|
||||
|
||||
return (
|
||||
<div className="compare-view" data-testid="compare-view">
|
||||
<div className="compare-pickers">
|
||||
<div className="compare-picker">
|
||||
<span className="compare-picker-label">Reference</span>
|
||||
<select
|
||||
className="compare-picker-select"
|
||||
value={driverA ?? ''}
|
||||
onChange={(e) => setDriverA(Number(e.target.value))}
|
||||
data-testid="compare-picker-a"
|
||||
>
|
||||
{driverOptions.map((d) => (
|
||||
<option key={d.driver_number} value={d.driver_number}>
|
||||
{d.name_acronym} · #{d.driver_number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{driverAInfo && (
|
||||
<DriverCell
|
||||
acronym={driverAInfo.name_acronym}
|
||||
number={driverAInfo.driver_number}
|
||||
colour={driverAInfo.team_colour}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="compare-picker">
|
||||
<span className="compare-picker-label">Challenger</span>
|
||||
<select
|
||||
className="compare-picker-select"
|
||||
value={driverB ?? ''}
|
||||
onChange={(e) => setDriverB(Number(e.target.value))}
|
||||
data-testid="compare-picker-b"
|
||||
>
|
||||
{driverOptions.map((d) => (
|
||||
<option key={d.driver_number} value={d.driver_number}>
|
||||
{d.name_acronym} · #{d.driver_number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{driverBInfo && (
|
||||
<DriverCell
|
||||
acronym={driverBInfo.name_acronym}
|
||||
number={driverBInfo.driver_number}
|
||||
colour={driverBInfo.team_colour}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{driverA === driverB && (
|
||||
<div className="analysis-notice">
|
||||
<strong>Pick two different drivers</strong> to run a comparison.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="compare-section" data-testid="compare-telemetry-section">
|
||||
<div>
|
||||
<div className="compare-section-title">Best lap telemetry</div>
|
||||
<div className="compare-section-meta">
|
||||
Speed, throttle, and brake traces for each driver's fastest lap
|
||||
</div>
|
||||
</div>
|
||||
<SectionState
|
||||
loading={telemetryLoading}
|
||||
error={telemetryError instanceof Error ? telemetryError : null}
|
||||
empty={!telemetryLoading && !telemetryError && traceSeries.length === 0}
|
||||
emptyMessage="No telemetry samples for the best laps. Car data may not be available for this session."
|
||||
>
|
||||
<TelemetryTraceChart series={traceSeries} />
|
||||
</SectionState>
|
||||
</section>
|
||||
|
||||
<section className="compare-section" data-testid="compare-pace-section">
|
||||
<div>
|
||||
<div className="compare-section-title">Race pace</div>
|
||||
<div className="compare-section-meta">
|
||||
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}
|
||||
</div>
|
||||
</div>
|
||||
<SectionState
|
||||
loading={comparisonQuery.isLoading}
|
||||
error={comparisonQuery.error instanceof Error ? comparisonQuery.error : null}
|
||||
empty={!comparisonQuery.isLoading && !comparisonQuery.error && deltaSeries.length < 2}
|
||||
emptyMessage="No lap comparison data for the selected drivers."
|
||||
>
|
||||
<DeltaTimeGraph series={deltaSeries} referenceLabel={referenceLabel} />
|
||||
{pitCaption && (
|
||||
<p className="compare-pit-caption" data-testid="compare-pit-caption">
|
||||
{pitCaption}
|
||||
</p>
|
||||
)}
|
||||
</SectionState>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type Tab =
|
||||
| 'overview'
|
||||
| 'race_story'
|
||||
| 'strategy'
|
||||
| 'compare'
|
||||
| 'lap_data'
|
||||
| 'conditions'
|
||||
| 'race_control'
|
||||
@@ -11,6 +12,7 @@ const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'race_story', label: 'Race Story' },
|
||||
{ id: 'strategy', label: 'Strategy' },
|
||||
{ id: 'compare', label: 'Compare' },
|
||||
{ id: 'lap_data', label: 'Lap Data' },
|
||||
{ id: 'conditions', label: 'Conditions' },
|
||||
{ id: 'race_control', label: 'Race Control' },
|
||||
|
||||
202
frontend/src/lib/compare.ts
Normal file
202
frontend/src/lib/compare.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Driver comparison mapping — pure functions for telemetry traces and lap deltas.
|
||||
|
||||
import type { TelemetryTraceSeries } from '../components/charts/TelemetryTraceChart'
|
||||
import type { DeltaSeries } from '../lib/delta'
|
||||
import type {
|
||||
CarDataSample,
|
||||
ComparisonLap,
|
||||
Driver,
|
||||
EnrichedResult,
|
||||
LapsComparisonResponse,
|
||||
} from '../types'
|
||||
import { compareFinishPosition, teamColor } from '../utils'
|
||||
|
||||
function sortDriversByResults(drivers: Driver[], results: EnrichedResult[]): Driver[] {
|
||||
const order = new Map(
|
||||
[...results]
|
||||
.filter((r) => r.position > 0)
|
||||
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||
.map((r, i) => [r.driver_number, i]),
|
||||
)
|
||||
return [...drivers].sort((a, b) => {
|
||||
const ao = order.get(a.driver_number) ?? 999
|
||||
const bo = order.get(b.driver_number) ?? 999
|
||||
if (ao !== bo) return ao - bo
|
||||
return a.driver_number - b.driver_number
|
||||
})
|
||||
}
|
||||
|
||||
/** Default compare pair: top two classified finishers, else first two session drivers. */
|
||||
export function defaultCompareDriverNumbers(
|
||||
results: EnrichedResult[],
|
||||
drivers: Driver[],
|
||||
): [number, number] | null {
|
||||
const classified = [...results]
|
||||
.filter((r) => r.position > 0)
|
||||
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||
|
||||
if (classified.length >= 2) {
|
||||
return [classified[0].driver_number, classified[1].driver_number]
|
||||
}
|
||||
|
||||
if (drivers.length >= 2) {
|
||||
const sorted = sortDriversByResults(drivers, results)
|
||||
return [sorted[0].driver_number, sorted[1].driver_number]
|
||||
}
|
||||
|
||||
if (classified.length === 1 && drivers.length >= 1) {
|
||||
const other = drivers.find((d) => d.driver_number !== classified[0].driver_number)
|
||||
if (other) return [classified[0].driver_number, other.driver_number]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Lap records → per-lap duration array (index 0 = lap 1); null for pit/out laps. */
|
||||
export function lapsToLapTimes(laps: ComparisonLap[]): (number | null)[] {
|
||||
if (laps.length === 0) return []
|
||||
|
||||
const maxLap = laps.reduce((max, lap) => Math.max(max, lap.lap_number), 0)
|
||||
const times: (number | null)[] = Array.from({ length: maxLap }, () => null)
|
||||
|
||||
for (const lap of laps) {
|
||||
const idx = lap.lap_number - 1
|
||||
if (idx < 0) continue
|
||||
if (lap.is_pit_out_lap || lap.lap_duration == null || lap.lap_duration <= 0) {
|
||||
times[idx] = null
|
||||
} else {
|
||||
times[idx] = lap.lap_duration
|
||||
}
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
export function findBestLap(laps: ComparisonLap[]): ComparisonLap | null {
|
||||
let best: ComparisonLap | null = null
|
||||
for (const lap of laps) {
|
||||
if (lap.lap_duration == null || lap.lap_duration <= 0) continue
|
||||
if (!best || (best.lap_duration ?? Infinity) > lap.lap_duration) {
|
||||
best = lap
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Keep car-data samples whose timestamps fall within a lap window. */
|
||||
export function filterCarDataToLap(
|
||||
samples: CarDataSample[],
|
||||
lap: ComparisonLap,
|
||||
): CarDataSample[] {
|
||||
if (!lap.date_start || samples.length === 0) return samples
|
||||
|
||||
const start = new Date(lap.date_start).getTime()
|
||||
if (!Number.isFinite(start)) return samples
|
||||
|
||||
const end = start + (lap.lap_duration ?? 0) * 1000 + 500
|
||||
|
||||
return samples.filter((s) => {
|
||||
const t = new Date(s.date).getTime()
|
||||
return Number.isFinite(t) && t >= start && t <= end
|
||||
})
|
||||
}
|
||||
|
||||
export function carDataToTraceSeries(
|
||||
samples: CarDataSample[],
|
||||
label: string,
|
||||
color: string,
|
||||
): TelemetryTraceSeries {
|
||||
return {
|
||||
label,
|
||||
color,
|
||||
samples: samples.map((s) => ({
|
||||
speed: s.speed,
|
||||
throttle: s.throttle,
|
||||
brake: s.brake,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBestLapTraceSeries(
|
||||
carData: CarDataSample[],
|
||||
laps: ComparisonLap[],
|
||||
label: string,
|
||||
color: string,
|
||||
): TelemetryTraceSeries | null {
|
||||
const best = findBestLap(laps)
|
||||
if (!best) return null
|
||||
|
||||
const filtered = filterCarDataToLap(carData, best)
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
return carDataToTraceSeries(filtered, label, color)
|
||||
}
|
||||
|
||||
function driverMeta(
|
||||
driverNumber: number,
|
||||
comparison: LapsComparisonResponse | undefined,
|
||||
drivers: Driver[],
|
||||
): { label: string; color: string; laps: ComparisonLap[] } {
|
||||
const comp = comparison?.drivers.find((d) => d.driver_number === driverNumber)
|
||||
const session = drivers.find((d) => d.driver_number === driverNumber)
|
||||
return {
|
||||
label: comp?.name_acronym || session?.name_acronym || `#${driverNumber}`,
|
||||
color: teamColor(comp?.team_colour || session?.team_colour),
|
||||
laps: comp?.laps ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function comparisonToDeltaSeries(
|
||||
comparison: LapsComparisonResponse,
|
||||
driverNumbers: [number, number],
|
||||
drivers: Driver[],
|
||||
): DeltaSeries[] {
|
||||
return driverNumbers.map((dn) => {
|
||||
const meta = driverMeta(dn, comparison, drivers)
|
||||
return {
|
||||
label: meta.label,
|
||||
color: meta.color,
|
||||
lapTimes: lapsToLapTimes(meta.laps),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function formatPitLapsCaption(
|
||||
pitLaps: Record<string, number[]>,
|
||||
driverNumbers: [number, number],
|
||||
comparison: LapsComparisonResponse | undefined,
|
||||
drivers: Driver[],
|
||||
): string | null {
|
||||
const parts: string[] = []
|
||||
|
||||
for (const dn of driverNumbers) {
|
||||
const laps = pitLaps[String(dn)]
|
||||
if (!laps?.length) continue
|
||||
const meta = driverMeta(dn, comparison, drivers)
|
||||
parts.push(`${meta.label}: L${laps.join(', L')}`)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? `Pit stops — ${parts.join(' · ')}` : null
|
||||
}
|
||||
|
||||
export function compareDriverOptions(
|
||||
drivers: Driver[],
|
||||
results: EnrichedResult[],
|
||||
): Driver[] {
|
||||
if (drivers.length > 0) {
|
||||
return sortDriversByResults(drivers, results)
|
||||
}
|
||||
return results.map((r) => ({
|
||||
driver_number: r.driver_number,
|
||||
name_acronym: r.name_acronym,
|
||||
full_name: r.full_name,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
team_name: r.team_name,
|
||||
team_colour: r.team_colour,
|
||||
headshot_url: '',
|
||||
broadcast_name: r.full_name,
|
||||
session_key: r.session_key,
|
||||
meeting_key: r.meeting_key,
|
||||
}))
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { TabBar, type Tab } from '../components/TabBar'
|
||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||
import { StrategyView } from '../components/StrategyView'
|
||||
import { LapsView } from '../components/LapsView'
|
||||
import { CompareView } from '../components/CompareView'
|
||||
import { RaceControlView } from '../components/RaceControlView'
|
||||
import { WeatherView } from '../components/WeatherView'
|
||||
import { OverviewView } from '../components/OverviewView'
|
||||
@@ -307,6 +308,19 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'compare' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Driver Compare</span>
|
||||
</div>
|
||||
<CompareView
|
||||
sessionKey={sessionKey}
|
||||
results={data.results}
|
||||
drivers={data.drivers}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'lap_data' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
|
||||
78
frontend/src/styles/compare-view.css
Normal file
78
frontend/src/styles/compare-view.css
Normal file
@@ -0,0 +1,78 @@
|
||||
.compare-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.compare-pickers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s4);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.compare-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.compare-picker-label {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.compare-picker-select {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 13px;
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r1);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.compare-picker-select:focus {
|
||||
outline: 2px solid var(--gp-accent, var(--red));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.compare-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.compare-section-title {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.compare-section-meta {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.compare-pit-caption {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compare-stale-notice {
|
||||
font-size: 12px;
|
||||
color: var(--amber, #f59e0b);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: var(--r1);
|
||||
padding: var(--s2) var(--s3);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ describe('TabBar', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||
|
||||
341
frontend/src/test/compare-view.test.tsx
Normal file
341
frontend/src/test/compare-view.test.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { CompareView } from '../components/CompareView'
|
||||
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
|
||||
import {
|
||||
buildBestLapTraceSeries,
|
||||
carDataToTraceSeries,
|
||||
comparisonToDeltaSeries,
|
||||
defaultCompareDriverNumbers,
|
||||
filterCarDataToLap,
|
||||
findBestLap,
|
||||
formatPitLapsCaption,
|
||||
lapsToLapTimes,
|
||||
} from '../lib/compare'
|
||||
import { computeCumulativeDeltas } from '../lib/delta'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchTelemetry: vi.fn(),
|
||||
fetchLapsComparison: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchTelemetry, fetchLapsComparison } from '../api'
|
||||
|
||||
const mockFetchTelemetry = vi.mocked(fetchTelemetry)
|
||||
const mockFetchLapsComparison = vi.mocked(fetchLapsComparison)
|
||||
|
||||
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 drivers: Driver[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
first_name: 'Max',
|
||||
last_name: 'Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671C6',
|
||||
headshot_url: '',
|
||||
broadcast_name: 'M VERSTAPPEN',
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
first_name: 'Lewis',
|
||||
last_name: 'Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
headshot_url: '',
|
||||
broadcast_name: 'L HAMILTON',
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
]
|
||||
|
||||
const comparison: LapsComparisonResponse = {
|
||||
session_key: 9472,
|
||||
sc_periods: [],
|
||||
pit_laps: { '44': [19, 40] },
|
||||
drivers: [
|
||||
{
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
team_colour: '3671C6',
|
||||
laps: [
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
lap_number: 1,
|
||||
date_start: '2025-05-25T13:00:00.000Z',
|
||||
lap_duration: 92.1,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
lap_number: 2,
|
||||
date_start: '2025-05-25T13:01:32.100Z',
|
||||
lap_duration: 90.5,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
name_acronym: 'HAM',
|
||||
team_colour: 'E8002D',
|
||||
laps: [
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
lap_number: 1,
|
||||
date_start: '2025-05-25T13:00:01.000Z',
|
||||
lap_duration: 93.0,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
lap_number: 2,
|
||||
date_start: '2025-05-25T13:01:34.000Z',
|
||||
lap_duration: null,
|
||||
is_pit_out_lap: true,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
lap_number: 3,
|
||||
date_start: '2025-05-25T13:03:10.000Z',
|
||||
lap_duration: 91.2,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function renderCompareView() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompareView sessionKey={9472} results={results} drivers={drivers} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('compare mapping helpers', () => {
|
||||
it('defaults to top two finishers', () => {
|
||||
expect(defaultCompareDriverNumbers(results, drivers)).toEqual([1, 44])
|
||||
})
|
||||
|
||||
it('maps comparison laps to delta series with null pit laps', () => {
|
||||
const series = comparisonToDeltaSeries(comparison, [1, 44], drivers)
|
||||
expect(series).toHaveLength(2)
|
||||
expect(series[0].label).toBe('VER')
|
||||
expect(series[1].lapTimes[1]).toBeNull()
|
||||
|
||||
const deltas = computeCumulativeDeltas(series, 'VER')
|
||||
expect(deltas[0].deltas[0]).toBeCloseTo(0.9)
|
||||
})
|
||||
|
||||
it('maps car data to trace series', () => {
|
||||
const trace = carDataToTraceSeries(
|
||||
[
|
||||
{
|
||||
speed: 300,
|
||||
throttle: 100,
|
||||
brake: 0,
|
||||
date: '',
|
||||
driver_number: 1,
|
||||
drs: 0,
|
||||
meeting_key: 1,
|
||||
n_gear: 8,
|
||||
rpm: 12000,
|
||||
session_key: 1,
|
||||
},
|
||||
],
|
||||
'VER',
|
||||
'#3671C6',
|
||||
)
|
||||
expect(trace.samples[0]).toEqual({ speed: 300, throttle: 100, brake: 0 })
|
||||
})
|
||||
|
||||
it('filters car data to best lap window', () => {
|
||||
const best = findBestLap(comparison.drivers[0].laps)
|
||||
expect(best?.lap_number).toBe(2)
|
||||
|
||||
const samples = [
|
||||
{
|
||||
date: '2025-05-25T13:01:32.100Z',
|
||||
speed: 280,
|
||||
throttle: 90,
|
||||
brake: 0,
|
||||
driver_number: 1,
|
||||
drs: 0,
|
||||
meeting_key: 1229,
|
||||
n_gear: 7,
|
||||
rpm: 11000,
|
||||
session_key: 9472,
|
||||
},
|
||||
{
|
||||
date: '2025-05-25T13:00:00.000Z',
|
||||
speed: 200,
|
||||
throttle: 50,
|
||||
brake: 10,
|
||||
driver_number: 1,
|
||||
drs: 0,
|
||||
meeting_key: 1229,
|
||||
n_gear: 4,
|
||||
rpm: 9000,
|
||||
session_key: 9472,
|
||||
},
|
||||
]
|
||||
|
||||
const filtered = filterCarDataToLap(samples, best!)
|
||||
expect(filtered).toHaveLength(1)
|
||||
expect(filtered[0].speed).toBe(280)
|
||||
})
|
||||
|
||||
it('builds best-lap trace series from car data and laps', () => {
|
||||
const series = buildBestLapTraceSeries(
|
||||
[
|
||||
{
|
||||
date: '2025-05-25T13:01:33.000Z',
|
||||
speed: 310,
|
||||
throttle: 100,
|
||||
brake: 0,
|
||||
driver_number: 1,
|
||||
drs: 10,
|
||||
meeting_key: 1229,
|
||||
n_gear: 8,
|
||||
rpm: 12000,
|
||||
session_key: 9472,
|
||||
},
|
||||
],
|
||||
comparison.drivers[0].laps,
|
||||
'VER',
|
||||
'#3671C6',
|
||||
)
|
||||
expect(series?.samples).toHaveLength(1)
|
||||
expect(series?.samples[0].speed).toBe(310)
|
||||
})
|
||||
|
||||
it('maps laps to lap time arrays with nulls', () => {
|
||||
expect(lapsToLapTimes(comparison.drivers[1].laps)).toEqual([93.0, null, 91.2])
|
||||
})
|
||||
|
||||
it('formats pit lap captions', () => {
|
||||
expect(formatPitLapsCaption(comparison.pit_laps, [1, 44], comparison, drivers)).toBe(
|
||||
'Pit stops — HAM: L19, L40',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompareView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchLapsComparison.mockResolvedValue(comparison)
|
||||
mockFetchTelemetry.mockResolvedValue([
|
||||
{
|
||||
date: '2025-05-25T13:01:33.000Z',
|
||||
speed: 310,
|
||||
throttle: 100,
|
||||
brake: 0,
|
||||
driver_number: 1,
|
||||
drs: 10,
|
||||
meeting_key: 1229,
|
||||
n_gear: 8,
|
||||
rpm: 12000,
|
||||
session_key: 9472,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('renders pickers defaulting to top two finishers', async () => {
|
||||
renderCompareView()
|
||||
expect(screen.getByTestId('compare-picker-a')).toHaveValue('1')
|
||||
expect(screen.getByTestId('compare-picker-b')).toHaveValue('44')
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders telemetry and pace sections with mocked queries', async () => {
|
||||
renderCompareView()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchLapsComparison).toHaveBeenCalledWith(9472, [1, 44])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('telemetry-trace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('compare-pit-caption')).toHaveTextContent('HAM: L19, L40')
|
||||
})
|
||||
|
||||
it('shows error state when comparison fetch fails', async () => {
|
||||
mockFetchLapsComparison.mockRejectedValue(new Error('comparison unavailable'))
|
||||
renderCompareView()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('comparison unavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when fewer than two drivers', () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<CompareView sessionKey={9472} results={[]} drivers={[drivers[0]]} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByTestId('compare-view-empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -158,6 +158,37 @@ export interface Lap {
|
||||
is_pit_out_lap: boolean
|
||||
}
|
||||
|
||||
export interface CarDataSample {
|
||||
brake: number
|
||||
date: string
|
||||
driver_number: number
|
||||
drs: number
|
||||
meeting_key: number
|
||||
n_gear: number
|
||||
rpm: number
|
||||
session_key: number
|
||||
speed: number
|
||||
throttle: number
|
||||
}
|
||||
|
||||
export interface ComparisonLap extends Lap {
|
||||
compound?: string
|
||||
}
|
||||
|
||||
export interface ComparisonDriver {
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
team_colour: string
|
||||
laps: ComparisonLap[]
|
||||
}
|
||||
|
||||
export interface LapsComparisonResponse {
|
||||
session_key: number
|
||||
sc_periods: unknown[]
|
||||
pit_laps: Record<string, number[]>
|
||||
drivers: ComparisonDriver[]
|
||||
}
|
||||
|
||||
export interface WeekendSession {
|
||||
session: Session
|
||||
source: 'local' | 'partial' | 'none' | 'cancelled'
|
||||
|
||||
Reference in New Issue
Block a user