Phase 2: predictive model — Elo + Dixon-Coles + Monte Carlo
- buildRatings.ts: walks 49k historical results → World-Football-Elo per team, data-calibrated goals model (goals-per-Elo slope, mean goals) + MLE-fit Dixon-Coles rho. Top: Spain/Argentina/France (the real 2026 favourites) - src/lib/model: elo, poisson/dixon-coles, predict, host-advantage, monteCarlo (full 48-team sim — group sampling, best-third bipartite allocation, knockout advance probs). 20 vitest cases incl. exact per-round count invariants - Server ModelEngine: live Elo re-rating after each result, per-match W/D/L, 20k-sim odds, odds-over-time history; broadcast on finished-result changes - Client: championship board, heat-shaded odds table, lazy-loaded title-race chart (Recharts split to its own chunk), match-prediction bars, bracket advance overlay - Verified: odds render, chart populates as injected results re-rate teams live Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { useMemo } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import type { OddsHistoryPoint } from '@/lib/types';
|
||||
|
||||
const PALETTE = [
|
||||
'var(--app-accent)',
|
||||
'var(--app-gold)',
|
||||
'var(--app-info)',
|
||||
'var(--app-loss)',
|
||||
'var(--app-success)',
|
||||
'var(--app-warning)',
|
||||
];
|
||||
|
||||
/** Championship odds over time — one line per top contender, a point per result
|
||||
* that moved the model. */
|
||||
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
|
||||
const { data, teams } = useMemo(() => {
|
||||
const last = history[history.length - 1];
|
||||
const teams = (last?.top ?? []).slice(0, 6).map((t) => t.team);
|
||||
const data = history.map((h, i) => {
|
||||
const row: Record<string, number | string> = { i: i + 1, label: h.label };
|
||||
for (const t of h.top) if (teams.includes(t.team)) row[t.team] = Math.round(t.champion * 1000) / 10;
|
||||
return row;
|
||||
});
|
||||
return { data, teams };
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
|
||||
<CartesianGrid stroke="var(--app-line)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="i" tick={{ fill: 'var(--app-faint)', fontSize: 11 }} stroke="var(--app-line)" />
|
||||
<YAxis
|
||||
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
|
||||
stroke="var(--app-line)"
|
||||
unit="%"
|
||||
width={42}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--app-panel)',
|
||||
border: '1px solid var(--app-line)',
|
||||
borderRadius: 10,
|
||||
fontSize: 12,
|
||||
color: 'var(--app-ink)',
|
||||
}}
|
||||
labelFormatter={(i) => data[Number(i) - 1]?.label ?? ''}
|
||||
formatter={(v: number, name: string) => [`${v}%`, `${teamFlag(name)} ${name}`]}
|
||||
/>
|
||||
<Legend formatter={(name: string) => `${teamFlag(name)} ${name}`} wrapperStyle={{ fontSize: 12 }} />
|
||||
{teams.map((t, idx) => (
|
||||
<Line
|
||||
key={t}
|
||||
type="monotone"
|
||||
dataKey={t}
|
||||
stroke={PALETTE[idx % PALETTE.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2 }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user