Merge pull request #42 from AmanTahiliani/feat/issue-18-annotate-every-number-with-meaning-ux-p

"Annotate every number with meaning" UX pass (#18)
This commit is contained in:
Aman Tahiliani
2026-07-04 00:25:39 -04:00
committed by GitHub
11 changed files with 401 additions and 5 deletions

View 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>
)
}

View File

@@ -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,6 +103,10 @@ 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]
@@ -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">

View File

@@ -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}`}>

146
frontend/src/lib/meaning.ts Normal file
View 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',
}
}

View File

@@ -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>

View 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;
}

View File

@@ -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 () => {

View File

@@ -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}`),

View 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')
})
})

View File

@@ -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={[]} />,

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