diff --git a/.gitignore b/.gitignore index 9b2eff6..8028176 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ public/data/h2h.json public/data/scorers.json public/data/backtest.json public/data/dcTrain.json +public/data/elohistory.json public/pwa-192x192.png public/pwa-512x512.png public/favicon.svg diff --git a/scripts/buildRatings.ts b/scripts/buildRatings.ts index 5c03796..1b560c0 100644 --- a/scripts/buildRatings.ts +++ b/scripts/buildRatings.ts @@ -74,6 +74,19 @@ function main(): void { let sxy = 0, sxx = 0; // regression of goal-diff on effective Elo diff const calib: { d: number; h: number; a: number }[] = []; + // Per-WC-team Elo trajectories: one sample per year historically, one per + // month for the recent window — small enough to serve, rich enough to plot. + const wcSet = new Set(ALL_TEAMS); + const RECENT_FROM = '2022-01-01'; + const history = new Map>(); // team → periodKey → rating + const sample = (team: string, date: string, rating: number): void => { + if (!wcSet.has(team)) return; + const key = date >= RECENT_FROM ? date.slice(0, 7) : date.slice(0, 4); + let m = history.get(team); + if (!m) { m = new Map(); history.set(team, m); } + m.set(key, Math.round(rating)); + }; + let lastDate = ''; for (const r of rows) { const home = canonicalTeam(r.home); @@ -98,6 +111,8 @@ function main(): void { const fastDelta = eloDelta(r.hs, r.as, feh, fea, k * FAST_M, homeAdv); eloFast.set(home, feh + fastDelta); eloFast.set(away, fea - fastDelta); + sample(home, r.date, eh + delta); + sample(away, r.date, ea - delta); lastDate = r.date; } @@ -202,6 +217,14 @@ function main(): void { mkdirSync(OUT_DIR, { recursive: true }); writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out)); + // Per-team Elo trajectories for the team pages. + const eloHistory: Record = {}; + for (const [team, m] of history) { + eloHistory[team] = [...m.entries()].sort((a, b) => a[0].localeCompare(b[0])); + } + writeFileSync(join(OUT_DIR, 'elohistory.json'), JSON.stringify({ recentFrom: RECENT_FROM, teams: eloHistory })); + console.log(`elo history → public/data/elohistory.json (${Object.keys(eloHistory).length} teams)`); + // Compact training rows for the server's nightly in-tournament Team-DC refit // (absolute dates, so daysAgo can be recomputed as the tournament progresses). const dcRows = rows diff --git a/src/components/EloHistoryChart.tsx b/src/components/EloHistoryChart.tsx new file mode 100644 index 0000000..3911515 --- /dev/null +++ b/src/components/EloHistoryChart.tsx @@ -0,0 +1,74 @@ +import { useEffect, useMemo, useState } from 'react'; + +type Series = [string, number][]; +interface EloHistoryFile { + recentFrom: string; + teams: Record; +} + +let cache: Promise | null = null; +function loadHistory(): Promise { + cache ??= fetch('/data/elohistory.json') + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null); + return cache; +} + +const periodT = (key: string): number => new Date(key.length === 4 ? `${key}-07-01` : `${key}-15`).getTime(); + +/** A century of strength in one line: the team's Elo trajectory. */ +export function EloHistoryChart({ team }: { team: string }) { + const [series, setSeries] = useState(null); + + useEffect(() => { + let alive = true; + void loadHistory().then((d) => alive && setSeries(d?.teams[team] ?? null)); + return () => { alive = false; }; + }, [team]); + + const W = 600; + const H = 170; + const PAD = { l: 38, r: 8, t: 8, b: 18 }; + + const chart = useMemo(() => { + if (!series || series.length < 2) return null; + const t0 = periodT(series[0]![0]); + const t1 = periodT(series[series.length - 1]![0]); + const vals = series.map(([, v]) => v); + const lo = Math.floor((Math.min(...vals) - 40) / 100) * 100; + const hi = Math.ceil((Math.max(...vals) + 40) / 100) * 100; + const x = (t: number) => PAD.l + ((t - t0) / Math.max(1, t1 - t0)) * (W - PAD.l - PAD.r); + const y = (v: number) => PAD.t + (1 - (v - lo) / Math.max(1, hi - lo)) * (H - PAD.t - PAD.b); + const line = series.map(([k, v]) => `${x(periodT(k)).toFixed(1)},${y(v).toFixed(1)}`).join(' '); + const y0 = new Date(t0).getUTCFullYear(); + const y1 = new Date(t1).getUTCFullYear(); + const step = y1 - y0 > 80 ? 25 : y1 - y0 > 40 ? 20 : 10; + const xTicks: { px: number; label: string }[] = []; + for (let yr = Math.ceil(y0 / step) * step; yr <= y1; yr += step) { + xTicks.push({ px: x(new Date(`${yr}-07-01`).getTime()), label: String(yr) }); + } + const yTicks: { py: number; label: string }[] = []; + for (let v = lo + 100; v < hi; v += Math.max(100, Math.round((hi - lo) / 4 / 100) * 100)) { + yTicks.push({ py: y(v), label: String(v) }); + } + return { line, xTicks, yTicks }; + }, [series]); + + if (!chart) return null; + + return ( + + + {chart.yTicks.map((yt) => ( + + + {yt.label} + + ))} + + {chart.xTicks.map((xt) => ( + {xt.label} + ))} + + ); +} diff --git a/src/features/team/TeamProfilePage.tsx b/src/features/team/TeamProfilePage.tsx index 0610dbe..68c5c57 100644 --- a/src/features/team/TeamProfilePage.tsx +++ b/src/features/team/TeamProfilePage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { Link, useParams } from '@tanstack/react-router'; import { ArrowLeft, Shield, Star } from 'lucide-react'; +import { EloHistoryChart } from '@/components/EloHistoryChart'; import { useFavoriteStore } from '@/stores/favoriteStore'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; @@ -101,6 +102,13 @@ export function TeamProfilePage() { + + {t.team.eloHistory} + + + + +
{t.team.fixtures} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index e3b12db..850f837 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -390,6 +390,7 @@ export const de: Dict = { groupStanding: "Platz in der Gruppe", standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}", fixtures: "Spielplan", + eloHistory: "Elo-Wertung im Zeitverlauf", allTimeTopScorers: "Beste Torschützen aller Zeiten", groupShort: "Gr. {group}", awayIndicator: "bei", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index ffc8790..a06b548 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -389,6 +389,7 @@ export const en = { groupStanding: "Group standing", standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}", fixtures: "Fixtures", + eloHistory: "Elo rating over time", allTimeTopScorers: "All-time top scorers", groupShort: "Grp {group}", awayIndicator: "@",