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()
+ })
+})