Phase 22: Race Story Deepening

This commit is contained in:
2026-05-25 13:10:47 -04:00
parent 7ce2b1ace4
commit c172bb59b1
9 changed files with 321 additions and 803 deletions

View File

@@ -1,98 +0,0 @@
import type { EnrichedResult, EnrichedGrid } from '../types'
import { DriverCell } from './DriverCell'
import { formatDuration, formatGap, positionClass, gridDelta, gridDeltaClass } from '../utils'
interface Props {
results: EnrichedResult[]
grid: EnrichedGrid[]
}
export function ClassificationTable({ results, grid }: Props) {
if (results.length === 0) {
return (
<div className="missing-notice">
Results not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
const gridByDriver = Object.fromEntries(grid.map((g) => [g.driver_number, g.position]))
const isRace = results.some((r) => r.points > 0 || r.number_of_laps > 0)
return (
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 520 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="hide-mobile">Team</th>
{isRace && <th className="c hide-mobile">Grid</th>}
{isRace && <th className="c hide-mobile">Δ</th>}
<th className="r">Time / Gap</th>
{isRace && <th className="r hide-mobile">Pts</th>}
</tr>
</thead>
<tbody>
{results.map((r) => {
const gridPos = gridByDriver[r.driver_number] ?? 0
const timeStr = r.dnf
? null
: r.dns
? null
: r.dsq
? null
: r.position === 1
? formatDuration(r.duration)
: formatGap(r.gap_to_leader)
return (
<tr key={r.driver_number}>
<td className="c">
<span className={positionClass(r.position)}>{r.position}</span>
</td>
<td>
<DriverCell
acronym={r.name_acronym || String(r.driver_number)}
number={r.driver_number}
colour={r.team_colour}
/>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{r.team_name}
</td>
{isRace && (
<td className="c mono hide-mobile" style={{ color: 'var(--text-3)' }}>
{gridPos || '—'}
</td>
)}
{isRace && (
<td className="c hide-mobile">
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</td>
)}
<td className="r">
{r.dnf && <span className="status-dnf">DNF</span>}
{r.dns && <span className="status-dns">DNS</span>}
{r.dsq && <span className="status-dsq">DSQ</span>}
{!r.dnf && !r.dns && !r.dsq && (
<span style={{ fontFamily: 'var(--f-mono)' }}>{timeStr ?? '—'}</span>
)}
</td>
{isRace && (
<td className="r hide-mobile" style={{ fontWeight: r.points > 0 ? 700 : 400, color: r.points > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{r.points > 0 ? r.points : '—'}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -1,239 +0,0 @@
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types'
import { gridDelta, gridDeltaClass } from '../utils'
interface Props {
results: EnrichedResult[]
grid: EnrichedGrid[]
positions: PositionSample[]
laps: Lap[]
hasPositions: boolean
}
export function PositionEvolutionView({ results, grid, positions, laps: _laps, hasPositions }: Props) {
if (!hasPositions) {
return (
<div>
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> This session does not
have ingested position samples in <code>/api/v1/race-hub</code>. Evolution
charts require per-driver position samples over time.
</div>
{results.length > 0 && grid.length > 0 && (
<>
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
<span className="sec-title">Grid Finish</span>
<span className="sec-meta">net positions gained/lost</span>
</div>
<table className="data-table" style={{ maxWidth: 420 }}>
<thead>
<tr>
<th>Driver</th>
<th className="hide-mobile">Team</th>
<th className="c">Grid</th>
<th className="c">Finish</th>
<th className="r">Δ</th>
</tr>
</thead>
<tbody>
{results.map((r) => {
const gridPos =
grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
return (
<tr key={r.driver_number}>
<td>
<span
style={{
fontFamily: 'var(--f-mono)',
fontWeight: 700,
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
}}
>
{r.name_acronym || r.driver_number}
</span>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{r.team_name}
</td>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{gridPos || '—'}
</td>
<td className="c mono">{r.position}</td>
<td className="r">
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)}
</div>
)
}
// Build time-indexed position series per driver
const allTimes = [...new Set(positions.map((p) => p.date))].sort()
if (allTimes.length === 0) {
return <div className="missing-notice">No position samples in this dataset.</div>
}
const tMin = new Date(allTimes[0]).getTime()
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
const tRange = Math.max(tMax - tMin, 1)
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) {
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange,
pos: p.position,
})
}
for (const samples of byDriver.values()) {
samples.sort((a, b) => a.t - b.t)
}
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const W = 640
const H = 180
const PL = 40
const PR = 48 // right margin for driver labels
const PT = 8
const PB = 8
const plotW = W - PL - PR
const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
return (
<div data-testid="position-chart">
<div className="scroll-x">
<svg
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img"
aria-label="Position evolution chart"
>
{/* Horizontal grid lines + P# labels */}
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
<g key={pos}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
stroke="var(--border)"
strokeWidth={0.5}
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
fill="var(--text-3)"
fontSize={8}
fontFamily="var(--f-mono)"
>
P{pos}
</text>
</g>
))}
{/* Driver lines */}
{Array.from(byDriver.entries()).map(([dNum, samples]) => {
const colour = colorByDriver.get(dNum)
const color = colour ? `#${colour}` : '#888'
const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ')
const last = samples[samples.length - 1]
return (
<g key={dNum}>
<polyline
points={pts}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{samples.map((s, i) => (
<circle key={i} cx={toX(s.t)} cy={toY(s.pos)} r={3} fill={color} />
))}
{last && (
<text
x={toX(last.t) + 6}
y={toY(last.pos) + 4}
fill={color}
fontSize={9}
fontFamily="var(--f-mono)"
fontWeight={700}
>
{acronymByDriver.get(dNum) ?? dNum}
</text>
)}
</g>
)
})}
</svg>
</div>
{/* Grid → Finish table below chart for context */}
{results.length > 0 && grid.length > 0 && (
<>
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
<span className="sec-title">Grid Finish</span>
<span className="sec-meta">net positions</span>
</div>
<table className="data-table" style={{ maxWidth: 360 }}>
<thead>
<tr>
<th>Driver</th>
<th className="c">Grid</th>
<th className="c">Finish</th>
<th className="r">Δ</th>
</tr>
</thead>
<tbody>
{results.map((r) => {
const gridPos =
grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
return (
<tr key={r.driver_number}>
<td>
<span
style={{
fontFamily: 'var(--f-mono)',
fontWeight: 700,
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
}}
>
{r.name_acronym || r.driver_number}
</span>
</td>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{gridPos || '—'}
</td>
<td className="c mono">{r.position}</td>
<td className="r">
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,189 @@
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
interface Props {
data: {
results: EnrichedResult[]
starting_grid: EnrichedGrid[]
positions: PositionSample[]
laps: Lap[]
datasets: Record<string, any>
}
}
export function RaceStoryCanvas({ data }: Props) {
const { results, starting_grid: grid, positions, datasets } = data
const hasPositions = datasets['positions']?.status === 'available'
// Position Evolution Chart Logic
const allTimes = [...new Set(positions.map((p) => p.date))].sort()
const hasChartData = hasPositions && allTimes.length > 0
let chartContent = null
if (hasChartData) {
const tMin = new Date(allTimes[0]).getTime()
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
const tRange = Math.max(tMax - tMin, 1)
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
for (const p of positions) {
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
byDriver.get(p.driver_number)!.push({
t: (new Date(p.date).getTime() - tMin) / tRange,
pos: p.position,
})
}
for (const samples of byDriver.values()) {
samples.sort((a, b) => a.t - b.t)
}
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const W = 640
const H = 180
const PL = 40
const PR = 48
const PT = 8
const PB = 8
const plotW = W - PL - PR
const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
chartContent = (
<div className="rs-chart-container scroll-x" data-testid="position-chart">
<svg
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img"
aria-label="Position evolution chart"
>
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
<g key={pos}>
<line
x1={PL}
x2={W - PR}
y1={toY(pos)}
y2={toY(pos)}
stroke="var(--border)"
strokeWidth={0.5}
/>
<text
x={PL - 4}
y={toY(pos) + 4}
textAnchor="end"
fill="var(--text-3)"
fontSize={8}
fontFamily="var(--f-mono)"
>
P{pos}
</text>
</g>
))}
{Array.from(byDriver.entries()).map(([dNum, samples]) => {
const colour = colorByDriver.get(dNum)
const color = colour ? `#${colour}` : '#888'
const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ')
const last = samples[samples.length - 1]
return (
<g key={dNum}>
<polyline
points={pts}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
{samples.map((s, i) => (
<circle key={i} cx={toX(s.t)} cy={toY(s.pos)} r={3} fill={color} />
))}
{last && (
<text
x={toX(last.t) + 6}
y={toY(last.pos) + 4}
fill={color}
fontSize={9}
fontFamily="var(--f-mono)"
fontWeight={700}
>
{acronymByDriver.get(dNum) ?? dNum}
</text>
)}
</g>
)
})}
</svg>
</div>
)
}
return (
<div className="race-story-canvas">
{hasChartData ? (
chartContent
) : (
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> This session does not
have ingested position samples in <code>/api/v1/race-hub</code>.
</div>
)}
{results.length > 0 && (
<div className="rs-field-list">
{results.map((r, i) => {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const isWinner = i === 0 && r.position === 1
const pClass = r.position === 1 ? 'rs-pos-p1' : r.position === 2 ? 'rs-pos-p2' : r.position === 3 ? 'rs-pos-p3' : ''
return (
<div key={r.driver_number} className="rs-driver-row">
<div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}>{r.position}</div>
<div className="rs-driver-cell">
<div
className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
/>
<div className="rs-driver-identity">
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
<span className="rs-driver-team">{r.team_name}</span>
</div>
</div>
</div>
<div className="rs-driver-right">
<div className="rs-metric">
<span>
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</span>
<span className="rs-metric-label">Grid</span>
</div>
<div className="rs-metric" style={{ width: '80px' }}>
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
</div>
<div className="rs-metric" style={{ width: '40px' }}>
<span style={{ color: r.points > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{r.points}
</span>
<span className="rs-metric-label">Pts</span>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,57 +0,0 @@
import type { EnrichedGrid } from '../types'
import { DriverCell } from './DriverCell'
import { formatLapTime } from '../utils'
interface Props {
grid: EnrichedGrid[]
}
export function StartingGridTable({ grid }: Props) {
if (grid.length === 0) {
return (
<div className="missing-notice">
Starting grid not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
return (
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 380 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="hide-mobile">Team</th>
<th className="r">Lap Time</th>
</tr>
</thead>
<tbody>
{grid.map((g) => (
<tr key={g.driver_number}>
<td className="c">
<span style={{ fontFamily: 'var(--f-mono)', color: 'var(--text-2)' }}>
{g.position}
</span>
</td>
<td>
<DriverCell
acronym={g.name_acronym || String(g.driver_number)}
number={g.driver_number}
colour={g.team_colour}
/>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{g.team_name}
</td>
<td className="r" style={{ fontFamily: 'var(--f-mono)' }}>
{g.lap_duration != null ? formatLapTime(g.lap_duration) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}