mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Phase 22: Race Story Deepening
This commit is contained in:
@@ -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 <key></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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
189
frontend/src/components/RaceStoryCanvas.tsx
Normal file
189
frontend/src/components/RaceStoryCanvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 <key></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>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { DatasetStrip } from '../components/DatasetStrip'
|
||||
import { ClassificationTable } from '../components/ClassificationTable'
|
||||
import { StartingGridTable } from '../components/StartingGridTable'
|
||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||
import { TabBar, type Tab } from '../components/TabBar'
|
||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||
import { StrategyView } from '../components/StrategyView'
|
||||
import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
||||
import { LapsView } from '../components/LapsView'
|
||||
import { RaceControlView } from '../components/RaceControlView'
|
||||
import { WeatherView } from '../components/WeatherView'
|
||||
@@ -33,8 +31,6 @@ interface Props {
|
||||
sessionKey: number
|
||||
}
|
||||
|
||||
type RaceStorySubview = 'classification' | 'grid' | 'positions'
|
||||
|
||||
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
|
||||
if (!weekend) return undefined
|
||||
const local = weekend.sessions.filter((s) => s.source === 'local')
|
||||
@@ -50,7 +46,6 @@ function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | und
|
||||
export function RaceHubPage({ sessionKey }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||
const [storyView, setStoryView] = useState<RaceStorySubview>('classification')
|
||||
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||
|
||||
// ─── Auto-redirect when no session_key is supplied ───
|
||||
@@ -294,74 +289,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
|
||||
{activeTab === 'race_story' && (
|
||||
<div className="data-section">
|
||||
<div className="rh-story-controls" role="tablist" aria-label="Race story view">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={storyView === 'classification'}
|
||||
className={`rh-story-btn${storyView === 'classification' ? ' active' : ''}`}
|
||||
onClick={() => setStoryView('classification')}
|
||||
>
|
||||
Classification
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={storyView === 'grid'}
|
||||
className={`rh-story-btn${storyView === 'grid' ? ' active' : ''}`}
|
||||
onClick={() => setStoryView('grid')}
|
||||
>
|
||||
Starting Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={storyView === 'positions'}
|
||||
className={`rh-story-btn${storyView === 'positions' ? ' active' : ''}`}
|
||||
onClick={() => setStoryView('positions')}
|
||||
>
|
||||
Positions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{storyView === 'classification' && (
|
||||
<>
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Final Classification</span>
|
||||
{data.results.length > 0 && (
|
||||
<span className="sec-meta mono">{data.results.length} drivers</span>
|
||||
)}
|
||||
</div>
|
||||
<ClassificationTable results={data.results} grid={data.starting_grid} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{storyView === 'grid' && (
|
||||
<>
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Starting Grid</span>
|
||||
{data.starting_grid.length > 0 && (
|
||||
<span className="sec-meta mono">{data.starting_grid.length} positions</span>
|
||||
)}
|
||||
</div>
|
||||
<StartingGridTable grid={data.starting_grid} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{storyView === 'positions' && (
|
||||
<>
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Position Evolution</span>
|
||||
</div>
|
||||
<PositionEvolutionView
|
||||
results={data.results}
|
||||
grid={data.starting_grid}
|
||||
positions={data.positions}
|
||||
laps={data.laps}
|
||||
hasPositions={data.datasets['positions']?.status === 'available'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<RaceStoryCanvas data={data} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2370,3 +2370,131 @@ a { color: inherit; text-decoration: none; }
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Race Story Canvas ── */
|
||||
.race-story-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s6);
|
||||
}
|
||||
|
||||
.rs-chart-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--s2);
|
||||
padding: var(--s4) 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rs-field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
}
|
||||
|
||||
.rs-driver-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--surface);
|
||||
padding: var(--s3) var(--s4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--s1);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.rs-driver-row:hover {
|
||||
background: var(--surface-h);
|
||||
border-color: var(--border-2);
|
||||
}
|
||||
|
||||
.rs-driver-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s5);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rs-pos-col {
|
||||
width: 24px;
|
||||
text-align: right;
|
||||
font-family: var(--f-mono);
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.rs-pos-p1 { color: var(--yellow); }
|
||||
.rs-pos-p2 { color: var(--text); }
|
||||
.rs-pos-p3 { color: var(--text); }
|
||||
|
||||
.rs-driver-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rs-driver-color {
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rs-driver-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rs-driver-name {
|
||||
font-family: var(--f-mono);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rs-driver-team {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rs-driver-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s6);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rs-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-family: var(--f-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rs-metric-label {
|
||||
font-family: var(--f-ui);
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rs-driver-right {
|
||||
gap: var(--s4);
|
||||
}
|
||||
.rs-driver-team {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ClassificationTable } from '../components/ClassificationTable'
|
||||
import type { EnrichedResult, EnrichedGrid } from '../types'
|
||||
|
||||
const mockResults: 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: 5534.456,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1234,
|
||||
},
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 2,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: 3.456,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 9472,
|
||||
meeting_key: 1234,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
position: 5,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: 21.234,
|
||||
number_of_laps: 78,
|
||||
points: 10,
|
||||
session_key: 9472,
|
||||
meeting_key: 1234,
|
||||
},
|
||||
]
|
||||
|
||||
const mockGrid: EnrichedGrid[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
session_key: 9472,
|
||||
meeting_key: 1234,
|
||||
lap_duration: 74.892,
|
||||
},
|
||||
{
|
||||
driver_number: 16,
|
||||
position: 3,
|
||||
name_acronym: 'LEC',
|
||||
full_name: 'Charles Leclerc',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
session_key: 9472,
|
||||
meeting_key: 1234,
|
||||
lap_duration: 75.123,
|
||||
},
|
||||
]
|
||||
|
||||
describe('ClassificationTable', () => {
|
||||
it('renders driver acronyms', () => {
|
||||
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
|
||||
expect(screen.getByText('LEC')).toBeInTheDocument()
|
||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders P1 badge for LEC', () => {
|
||||
const { container } = render(<ClassificationTable results={mockResults} grid={mockGrid} />)
|
||||
const p1 = container.querySelector('.pos-p1')
|
||||
expect(p1).toBeInTheDocument()
|
||||
expect(p1?.textContent).toBe('1')
|
||||
})
|
||||
|
||||
it('shows grid gain arrow for LEC (started P3, finished P1)', () => {
|
||||
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
|
||||
expect(screen.getByText('↑2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows grid loss arrow for VER (started P1, finished P2)', () => {
|
||||
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
|
||||
expect(screen.getByText('↓1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing notice when results are empty', () => {
|
||||
render(<ClassificationTable results={[]} grid={[]} />)
|
||||
expect(screen.getByText(/not ingested/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClassificationTable — DNF/DNS/DSQ', () => {
|
||||
it('shows DNF label', () => {
|
||||
const dnfResult: EnrichedResult = {
|
||||
...mockResults[0],
|
||||
driver_number: 23,
|
||||
name_acronym: 'ALB',
|
||||
position: 20,
|
||||
dnf: true,
|
||||
duration: null,
|
||||
gap_to_leader: null,
|
||||
points: 0,
|
||||
}
|
||||
render(<ClassificationTable results={[dnfResult]} grid={[]} />)
|
||||
expect(screen.getByText('DNF')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,200 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types'
|
||||
|
||||
const results: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671C6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
position: 2,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: 5.1,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
]
|
||||
|
||||
const grid: EnrichedGrid[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671C6',
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
lap_duration: 71.234,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
position: 2,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
lap_duration: 71.456,
|
||||
},
|
||||
]
|
||||
|
||||
const positions: PositionSample[] = [
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
date: '2025-05-25T13:05:00+00:00',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
date: '2025-05-25T13:10:00+00:00',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
date: '2025-05-25T13:05:00+00:00',
|
||||
position: 2,
|
||||
},
|
||||
]
|
||||
|
||||
const laps: Lap[] = [
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
lap_number: 1,
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
lap_duration: 75.1,
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
]
|
||||
|
||||
describe('PositionEvolutionView — positions available', () => {
|
||||
it('renders the position chart container', () => {
|
||||
const { container } = render(
|
||||
<PositionEvolutionView
|
||||
results={results}
|
||||
grid={grid}
|
||||
positions={positions}
|
||||
laps={laps}
|
||||
hasPositions={true}
|
||||
/>
|
||||
)
|
||||
expect(container.querySelector('[data-testid="position-chart"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an SVG chart', () => {
|
||||
const { container } = render(
|
||||
<PositionEvolutionView
|
||||
results={results}
|
||||
grid={grid}
|
||||
positions={positions}
|
||||
laps={laps}
|
||||
hasPositions={true}
|
||||
/>
|
||||
)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not show the missing-data notice', () => {
|
||||
render(
|
||||
<PositionEvolutionView
|
||||
results={results}
|
||||
grid={grid}
|
||||
positions={positions}
|
||||
laps={laps}
|
||||
hasPositions={true}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText(/Lap-by-lap positions not available/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Grid → Finish table below chart', () => {
|
||||
render(
|
||||
<PositionEvolutionView
|
||||
results={results}
|
||||
grid={grid}
|
||||
positions={positions}
|
||||
laps={laps}
|
||||
hasPositions={true}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Grid → Finish')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PositionEvolutionView — positions missing', () => {
|
||||
it('shows the missing-data notice', () => {
|
||||
render(
|
||||
<PositionEvolutionView
|
||||
results={results}
|
||||
grid={grid}
|
||||
positions={[]}
|
||||
laps={[]}
|
||||
hasPositions={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Lap-by-lap positions not available/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to grid → finish table when both results and grid exist', () => {
|
||||
render(
|
||||
<PositionEvolutionView
|
||||
results={results}
|
||||
grid={grid}
|
||||
positions={[]}
|
||||
laps={[]}
|
||||
hasPositions={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Grid → Finish')).toBeInTheDocument()
|
||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the position chart', () => {
|
||||
const { container } = render(
|
||||
<PositionEvolutionView
|
||||
results={results}
|
||||
grid={grid}
|
||||
positions={[]}
|
||||
laps={[]}
|
||||
hasPositions={false}
|
||||
/>
|
||||
)
|
||||
expect(container.querySelector('[data-testid="position-chart"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -234,10 +234,8 @@ describe('RaceHubPage', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Race Story' }))
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Classification' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Starting Grid' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Final Classification')).toBeInTheDocument()
|
||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||
|
||||
})
|
||||
|
||||
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
|
||||
|
||||
Reference in New Issue
Block a user