Compare commits
9 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6fa531f2 | ||
|
|
ad379e0f07 | ||
|
|
cc4337be88 | ||
|
|
addfd6d24d | ||
|
|
b5d070e116 | ||
|
|
233eefaf12 | ||
|
|
51b0238b09 | ||
|
|
24bcac8038 | ||
|
|
93ac0ccebb |
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
|
||||||
|
|||||||
26
frontend/src/components/Meaning.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import '../styles/meaning.css'
|
||||||
|
|
||||||
|
export interface MeaningProps {
|
||||||
|
value: ReactNode
|
||||||
|
meaning?: string | null
|
||||||
|
/** Long-form explanation for the native tooltip; falls back to meaning. */
|
||||||
|
title?: string | null
|
||||||
|
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Meaning({ value, meaning, title, tone }: MeaningProps) {
|
||||||
|
if (!meaning) {
|
||||||
|
return <>{value}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = title ?? meaning
|
||||||
|
const toneClass = tone ? `meaning-caption--${tone}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="meaning" title={tooltip}>
|
||||||
|
<span className="meaning-value">{value}</span>
|
||||||
|
<span className={`meaning-caption ${toneClass}`.trim()}>{meaning}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
tyreLabel,
|
tyreLabel,
|
||||||
} from '../../lib/live'
|
} from '../../lib/live'
|
||||||
import type { GapHistoryMap } from '../../lib/gapHistory'
|
import type { GapHistoryMap } from '../../lib/gapHistory'
|
||||||
|
import { parseIntervalSeconds } from '../../lib/gapHistory'
|
||||||
|
import { intervalMeaning } from '../../lib/meaning'
|
||||||
|
import { Meaning } from '../Meaning'
|
||||||
import { GapSparkline } from './GapSparkline'
|
import { GapSparkline } from './GapSparkline'
|
||||||
import { StintHistory } from './StintHistory'
|
import { StintHistory } from './StintHistory'
|
||||||
import { Pin } from 'lucide-react'
|
import { Pin } from 'lucide-react'
|
||||||
@@ -100,7 +103,11 @@ export function TimingTower({
|
|||||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||||
|
|
||||||
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||||
|
const intervalAnnotation =
|
||||||
|
gapMode === 'interval' && isRace && row.Position > 1
|
||||||
|
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||||
|
: null
|
||||||
|
|
||||||
const renderSector = (idx: number) => {
|
const renderSector = (idx: number) => {
|
||||||
const sec = driver.Sectors?.[idx]
|
const sec = driver.Sectors?.[idx]
|
||||||
if (!sec) return '-'
|
if (!sec) return '-'
|
||||||
@@ -149,7 +156,14 @@ export function TimingTower({
|
|||||||
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
||||||
{driver.LastLapTime || '-'}
|
{driver.LastLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">{gapText || '-'}</td>
|
<td className="mono">
|
||||||
|
<Meaning
|
||||||
|
value={gapText || '-'}
|
||||||
|
meaning={intervalAnnotation?.caption}
|
||||||
|
title={intervalAnnotation?.title}
|
||||||
|
tone={intervalAnnotation?.tone}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
{isRace && (
|
{isRace && (
|
||||||
<td className="spark-cell">
|
<td className="spark-cell">
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
recordStintSamples,
|
recordStintSamples,
|
||||||
stintInputFromRow,
|
stintInputFromRow,
|
||||||
} from '../../lib/tyredeg'
|
} from '../../lib/tyredeg'
|
||||||
|
import { tyreAgeMeaning } from '../../lib/meaning'
|
||||||
|
import { Meaning } from '../Meaning'
|
||||||
import '../../styles/tyredeg.css'
|
import '../../styles/tyredeg.css'
|
||||||
|
|
||||||
const TOP_DRIVER_COUNT = 10
|
const TOP_DRIVER_COUNT = 10
|
||||||
@@ -92,12 +94,20 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
{visible.map((row) => {
|
{visible.map((row) => {
|
||||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||||
|
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||||
return (
|
return (
|
||||||
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
|
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
|
||||||
<span className="tyredeg-pos mono">P{row.Position}</span>
|
<span className="tyredeg-pos mono">P{row.Position}</span>
|
||||||
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
||||||
<span className="drv-code">{driverCode(row)}</span>
|
<span className="drv-code">{driverCode(row)}</span>
|
||||||
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>{tyreLabel(row.Tyre)}</span>
|
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>
|
||||||
|
<Meaning
|
||||||
|
value={tyreLabel(row.Tyre)}
|
||||||
|
meaning={ageAnnotation?.caption}
|
||||||
|
title={ageAnnotation?.title}
|
||||||
|
tone={ageAnnotation?.tone}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
{model ? (
|
{model ? (
|
||||||
<>
|
<>
|
||||||
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
|
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
146
frontend/src/lib/meaning.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Pure interpretation helpers for pairing numbers with their "so-what".
|
||||||
|
// Thresholds are exported consts so they are cheap to tune in one place.
|
||||||
|
|
||||||
|
import { PIT_LOSS_SECONDS } from './tyredeg'
|
||||||
|
|
||||||
|
/** Gaps under this (seconds) are DRS attack range. */
|
||||||
|
export const INTERVAL_DRS_MAX_SECONDS = 1.0
|
||||||
|
|
||||||
|
/** Lower bound of the undercut window (seconds); contiguous with DRS range. */
|
||||||
|
export const INTERVAL_UNDERCUT_MIN_SECONDS = INTERVAL_DRS_MAX_SECONDS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound of the undercut window (seconds). Kept well below typical pit
|
||||||
|
* loss ({@link PIT_LOSS_SECONDS}s) — only a few seconds matter for strategy.
|
||||||
|
*/
|
||||||
|
export const INTERVAL_UNDERCUT_MAX_SECONDS = Math.min(3.0, PIT_LOSS_SECONDS / 7)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough per-compound cliff lap estimates (dry compounds). Wet/intermediate
|
||||||
|
* values are conservative — deg varies wildly with conditions.
|
||||||
|
*/
|
||||||
|
export const TYRE_CLIFF_LAPS: Readonly<Record<string, number>> = {
|
||||||
|
SOFT: 18,
|
||||||
|
MEDIUM: 28,
|
||||||
|
HARD: 38,
|
||||||
|
INTERMEDIATE: 20,
|
||||||
|
WET: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default cliff when compound is unknown. */
|
||||||
|
export const TYRE_CLIFF_DEFAULT_LAPS = 25
|
||||||
|
|
||||||
|
/** Championship max points per race (winner). */
|
||||||
|
export const MAX_POINTS_PER_ROUND = 25
|
||||||
|
|
||||||
|
export interface MeaningAnnotation {
|
||||||
|
caption: string
|
||||||
|
title: string
|
||||||
|
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliffLaps(compound: string | null | undefined): number {
|
||||||
|
if (!compound) return TYRE_CLIFF_DEFAULT_LAPS
|
||||||
|
return TYRE_CLIFF_LAPS[compound.toUpperCase()] ?? TYRE_CLIFF_DEFAULT_LAPS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interval / gap-to-ahead meaning for the live timing tower.
|
||||||
|
* Returns null for leader gaps, out-of-range values, or unparsable input.
|
||||||
|
*/
|
||||||
|
export function intervalMeaning(seconds: number | null | undefined): MeaningAnnotation | null {
|
||||||
|
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null
|
||||||
|
|
||||||
|
if (seconds < INTERVAL_DRS_MAX_SECONDS) {
|
||||||
|
return {
|
||||||
|
caption: 'DRS range',
|
||||||
|
title: `Within ${INTERVAL_DRS_MAX_SECONDS}s — DRS enabled next straight`,
|
||||||
|
tone: 'good',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds >= INTERVAL_UNDERCUT_MIN_SECONDS && seconds <= INTERVAL_UNDERCUT_MAX_SECONDS) {
|
||||||
|
return {
|
||||||
|
caption: 'undercut window',
|
||||||
|
title: `${INTERVAL_UNDERCUT_MIN_SECONDS}–${INTERVAL_UNDERCUT_MAX_SECONDS}s — pit now could gain a position (vs ~${PIT_LOSS_SECONDS}s stop)`,
|
||||||
|
tone: 'warn',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tyre-age meaning for deg / stint panels.
|
||||||
|
*/
|
||||||
|
export function tyreAgeMeaning(
|
||||||
|
compound: string | null | undefined,
|
||||||
|
age: number | null | undefined,
|
||||||
|
): MeaningAnnotation | null {
|
||||||
|
if (age == null || !Number.isFinite(age) || age < 0) return null
|
||||||
|
|
||||||
|
const cliff = cliffLaps(compound)
|
||||||
|
const freshEnd = Math.ceil(cliff * 0.25)
|
||||||
|
const midEnd = Math.ceil(cliff * 0.65)
|
||||||
|
|
||||||
|
if (age <= freshEnd) {
|
||||||
|
return {
|
||||||
|
caption: 'fresh',
|
||||||
|
title: `${age} lap${age === 1 ? '' : 's'} on ${compound ?? 'tyre'} — early stint grip`,
|
||||||
|
tone: 'good',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (age <= midEnd) {
|
||||||
|
return {
|
||||||
|
caption: 'mid-life',
|
||||||
|
title: `${age} laps — tyre in its working window before cliff (~${cliff} laps)`,
|
||||||
|
tone: 'neutral',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lapsToCliff = cliff - age
|
||||||
|
if (lapsToCliff <= 0) {
|
||||||
|
return {
|
||||||
|
caption: 'past cliff',
|
||||||
|
title: `${age} laps — beyond typical ${compound ?? 'tyre'} cliff (~${cliff} laps)`,
|
||||||
|
tone: 'bad',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
caption: `~${lapsToCliff} laps to cliff`,
|
||||||
|
title: `${age} of ~${cliff} laps before deg cliff on ${compound ?? 'tyre'}`,
|
||||||
|
tone: 'warn',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points gap to the driver directly ahead — catchable-or-not v1.
|
||||||
|
*/
|
||||||
|
export function pointsGapMeaning(
|
||||||
|
gapToAhead: number | null | undefined,
|
||||||
|
roundsLeft: number,
|
||||||
|
driverAhead?: string | null,
|
||||||
|
): MeaningAnnotation | null {
|
||||||
|
if (gapToAhead == null || !Number.isFinite(gapToAhead) || gapToAhead <= 0) return null
|
||||||
|
if (!Number.isFinite(roundsLeft) || roundsLeft <= 0) return null
|
||||||
|
|
||||||
|
const maxCatchable = roundsLeft * MAX_POINTS_PER_ROUND
|
||||||
|
const ahead = driverAhead?.trim() || 'ahead'
|
||||||
|
|
||||||
|
if (gapToAhead > maxCatchable) {
|
||||||
|
return {
|
||||||
|
caption: 'out of reach',
|
||||||
|
title: `+${gapToAhead} pts with ${roundsLeft} round${roundsLeft === 1 ? '' : 's'} left (max ${maxCatchable} available)`,
|
||||||
|
tone: 'bad',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const perRound = Math.ceil(gapToAhead / roundsLeft)
|
||||||
|
return {
|
||||||
|
caption: `~${perRound} pts/round`,
|
||||||
|
title: `Needs ~${perRound} pts per round on ${ahead} to catch (${gapToAhead} pts in ${roundsLeft} round${roundsLeft === 1 ? '' : 's'})`,
|
||||||
|
tone: perRound <= 10 ? 'good' : 'warn',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
|
|||||||
import { teamColor } from '../utils'
|
import { teamColor } from '../utils'
|
||||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||||
|
import { Meaning } from '../components/Meaning'
|
||||||
|
import { pointsGapMeaning } from '../lib/meaning'
|
||||||
|
|
||||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
||||||
|
|
||||||
@@ -138,6 +140,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
color: teamColor(d.team_colour),
|
color: teamColor(d.team_colour),
|
||||||
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
|
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
|
||||||
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
|
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
|
||||||
|
gapAheadNum,
|
||||||
|
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
|
||||||
spark: sparkPoints(d.form),
|
spark: sparkPoints(d.form),
|
||||||
h2h: `${d.teammate_wins}–${d.teammate_losses}`,
|
h2h: `${d.teammate_wins}–${d.teammate_losses}`,
|
||||||
h2hWin: d.teammate_wins >= d.teammate_losses,
|
h2hWin: d.teammate_wins >= d.teammate_losses,
|
||||||
@@ -233,7 +237,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'drivers' && (
|
{view === 'drivers' && (
|
||||||
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
|
<DriversView
|
||||||
|
enriched={enriched}
|
||||||
|
leaderPoints={leader.points}
|
||||||
|
titleMath={titleMath}
|
||||||
|
roundsLeft={hub.rounds_left}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||||
@@ -248,6 +257,8 @@ interface EnrichedDriver {
|
|||||||
color: string
|
color: string
|
||||||
gapLeader: string
|
gapLeader: string
|
||||||
gapAhead: string
|
gapAhead: string
|
||||||
|
gapAheadNum: number | null
|
||||||
|
driverAhead: string | null
|
||||||
spark: string
|
spark: string
|
||||||
h2h: string
|
h2h: string
|
||||||
h2hWin: boolean
|
h2hWin: boolean
|
||||||
@@ -259,10 +270,12 @@ function DriversView({
|
|||||||
enriched,
|
enriched,
|
||||||
leaderPoints,
|
leaderPoints,
|
||||||
titleMath,
|
titleMath,
|
||||||
|
roundsLeft,
|
||||||
}: {
|
}: {
|
||||||
enriched: EnrichedDriver[]
|
enriched: EnrichedDriver[]
|
||||||
leaderPoints: number
|
leaderPoints: number
|
||||||
titleMath: string
|
titleMath: string
|
||||||
|
roundsLeft: number
|
||||||
}) {
|
}) {
|
||||||
const podium = enriched.slice(0, 3)
|
const podium = enriched.slice(0, 3)
|
||||||
return (
|
return (
|
||||||
@@ -357,7 +370,19 @@ function DriversView({
|
|||||||
<td className="champ-td-team">{e.d.team_name}</td>
|
<td className="champ-td-team">{e.d.team_name}</td>
|
||||||
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
||||||
<td className="r mono champ-td-muted">{e.gapLeader}</td>
|
<td className="r mono champ-td-muted">{e.gapLeader}</td>
|
||||||
<td className="r mono champ-td-dim">{e.gapAhead}</td>
|
<td className="r mono champ-td-dim">
|
||||||
|
{(() => {
|
||||||
|
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
|
||||||
|
return (
|
||||||
|
<Meaning
|
||||||
|
value={e.gapAhead}
|
||||||
|
meaning={gapAnnotation?.caption}
|
||||||
|
title={gapAnnotation?.title}
|
||||||
|
tone={gapAnnotation?.tone}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||||||
{e.d.wins}
|
{e.d.wins}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
48
frontend/src/styles/meaning.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* Compact value + muted meaning caption (issue #18). */
|
||||||
|
|
||||||
|
.meaning {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: inherit;
|
||||||
|
gap: 1px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-value {
|
||||||
|
/* inherits table cell mono styling from parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--good {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--bad {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--warn {
|
||||||
|
color: var(--yellow, #e8c547);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--neutral {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table cells: right-align caption under numeric values */
|
||||||
|
td.r .meaning,
|
||||||
|
.champ-td-dim .meaning,
|
||||||
|
.champ-td-muted .meaning {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-row .meaning {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ describe('ChampionshipPage', () => {
|
|||||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
|
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
|
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
|
||||||
|
expect(screen.getAllByText('~10 pts/round').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches to constructors and progression views', async () => {
|
it('switches to constructors and progression views', async () => {
|
||||||
|
|||||||
@@ -189,6 +189,26 @@ describe('TimingTower', () => {
|
|||||||
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
|
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('annotates DRS-range intervals in race mode', () => {
|
||||||
|
const raceRows = [
|
||||||
|
makeRow('1', 1, 'VER'),
|
||||||
|
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
|
||||||
|
]
|
||||||
|
render(
|
||||||
|
<TimingTower
|
||||||
|
rows={raceRows}
|
||||||
|
session={{
|
||||||
|
MeetingName: 'Monaco Grand Prix',
|
||||||
|
CircuitName: 'Monaco',
|
||||||
|
SessionType: 'Race',
|
||||||
|
SessionName: 'Race',
|
||||||
|
Path: '',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
|
|||||||
26
frontend/src/test/Meaning.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Meaning } from '../components/Meaning'
|
||||||
|
|
||||||
|
describe('Meaning', () => {
|
||||||
|
it('renders bare value when meaning is null', () => {
|
||||||
|
render(<Meaning value="+1.234" meaning={null} />)
|
||||||
|
expect(screen.getByText('+1.234')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('DRS range')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders value with caption and tooltip title', () => {
|
||||||
|
render(
|
||||||
|
<Meaning
|
||||||
|
value="+0.4"
|
||||||
|
meaning="DRS range"
|
||||||
|
title="Within 1.0s — DRS enabled next straight"
|
||||||
|
tone="good"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
const value = screen.getByText('+0.4')
|
||||||
|
expect(value).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('DRS range')).toHaveClass('meaning-caption--good')
|
||||||
|
expect(value.closest('.meaning')).toHaveAttribute('title', 'Within 1.0s — DRS enabled next straight')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,9 +52,18 @@ describe('TyreDegPanel', () => {
|
|||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
expect(panel).toHaveTextContent('VER')
|
expect(panel).toHaveTextContent('VER')
|
||||||
expect(panel).toHaveTextContent('M +5')
|
expect(panel).toHaveTextContent('M +5')
|
||||||
|
expect(panel).toHaveTextContent('fresh')
|
||||||
expect(panel).toHaveTextContent('warming up')
|
expect(panel).toHaveTextContent('warming up')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('annotates tyre age meaning on stint rows', () => {
|
||||||
|
const rows = [
|
||||||
|
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||||
|
]
|
||||||
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||||
|
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
|
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
71
frontend/src/test/meaning.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
INTERVAL_DRS_MAX_SECONDS,
|
||||||
|
INTERVAL_UNDERCUT_MAX_SECONDS,
|
||||||
|
INTERVAL_UNDERCUT_MIN_SECONDS,
|
||||||
|
MAX_POINTS_PER_ROUND,
|
||||||
|
TYRE_CLIFF_LAPS,
|
||||||
|
intervalMeaning,
|
||||||
|
pointsGapMeaning,
|
||||||
|
tyreAgeMeaning,
|
||||||
|
} from '../lib/meaning'
|
||||||
|
|
||||||
|
describe('intervalMeaning', () => {
|
||||||
|
it('returns DRS range below the threshold', () => {
|
||||||
|
expect(intervalMeaning(0.4)?.caption).toBe('DRS range')
|
||||||
|
expect(intervalMeaning(INTERVAL_DRS_MAX_SECONDS - 0.01)?.caption).toBe('DRS range')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undercut window in the middle band', () => {
|
||||||
|
expect(intervalMeaning(INTERVAL_UNDERCUT_MIN_SECONDS)?.caption).toBe('undercut window')
|
||||||
|
expect(intervalMeaning(2.0)?.caption).toBe('undercut window')
|
||||||
|
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS)?.caption).toBe('undercut window')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null outside known bands', () => {
|
||||||
|
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS + 0.5)).toBeNull()
|
||||||
|
expect(intervalMeaning(10)).toBeNull()
|
||||||
|
expect(intervalMeaning(null)).toBeNull()
|
||||||
|
expect(intervalMeaning(undefined)).toBeNull()
|
||||||
|
expect(intervalMeaning(-1)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tyreAgeMeaning', () => {
|
||||||
|
it('labels fresh, mid-life, and laps-to-cliff for SOFT', () => {
|
||||||
|
const cliff = TYRE_CLIFF_LAPS.SOFT
|
||||||
|
expect(tyreAgeMeaning('SOFT', 2)?.caption).toBe('fresh')
|
||||||
|
expect(tyreAgeMeaning('SOFT', Math.ceil(cliff * 0.5))?.caption).toBe('mid-life')
|
||||||
|
expect(tyreAgeMeaning('SOFT', cliff - 2)?.caption).toBe('~2 laps to cliff')
|
||||||
|
expect(tyreAgeMeaning('SOFT', cliff + 5)?.caption).toBe('past cliff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles unknown compounds with defaults', () => {
|
||||||
|
expect(tyreAgeMeaning('UNKNOWN', 3)?.caption).toBe('fresh')
|
||||||
|
expect(tyreAgeMeaning(undefined, 3)?.caption).toBe('fresh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for invalid age', () => {
|
||||||
|
expect(tyreAgeMeaning('MEDIUM', null)).toBeNull()
|
||||||
|
expect(tyreAgeMeaning('MEDIUM', -1)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pointsGapMeaning', () => {
|
||||||
|
it('computes catchable pts/round', () => {
|
||||||
|
const result = pointsGapMeaning(40, 4, 'VER')
|
||||||
|
expect(result?.caption).toBe('~10 pts/round')
|
||||||
|
expect(result?.title).toContain('VER')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks uncatchable gaps', () => {
|
||||||
|
const max = 3 * MAX_POINTS_PER_ROUND
|
||||||
|
expect(pointsGapMeaning(max + 1, 3, 'VER')?.caption).toBe('out of reach')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for leader or invalid input', () => {
|
||||||
|
expect(pointsGapMeaning(0, 4)).toBeNull()
|
||||||
|
expect(pointsGapMeaning(10, 0)).toBeNull()
|
||||||
|
expect(pointsGapMeaning(null, 4)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
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 |