Elo trajectories: a century of strength on every team page

buildRatings samples each WC team's rating along the full walk (yearly
historically, monthly since 2022) into elohistory.json (~72KB, 48
teams). Team pages plot it as a clean SVG line with year and rating
gridlines — Germany's century, 1908 → today, in one glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:12:36 +02:00
parent f58d6566fb
commit 047e9f8767
6 changed files with 108 additions and 0 deletions
+1
View File
@@ -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
+23
View File
@@ -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<string, Map<string, number>>(); // 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<string, [string, number][]> = {};
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
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useMemo, useState } from 'react';
type Series = [string, number][];
interface EloHistoryFile {
recentFrom: string;
teams: Record<string, Series>;
}
let cache: Promise<EloHistoryFile | null> | null = null;
function loadHistory(): Promise<EloHistoryFile | null> {
cache ??= fetch('/data/elohistory.json')
.then((r) => (r.ok ? (r.json() as Promise<EloHistoryFile>) : 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<Series | null>(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 (
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`Elo · ${team}`}>
<rect x={PAD.l} y={PAD.t} width={W - PAD.l - PAD.r} height={H - PAD.t - PAD.b} className="fill-[var(--app-elevated)]" rx={4} />
{chart.yTicks.map((yt) => (
<g key={yt.label}>
<line x1={PAD.l} x2={W - PAD.r} y1={yt.py} y2={yt.py} className="stroke-[var(--app-line)]" strokeWidth={1} />
<text x={PAD.l - 4} y={yt.py + 3} textAnchor="end" className="fill-[var(--app-faint)] text-[10px]">{yt.label}</text>
</g>
))}
<polyline points={chart.line} fill="none" className="stroke-[var(--app-accent)]" strokeWidth={2} strokeLinejoin="round" />
{chart.xTicks.map((xt) => (
<text key={xt.label} x={xt.px} y={H - 4} textAnchor="middle" className="fill-[var(--app-faint)] text-[10px]">{xt.label}</text>
))}
</svg>
);
}
+8
View File
@@ -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() {
</CardBody>
</Card>
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.eloHistory}</span></CardHeader>
<CardBody>
<EloHistoryChart team={profile.team} />
</CardBody>
</Card>
<div className="grid gap-5 md:grid-cols-[1.3fr_1fr]">
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.team.fixtures}</span></CardHeader>
+1
View File
@@ -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",
+1
View File
@@ -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: "@",