import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { fetchChampionshipHub, fetchSeasons } from '../api' import { teamColor } from '../utils' import type { ChampHubDriver, ChampionshipHub } from '../types' import { ChampionshipSimulator } from '../components/ChampionshipSimulator' import { RivalryCompare } from '../components/RivalryCompare' import { Meaning } from '../components/Meaning' import { TeammateH2H } from '../components/TeammateH2H' import { teammatePairs } from '../lib/h2h' import { pointsGapMeaning } from '../lib/meaning' type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator' const GOLD = '#ffd700' const SILVER = '#c0c0c0' const BRONZE = '#cd7f32' function medalColor(pos: number): string { if (pos === 1) return GOLD if (pos === 2) return SILVER if (pos === 3) return BRONZE return 'var(--text-2)' } function ghostColor(pos: number): string { if (pos === 1) return 'rgba(255,215,0,0.08)' if (pos === 2) return 'rgba(192,192,192,0.07)' if (pos === 3) return 'rgba(205,127,50,0.07)' return 'rgba(255,255,255,0.03)' } function fmtPts(n: number): string { return Number.isInteger(n) ? String(n) : n.toFixed(1) } /** Sparkline polyline points inside a 62×20 box. */ function sparkPoints(form: number[]): string { if (!form.length) return '' const fmax = Math.max(25, ...form) const n = form.length return form .map((v, k) => { const x = n === 1 ? 0 : (k * 62) / (n - 1) const y = 18 - (fmax > 0 ? (v / fmax) * 16 : 0) return `${x.toFixed(1)},${y.toFixed(1)}` }) .join(' ') } interface TeamSplit { driverA: string ptsA: number driverB: string ptsB: number splitA: number splitB: number shareLabel: string } function teamSplit(teamName: string, drivers: ChampHubDriver[]): TeamSplit { const ds = drivers.filter((d) => d.team_name === teamName).sort((a, b) => b.points - a.points) const a = ds[0] const b = ds[1] const ptsA = a?.points ?? 0 const ptsB = b?.points ?? 0 const total = ptsA + ptsB || 1 const splitA = Math.round((ptsA / total) * 100) return { driverA: a?.name_acronym ?? '—', ptsA, driverB: b?.name_acronym ?? '—', ptsB, splitA, splitB: 100 - splitA, shareLabel: `${splitA}/${100 - splitA}`, } } export function ChampionshipPage() { const [view, setView] = useState('drivers') const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) const latestSeason = seasonsQuery.data?.[0] ?? null const hubQuery = useQuery({ queryKey: ['championship-hub', latestSeason], queryFn: () => fetchChampionshipHub(latestSeason ?? undefined), enabled: latestSeason != null, staleTime: 5 * 60_000, }) if (seasonsQuery.isLoading || hubQuery.isLoading) { return
loading championship…
} if (hubQuery.isError) { return (
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
) } const hub = hubQuery.data if (!hub || hub.drivers.length === 0) { return (
box-box · championship

No championship data

Standings for {latestSeason ?? 'this season'} are not available yet. Once race results are ingested they will appear here.

) } return } interface BodyProps { hub: ChampionshipHub view: View setView: (v: View) => void } function ChampionshipBody({ hub, view, setView }: BodyProps) { const { drivers, teams } = hub const leader = drivers[0] const remaining = hub.rounds_left * 25 const enriched = useMemo( () => drivers.map((d, i) => { const gapLeaderNum = leader.points - d.points const gapAheadNum = i === 0 ? null : drivers[i - 1].points - d.points const alive = i === 0 || gapLeaderNum <= remaining return { d, pos: d.position, 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, aliveLabel: i === 0 ? 'LEADS' : alive ? 'ALIVE' : 'OUT', aliveColor: i === 0 ? GOLD : alive ? 'var(--green)' : 'var(--text-3)', } }), [drivers, leader, remaining], ) const aliveCount = enriched.filter((e) => e.aliveLabel === 'ALIVE' || e.aliveLabel === 'LEADS').length const titleMath = `${aliveCount} driver${aliveCount === 1 ? '' : 's'} can still mathematically win the title. ` + `With ${hub.rounds_left} round${hub.rounds_left === 1 ? '' : 's'} left (max ${remaining} pts), ` + `${leader.name_acronym} leads ` + (drivers[1] ? `${drivers[1].name_acronym} by ${fmtPts(leader.points - drivers[1].points)}` : 'the field') + (drivers[2] ? ` and ${drivers[2].name_acronym} by ${fmtPts(leader.points - drivers[2].points)}.` : '.') const topTeam = teams[0] const teamGap = teams[1] ? topTeam.points - teams[1].points : 0 const seasonPct = hub.total_rounds > 0 ? Math.round((hub.round / hub.total_rounds) * 100) : 0 const statRail = [ { label: 'Drivers Leader', value: leader.name_acronym, sub: `${fmtPts(leader.points)} pts · ${leader.wins} wins`, color: '#fff' }, { label: 'Constructors Leader', value: topTeam?.team_name ?? '—', sub: teams[1] ? `+${fmtPts(teamGap)} ahead` : 'Sole entry', color: '#fff', }, { label: 'Title Fight', value: `${aliveCount} alive`, sub: `${hub.rounds_left} rounds remain`, color: 'var(--red)' }, { label: 'Season Progress', value: `${seasonPct}%`, sub: `Round ${hub.round}/${hub.total_rounds}`, color: '#fff' }, ] return (
{hub.last_race ? `After ${hub.last_race} · ` : ''}Round {hub.round} of {hub.total_rounds}
{statRail.map((stat) => (
{stat.label}
{stat.value}
{stat.sub}
))}
{view === 'drivers' && ( )} {view === 'constructors' && } {view === 'progression' && } {view === 'rivalry' && } {view === 'simulator' && }
) } interface EnrichedDriver { d: ChampHubDriver pos: number color: string gapLeader: string gapAhead: string gapAheadNum: number | null driverAhead: string | null spark: string h2h: string h2hWin: boolean aliveLabel: string aliveColor: string } function DriversView({ enriched, leaderPoints, titleMath, roundsLeft, drivers, season, }: { enriched: EnrichedDriver[] leaderPoints: number titleMath: string roundsLeft: number drivers: ChampHubDriver[] season: number }) { const podium = enriched.slice(0, 3) const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers]) return (
{podium.map((e) => (
P{e.pos}
P{e.pos} {e.d.team_name}
{e.d.name_acronym}
{e.d.full_name}
{fmtPts(e.d.points)} PTS {e.pos === 1 ? 'P1' : `+${fmtPts(leaderPoints - e.d.points)}`}
{e.d.wins}
Wins
{e.d.podiums}
Podiums
{e.d.poles}
Poles
))}
Title Math {titleMath}
{h2hPairs.length > 0 && (

Teammate battles

race finishes · closest first
{h2hPairs.map((pair) => ( 0 ? `+${pair.extraCount}` : undefined} /> ))}
)}
{enriched.map((e) => ( ))}
Pos Driver Team Pts Gap Int Wins Pod Form vs Teammate Title
P{e.pos} {e.d.name_acronym} {e.d.full_name} #{e.d.driver_number} {e.d.team_name} {fmtPts(e.d.points)} {e.gapLeader} {(() => { const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead) return ( ) })()} 0 ? 'var(--text)' : 'var(--text-3)' }}> {e.d.wins} {e.d.podiums} {e.spark ? ( ) : ( )} {e.h2h} {e.aliveLabel}
) } function ConstructorsView({ hub }: { hub: ChampionshipHub }) { const { teams, drivers } = hub const leaderPts = teams[0]?.points ?? 0 const podium = teams.slice(0, 3) return (
{podium.map((t) => { const color = teamColor(t.team_colour) const split = teamSplit(t.team_name, drivers) return (
P{t.position}
P{t.position}
{t.team_name}
{fmtPts(t.points)} PTS {t.position === 1 ? 'P1' : `+${fmtPts(leaderPts - t.points)}`}
{split.driverA} {fmtPts(split.ptsA)} {split.driverB} {fmtPts(split.ptsB)}
) })}
{teams.map((t) => { const color = teamColor(t.team_colour) const split = teamSplit(t.team_name, drivers) return ( ) })}
Pos Constructor Pts Gap Wins Driver Contribution Share
P{t.position}
{t.team_name}
{fmtPts(t.points)} {t.position === 1 ? 'LEADER' : `+${fmtPts(leaderPts - t.points)}`} 0 ? 'var(--text)' : 'var(--text-3)' }}> {t.wins}
{split.driverA} · {split.driverB}
{split.shareLabel}
) } const CHART_PAD_L = 48 const CHART_PAD_T = 16 const CHART_PLOT_W = 882 const CHART_PLOT_H = 316 function ProgressionView({ hub }: { hub: ChampionshipHub }) { const top = hub.drivers.slice(0, 6).filter((d) => d.cumulative.length > 0) if (top.length === 0) { return (
No completed rounds yet — progression will appear after the first race.
) } const N = Math.max(...top.map((d) => d.cumulative.length)) const peak = Math.max(...top.flatMap((d) => d.cumulative)) const maxY = Math.max(100, Math.ceil(peak / 100) * 100) const x = (i: number) => (N <= 1 ? CHART_PAD_L : CHART_PAD_L + (i * CHART_PLOT_W) / (N - 1)) const y = (v: number) => CHART_PAD_T + CHART_PLOT_H - (v / maxY) * CHART_PLOT_H const yGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => { const value = Math.round(maxY * f) const yy = y(value) return { y: yy, label: value } }) // x ticks: up to ~7 evenly spaced round labels. const tickStep = Math.max(1, Math.ceil(N / 7)) const xGrid: { x: number; label: string }[] = [] for (let i = 0; i < N; i += tickStep) { xGrid.push({ x: x(i), label: hub.round_labels[i] ?? `R${i + 1}` }) } if (xGrid[xGrid.length - 1]?.label !== (hub.round_labels[N - 1] ?? `R${N}`)) { xGrid.push({ x: x(N - 1), label: hub.round_labels[N - 1] ?? `R${N}` }) } const seenTeams = new Set() const series = top.map((d, idx) => { const dashed = seenTeams.has(d.team_name) seenTeams.add(d.team_name) const pts = d.cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ') const endVal = d.cumulative[d.cumulative.length - 1] const endX = x(d.cumulative.length - 1) const endY = y(endVal) return { code: d.name_acronym, name: d.full_name, total: d.points, color: teamColor(d.team_colour), width: idx < 3 ? 2.4 : 1.8, dash: dashed ? '5 4' : '0', points: pts, endX, endY, } }) return (
Cumulative points — top {top.length} drivers Rounds 1–{hub.round} · {hub.season}
{yGrid.map((g) => ( {g.label} ))} {xGrid.map((g, i) => ( {g.label} ))} {series.map((s) => ( {s.code} ))}
{series.map((s) => (
{s.code} {s.name} {fmtPts(s.total)}
))}
) }