mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
|
|
import { useQuery } from '@tanstack/react-query'
|
||
|
|
import { Link } from '@tanstack/react-router'
|
||
|
|
import { fetchDriverSummary, fetchSeasons } from '../api'
|
||
|
|
import { teamColor } from '../utils'
|
||
|
|
import { countryFlag } from '../lib/gpIdentity'
|
||
|
|
import {
|
||
|
|
formatDelta,
|
||
|
|
formatPosition,
|
||
|
|
gridFinishDeltas,
|
||
|
|
type RoundDelta,
|
||
|
|
} from '../lib/driverProfile'
|
||
|
|
import type { DriverSummary } from '../types'
|
||
|
|
import '../styles/driver-profile.css'
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
driverNumber: number
|
||
|
|
year?: number
|
||
|
|
}
|
||
|
|
|
||
|
|
function fmtPts(n: number): string {
|
||
|
|
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DriverProfilePage({ driverNumber, year }: Props) {
|
||
|
|
// Without an explicit ?year=, default to the latest ingested season; if the
|
||
|
|
// seasons list is empty/unavailable the backend falls back to the current year.
|
||
|
|
const seasonsQuery = useQuery({
|
||
|
|
queryKey: ['seasons'],
|
||
|
|
queryFn: fetchSeasons,
|
||
|
|
enabled: year == null,
|
||
|
|
})
|
||
|
|
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
||
|
|
const seasonsSettled = year != null || !seasonsQuery.isLoading
|
||
|
|
|
||
|
|
const summaryQuery = useQuery({
|
||
|
|
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
||
|
|
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
|
||
|
|
enabled: seasonsSettled && driverNumber > 0,
|
||
|
|
staleTime: 5 * 60_000,
|
||
|
|
})
|
||
|
|
|
||
|
|
if (driverNumber <= 0) {
|
||
|
|
return <div className="page error-box">Invalid driver number</div>
|
||
|
|
}
|
||
|
|
if (!seasonsSettled || summaryQuery.isLoading) {
|
||
|
|
return <div className="page loading-state">loading driver profile…</div>
|
||
|
|
}
|
||
|
|
if (summaryQuery.isError) {
|
||
|
|
return (
|
||
|
|
<div className="page error-box">
|
||
|
|
{summaryQuery.error instanceof Error
|
||
|
|
? summaryQuery.error.message
|
||
|
|
: 'Failed to load driver profile'}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
const summary = summaryQuery.data
|
||
|
|
if (!summary) {
|
||
|
|
return <div className="page error-box">No driver data</div>
|
||
|
|
}
|
||
|
|
return <DriverProfileBody summary={summary} />
|
||
|
|
}
|
||
|
|
|
||
|
|
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
|
||
|
|
const color = teamColor(summary.team_colour)
|
||
|
|
const deltas = gridFinishDeltas(summary.rounds)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="dp-page" data-testid="driver-profile">
|
||
|
|
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||
|
|
<div className="dp-identity">
|
||
|
|
<div className="dp-title-row">
|
||
|
|
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
|
||
|
|
<span className="dp-num mono" style={{ color }}>
|
||
|
|
#{summary.driver_number}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
|
||
|
|
<div className="dp-team">
|
||
|
|
<span className="dp-team-swatch" style={{ background: color }} />
|
||
|
|
{summary.team_name || 'Unknown team'}
|
||
|
|
<span className="dp-season mono">{summary.season}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="dp-stat-rail">
|
||
|
|
<div className="dp-stat">
|
||
|
|
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
|
||
|
|
<div className="dp-stat-label">Championship</div>
|
||
|
|
</div>
|
||
|
|
<div className="dp-stat">
|
||
|
|
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
|
||
|
|
<div className="dp-stat-label">Points</div>
|
||
|
|
</div>
|
||
|
|
<div className="dp-stat">
|
||
|
|
<div className="dp-stat-value mono">{summary.wins}</div>
|
||
|
|
<div className="dp-stat-label">Wins</div>
|
||
|
|
</div>
|
||
|
|
<div className="dp-stat">
|
||
|
|
<div className="dp-stat-value mono">{summary.podiums}</div>
|
||
|
|
<div className="dp-stat-label">Podiums</div>
|
||
|
|
</div>
|
||
|
|
<div className="dp-stat">
|
||
|
|
<div className="dp-stat-value mono">{summary.poles}</div>
|
||
|
|
<div className="dp-stat-label">Poles</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Link to="/championship" className="dp-back mono">
|
||
|
|
← Championship
|
||
|
|
</Link>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<section className="dp-section" data-testid="dp-form">
|
||
|
|
<h2 className="dp-section-title mono">Season form</h2>
|
||
|
|
{summary.cumulative.length > 0 ? (
|
||
|
|
<div className="dp-form-grid">
|
||
|
|
<CumulativeLine
|
||
|
|
cumulative={summary.cumulative}
|
||
|
|
labels={summary.round_labels}
|
||
|
|
color={color}
|
||
|
|
/>
|
||
|
|
<FormStrip form={summary.form} color={color} />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="dp-empty">No completed rounds yet.</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="dp-section" data-testid="dp-quali-race">
|
||
|
|
<h2 className="dp-section-title mono">Quali vs race</h2>
|
||
|
|
{deltas.some((d) => d.delta != null) ? (
|
||
|
|
<GridVsRaceChart deltas={deltas} color={color} />
|
||
|
|
) : (
|
||
|
|
<div className="dp-empty">No grid-vs-finish data yet.</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="dp-section" data-testid="dp-rounds">
|
||
|
|
<h2 className="dp-section-title mono">Track by track</h2>
|
||
|
|
{summary.rounds.length > 0 ? (
|
||
|
|
<RoundsTable summary={summary} deltas={deltas} />
|
||
|
|
) : (
|
||
|
|
<div className="dp-empty">No completed rounds yet.</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const LINE_W = 640
|
||
|
|
const LINE_H = 180
|
||
|
|
const LINE_PL = 44
|
||
|
|
const LINE_PR = 16
|
||
|
|
const LINE_PT = 12
|
||
|
|
const LINE_PB = 24
|
||
|
|
|
||
|
|
function CumulativeLine({
|
||
|
|
cumulative,
|
||
|
|
labels,
|
||
|
|
color,
|
||
|
|
}: {
|
||
|
|
cumulative: number[]
|
||
|
|
labels: string[]
|
||
|
|
color: string
|
||
|
|
}) {
|
||
|
|
const n = cumulative.length
|
||
|
|
const maxY = Math.max(25, ...cumulative)
|
||
|
|
const plotW = LINE_W - LINE_PL - LINE_PR
|
||
|
|
const plotH = LINE_H - LINE_PT - LINE_PB
|
||
|
|
const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1))
|
||
|
|
const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH
|
||
|
|
const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||
|
|
|
||
|
|
const tickStep = Math.max(1, Math.ceil(n / 8))
|
||
|
|
const ticks: { x: number; label: string }[] = []
|
||
|
|
for (let i = 0; i < n; i += tickStep) {
|
||
|
|
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<figure className="dp-chart">
|
||
|
|
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
|
||
|
|
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
|
||
|
|
{[0, 0.5, 1].map((f) => {
|
||
|
|
const v = Math.round(maxY * f)
|
||
|
|
return (
|
||
|
|
<g key={f}>
|
||
|
|
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
|
||
|
|
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
|
||
|
|
{v}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
{ticks.map((t) => (
|
||
|
|
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
|
||
|
|
{t.label}
|
||
|
|
</text>
|
||
|
|
))}
|
||
|
|
<polyline
|
||
|
|
points={points}
|
||
|
|
fill="none"
|
||
|
|
stroke={color}
|
||
|
|
strokeWidth="2.2"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
strokeLinecap="round"
|
||
|
|
/>
|
||
|
|
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
|
||
|
|
</svg>
|
||
|
|
</figure>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function FormStrip({ form, color }: { form: number[]; color: string }) {
|
||
|
|
const max = Math.max(25, ...form)
|
||
|
|
return (
|
||
|
|
<div className="dp-form-strip">
|
||
|
|
<div className="dp-chart-caption mono">Last {form.length} races</div>
|
||
|
|
<div className="dp-form-bars">
|
||
|
|
{form.map((pts, i) => (
|
||
|
|
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
|
||
|
|
<div
|
||
|
|
className="dp-form-bar-fill"
|
||
|
|
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
|
||
|
|
/>
|
||
|
|
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const DELTA_H = 190
|
||
|
|
const DELTA_PT = 14
|
||
|
|
const DELTA_PB = 30
|
||
|
|
|
||
|
|
function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) {
|
||
|
|
const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0)))
|
||
|
|
const colW = 34
|
||
|
|
const w = Math.max(240, deltas.length * colW)
|
||
|
|
const plotH = DELTA_H - DELTA_PT - DELTA_PB
|
||
|
|
const zeroY = DELTA_PT + plotH / 2
|
||
|
|
const scale = plotH / 2 / maxAbs
|
||
|
|
|
||
|
|
return (
|
||
|
|
<figure className="dp-chart">
|
||
|
|
<figcaption className="dp-chart-caption mono">
|
||
|
|
Positions gained (▲) / lost (▼) from grid to flag, per round
|
||
|
|
</figcaption>
|
||
|
|
<div className="dp-chart-scroll">
|
||
|
|
<svg
|
||
|
|
viewBox={`0 0 ${w} ${DELTA_H}`}
|
||
|
|
width={w}
|
||
|
|
height={DELTA_H}
|
||
|
|
className="dp-delta-svg"
|
||
|
|
role="img"
|
||
|
|
aria-label="Positions gained or lost per round"
|
||
|
|
>
|
||
|
|
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
|
||
|
|
{deltas.map((d, i) => {
|
||
|
|
const cx = i * colW + colW / 2
|
||
|
|
if (d.delta == null) {
|
||
|
|
return (
|
||
|
|
<g key={d.round}>
|
||
|
|
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
|
||
|
|
·
|
||
|
|
</text>
|
||
|
|
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||
|
|
R{d.round}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
const h = Math.abs(d.delta) * scale
|
||
|
|
const yTop = d.delta >= 0 ? zeroY - h : zeroY
|
||
|
|
return (
|
||
|
|
<g key={d.round}>
|
||
|
|
<title>{`${d.label}: ${formatPosition(d.grid)} → ${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
|
||
|
|
<rect
|
||
|
|
x={cx - 8}
|
||
|
|
y={yTop}
|
||
|
|
width={16}
|
||
|
|
height={Math.max(1.5, h)}
|
||
|
|
rx={2}
|
||
|
|
fill={color}
|
||
|
|
opacity={d.delta >= 0 ? 0.95 : 0.45}
|
||
|
|
/>
|
||
|
|
<text
|
||
|
|
x={cx}
|
||
|
|
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
|
||
|
|
textAnchor="middle"
|
||
|
|
className="dp-delta-val mono"
|
||
|
|
>
|
||
|
|
{formatDelta(d.delta)}
|
||
|
|
</text>
|
||
|
|
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||
|
|
R{d.round}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
</figure>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusNote(d: RoundDelta): string {
|
||
|
|
if (d.status === 'dnf') return 'DNF'
|
||
|
|
if (d.status === 'dns') return 'DNS'
|
||
|
|
if (d.status === 'dsq') return 'DSQ'
|
||
|
|
if (d.status === 'absent') return '—'
|
||
|
|
return ''
|
||
|
|
}
|
||
|
|
|
||
|
|
function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) {
|
||
|
|
return (
|
||
|
|
<div className="dp-scroll">
|
||
|
|
<table className="dp-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th className="l">Rnd</th>
|
||
|
|
<th className="l">Grand Prix</th>
|
||
|
|
<th className="r">Grid</th>
|
||
|
|
<th className="r">Race</th>
|
||
|
|
<th className="r">Δ</th>
|
||
|
|
<th className="r">Pts</th>
|
||
|
|
<th className="l"></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{summary.rounds.map((r, i) => {
|
||
|
|
const d = deltas[i]
|
||
|
|
return (
|
||
|
|
<tr key={r.meeting_key}>
|
||
|
|
<td className="mono dp-td-dim">R{i + 1}</td>
|
||
|
|
<td>
|
||
|
|
<span className="dp-flag" aria-hidden="true">
|
||
|
|
{countryFlag({ country_code: r.country_code, country_flag: '' })}
|
||
|
|
</span>
|
||
|
|
{r.meeting_name}
|
||
|
|
</td>
|
||
|
|
<td className="r mono">{formatPosition(d.grid)}</td>
|
||
|
|
<td className="r mono">{formatPosition(d.finish)}</td>
|
||
|
|
<td
|
||
|
|
className="r mono"
|
||
|
|
style={{
|
||
|
|
color:
|
||
|
|
d.delta == null
|
||
|
|
? 'var(--text-3)'
|
||
|
|
: d.delta > 0
|
||
|
|
? 'var(--green)'
|
||
|
|
: d.delta < 0
|
||
|
|
? 'var(--red)'
|
||
|
|
: 'var(--text-2)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{formatDelta(d.delta)}
|
||
|
|
</td>
|
||
|
|
<td className="r mono">{fmtPts(r.points)}</td>
|
||
|
|
<td className="dp-td-dim mono">{statusNote(d)}</td>
|
||
|
|
</tr>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|