mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
feat(#13): Live tyre-degradation & pit-window panel
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
130
frontend/src/components/live/TyreDegPanel.tsx
Normal file
130
frontend/src/components/live/TyreDegPanel.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
|
import { compoundClass, driverCode, tyreLabel } from '../../lib/live'
|
||||||
|
import { sparklinePoints } from '../../lib/gapHistory'
|
||||||
|
import { isRaceSession } from '../../lib/battles'
|
||||||
|
import type { StintHistoryMap } from '../../lib/tyredeg'
|
||||||
|
import {
|
||||||
|
PIT_LOSS_SECONDS,
|
||||||
|
degradationModel,
|
||||||
|
estimatePitRejoin,
|
||||||
|
formatSlope,
|
||||||
|
recordStintSamples,
|
||||||
|
stintInputFromRow,
|
||||||
|
} from '../../lib/tyredeg'
|
||||||
|
import '../../styles/tyredeg.css'
|
||||||
|
|
||||||
|
const TOP_DRIVER_COUNT = 10
|
||||||
|
const SPARK_WIDTH = 64
|
||||||
|
const SPARK_HEIGHT = 16
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: LiveTimingRow[]
|
||||||
|
sessionType: string | undefined
|
||||||
|
pinned: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function StintSparkline({ seconds }: { seconds: number[] }) {
|
||||||
|
if (seconds.length < 2) return <span className="tyredeg-spark-empty">·</span>
|
||||||
|
const points = sparklinePoints(seconds, SPARK_WIDTH, SPARK_HEIGHT)
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="tyredeg-spark"
|
||||||
|
width={SPARK_WIDTH}
|
||||||
|
height={SPARK_HEIGHT}
|
||||||
|
viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
|
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||||
|
useEffect(() => {
|
||||||
|
if (rows.length === 0) return
|
||||||
|
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
|
const isRace = isRaceSession(sessionType)
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
rows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.Position > 0 &&
|
||||||
|
!row.Driver.Retired &&
|
||||||
|
(row.Position <= TOP_DRIVER_COUNT || pinned.includes(row.RacingNumber)),
|
||||||
|
),
|
||||||
|
[rows, pinned],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="live-tyredeg-panel" data-testid="tyredeg-panel">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sec-header tyredeg-toggle"
|
||||||
|
onClick={() => setCollapsed((prev) => !prev)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
>
|
||||||
|
<span className="sec-title">Tyre Deg & Pit Window</span>
|
||||||
|
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
|
||||||
|
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="tyredeg-rows">
|
||||||
|
{visible.map((row) => {
|
||||||
|
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||||
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||||
|
return (
|
||||||
|
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
|
||||||
|
<span className="tyredeg-pos mono">P{row.Position}</span>
|
||||||
|
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
||||||
|
<span className="drv-code">{driverCode(row)}</span>
|
||||||
|
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>{tyreLabel(row.Tyre)}</span>
|
||||||
|
{model ? (
|
||||||
|
<>
|
||||||
|
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
|
||||||
|
<StintSparkline seconds={model.samples.map((sample) => sample.seconds)} />
|
||||||
|
</span>
|
||||||
|
<span className={`tyredeg-slope mono tyredeg-trend-${model.trend}`}>
|
||||||
|
{formatSlope(model.slope)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="tyredeg-warmup">warming up</span>
|
||||||
|
)}
|
||||||
|
{rejoin && (
|
||||||
|
<span className="tyredeg-rejoin mono" title={`Pits now: rejoins ~P${rejoin.rejoinPosition}`}>
|
||||||
|
→ ~P{rejoin.rejoinPosition}
|
||||||
|
{rejoin.aheadCode && rejoin.behindCode && (
|
||||||
|
<span className="tyredeg-rejoin-between">
|
||||||
|
{rejoin.aheadCode} · {rejoin.behindCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
271
frontend/src/lib/tyredeg.ts
Normal file
271
frontend/src/lib/tyredeg.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
// Tyre degradation + pit-window estimation for the live timing page.
|
||||||
|
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||||||
|
//
|
||||||
|
// Lap-time samples are accumulated client-side from successive SSE snapshots
|
||||||
|
// (mirroring the gapHistory/battles precedent); nothing here talks to the server.
|
||||||
|
|
||||||
|
import type { LiveTimingRow } from './live'
|
||||||
|
import { driverCode } from './live'
|
||||||
|
import { parseIntervalSeconds } from './gapHistory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough typical pit-lane time loss (entry + stop + exit vs a flying lap), in
|
||||||
|
* seconds. Per-track values are deliberately out of scope for v1.
|
||||||
|
*/
|
||||||
|
export const PIT_LOSS_SECONDS = 22
|
||||||
|
|
||||||
|
/** Minimum clean laps in the current stint before a slope is trustworthy. */
|
||||||
|
export const MIN_CLEAN_LAPS = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laps more than this many seconds off the stint median are treated as
|
||||||
|
* outliers (traffic, spins, safety car) and excluded from the fit.
|
||||||
|
*/
|
||||||
|
export const OUTLIER_DELTA_SECONDS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slope classification thresholds (seconds per lap):
|
||||||
|
* slope <= -DEG_SLOPE_THRESHOLD -> 'improving' (track evolution, fuel burn dominating)
|
||||||
|
* slope >= +DEG_SLOPE_THRESHOLD -> 'degrading' (tyre wear dominating)
|
||||||
|
* otherwise -> 'stable'
|
||||||
|
*/
|
||||||
|
export const DEG_SLOPE_THRESHOLD = 0.05
|
||||||
|
|
||||||
|
/** Safety cap so a very long stint cannot grow the buffer unbounded. */
|
||||||
|
export const MAX_STINT_SAMPLES = 80
|
||||||
|
|
||||||
|
export type DegTrend = 'improving' | 'stable' | 'degrading'
|
||||||
|
|
||||||
|
export interface StintSample {
|
||||||
|
lap: number
|
||||||
|
seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverStintHistory {
|
||||||
|
compound: string
|
||||||
|
tyreAge: number
|
||||||
|
lastLapNumber: number
|
||||||
|
lastLapTime: string
|
||||||
|
/** The next completed lap is an out-lap (fresh stint) and must be discarded. */
|
||||||
|
skipNextLap: boolean
|
||||||
|
samples: StintSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-driver current-stint lap history, keyed by racing number. */
|
||||||
|
export type StintHistoryMap = Record<string, DriverStintHistory>
|
||||||
|
|
||||||
|
export interface StintLapInput {
|
||||||
|
racingNumber: string
|
||||||
|
lapNumber: number
|
||||||
|
lastLapTime: string
|
||||||
|
compound: string
|
||||||
|
tyreAge: number
|
||||||
|
inPit: boolean
|
||||||
|
pitOut: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an F1 live-timing lap time string ("1:23.456", "83.456") into seconds.
|
||||||
|
* Same conventions as parseIntervalSeconds, but a lap time is never signed and
|
||||||
|
* never a lapped/leader marker; zero or negative values are rejected.
|
||||||
|
*/
|
||||||
|
export function parseLapTimeSeconds(raw: string | null | undefined): number | null {
|
||||||
|
const value = parseIntervalSeconds(raw)
|
||||||
|
if (value === null || value <= 0) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stintInputFromRow(row: LiveTimingRow): StintLapInput {
|
||||||
|
return {
|
||||||
|
racingNumber: row.RacingNumber,
|
||||||
|
lapNumber: row.Driver.NumberOfLaps || 0,
|
||||||
|
lastLapTime: row.Driver.LastLapTime || '',
|
||||||
|
compound: row.Tyre?.Compound || '',
|
||||||
|
tyreAge: row.Tyre?.Age ?? 0,
|
||||||
|
inPit: Boolean(row.Driver.InPit),
|
||||||
|
pitOut: Boolean(row.Driver.PitOut),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record one snapshot's worth of lap samples for each driver's current stint.
|
||||||
|
* Returns a new map (input is not mutated); drivers missing from `inputs` are
|
||||||
|
* pruned.
|
||||||
|
*
|
||||||
|
* A lap counts as completed when LastLapTime changes to a new non-empty value
|
||||||
|
* (NumberOfLaps can tick before the lap time arrives, so the time string is
|
||||||
|
* the trigger; a sample for the same lap number is replaced, not duplicated).
|
||||||
|
* The stint buffer resets when the compound changes or the tyre age drops
|
||||||
|
* (new set fitted), and laps completed in the pit lane (in-lap), on pit exit
|
||||||
|
* (out-lap), or immediately after a reset are excluded.
|
||||||
|
*/
|
||||||
|
export function recordStintSamples(
|
||||||
|
history: StintHistoryMap,
|
||||||
|
inputs: ReadonlyArray<StintLapInput>,
|
||||||
|
): StintHistoryMap {
|
||||||
|
const next: StintHistoryMap = {}
|
||||||
|
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (!input.racingNumber) continue
|
||||||
|
|
||||||
|
const prior = history[input.racingNumber]
|
||||||
|
if (!prior) {
|
||||||
|
next[input.racingNumber] = {
|
||||||
|
compound: input.compound,
|
||||||
|
tyreAge: input.tyreAge,
|
||||||
|
lastLapNumber: input.lapNumber,
|
||||||
|
lastLapTime: input.lastLapTime,
|
||||||
|
skipNextLap: false,
|
||||||
|
samples: [],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples = prior.samples
|
||||||
|
let skipNextLap = prior.skipNextLap
|
||||||
|
|
||||||
|
const compoundChanged = Boolean(input.compound) && Boolean(prior.compound) && input.compound !== prior.compound
|
||||||
|
const freshSet = input.tyreAge < prior.tyreAge
|
||||||
|
if (compoundChanged || freshSet || input.inPit) {
|
||||||
|
// Stint over (or a new one starting): drop the old laps and flag the
|
||||||
|
// upcoming out-lap for exclusion.
|
||||||
|
samples = []
|
||||||
|
skipNextLap = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const lapCompleted = Boolean(input.lastLapTime) && input.lastLapTime !== prior.lastLapTime
|
||||||
|
if (lapCompleted) {
|
||||||
|
const seconds = parseLapTimeSeconds(input.lastLapTime)
|
||||||
|
const dirty = input.inPit || input.pitOut || skipNextLap
|
||||||
|
if (seconds !== null && !dirty) {
|
||||||
|
const last = samples[samples.length - 1]
|
||||||
|
if (last && last.lap === input.lapNumber) {
|
||||||
|
samples = [...samples.slice(0, -1), { lap: input.lapNumber, seconds }]
|
||||||
|
} else {
|
||||||
|
samples = [...samples, { lap: input.lapNumber, seconds }]
|
||||||
|
if (samples.length > MAX_STINT_SAMPLES) {
|
||||||
|
samples = samples.slice(samples.length - MAX_STINT_SAMPLES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!input.inPit) skipNextLap = false
|
||||||
|
}
|
||||||
|
|
||||||
|
next[input.racingNumber] = {
|
||||||
|
compound: input.compound || prior.compound,
|
||||||
|
tyreAge: input.tyreAge,
|
||||||
|
lastLapNumber: input.lapNumber,
|
||||||
|
lastLapTime: input.lastLapTime || prior.lastLapTime,
|
||||||
|
skipNextLap,
|
||||||
|
samples,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DegModel {
|
||||||
|
/** Least-squares slope of lap time vs lap number, in seconds per lap. */
|
||||||
|
slope: number
|
||||||
|
trend: DegTrend
|
||||||
|
/** Clean (outlier-filtered) samples the fit ran over, in lap order. */
|
||||||
|
samples: StintSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop laps more than OUTLIER_DELTA_SECONDS off the stint median. */
|
||||||
|
export function cleanStintSamples(
|
||||||
|
samples: ReadonlyArray<StintSample>,
|
||||||
|
maxDelta = OUTLIER_DELTA_SECONDS,
|
||||||
|
): StintSample[] {
|
||||||
|
if (samples.length === 0) return []
|
||||||
|
const sorted = samples.map((sample) => sample.seconds).sort((a, b) => a - b)
|
||||||
|
const mid = Math.floor(sorted.length / 2)
|
||||||
|
const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||||
|
return samples.filter((sample) => Math.abs(sample.seconds - median) <= maxDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit a linear degradation model over the stint's clean laps.
|
||||||
|
* Returns null with fewer than MIN_CLEAN_LAPS clean samples — the caller
|
||||||
|
* should show a "warming up" placeholder rather than a junk slope.
|
||||||
|
*/
|
||||||
|
export function degradationModel(samples: ReadonlyArray<StintSample>): DegModel | null {
|
||||||
|
const clean = cleanStintSamples(samples)
|
||||||
|
if (clean.length < MIN_CLEAN_LAPS) return null
|
||||||
|
|
||||||
|
const n = clean.length
|
||||||
|
const meanLap = clean.reduce((sum, s) => sum + s.lap, 0) / n
|
||||||
|
const meanSec = clean.reduce((sum, s) => sum + s.seconds, 0) / n
|
||||||
|
|
||||||
|
let numerator = 0
|
||||||
|
let denominator = 0
|
||||||
|
for (const sample of clean) {
|
||||||
|
numerator += (sample.lap - meanLap) * (sample.seconds - meanSec)
|
||||||
|
denominator += (sample.lap - meanLap) ** 2
|
||||||
|
}
|
||||||
|
if (denominator === 0) return null
|
||||||
|
|
||||||
|
const slope = numerator / denominator
|
||||||
|
const trend: DegTrend =
|
||||||
|
slope <= -DEG_SLOPE_THRESHOLD ? 'improving' : slope >= DEG_SLOPE_THRESHOLD ? 'degrading' : 'stable'
|
||||||
|
|
||||||
|
return { slope, trend, samples: clean }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "+0.08s/lap" / "-0.12s/lap" display form. */
|
||||||
|
export function formatSlope(slope: number): string {
|
||||||
|
const sign = slope >= 0 ? '+' : '−'
|
||||||
|
return `${sign}${Math.abs(slope).toFixed(2)}s/lap`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PitRejoinEstimate {
|
||||||
|
rejoinPosition: number
|
||||||
|
/** Cars behind that would get past during the stop. */
|
||||||
|
positionsLost: number
|
||||||
|
/** Driver code directly ahead after rejoining, if any. */
|
||||||
|
aheadCode: string | null
|
||||||
|
/** Driver code directly behind after rejoining, if any. */
|
||||||
|
behindCode: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate where a driver rejoins after a pit stop costing `pitLoss` seconds.
|
||||||
|
*
|
||||||
|
* Walks the cars behind the driver, accumulating their Interval (gap to car
|
||||||
|
* ahead) values: every car whose cumulative gap to the driver is under the
|
||||||
|
* pit loss gets past. An unparsable interval (lapped marker like "1L") ends
|
||||||
|
* the walk — those cars are at least a lap down and stay behind. Cars in the
|
||||||
|
* pits or retired are excluded from the ladder.
|
||||||
|
*/
|
||||||
|
export function estimatePitRejoin(
|
||||||
|
rows: ReadonlyArray<LiveTimingRow>,
|
||||||
|
racingNumber: string,
|
||||||
|
pitLoss = PIT_LOSS_SECONDS,
|
||||||
|
): PitRejoinEstimate | null {
|
||||||
|
const ladder = rows
|
||||||
|
.filter((row) => row.Position > 0 && !row.Driver.Retired && !row.Driver.InPit)
|
||||||
|
.sort((a, b) => a.Position - b.Position)
|
||||||
|
|
||||||
|
const index = ladder.findIndex((row) => row.RacingNumber === racingNumber)
|
||||||
|
if (index === -1) return null
|
||||||
|
|
||||||
|
let cumulative = 0
|
||||||
|
let positionsLost = 0
|
||||||
|
for (let i = index + 1; i < ladder.length; i++) {
|
||||||
|
const gap = parseIntervalSeconds(ladder[i].Driver.Interval)
|
||||||
|
if (gap === null) break
|
||||||
|
cumulative += Math.max(0, gap)
|
||||||
|
if (cumulative >= pitLoss) break
|
||||||
|
positionsLost += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const ahead = positionsLost > 0 ? ladder[index + positionsLost] : ladder[index - 1]
|
||||||
|
const behind = ladder[index + positionsLost + 1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
rejoinPosition: ladder[index].Position + positionsLost,
|
||||||
|
positionsLost,
|
||||||
|
aheadCode: ahead ? driverCode(ahead) : null,
|
||||||
|
behindCode: behind ? driverCode(behind) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { BattleChips } from '../components/live/BattleChips'
|
|||||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||||
import { TrackMap } from '../components/live/TrackMap'
|
import { TrackMap } from '../components/live/TrackMap'
|
||||||
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||||
import { Radio } from 'lucide-react'
|
import { Radio } from 'lucide-react'
|
||||||
|
|
||||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
@@ -193,6 +194,7 @@ export function LiveTimingPage() {
|
|||||||
driverInfo={snapshot.DriverInfo}
|
driverInfo={snapshot.DriverInfo}
|
||||||
loading={trackOutlineQuery.isLoading}
|
loading={trackOutlineQuery.isLoading}
|
||||||
/>
|
/>
|
||||||
|
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||||
<div className="live-columns">
|
<div className="live-columns">
|
||||||
<div className="live-tower-col">
|
<div className="live-tower-col">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
|
|||||||
84
frontend/src/styles/tyredeg.css
Normal file
84
frontend/src/styles/tyredeg.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/* Live tyre-degradation & pit-window panel (see components/live/TyreDegPanel.tsx) */
|
||||||
|
|
||||||
|
.live-tyredeg-panel {
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-toggle {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s2) var(--s4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-row .drv-bar { height: 16px; }
|
||||||
|
|
||||||
|
.tyredeg-pos {
|
||||||
|
min-width: 28px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-spark { display: block; }
|
||||||
|
.tyredeg-spark-empty { color: var(--text-3); }
|
||||||
|
|
||||||
|
.tyredeg-trend { display: inline-flex; align-items: center; }
|
||||||
|
.tyredeg-trend-improving { color: var(--green); }
|
||||||
|
.tyredeg-trend-stable { color: var(--text-3); }
|
||||||
|
.tyredeg-trend-degrading { color: #ff6b6b; }
|
||||||
|
|
||||||
|
.tyredeg-slope {
|
||||||
|
min-width: 78px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-warmup {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-rejoin {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-rejoin-between {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tyredeg-rejoin-between { display: none; }
|
||||||
|
.tyredeg-slope { min-width: 0; }
|
||||||
|
}
|
||||||
99
frontend/src/test/TyreDegPanel.test.tsx
Normal file
99
frontend/src/test/TyreDegPanel.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||||
|
import type { LiveTimingRow } from '../lib/live'
|
||||||
|
import type { LiveDriverData, LiveTyreData } from '../types'
|
||||||
|
|
||||||
|
function makeRow(
|
||||||
|
number: string,
|
||||||
|
position: number,
|
||||||
|
tla: string,
|
||||||
|
driver: Partial<LiveDriverData> = {},
|
||||||
|
tyre: Partial<LiveTyreData> = {},
|
||||||
|
): LiveTimingRow {
|
||||||
|
return {
|
||||||
|
RacingNumber: number,
|
||||||
|
Position: position,
|
||||||
|
Driver: {
|
||||||
|
RacingNumber: number,
|
||||||
|
Position: position,
|
||||||
|
Interval: position === 1 ? '' : '+5.0',
|
||||||
|
GapToLeader: '',
|
||||||
|
LastLapTime: '1:30.000',
|
||||||
|
NumberOfLaps: 10,
|
||||||
|
InPit: false,
|
||||||
|
PitOut: false,
|
||||||
|
Retired: false,
|
||||||
|
...driver,
|
||||||
|
} as LiveDriverData,
|
||||||
|
Info: {
|
||||||
|
RacingNumber: number,
|
||||||
|
BroadcastName: '',
|
||||||
|
Tla: tla,
|
||||||
|
TeamName: '',
|
||||||
|
TeamColour: '3671c6',
|
||||||
|
FirstName: '',
|
||||||
|
LastName: '',
|
||||||
|
},
|
||||||
|
Tyre: { Compound: 'MEDIUM', New: false, Age: 5, ...tyre } as LiveTyreData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
||||||
|
return [
|
||||||
|
makeRow('1', 1, 'VER', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
|
||||||
|
makeRow('4', 2, 'NOR', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TyreDegPanel', () => {
|
||||||
|
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
|
||||||
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
expect(panel).toHaveTextContent('VER')
|
||||||
|
expect(panel).toHaveTextContent('M +5')
|
||||||
|
expect(panel).toHaveTextContent('warming up')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
)
|
||||||
|
// Five completed laps at +0.1s/lap after the first observed snapshot.
|
||||||
|
for (let lap = 2; lap <= 6; lap++) {
|
||||||
|
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||||
|
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
expect(panel).toHaveTextContent('+0.10s/lap')
|
||||||
|
expect(panel).not.toHaveTextContent('warming up')
|
||||||
|
// VER pits from P1 with NOR +5.0 behind: NOR gets past -> rejoin ~P2.
|
||||||
|
expect(panel).toHaveTextContent('→ ~P2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
|
||||||
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
||||||
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
|
expect(panel).not.toHaveTextContent('~P')
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits rows to the top ten plus pinned drivers', () => {
|
||||||
|
const rows = Array.from({ length: 15 }, (_, index) =>
|
||||||
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
|
)
|
||||||
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
||||||
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||||
|
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing without rows', () => {
|
||||||
|
render(<TyreDegPanel rows={[]} sessionType="Race" pinned={[]} />)
|
||||||
|
expect(screen.queryByTestId('tyredeg-panel')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
281
frontend/src/test/tyredeg.test.ts
Normal file
281
frontend/src/test/tyredeg.test.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEG_SLOPE_THRESHOLD,
|
||||||
|
MIN_CLEAN_LAPS,
|
||||||
|
PIT_LOSS_SECONDS,
|
||||||
|
cleanStintSamples,
|
||||||
|
degradationModel,
|
||||||
|
estimatePitRejoin,
|
||||||
|
formatSlope,
|
||||||
|
parseLapTimeSeconds,
|
||||||
|
recordStintSamples,
|
||||||
|
stintInputFromRow,
|
||||||
|
} from '../lib/tyredeg'
|
||||||
|
import type { StintHistoryMap, StintLapInput, StintSample } from '../lib/tyredeg'
|
||||||
|
import type { LiveTimingRow } from '../lib/live'
|
||||||
|
import type { LiveDriverData } from '../types'
|
||||||
|
|
||||||
|
function makeInput(overrides: Partial<StintLapInput> = {}): StintLapInput {
|
||||||
|
return {
|
||||||
|
racingNumber: '1',
|
||||||
|
lapNumber: 1,
|
||||||
|
lastLapTime: '1:30.000',
|
||||||
|
compound: 'MEDIUM',
|
||||||
|
tyreAge: 1,
|
||||||
|
inPit: false,
|
||||||
|
pitOut: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed a sequence of snapshots for one driver through the accumulator. */
|
||||||
|
function accumulate(snapshots: Array<Partial<StintLapInput>>): StintHistoryMap {
|
||||||
|
let history: StintHistoryMap = {}
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
history = recordStintSamples(history, [makeInput(snapshot)])
|
||||||
|
}
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRow(
|
||||||
|
number: string,
|
||||||
|
position: number,
|
||||||
|
tla: string,
|
||||||
|
driver: Partial<LiveDriverData> = {},
|
||||||
|
): LiveTimingRow {
|
||||||
|
return {
|
||||||
|
RacingNumber: number,
|
||||||
|
Position: position,
|
||||||
|
Driver: {
|
||||||
|
RacingNumber: number,
|
||||||
|
Position: position,
|
||||||
|
Interval: '',
|
||||||
|
GapToLeader: '',
|
||||||
|
LastLapTime: '1:30.000',
|
||||||
|
NumberOfLaps: 10,
|
||||||
|
InPit: false,
|
||||||
|
PitOut: false,
|
||||||
|
Retired: false,
|
||||||
|
...driver,
|
||||||
|
} as LiveDriverData,
|
||||||
|
Info: {
|
||||||
|
RacingNumber: number,
|
||||||
|
BroadcastName: '',
|
||||||
|
Tla: tla,
|
||||||
|
TeamName: '',
|
||||||
|
TeamColour: '3671c6',
|
||||||
|
FirstName: '',
|
||||||
|
LastName: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseLapTimeSeconds', () => {
|
||||||
|
it('parses minute-form and plain-second lap times', () => {
|
||||||
|
expect(parseLapTimeSeconds('1:23.456')).toBeCloseTo(83.456)
|
||||||
|
expect(parseLapTimeSeconds('83.456')).toBeCloseTo(83.456)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty, garbage, and non-positive values', () => {
|
||||||
|
expect(parseLapTimeSeconds('')).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds(undefined)).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds(null)).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds('LAP 12')).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds('-1:30.000')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recordStintSamples', () => {
|
||||||
|
it('accumulates one sample per completed lap across snapshots', () => {
|
||||||
|
const history = accumulate([
|
||||||
|
{ lapNumber: 1, lastLapTime: '1:30.000' },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.100' },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.100' }, // repeated snapshot, same lap
|
||||||
|
{ lapNumber: 3, lastLapTime: '1:30.200' },
|
||||||
|
])
|
||||||
|
expect(history['1'].samples).toEqual([
|
||||||
|
{ lap: 2, seconds: 90.1 },
|
||||||
|
{ lap: 3, seconds: 90.2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not record a lap from the very first snapshot seen', () => {
|
||||||
|
const history = accumulate([{ lapNumber: 5, lastLapTime: '1:29.000' }])
|
||||||
|
expect(history['1'].samples).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces (not duplicates) a sample when the lap time is corrected', () => {
|
||||||
|
const history = accumulate([
|
||||||
|
{ lapNumber: 1, lastLapTime: '1:30.000' },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.500' },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.400' }, // late correction for lap 2
|
||||||
|
])
|
||||||
|
expect(history['1'].samples).toEqual([{ lap: 2, seconds: 90.4 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets the stint on compound change and skips the next lap', () => {
|
||||||
|
const history = accumulate([
|
||||||
|
{ lapNumber: 1, lastLapTime: '1:30.000', compound: 'SOFT', tyreAge: 1 },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.100', compound: 'SOFT', tyreAge: 2 },
|
||||||
|
{ lapNumber: 3, lastLapTime: '1:30.200', compound: 'SOFT', tyreAge: 3 },
|
||||||
|
// Boxed for hards: old samples dropped, out-lap (lap 4) excluded.
|
||||||
|
{ lapNumber: 4, lastLapTime: '1:52.000', compound: 'HARD', tyreAge: 0 },
|
||||||
|
{ lapNumber: 5, lastLapTime: '1:31.000', compound: 'HARD', tyreAge: 1 },
|
||||||
|
{ lapNumber: 6, lastLapTime: '1:31.100', compound: 'HARD', tyreAge: 2 },
|
||||||
|
])
|
||||||
|
expect(history['1'].samples).toEqual([
|
||||||
|
{ lap: 5, seconds: 91.0 },
|
||||||
|
{ lap: 6, seconds: 91.1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets on a fresh set of the same compound (tyre age drops)', () => {
|
||||||
|
const history = accumulate([
|
||||||
|
{ lapNumber: 1, lastLapTime: '1:30.000', tyreAge: 10 },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.100', tyreAge: 11 },
|
||||||
|
{ lapNumber: 3, lastLapTime: '1:50.000', tyreAge: 0 }, // new mediums
|
||||||
|
{ lapNumber: 4, lastLapTime: '1:30.500', tyreAge: 1 },
|
||||||
|
])
|
||||||
|
expect(history['1'].samples).toEqual([{ lap: 4, seconds: 90.5 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes laps completed in the pit lane or on pit exit', () => {
|
||||||
|
const history = accumulate([
|
||||||
|
{ lapNumber: 1, lastLapTime: '1:30.000' },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.100' },
|
||||||
|
{ lapNumber: 3, lastLapTime: '1:48.000', inPit: true }, // in-lap
|
||||||
|
{ lapNumber: 4, lastLapTime: '1:45.000', pitOut: true, tyreAge: 0 }, // out-lap
|
||||||
|
{ lapNumber: 5, lastLapTime: '1:30.300', tyreAge: 1 },
|
||||||
|
])
|
||||||
|
expect(history['1'].samples).toEqual([{ lap: 5, seconds: 90.3 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prunes drivers missing from the snapshot and does not mutate input', () => {
|
||||||
|
const initial = accumulate([
|
||||||
|
{ lapNumber: 1, lastLapTime: '1:30.000' },
|
||||||
|
{ lapNumber: 2, lastLapTime: '1:30.100' },
|
||||||
|
])
|
||||||
|
const next = recordStintSamples(initial, [makeInput({ racingNumber: '44' })])
|
||||||
|
expect(next['1']).toBeUndefined()
|
||||||
|
expect(next['44']).toBeDefined()
|
||||||
|
expect(initial['1'].samples).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('degradationModel', () => {
|
||||||
|
const series = (times: number[], startLap = 1): StintSample[] =>
|
||||||
|
times.map((seconds, index) => ({ lap: startLap + index, seconds }))
|
||||||
|
|
||||||
|
it('fits the slope of a known linear series', () => {
|
||||||
|
const model = degradationModel(series([90.0, 90.1, 90.2, 90.3, 90.4]))
|
||||||
|
expect(model).not.toBeNull()
|
||||||
|
expect(model!.slope).toBeCloseTo(0.1, 5)
|
||||||
|
expect(model!.trend).toBe('degrading')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies improving and stable stints', () => {
|
||||||
|
expect(degradationModel(series([91.0, 90.8, 90.6, 90.4]))!.trend).toBe('improving')
|
||||||
|
expect(degradationModel(series([90.0, 90.01, 90.0, 90.02, 90.01]))!.trend).toBe('stable')
|
||||||
|
// A slope clearly under the threshold stays stable.
|
||||||
|
expect(DEG_SLOPE_THRESHOLD).toBeGreaterThan(0.03)
|
||||||
|
expect(degradationModel(series([90.0, 90.03, 90.06, 90.09, 90.12]))!.trend).toBe('stable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes outliers more than ~5s off the stint median', () => {
|
||||||
|
// Clean laps follow +0.1s/lap; the 97.5 (traffic/spin) must not skew the fit.
|
||||||
|
const samples = series([90.0, 90.1, 97.5, 90.3, 90.4, 90.5])
|
||||||
|
expect(cleanStintSamples(samples)).toHaveLength(5)
|
||||||
|
const model = degradationModel(samples)
|
||||||
|
expect(model!.samples).toHaveLength(5)
|
||||||
|
expect(model!.slope).toBeCloseTo(0.1, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null with fewer than MIN_CLEAN_LAPS clean laps', () => {
|
||||||
|
expect(degradationModel(series([90.0, 90.1, 90.2]))).toBeNull()
|
||||||
|
// 4 raw laps but one outlier -> only 3 clean -> still warming up
|
||||||
|
expect(degradationModel(series([90.0, 90.1, 90.2, 99.0]))).toBeNull()
|
||||||
|
expect(MIN_CLEAN_LAPS).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatSlope', () => {
|
||||||
|
it('renders signed seconds-per-lap', () => {
|
||||||
|
expect(formatSlope(0.083)).toBe('+0.08s/lap')
|
||||||
|
expect(formatSlope(-0.125)).toBe('−0.13s/lap')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('estimatePitRejoin', () => {
|
||||||
|
const ladder = [
|
||||||
|
makeRow('1', 1, 'VER'),
|
||||||
|
makeRow('4', 2, 'NOR', { Interval: '+5.0' }),
|
||||||
|
makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
|
||||||
|
makeRow('14', 4, 'ALO', { Interval: '+15.0' }),
|
||||||
|
makeRow('16', 5, 'LEC', { Interval: '+20.0' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
it('projects the rejoin slot between the right cars', () => {
|
||||||
|
// VER pits from P1: NOR (5s) and HAM (15s cumulative) get past;
|
||||||
|
// ALO would be 30s back -> stays behind. Rejoin ~P3.
|
||||||
|
const estimate = estimatePitRejoin(ladder, '1')
|
||||||
|
expect(estimate).toEqual({
|
||||||
|
rejoinPosition: 3,
|
||||||
|
positionsLost: 2,
|
||||||
|
aheadCode: 'HAM',
|
||||||
|
behindCode: 'ALO',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps position when the cars behind are further than the pit loss', () => {
|
||||||
|
const estimate = estimatePitRejoin(ladder, '14')
|
||||||
|
// LEC is 20s behind ALO — inside the 22s window, so he gets past.
|
||||||
|
expect(estimate!.rejoinPosition).toBe(5)
|
||||||
|
expect(estimate!.aheadCode).toBe('LEC')
|
||||||
|
expect(estimate!.behindCode).toBeNull()
|
||||||
|
|
||||||
|
const wide = estimatePitRejoin(
|
||||||
|
[makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: `+${PIT_LOSS_SECONDS + 3}.0` })],
|
||||||
|
'1',
|
||||||
|
)
|
||||||
|
expect(wide!.rejoinPosition).toBe(1)
|
||||||
|
expect(wide!.positionsLost).toBe(0)
|
||||||
|
expect(wide!.behindCode).toBe('NOR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops the ladder walk at lapped cars and skips pitted/retired ones', () => {
|
||||||
|
const estimate = estimatePitRejoin(
|
||||||
|
[
|
||||||
|
makeRow('1', 1, 'VER'),
|
||||||
|
makeRow('4', 2, 'NOR', { Interval: '+5.0', InPit: true }), // mid-stop, excluded
|
||||||
|
makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
|
||||||
|
makeRow('16', 4, 'LEC', { Interval: '1L' }), // lapped — walk ends here
|
||||||
|
makeRow('14', 5, 'ALO', { Interval: '+2.0' }),
|
||||||
|
],
|
||||||
|
'1',
|
||||||
|
)
|
||||||
|
expect(estimate!.positionsLost).toBe(1) // only HAM gets past
|
||||||
|
expect(estimate!.rejoinPosition).toBe(2)
|
||||||
|
expect(estimate!.aheadCode).toBe('HAM')
|
||||||
|
expect(estimate!.behindCode).toBe('LEC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for unknown drivers', () => {
|
||||||
|
expect(estimatePitRejoin(ladder, '99')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('stintInputFromRow', () => {
|
||||||
|
it('maps tower rows into accumulator inputs', () => {
|
||||||
|
const row = makeRow('4', 2, 'NOR', { NumberOfLaps: 12, LastLapTime: '1:29.900' })
|
||||||
|
row.Tyre = { Compound: 'HARD', New: false, Age: 7 }
|
||||||
|
expect(stintInputFromRow(row)).toEqual({
|
||||||
|
racingNumber: '4',
|
||||||
|
lapNumber: 12,
|
||||||
|
lastLapTime: '1:29.900',
|
||||||
|
compound: 'HARD',
|
||||||
|
tyreAge: 7,
|
||||||
|
inPit: false,
|
||||||
|
pitOut: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user