diff --git a/frontend/src/components/Meaning.tsx b/frontend/src/components/Meaning.tsx new file mode 100644 index 0000000..9c5d470 --- /dev/null +++ b/frontend/src/components/Meaning.tsx @@ -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 ( + + {value} + {meaning} + + ) +} diff --git a/frontend/src/components/live/TimingTower.tsx b/frontend/src/components/live/TimingTower.tsx index b20810b..0b38dcd 100644 --- a/frontend/src/components/live/TimingTower.tsx +++ b/frontend/src/components/live/TimingTower.tsx @@ -12,6 +12,9 @@ import { tyreLabel, } from '../../lib/live' 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 { StintHistory } from './StintHistory' import { Pin } from 'lucide-react' @@ -100,7 +103,11 @@ export function TimingTower({ const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition 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 sec = driver.Sectors?.[idx] if (!sec) return '-' @@ -149,7 +156,14 @@ export function TimingTower({ {driver.LastLapTime || '-'} - {gapText || '-'} + + + {isRace && ( diff --git a/frontend/src/components/live/TyreDegPanel.tsx b/frontend/src/components/live/TyreDegPanel.tsx index d7f072c..3c15f74 100644 --- a/frontend/src/components/live/TyreDegPanel.tsx +++ b/frontend/src/components/live/TyreDegPanel.tsx @@ -13,6 +13,8 @@ import { recordStintSamples, stintInputFromRow, } from '../../lib/tyredeg' +import { tyreAgeMeaning } from '../../lib/meaning' +import { Meaning } from '../Meaning' import '../../styles/tyredeg.css' const TOP_DRIVER_COUNT = 10 @@ -92,12 +94,20 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) { {visible.map((row) => { const model = degradationModel(stints[row.RacingNumber]?.samples ?? []) const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null + const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age) return (
P{row.Position} {driverCode(row)} - {tyreLabel(row.Tyre)} + + + {model ? ( <> diff --git a/frontend/src/lib/meaning.ts b/frontend/src/lib/meaning.ts new file mode 100644 index 0000000..25db0a9 --- /dev/null +++ b/frontend/src/lib/meaning.ts @@ -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> = { + 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', + } +} diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index d1c293e..c004eb1 100644 --- a/frontend/src/pages/ChampionshipPage.tsx +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -4,6 +4,8 @@ import { fetchChampionshipHub, fetchSeasons } from '../api' import { teamColor } from '../utils' import type { ChampHubDriver, ChampionshipHub } from '../types' import { ChampionshipSimulator } from '../components/ChampionshipSimulator' +import { Meaning } from '../components/Meaning' +import { pointsGapMeaning } from '../lib/meaning' type View = 'drivers' | 'constructors' | 'progression' | 'simulator' @@ -138,6 +140,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { color: teamColor(d.team_colour), gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`, gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`, + gapAheadNum, + driverAhead: i === 0 ? null : drivers[i - 1].name_acronym, spark: sparkPoints(d.form), h2h: `${d.teammate_wins}–${d.teammate_losses}`, h2hWin: d.teammate_wins >= d.teammate_losses, @@ -233,7 +237,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
{view === 'drivers' && ( - + )} {view === 'constructors' && } {view === 'progression' && } @@ -248,6 +257,8 @@ interface EnrichedDriver { color: string gapLeader: string gapAhead: string + gapAheadNum: number | null + driverAhead: string | null spark: string h2h: string h2hWin: boolean @@ -259,10 +270,12 @@ function DriversView({ enriched, leaderPoints, titleMath, + roundsLeft, }: { enriched: EnrichedDriver[] leaderPoints: number titleMath: string + roundsLeft: number }) { const podium = enriched.slice(0, 3) return ( @@ -357,7 +370,19 @@ function DriversView({ {e.d.team_name} {fmtPts(e.d.points)} {e.gapLeader} - {e.gapAhead} + + {(() => { + const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead) + return ( + + ) + })()} + 0 ? 'var(--text)' : 'var(--text-3)' }}> {e.d.wins} diff --git a/frontend/src/styles/meaning.css b/frontend/src/styles/meaning.css new file mode 100644 index 0000000..3c245bf --- /dev/null +++ b/frontend/src/styles/meaning.css @@ -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; +} diff --git a/frontend/src/test/ChampionshipPage.test.tsx b/frontend/src/test/ChampionshipPage.test.tsx index 75b2833..35c5774 100644 --- a/frontend/src/test/ChampionshipPage.test.tsx +++ b/frontend/src/test/ChampionshipPage.test.tsx @@ -112,6 +112,7 @@ describe('ChampionshipPage', () => { expect(screen.getAllByText('VER').length).toBeGreaterThan(0) expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument() 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 () => { diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx index 7af18bc..95ea7ac 100644 --- a/frontend/src/test/LiveComponents.test.tsx +++ b/frontend/src/test/LiveComponents.test.tsx @@ -189,6 +189,26 @@ describe('TimingTower', () => { 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( + , + ) + expect(screen.getByText('DRS range')).toBeInTheDocument() + }) + it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => { const sprintRows = Array.from({ length: 22 }, (_, index) => makeRow(String(index + 1), index + 1, `D${index + 1}`), diff --git a/frontend/src/test/Meaning.test.tsx b/frontend/src/test/Meaning.test.tsx new file mode 100644 index 0000000..94b0e58 --- /dev/null +++ b/frontend/src/test/Meaning.test.tsx @@ -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() + expect(screen.getByText('+1.234')).toBeInTheDocument() + expect(screen.queryByText('DRS range')).not.toBeInTheDocument() + }) + + it('renders value with caption and tooltip title', () => { + render( + , + ) + 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') + }) +}) diff --git a/frontend/src/test/TyreDegPanel.test.tsx b/frontend/src/test/TyreDegPanel.test.tsx index 1bb9eff..40975a3 100644 --- a/frontend/src/test/TyreDegPanel.test.tsx +++ b/frontend/src/test/TyreDegPanel.test.tsx @@ -52,9 +52,18 @@ describe('TyreDegPanel', () => { const panel = screen.getByTestId('tyredeg-panel') expect(panel).toHaveTextContent('VER') expect(panel).toHaveTextContent('M +5') + expect(panel).toHaveTextContent('fresh') 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() + expect(screen.getByText('mid-life')).toBeInTheDocument() + }) + it('renders slope and rejoin estimate once laps accumulate across snapshots', () => { const { rerender } = render( , diff --git a/frontend/src/test/meaning.test.ts b/frontend/src/test/meaning.test.ts new file mode 100644 index 0000000..8d7c6e6 --- /dev/null +++ b/frontend/src/test/meaning.test.ts @@ -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() + }) +})