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:
@@ -20,6 +20,7 @@ public/data/h2h.json
|
|||||||
public/data/scorers.json
|
public/data/scorers.json
|
||||||
public/data/backtest.json
|
public/data/backtest.json
|
||||||
public/data/dcTrain.json
|
public/data/dcTrain.json
|
||||||
|
public/data/elohistory.json
|
||||||
public/pwa-192x192.png
|
public/pwa-192x192.png
|
||||||
public/pwa-512x512.png
|
public/pwa-512x512.png
|
||||||
public/favicon.svg
|
public/favicon.svg
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ function main(): void {
|
|||||||
let sxy = 0, sxx = 0; // regression of goal-diff on effective Elo diff
|
let sxy = 0, sxx = 0; // regression of goal-diff on effective Elo diff
|
||||||
const calib: { d: number; h: number; a: number }[] = [];
|
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 = '';
|
let lastDate = '';
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const home = canonicalTeam(r.home);
|
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);
|
const fastDelta = eloDelta(r.hs, r.as, feh, fea, k * FAST_M, homeAdv);
|
||||||
eloFast.set(home, feh + fastDelta);
|
eloFast.set(home, feh + fastDelta);
|
||||||
eloFast.set(away, fea - fastDelta);
|
eloFast.set(away, fea - fastDelta);
|
||||||
|
sample(home, r.date, eh + delta);
|
||||||
|
sample(away, r.date, ea - delta);
|
||||||
lastDate = r.date;
|
lastDate = r.date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +217,14 @@ function main(): void {
|
|||||||
mkdirSync(OUT_DIR, { recursive: true });
|
mkdirSync(OUT_DIR, { recursive: true });
|
||||||
writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out));
|
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
|
// Compact training rows for the server's nightly in-tournament Team-DC refit
|
||||||
// (absolute dates, so daysAgo can be recomputed as the tournament progresses).
|
// (absolute dates, so daysAgo can be recomputed as the tournament progresses).
|
||||||
const dcRows = rows
|
const dcRows = rows
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
import { ArrowLeft, Shield, Star } from 'lucide-react';
|
import { ArrowLeft, Shield, Star } from 'lucide-react';
|
||||||
|
import { EloHistoryChart } from '@/components/EloHistoryChart';
|
||||||
import { useFavoriteStore } from '@/stores/favoriteStore';
|
import { useFavoriteStore } from '@/stores/favoriteStore';
|
||||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
@@ -101,6 +102,13 @@ export function TeamProfilePage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</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]">
|
<div className="grid gap-5 md:grid-cols-[1.3fr_1fr]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><span className="font-display font-bold text-ink">{t.team.fixtures}</span></CardHeader>
|
<CardHeader><span className="font-display font-bold text-ink">{t.team.fixtures}</span></CardHeader>
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ export const de: Dict = {
|
|||||||
groupStanding: "Platz in der Gruppe",
|
groupStanding: "Platz in der Gruppe",
|
||||||
standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}",
|
standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}",
|
||||||
fixtures: "Spielplan",
|
fixtures: "Spielplan",
|
||||||
|
eloHistory: "Elo-Wertung im Zeitverlauf",
|
||||||
allTimeTopScorers: "Beste Torschützen aller Zeiten",
|
allTimeTopScorers: "Beste Torschützen aller Zeiten",
|
||||||
groupShort: "Gr. {group}",
|
groupShort: "Gr. {group}",
|
||||||
awayIndicator: "bei",
|
awayIndicator: "bei",
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ export const en = {
|
|||||||
groupStanding: "Group standing",
|
groupStanding: "Group standing",
|
||||||
standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}",
|
standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}",
|
||||||
fixtures: "Fixtures",
|
fixtures: "Fixtures",
|
||||||
|
eloHistory: "Elo rating over time",
|
||||||
allTimeTopScorers: "All-time top scorers",
|
allTimeTopScorers: "All-time top scorers",
|
||||||
groupShort: "Grp {group}",
|
groupShort: "Grp {group}",
|
||||||
awayIndicator: "@",
|
awayIndicator: "@",
|
||||||
|
|||||||
Reference in New Issue
Block a user