import { useMemo, useState } from 'react' import type { ChampHubDriver, ChampionshipHub } from '../types' import { teamColor } from '../utils' import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry' import '../styles/rivalry.css' const PAD_L = 48 const PAD_T = 16 const PLOT_W = 882 const PLOT_H = 316 const GAP_PLOT_H = 200 function fmtPts(n: number): string { return Number.isInteger(n) ? String(n) : n.toFixed(1) } /** Up to ~7 evenly spaced round labels, always including the last round. */ function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] { const step = Math.max(1, Math.ceil(n / 7)) const ticks: { x: number; label: string }[] = [] for (let i = 0; i < n; i += step) { ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` }) } const last = labels[n - 1] ?? `R${n}` if (ticks[ticks.length - 1]?.label !== last) { ticks.push({ x: x(n - 1), label: last }) } return ticks } function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string { return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ') } interface PickerProps { id: 'a' | 'b' label: string drivers: ChampHubDriver[] value: number color: string onChange: (driverNumber: number) => void } function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) { return ( {label} onChange(Number(e.target.value))} > {drivers.map((d) => ( {d.name_acronym} · {d.full_name} ))} ) } export function RivalryCompare({ hub }: { hub: ChampionshipHub }) { const { drivers } = hub const [pickA, setPickA] = useState(null) const [pickB, setPickB] = useState(null) // Default to the top two in the standings; fall back there if a picked // driver disappears (e.g. season switch re-fetches the hub). const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0] const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1] const tally = useMemo( () => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null), [a, b, hub.round_labels], ) if (drivers.length < 2) { return ( Need at least two drivers in the standings to compare a rivalry. ) } const colorA = teamColor(a.team_colour) const colorB = teamColor(b.team_colour) const sameTeam = a.team_name === b.team_name const cumA = a.cumulative ?? [] const cumB = b.cumulative ?? [] const rounds = Math.min(cumA.length, cumB.length) const gaps = gapSeries(cumA, cumB) const lastGap = gaps[gaps.length - 1] ?? 0 const strip = tally ? lastRounds(tally, 5) : [] const pickers = ( vs ) if (rounds === 0) { return ( {pickers} No completed rounds yet — the rivalry will appear after the first race. ) } // Points race scales. const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1) const maxY = Math.max(50, Math.ceil(peak / 50) * 50) const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1)) const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) })) const pxGrid = xTicks(rounds, hub.round_labels, px) // Gap scales: symmetric around zero. const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10) const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2) const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) })) const gxGrid = xTicks(rounds, hub.round_labels, px) const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b const gapCaption = gapLeader ? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.` : 'Dead level on points.' return ( {pickers} {a.name_acronym} {tally ? `${tally.a}–${tally.b}` : '—'} {b.name_acronym} Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted {tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''} {strip.length > 0 && ( Last {strip.length} {strip.map((r) => ( {r.label} {r.winner === 'a' ? a.name_acronym : b.name_acronym} ))} )} Points race — {a.name_acronym} vs {b.name_acronym} Rounds 1–{rounds} · {hub.season} {pyGrid.map((g) => ( {g.label} ))} {pxGrid.map((g, i) => ( {g.label} ))} {[ { slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) }, { slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) }, ].map((s) => ( {s.d.name_acronym} ))} Gap over season — {a.name_acronym} − {b.name_acronym} {gapCaption} {gyGrid.map((g) => ( {g.label} ))} {gxGrid.map((g, i) => ( {g.label} ))} Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead. ) }
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.