mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add shared Meaning primitive and pure interpretation helpers for interval, tyre age, and championship gap columns on the live tower, tyre deg panel, and championship hub. Thresholds are exported consts (undercut window tied to PIT_LOSS_SECONDS from #13). Co-authored-by: Cursor <cursoragent@cursor.com>
654 lines
24 KiB
TypeScript
654 lines
24 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
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'
|
||
|
||
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<View>('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 <div className="page loading-state">loading championship…</div>
|
||
}
|
||
if (hubQuery.isError) {
|
||
return (
|
||
<div className="page error-box">
|
||
{hubQuery.error instanceof Error ? hubQuery.error.message : 'Failed to load championship'}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const hub = hubQuery.data
|
||
if (!hub || hub.drivers.length === 0) {
|
||
return (
|
||
<div className="champ-page champ-empty" data-testid="championship-empty">
|
||
<div className="champ-empty-band">
|
||
<span className="champ-empty-eyebrow mono">box-box · championship</span>
|
||
<h1 className="champ-empty-title">No championship data</h1>
|
||
<p className="champ-empty-sub">
|
||
Standings for {latestSeason ?? 'this season'} are not available yet. Once race results are
|
||
ingested they will appear here.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return <ChampionshipBody hub={hub} view={view} setView={setView} />
|
||
}
|
||
|
||
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<EnrichedDriver[]>(
|
||
() =>
|
||
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 (
|
||
<div className="champ-page" data-testid="championship">
|
||
<div className="champ-header">
|
||
<div className="champ-title-row">
|
||
<span className="champ-accent" aria-hidden="true" />
|
||
<h1 className="champ-title">Championship</h1>
|
||
<span className="champ-season mono">{hub.season}</span>
|
||
</div>
|
||
<div className="champ-sub">
|
||
{hub.last_race ? `After ${hub.last_race} · ` : ''}Round {hub.round} of {hub.total_rounds}
|
||
</div>
|
||
<div className="champ-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
className={`champ-tab${view === 'drivers' ? ' is-active' : ''}`}
|
||
onClick={() => setView('drivers')}
|
||
data-testid="champ-tab-drivers"
|
||
>
|
||
Drivers
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`champ-tab${view === 'constructors' ? ' is-active' : ''}`}
|
||
onClick={() => setView('constructors')}
|
||
data-testid="champ-tab-constructors"
|
||
>
|
||
Constructors
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`champ-tab${view === 'progression' ? ' is-active' : ''}`}
|
||
onClick={() => setView('progression')}
|
||
data-testid="champ-tab-progression"
|
||
>
|
||
Progression
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
||
onClick={() => setView('simulator')}
|
||
data-testid="champ-tab-simulator"
|
||
>
|
||
Simulator
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="champ-stat-rail">
|
||
{statRail.map((stat) => (
|
||
<div className="champ-stat" key={stat.label}>
|
||
<div className="champ-stat-label mono">{stat.label}</div>
|
||
<div className="champ-stat-value mono" style={{ color: stat.color }}>
|
||
{stat.value}
|
||
</div>
|
||
<div className="champ-stat-sub">{stat.sub}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{view === 'drivers' && (
|
||
<DriversView
|
||
enriched={enriched}
|
||
leaderPoints={leader.points}
|
||
titleMath={titleMath}
|
||
roundsLeft={hub.rounds_left}
|
||
/>
|
||
)}
|
||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||
{view === 'progression' && <ProgressionView hub={hub} />}
|
||
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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,
|
||
}: {
|
||
enriched: EnrichedDriver[]
|
||
leaderPoints: number
|
||
titleMath: string
|
||
roundsLeft: number
|
||
}) {
|
||
const podium = enriched.slice(0, 3)
|
||
return (
|
||
<div data-testid="champ-view-drivers">
|
||
<div className="champ-podium">
|
||
{podium.map((e) => (
|
||
<div
|
||
className="champ-podium-card"
|
||
key={e.d.driver_number}
|
||
style={{ borderTopColor: e.color }}
|
||
>
|
||
<span className="champ-podium-ghost mono" style={{ color: ghostColor(e.pos) }}>
|
||
P{e.pos}
|
||
</span>
|
||
<div className="champ-podium-inner">
|
||
<div className="champ-podium-top">
|
||
<span className="mono" style={{ color: medalColor(e.pos), fontWeight: 700 }}>
|
||
P{e.pos}
|
||
</span>
|
||
<span className="champ-podium-team mono">{e.d.team_name}</span>
|
||
</div>
|
||
<div className="champ-podium-id">
|
||
<span className="champ-podium-bar" style={{ background: e.color }} />
|
||
<span className="champ-podium-code mono">{e.d.name_acronym}</span>
|
||
</div>
|
||
<div className="champ-podium-name">{e.d.full_name}</div>
|
||
<div className="champ-podium-pts">
|
||
<span className="champ-podium-pts-num mono">{fmtPts(e.d.points)}</span>
|
||
<span className="champ-podium-pts-unit">PTS</span>
|
||
<span
|
||
className="champ-podium-gap mono"
|
||
style={{ color: e.pos === 1 ? GOLD : 'var(--text-2)' }}
|
||
>
|
||
{e.pos === 1 ? 'P1' : `+${fmtPts(leaderPoints - e.d.points)}`}
|
||
</span>
|
||
</div>
|
||
<div className="champ-podium-stats">
|
||
<div>
|
||
<div className="champ-podium-stat-num mono">{e.d.wins}</div>
|
||
<div className="champ-podium-stat-label">Wins</div>
|
||
</div>
|
||
<div>
|
||
<div className="champ-podium-stat-num mono">{e.d.podiums}</div>
|
||
<div className="champ-podium-stat-label">Podiums</div>
|
||
</div>
|
||
<div>
|
||
<div className="champ-podium-stat-num mono">{e.d.poles}</div>
|
||
<div className="champ-podium-stat-label">Poles</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="champ-titlemath" data-testid="champ-titlemath">
|
||
<span className="champ-titlemath-tag mono">Title Math</span>
|
||
<span className="champ-titlemath-text">{titleMath}</span>
|
||
</div>
|
||
|
||
<div className="champ-scroll">
|
||
<table className="champ-table champ-table-drivers">
|
||
<thead>
|
||
<tr>
|
||
<th className="l">Pos</th>
|
||
<th className="l">Driver</th>
|
||
<th className="l">Team</th>
|
||
<th className="r">Pts</th>
|
||
<th className="r">Gap</th>
|
||
<th className="r">Int</th>
|
||
<th className="c">Wins</th>
|
||
<th className="c">Pod</th>
|
||
<th className="l">Form</th>
|
||
<th className="c">vs Teammate</th>
|
||
<th className="r">Title</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{enriched.map((e) => (
|
||
<tr key={e.d.driver_number}>
|
||
<td className="mono" style={{ color: medalColor(e.pos), fontWeight: 700 }}>
|
||
P{e.pos}
|
||
</td>
|
||
<td>
|
||
<div className="champ-drv">
|
||
<span className="champ-drv-bar" style={{ background: e.color }} />
|
||
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
|
||
<span className="champ-drv-name">{e.d.full_name}</span>
|
||
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
||
</div>
|
||
</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-muted">{e.gapLeader}</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)' }}>
|
||
{e.d.wins}
|
||
</td>
|
||
<td className="c mono champ-td-muted">{e.d.podiums}</td>
|
||
<td>
|
||
{e.spark ? (
|
||
<svg width="62" height="20" viewBox="0 0 62 20" className="champ-spark">
|
||
<polyline
|
||
points={e.spark}
|
||
fill="none"
|
||
stroke={e.color}
|
||
strokeWidth="1.5"
|
||
strokeLinejoin="round"
|
||
strokeLinecap="round"
|
||
opacity="0.9"
|
||
/>
|
||
</svg>
|
||
) : (
|
||
<span className="champ-td-dim mono">—</span>
|
||
)}
|
||
</td>
|
||
<td className="c mono" style={{ color: e.h2hWin ? 'var(--green)' : 'var(--text-2)' }}>
|
||
{e.h2h}
|
||
</td>
|
||
<td className="r">
|
||
<span className="champ-alive mono" style={{ color: e.aliveColor }}>
|
||
{e.aliveLabel}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ConstructorsView({ hub }: { hub: ChampionshipHub }) {
|
||
const { teams, drivers } = hub
|
||
const leaderPts = teams[0]?.points ?? 0
|
||
const podium = teams.slice(0, 3)
|
||
|
||
return (
|
||
<div data-testid="champ-view-constructors">
|
||
<div className="champ-podium">
|
||
{podium.map((t) => {
|
||
const color = teamColor(t.team_colour)
|
||
const split = teamSplit(t.team_name, drivers)
|
||
return (
|
||
<div className="champ-podium-card" key={t.team_name} style={{ borderTopColor: color }}>
|
||
<span className="champ-podium-ghost mono" style={{ color: ghostColor(t.position) }}>
|
||
P{t.position}
|
||
</span>
|
||
<div className="champ-podium-inner">
|
||
<div className="champ-podium-top">
|
||
<span className="mono" style={{ color: medalColor(t.position), fontWeight: 700 }}>
|
||
P{t.position}
|
||
</span>
|
||
</div>
|
||
<div className="champ-podium-id">
|
||
<span className="champ-podium-bar" style={{ background: color }} />
|
||
<span className="champ-podium-team-name">{t.team_name}</span>
|
||
</div>
|
||
<div className="champ-podium-pts">
|
||
<span className="champ-podium-pts-num mono">{fmtPts(t.points)}</span>
|
||
<span className="champ-podium-pts-unit">PTS</span>
|
||
<span
|
||
className="champ-podium-gap mono"
|
||
style={{ color: t.position === 1 ? GOLD : 'var(--text-2)' }}
|
||
>
|
||
{t.position === 1 ? 'P1' : `+${fmtPts(leaderPts - t.points)}`}
|
||
</span>
|
||
</div>
|
||
<div className="champ-contrib">
|
||
<div className="champ-contrib-bar">
|
||
<span style={{ width: `${split.splitA}%`, background: color }} />
|
||
<span style={{ width: `${split.splitB}%`, background: color, opacity: 0.4 }} />
|
||
</div>
|
||
<div className="champ-contrib-legend mono">
|
||
<span>
|
||
{split.driverA} <em>{fmtPts(split.ptsA)}</em>
|
||
</span>
|
||
<span>
|
||
{split.driverB} <em>{fmtPts(split.ptsB)}</em>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="champ-scroll">
|
||
<table className="champ-table champ-table-teams">
|
||
<thead>
|
||
<tr>
|
||
<th className="l">Pos</th>
|
||
<th className="l">Constructor</th>
|
||
<th className="r">Pts</th>
|
||
<th className="r">Gap</th>
|
||
<th className="c">Wins</th>
|
||
<th className="l">Driver Contribution</th>
|
||
<th className="r">Share</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{teams.map((t) => {
|
||
const color = teamColor(t.team_colour)
|
||
const split = teamSplit(t.team_name, drivers)
|
||
return (
|
||
<tr key={t.team_name}>
|
||
<td className="mono" style={{ color: medalColor(t.position), fontWeight: 700 }}>
|
||
P{t.position}
|
||
</td>
|
||
<td>
|
||
<div className="champ-drv">
|
||
<span className="champ-drv-bar" style={{ background: color }} />
|
||
<span className="champ-team-name">{t.team_name}</span>
|
||
</div>
|
||
</td>
|
||
<td className="r mono champ-td-pts">{fmtPts(t.points)}</td>
|
||
<td className="r mono champ-td-muted">
|
||
{t.position === 1 ? 'LEADER' : `+${fmtPts(leaderPts - t.points)}`}
|
||
</td>
|
||
<td className="c mono" style={{ color: t.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||
{t.wins}
|
||
</td>
|
||
<td>
|
||
<div className="champ-contrib-row">
|
||
<div className="champ-contrib-bar">
|
||
<span style={{ width: `${split.splitA}%`, background: color }} />
|
||
<span style={{ width: `${split.splitB}%`, background: color, opacity: 0.4 }} />
|
||
</div>
|
||
<span className="champ-contrib-names mono">
|
||
{split.driverA}
|
||
<em> · </em>
|
||
{split.driverB}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td className="r mono champ-td-dim">{split.shareLabel}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="champ-chart-empty" data-testid="champ-view-progression">
|
||
No completed rounds yet — progression will appear after the first race.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<string>()
|
||
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 (
|
||
<div className="champ-progression" data-testid="champ-view-progression">
|
||
<div className="champ-chart-head">
|
||
<span className="champ-chart-title mono">Cumulative points — top {top.length} drivers</span>
|
||
<span className="champ-chart-meta">
|
||
Rounds 1–{hub.round} · {hub.season}
|
||
</span>
|
||
</div>
|
||
<div className="champ-chart">
|
||
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
|
||
{yGrid.map((g) => (
|
||
<g key={g.label}>
|
||
<line x1={CHART_PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
|
||
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||
{g.label}
|
||
</text>
|
||
</g>
|
||
))}
|
||
{xGrid.map((g, i) => (
|
||
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
|
||
{g.label}
|
||
</text>
|
||
))}
|
||
{series.map((s) => (
|
||
<g key={s.code}>
|
||
<polyline
|
||
points={s.points}
|
||
fill="none"
|
||
stroke={s.color}
|
||
strokeWidth={s.width}
|
||
strokeDasharray={s.dash}
|
||
strokeLinejoin="round"
|
||
strokeLinecap="round"
|
||
/>
|
||
<circle cx={s.endX} cy={s.endY} r="3" fill={s.color} />
|
||
<text x={s.endX + 8} y={s.endY + 4} fill={s.color} className="champ-chart-label">
|
||
{s.code}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
<div className="champ-legend">
|
||
{series.map((s) => (
|
||
<div className="champ-legend-item" key={s.code}>
|
||
<span className="champ-legend-swatch" style={{ background: s.color }} />
|
||
<span className="champ-legend-code mono">{s.code}</span>
|
||
<span className="champ-legend-name">{s.name}</span>
|
||
<span className="champ-legend-total mono">{fmtPts(s.total)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|