Starters-on-pitch, chart movers sort, pre-filled what-if bracket, vs/@ fix
- Finished matches show the STARTING XIs on the pitch (red markers with the minute on everyone subbed off) with 'Subbed on' / 'Subbed off' lists below, minutes and ratings included; live matches keep the current-XI view that swaps substitutes in - Title-race chart gets a sort toggle, defaulting to 'Biggest movers' (the teams whose odds changed most at the latest update — what's relevant now) with 'Title favorites' as the alternative - Entering what-if on the bracket now pre-fills the model's most likely tournament: group slots seeded from win-group/advance odds (best- thirds assigned to their allowed slots via backtracking), every pairing picked by the model — a complete overview to edit by tapping; Reset returns to the projection - Team fixtures drop the US 'vs/@' convention: always 'vs' plus an H/A badge with a tooltip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,9 +39,10 @@ interface Slot {
|
||||
/** The starter the slot belongs to (=== player until a swap). */
|
||||
starter: PitchPlayer;
|
||||
cameOnAt: number | null;
|
||||
/** The occupant went off but the replacement couldn't be paired — keep the
|
||||
* dot with a ▼ marker instead of guessing. */
|
||||
unresolvedOff: boolean;
|
||||
/** Minute the occupant went off (▼ marker), when he stays on the pitch. */
|
||||
offAt: number | null;
|
||||
/** Fade the dot — live mode only, when a replacement couldn't be paired. */
|
||||
dim: boolean;
|
||||
}
|
||||
|
||||
interface SubPair { in: string; out: string }
|
||||
@@ -54,14 +55,14 @@ interface SubPair { in: string; out: string }
|
||||
function currentXI(team: LineupTeam, pairs: SubPair[]): { slots: Slot[]; off: PitchPlayer[] } {
|
||||
const off: PitchPlayer[] = [];
|
||||
const used = new Set<PitchPlayer>();
|
||||
const slots: Slot[] = team.starters.map((starter) => ({ player: starter, starter, cameOnAt: null, unresolvedOff: false }));
|
||||
const slots: Slot[] = team.starters.map((starter) => ({ player: starter, starter, cameOnAt: null, offAt: null, dim: false }));
|
||||
const swap = (slot: Slot, repl: PitchPlayer) => {
|
||||
used.add(repl);
|
||||
off.push(slot.player);
|
||||
slot.player = repl;
|
||||
slot.cameOnAt = repl.subIn;
|
||||
};
|
||||
const pending = () => slots.filter((s) => s.player.subOut != null && !s.unresolvedOff);
|
||||
const pending = () => slots.filter((s) => s.player.subOut != null && s.offAt == null);
|
||||
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let progress = false;
|
||||
@@ -78,10 +79,22 @@ function currentXI(team: LineupTeam, pairs: SubPair[]): { slots: Slot[]; off: Pi
|
||||
}
|
||||
if (!progress) break;
|
||||
}
|
||||
for (const slot of pending()) slot.unresolvedOff = true;
|
||||
for (const slot of pending()) {
|
||||
slot.offAt = slot.player.subOut;
|
||||
slot.dim = true;
|
||||
}
|
||||
return { slots, off };
|
||||
}
|
||||
|
||||
/** The starting XI as kicked off — subbed-off starters keep their spot with a
|
||||
* ▼ minute marker. The post-match view of how the teams lined up. */
|
||||
function startersXI(team: LineupTeam): { slots: Slot[]; off: PitchPlayer[] } {
|
||||
return {
|
||||
slots: team.starters.map((starter) => ({ player: starter, starter, cameOnAt: null, offAt: starter.subOut, dim: false })),
|
||||
off: team.starters.filter((p) => p.subOut != null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Last name token, capped — fits under a dot on a 68-unit-wide pitch. */
|
||||
function shortName(name: string): string {
|
||||
const last = name.trim().split(' ').pop() ?? name;
|
||||
@@ -104,7 +117,7 @@ function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny:
|
||||
const gk = ny <= 0.1;
|
||||
const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)';
|
||||
return (
|
||||
<g opacity={slot.unresolvedOff ? 0.55 : 1}>
|
||||
<g opacity={slot.dim ? 0.55 : 1}>
|
||||
<circle cx={cx} cy={cy} r={3.3} fill="var(--app-panel-2)" stroke={stroke} strokeWidth={0.6} />
|
||||
<text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum">
|
||||
{p.num ?? ''}
|
||||
@@ -147,14 +160,14 @@ function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny:
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
{slot.unresolvedOff && (
|
||||
{slot.offAt != null && (
|
||||
<g>
|
||||
<polygon
|
||||
points={`${markX - 1},${cy - 1.4} ${markX + 1},${cy - 1.4} ${markX},${cy + 0.4}`}
|
||||
fill="var(--app-loss)"
|
||||
/>
|
||||
<text x={markX} y={cy + 3.2} textAnchor="middle" fontSize={1.9} fill="var(--app-loss)" className="tnum">
|
||||
{slot.player.subOut}'
|
||||
{slot.offAt}'
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
@@ -198,33 +211,39 @@ function PitchLines() {
|
||||
);
|
||||
}
|
||||
|
||||
function SubbedOffStrip({ off, tone }: { off: PitchPlayer[]; tone: 'home' | 'away' }) {
|
||||
function BenchStrip({ players, tone, label, dir }: { players: PitchPlayer[]; tone: 'home' | 'away'; label: string; dir: 'on' | 'off' }) {
|
||||
const t = useT();
|
||||
if (!off.length) return null;
|
||||
if (!players.length) return null;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className={`mb-1 text-[10px] font-bold uppercase tracking-wide ${tone === 'home' ? 'text-accent' : 'text-info'}`}>
|
||||
{t.match.subbedOff}
|
||||
{label}
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-xs text-muted">
|
||||
{off.map((p) => (
|
||||
{players.map((p) => {
|
||||
const minute = dir === 'on' ? p.subIn : p.subOut;
|
||||
return (
|
||||
<li key={p.name} className="flex items-center gap-1.5">
|
||||
<span aria-hidden className="text-loss">▼</span>
|
||||
<span aria-hidden className={dir === 'on' ? 'text-success' : 'text-loss'}>{dir === 'on' ? '▲' : '▼'}</span>
|
||||
<span className="truncate">{p.name}</span>
|
||||
{p.subOut != null && <span className="tnum shrink-0 text-faint">{fmt(t.live.minute, { n: p.subOut })}</span>}
|
||||
{minute != null && <span className="tnum shrink-0 text-faint">{fmt(t.live.minute, { n: minute })}</span>}
|
||||
{p.rating != null && <span className="tnum ml-auto shrink-0 text-faint">{p.rating.toFixed(1)}</span>}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineupPitch({ lineup, home, away, events }: {
|
||||
export function LineupPitch({ lineup, home, away, events, mode = 'current' }: {
|
||||
lineup: MatchLineup;
|
||||
home: string;
|
||||
away: string;
|
||||
events: MatchLiveEvent[];
|
||||
/** 'current' = who is on the pitch right now (live, subs swap in);
|
||||
* 'starters' = the XIs as they kicked off (finished/upcoming matches). */
|
||||
mode?: 'current' | 'starters';
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
@@ -233,12 +252,15 @@ export function LineupPitch({ lineup, home, away, events }: {
|
||||
const pairsFor = (side: 'home' | 'away'): SubPair[] =>
|
||||
subs.filter((e) => e.side === side).map((e) => ({ in: e.title, out: e.subOut! }));
|
||||
return {
|
||||
homeXI: currentXI(lineup.home, pairsFor('home')),
|
||||
awayXI: currentXI(lineup.away, pairsFor('away')),
|
||||
homeXI: mode === 'starters' ? startersXI(lineup.home) : currentXI(lineup.home, pairsFor('home')),
|
||||
awayXI: mode === 'starters' ? startersXI(lineup.away) : currentXI(lineup.away, pairsFor('away')),
|
||||
homeNy: rowSpread(lineup.home.starters),
|
||||
awayNy: rowSpread(lineup.away.starters),
|
||||
};
|
||||
}, [lineup, events]);
|
||||
}, [lineup, events, mode]);
|
||||
|
||||
const cameOn = (tm: LineupTeam) =>
|
||||
tm.subs.filter((p) => p.subIn != null).sort((a, b) => (a.subIn ?? 0) - (b.subIn ?? 0));
|
||||
|
||||
const teamLabel = (name: string, tm: LineupTeam) =>
|
||||
[name, tm.formation, tm.coach].filter(Boolean).join(' · ');
|
||||
@@ -257,11 +279,26 @@ export function LineupPitch({ lineup, home, away, events }: {
|
||||
{awayXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="away" ny={awayNy.get(s.starter) ?? 0.5} />)}
|
||||
</svg>
|
||||
<div className="mt-1.5 truncate text-center text-xs font-bold text-info">{teamLabel(away, lineup.away)}</div>
|
||||
{(homeXI.off.length > 0 || awayXI.off.length > 0) && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-4">
|
||||
<SubbedOffStrip off={homeXI.off} tone="home" />
|
||||
<SubbedOffStrip off={awayXI.off} tone="away" />
|
||||
{mode === 'starters' ? (
|
||||
(cameOn(lineup.home).length > 0 || cameOn(lineup.away).length > 0) && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<BenchStrip players={cameOn(lineup.home)} tone="home" label={t.match.subbedOn} dir="on" />
|
||||
<BenchStrip players={cameOn(lineup.away)} tone="away" label={t.match.subbedOn} dir="on" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<BenchStrip players={homeXI.off} tone="home" label={t.match.subbedOff} dir="off" />
|
||||
<BenchStrip players={awayXI.off} tone="away" label={t.match.subbedOff} dir="off" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
(homeXI.off.length > 0 || awayXI.off.length > 0) && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-4">
|
||||
<BenchStrip players={homeXI.off} tone="home" label={t.match.subbedOff} dir="off" />
|
||||
<BenchStrip players={awayXI.off} tone="away" label={t.match.subbedOff} dir="off" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { useFormat } from '@/lib/i18n';
|
||||
import { useFormat, useT } from '@/lib/i18n';
|
||||
import type { OddsHistoryPoint } from '@/lib/types';
|
||||
|
||||
const PALETTE = [
|
||||
@@ -15,11 +15,25 @@ const PALETTE = [
|
||||
|
||||
/** Championship odds over time — one line per top contender, a point per result
|
||||
* that moved the model. */
|
||||
type ChartSort = 'movers' | 'leaders';
|
||||
|
||||
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
|
||||
const { locale } = useFormat();
|
||||
const t = useT();
|
||||
// Default to the teams whose odds moved most at the latest update — what
|
||||
// changed just now is more interesting than a static favorites list.
|
||||
const [sort, setSort] = useState<ChartSort>('movers');
|
||||
const { data, teams } = useMemo(() => {
|
||||
const last = history[history.length - 1];
|
||||
const teams = (last?.top ?? []).slice(0, 6).map((t) => t.team);
|
||||
const prev = history[history.length - 2];
|
||||
const latest = last?.top ?? [];
|
||||
let picked = latest.slice(0, 6);
|
||||
if (sort === 'movers' && prev) {
|
||||
const before = new Map(prev.top.map((x) => [x.team, x.champion]));
|
||||
const delta = (x: { team: string; champion: number }) => Math.abs(x.champion - (before.get(x.team) ?? x.champion));
|
||||
picked = [...latest].sort((a, b) => delta(b) - delta(a) || b.champion - a.champion).slice(0, 6);
|
||||
}
|
||||
const teams = picked.map((x) => x.team);
|
||||
const tag = locale === 'de' ? 'de-DE' : 'en-GB';
|
||||
const data = history.map((h, i) => {
|
||||
const day = new Date(h.t).toLocaleDateString(tag, { day: 'numeric', month: 'short' });
|
||||
@@ -28,9 +42,24 @@ export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
|
||||
return row;
|
||||
});
|
||||
return { data, teams };
|
||||
}, [history, locale]);
|
||||
}, [history, locale, sort]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-2 flex justify-end">
|
||||
<div className="flex overflow-hidden rounded-lg border border-line text-xs font-medium">
|
||||
{([['movers', t.predict.titleRace.sortMovers], ['leaders', t.predict.titleRace.sortLeaders]] as const).map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setSort(v)}
|
||||
className={v === sort ? 'bg-accent-glow px-3 py-1 font-bold text-accent' : 'px-3 py-1 text-muted hover:bg-elevated hover:text-ink'}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
|
||||
@@ -75,5 +104,6 @@ export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
@@ -8,7 +8,7 @@ import { cn } from '@/lib/cn';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { hostAdvantage } from '@/lib/model/hosts';
|
||||
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict';
|
||||
import { championPath, effectiveBracket, prunePicks, type EffMatch, type Picks, type PickSide } from '@/lib/whatif';
|
||||
import { championPath, effectiveBracket, modelBracket, projectedSeeds, prunePicks, type EffMatch, type Picks, type PickSide, type Seeds } from '@/lib/whatif';
|
||||
import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types';
|
||||
|
||||
type KnockoutStage = (typeof COLUMN_STAGES)[number];
|
||||
@@ -158,16 +158,37 @@ export function BracketPage() {
|
||||
fetch('/data/ratings.json').then((r) => r.json()).then((d: RatingsModel) => setRatings(d)).catch(() => {});
|
||||
}, [whatIf, ratings]);
|
||||
|
||||
const eff = useMemo(() => (whatIf ? effectiveBracket(fixtures, picks) : null), [whatIf, fixtures, picks]);
|
||||
// What-if plays out the projected bracket: group-derived slots are seeded
|
||||
// with the model's most likely qualifiers so every round has teams.
|
||||
const seeds = useMemo<Seeds>(
|
||||
() => (whatIf && model ? projectedSeeds(fixtures, model.odds) : {}),
|
||||
[whatIf, model, fixtures],
|
||||
);
|
||||
|
||||
/** model probability the home side advances, for a (possibly hypothetical) pairing */
|
||||
const advHomeFor = (f: Fixture, m: EffMatch): number | null => {
|
||||
if (!ratings || !m.home || !m.away) return null;
|
||||
const adv = hostAdvantage(m.home, m.away, f.venue, ratings.params.homeAdvElo);
|
||||
const p = predictMatch(m.home, m.away, ratings, adv);
|
||||
const eff = useMemo(() => (whatIf ? effectiveBracket(fixtures, picks, seeds) : null), [whatIf, fixtures, picks, seeds]);
|
||||
|
||||
const advFor = (f: Fixture, home: string, away: string): number | null => {
|
||||
if (!ratings) return null;
|
||||
const adv = hostAdvantage(home, away, f.venue, ratings.params.homeAdvElo);
|
||||
const p = predictMatch(home, away, ratings, adv);
|
||||
return knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway);
|
||||
};
|
||||
|
||||
/** model probability the home side advances, for a (possibly hypothetical) pairing */
|
||||
const advHomeFor = (f: Fixture, m: EffMatch): number | null =>
|
||||
m.home && m.away ? advFor(f, m.home, m.away) : null;
|
||||
|
||||
// Entering what-if pre-fills the whole bracket with the model's most
|
||||
// likely run — a complete overview the user then edits by tapping.
|
||||
const filled = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!whatIf) { filled.current = false; return; }
|
||||
if (!ratings || filled.current || !model) return;
|
||||
filled.current = true;
|
||||
setPicks(modelBracket(fixtures, projectedSeeds(fixtures, model.odds), advFor));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [whatIf, ratings, model, fixtures]);
|
||||
|
||||
const champ = useMemo(() => {
|
||||
if (!whatIf || !eff) return null;
|
||||
const path = championPath(fixtures, eff);
|
||||
@@ -189,7 +210,7 @@ export function BracketPage() {
|
||||
const next: Picks = { ...prev };
|
||||
if (next[num] === side) delete next[num];
|
||||
else next[num] = side;
|
||||
return prunePicks(fixtures, next);
|
||||
return prunePicks(fixtures, next, seeds);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -219,7 +240,7 @@ export function BracketPage() {
|
||||
{whatIf && Object.keys(picks).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicks({})}
|
||||
onClick={() => setPicks(model ? modelBracket(fixtures, projectedSeeds(fixtures, model.odds), advFor) : {})}
|
||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium text-muted hover:bg-elevated hover:text-ink"
|
||||
>
|
||||
{t.bracket.reset}
|
||||
|
||||
@@ -434,7 +434,7 @@ export function MatchPreviewPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{hasScore ? t.match.lineups : t.match.predictedLineups}</span></CardHeader>
|
||||
<CardBody>
|
||||
<LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} />
|
||||
<LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} mode={isLive ? 'current' : 'starters'} />
|
||||
<p className="mt-2 text-xs text-faint">{hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -23,9 +23,15 @@ function FixtureRow({ f, team }: { f: Fixture; team: string }) {
|
||||
return (
|
||||
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated">
|
||||
<span className="w-10 text-xs text-faint">{f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round}</span>
|
||||
<span className="text-faint">{isHome ? t.common.vs : t.team.awayIndicator}</span>
|
||||
<span className="text-faint">{t.common.vs}</span>
|
||||
<span aria-hidden className="grid w-5 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</span>
|
||||
<span className="truncate text-ink">{opp.label}</span>
|
||||
<span
|
||||
className="shrink-0 rounded bg-elevated px-1 py-px text-[10px] font-bold uppercase text-faint"
|
||||
title={isHome ? t.team.homeGame : t.team.awayGame}
|
||||
>
|
||||
{isHome ? t.team.homeShort : t.team.awayShort}
|
||||
</span>
|
||||
<span className="ml-auto tnum text-faint">
|
||||
{done && us != null ? <span className="text-ink">{us}–{them}</span> : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
|
||||
</span>
|
||||
|
||||
+8
-2
@@ -130,6 +130,7 @@ export const de: Dict = {
|
||||
assist: "Vorlage · {name}",
|
||||
noGoalsYet: "Noch keine Tore.",
|
||||
subbedOff: "Ausgewechselt",
|
||||
subbedOn: "Eingewechselt",
|
||||
predictedLineups: "Voraussichtliche Aufstellungen",
|
||||
predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.",
|
||||
predictedVsActual: "Modell-Prognose vor Anstoß: {ph}–{pa} · Endstand {ah}–{aa}",
|
||||
@@ -227,7 +228,7 @@ export const de: Dict = {
|
||||
thirdPlacePlayoff: "Spiel um Platz 3",
|
||||
advanceTooltip: "Modell: Chance jedes Teams aufs Weiterkommen",
|
||||
whatIf: "Was wäre wenn?",
|
||||
whatIfHint: "Tipp auf ein Team, um es durch den Turnierbaum zu schicken. Echte Ergebnisse bleiben fest. Die Prozente sind die Modell-Sicht auf jede Paarung.",
|
||||
whatIfHint: "Vorausgefüllt mit dem wahrscheinlichsten Turnierbaum laut Modell — tippe auf ein Team, um eine Auswahl zu ändern. Echte Ergebnisse bleiben fest; die Prozente sind die Modell-Sicht auf jede Paarung.",
|
||||
reset: "Auswahl zurücksetzen",
|
||||
champion: "Dein Weltmeister: {team}",
|
||||
pathChance: "Modell-Chance für genau diesen Lauf: {pct}",
|
||||
@@ -255,6 +256,8 @@ export const de: Dict = {
|
||||
},
|
||||
titleRace: {
|
||||
title: "Titelrennen im Zeitverlauf",
|
||||
sortMovers: "Größte Bewegungen",
|
||||
sortLeaders: "Titelfavoriten",
|
||||
empty: "Diese Grafik füllt sich, sobald Ergebnisse reinkommen. Nach jedem beendeten Spiel werden die Teams neu bewertet und die Simulation läuft erneut.",
|
||||
},
|
||||
fullOddsHeading: "Alle Chancen — Gruppe, K.o.-Runde & Titel",
|
||||
@@ -464,7 +467,10 @@ export const de: Dict = {
|
||||
eloHistory: "Elo-Wertung im Zeitverlauf",
|
||||
allTimeTopScorers: "Beste Torschützen aller Zeiten",
|
||||
groupShort: "Gr. {group}",
|
||||
awayIndicator: "bei",
|
||||
homeShort: "H",
|
||||
awayShort: "A",
|
||||
homeGame: "Heimspiel (im Gastgeberland gesetzt)",
|
||||
awayGame: "Auswärtsspiel",
|
||||
resultShort: {
|
||||
win: "S",
|
||||
draw: "U",
|
||||
|
||||
+8
-2
@@ -129,6 +129,7 @@ export const en = {
|
||||
assist: "Assist · {name}",
|
||||
noGoalsYet: "No goals yet.",
|
||||
subbedOff: "Subbed off",
|
||||
subbedOn: "Subbed on",
|
||||
predictedLineups: "Predicted lineups",
|
||||
predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.",
|
||||
predictedVsActual: "Model's pre-match prediction: {ph}–{pa} · final score {ah}–{aa}",
|
||||
@@ -226,7 +227,7 @@ export const en = {
|
||||
thirdPlacePlayoff: "Third-place play-off",
|
||||
advanceTooltip: "Model: each team's chance to advance",
|
||||
whatIf: "What if?",
|
||||
whatIfHint: "Tap a team to send them through the bracket. Real results stay fixed. Percentages are the model's view of each pairing.",
|
||||
whatIfHint: "Pre-filled with the model's most likely bracket — tap any team to change a pick. Real results stay fixed; percentages are the model's view of each pairing.",
|
||||
reset: "Reset picks",
|
||||
champion: "Your champion: {team}",
|
||||
pathChance: "Model chance of exactly this run: {pct}",
|
||||
@@ -254,6 +255,8 @@ export const en = {
|
||||
},
|
||||
titleRace: {
|
||||
title: "Title race over time",
|
||||
sortMovers: "Biggest movers",
|
||||
sortLeaders: "Title favorites",
|
||||
empty: "This chart fills in as results come in. After every finished match, the teams are re-rated and the simulation runs again.",
|
||||
},
|
||||
fullOddsHeading: "Full odds — group, knockout & title",
|
||||
@@ -463,7 +466,10 @@ export const en = {
|
||||
eloHistory: "Elo rating over time",
|
||||
allTimeTopScorers: "All-time top scorers",
|
||||
groupShort: "Grp {group}",
|
||||
awayIndicator: "@",
|
||||
homeShort: "H",
|
||||
awayShort: "A",
|
||||
homeGame: "Home game (host nation venue)",
|
||||
awayGame: "Away game",
|
||||
resultShort: {
|
||||
win: "W",
|
||||
draw: "D",
|
||||
|
||||
+52
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { championPath, effectiveBracket, prunePicks, type Picks } from './whatif';
|
||||
import { championPath, effectiveBracket, modelBracket, projectedSeeds, prunePicks, type Picks } from './whatif';
|
||||
import type { Fixture } from './types';
|
||||
|
||||
const slot = (team: string | null, placeholder: string | null = null) =>
|
||||
@@ -70,3 +70,54 @@ describe('championPath', () => {
|
||||
expect(championPath(bracket(), effectiveBracket(bracket(), { 90: 'home' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectedSeeds + modelBracket', () => {
|
||||
const slot = (team: string | null, placeholder: string | null) => ({ team, placeholder, label: team ?? placeholder ?? '' });
|
||||
const groupFx = (num: number, group: string, home: string, away: string) => ({
|
||||
num, stage: 'group' as const, group, round: '', groupRound: 1,
|
||||
kickoff: '2026-06-11T00:00:00Z', venue: 'X',
|
||||
home: slot(home, null), away: slot(away, null),
|
||||
status: 'scheduled' as const, homeScore: null, awayScore: null, minute: null,
|
||||
});
|
||||
const koFx = (num: number, stage: 'r32' | 'r16', hp: string, ap: string) => ({
|
||||
num, stage, group: null, round: '', groupRound: null,
|
||||
kickoff: '2026-06-29T00:00:00Z', venue: 'X',
|
||||
home: slot(null, hp), away: slot(null, ap),
|
||||
status: 'scheduled' as const, homeScore: null, awayScore: null, minute: null,
|
||||
});
|
||||
const odds = (team: string, winGroup: number, qualify: number) => ({
|
||||
team, winGroup, qualify, reachR16: 0, reachQF: 0, reachSF: 0, reachFinal: 0, champion: 0,
|
||||
});
|
||||
|
||||
const fixtures = [
|
||||
groupFx(1, 'A', 'Alpha', 'Beta'), groupFx(2, 'A', 'Gamma', 'Beta'),
|
||||
groupFx(3, 'B', 'Delta', 'Echo'), groupFx(4, 'B', 'Foxtrot', 'Echo'),
|
||||
koFx(73, 'r32', '1A', '2B'), koFx(74, 'r32', '1B', '3A/B'),
|
||||
koFx(75, 'r16', 'W73', 'W74'),
|
||||
];
|
||||
const teamOdds = [
|
||||
odds('Alpha', 0.7, 0.95), odds('Beta', 0.2, 0.6), odds('Gamma', 0.1, 0.3),
|
||||
odds('Delta', 0.6, 0.9), odds('Echo', 0.3, 0.7), odds('Foxtrot', 0.1, 0.4),
|
||||
];
|
||||
|
||||
it('seeds winners, runners-up and third-place slots from the odds', () => {
|
||||
const seeds = projectedSeeds(fixtures, teamOdds);
|
||||
expect(seeds['1A']).toBe('Alpha');
|
||||
expect(seeds['2A']).toBe('Beta');
|
||||
expect(seeds['1B']).toBe('Delta');
|
||||
// best third by qualify between Gamma (0.3) and Foxtrot (0.4) → Foxtrot
|
||||
expect(seeds['3A/B']).toBe('Foxtrot');
|
||||
});
|
||||
|
||||
it('fills every resolvable pairing with the model-preferred side', () => {
|
||||
const seeds = projectedSeeds(fixtures, teamOdds);
|
||||
// favor the alphabetically-first team so outcomes are deterministic
|
||||
const adv = (_f: unknown, home: string, away: string) => (home < away ? 0.8 : 0.2);
|
||||
const picks = modelBracket(fixtures, seeds, adv);
|
||||
expect(picks[73]).toBe('home'); // Alpha vs Echo → Alpha
|
||||
expect(picks[74]).toBe('home'); // Delta vs Foxtrot → Delta
|
||||
expect(picks[75]).toBe('home'); // Alpha vs Delta → Alpha
|
||||
const eff = effectiveBracket(fixtures, picks, seeds);
|
||||
expect(eff.get(75)?.winner).toBe('Alpha');
|
||||
});
|
||||
});
|
||||
|
||||
+95
-8
@@ -1,7 +1,7 @@
|
||||
// Pure what-if bracket logic: user picks propagate winners through the
|
||||
// knockout tree (placeholder codes 'W74'/'L101' reference earlier matches).
|
||||
// Real finished results always win over picks.
|
||||
import type { Fixture } from './types';
|
||||
import type { Fixture, TeamOdds } from './types';
|
||||
|
||||
export type PickSide = 'home' | 'away';
|
||||
export type Picks = Record<number, PickSide>;
|
||||
@@ -15,14 +15,20 @@ export interface EffMatch {
|
||||
decided: boolean;
|
||||
}
|
||||
|
||||
/** Resolve every knockout fixture's effective teams + winner under `picks`. */
|
||||
export function effectiveBracket(fixtures: Fixture[], picks: Picks): Map<number, EffMatch> {
|
||||
/** Group-derived placeholder → team ('1A' → projected Group A winner). */
|
||||
export type Seeds = Record<string, string>;
|
||||
|
||||
/** Resolve every knockout fixture's effective teams + winner under `picks`.
|
||||
* `seeds` optionally fills group-derived placeholders ('1A', '3A/B/…') with
|
||||
* projected teams so the bracket can be played out before the groups end. */
|
||||
export function effectiveBracket(fixtures: Fixture[], picks: Picks, seeds: Seeds = {}): Map<number, EffMatch> {
|
||||
const eff = new Map<number, EffMatch>();
|
||||
const slotTeam = (f: Fixture, side: PickSide): string | null => {
|
||||
const slot = side === 'home' ? f.home : f.away;
|
||||
if (slot.team) return slot.team;
|
||||
const m = /^([WL])(\d+)$/.exec(slot.placeholder ?? '');
|
||||
if (!m) return null;
|
||||
const ph = slot.placeholder ?? '';
|
||||
const m = /^([WL])(\d+)$/.exec(ph);
|
||||
if (!m) return seeds[ph] ?? null;
|
||||
const src = eff.get(Number(m[2]));
|
||||
return src ? (m[1] === 'W' ? src.winner : src.loser) : null;
|
||||
};
|
||||
@@ -51,9 +57,9 @@ export function effectiveBracket(fixtures: Fixture[], picks: Picks): Map<number,
|
||||
|
||||
/** Drop picks that no longer make sense (an upstream pick changed, so the
|
||||
* picked team is not in this match anymore). Call after every pick change. */
|
||||
export function prunePicks(fixtures: Fixture[], picks: Picks): Picks {
|
||||
export function prunePicks(fixtures: Fixture[], picks: Picks, seeds: Seeds = {}): Picks {
|
||||
const out: Picks = {};
|
||||
let eff = effectiveBracket(fixtures, picks);
|
||||
let eff = effectiveBracket(fixtures, picks, seeds);
|
||||
// iterate to a fixed point: removing one stale pick can invalidate others
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
let changed = false;
|
||||
@@ -71,11 +77,92 @@ export function prunePicks(fixtures: Fixture[], picks: Picks): Picks {
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
eff = effectiveBracket(fixtures, { ...out });
|
||||
eff = effectiveBracket(fixtures, { ...out }, seeds);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The model's projected group outcomes as bracket seeds: each group ranked by
|
||||
* win-group odds (1st/2nd), the 8 best third-placed candidates by advance
|
||||
* odds assigned to the third-place slots via backtracking over each slot's
|
||||
* allowed-group set (FIFA's allocation constraints).
|
||||
*/
|
||||
export function projectedSeeds(fixtures: Fixture[], odds: TeamOdds[]): Seeds {
|
||||
const groups = new Map<string, Set<string>>();
|
||||
for (const f of fixtures) {
|
||||
if (f.stage !== 'group' || !f.group) continue;
|
||||
let set = groups.get(f.group);
|
||||
if (!set) { set = new Set(); groups.set(f.group, set); }
|
||||
if (f.home.team) set.add(f.home.team);
|
||||
if (f.away.team) set.add(f.away.team);
|
||||
}
|
||||
const oddsBy = new Map(odds.map((o) => [o.team, o]));
|
||||
const seeds: Seeds = {};
|
||||
const thirds: { team: string; group: string; qualify: number }[] = [];
|
||||
for (const [g, teams] of groups) {
|
||||
const ranked = [...teams].sort((a, b) => (oddsBy.get(b)?.winGroup ?? 0) - (oddsBy.get(a)?.winGroup ?? 0));
|
||||
if (ranked[0]) seeds[`1${g}`] = ranked[0];
|
||||
if (ranked[1]) seeds[`2${g}`] = ranked[1];
|
||||
if (ranked[2]) thirds.push({ team: ranked[2], group: g, qualify: oddsBy.get(ranked[2])?.qualify ?? 0 });
|
||||
}
|
||||
thirds.sort((a, b) => b.qualify - a.qualify);
|
||||
|
||||
const slots: { ph: string; allowed: Set<string> }[] = [];
|
||||
for (const f of fixtures) {
|
||||
if (f.stage === 'group') continue;
|
||||
for (const slot of [f.home, f.away]) {
|
||||
const ph = slot.placeholder ?? '';
|
||||
if (/^3[A-L](\/[A-L])+$/.test(ph)) slots.push({ ph, allowed: new Set(ph.slice(1).split('/')) });
|
||||
}
|
||||
}
|
||||
// Tightest slots first; per slot prefer the strongest remaining third.
|
||||
const order = [...slots].sort((a, b) => a.allowed.size - b.allowed.size);
|
||||
const used = new Set<string>();
|
||||
const assign = new Map<string, string>();
|
||||
const backtrack = (i: number): boolean => {
|
||||
const s = order[i];
|
||||
if (!s) return true;
|
||||
for (const t of thirds) {
|
||||
if (used.has(t.group) || !s.allowed.has(t.group)) continue;
|
||||
used.add(t.group);
|
||||
assign.set(s.ph, t.team);
|
||||
if (backtrack(i + 1)) return true;
|
||||
used.delete(t.group);
|
||||
assign.delete(s.ph);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
backtrack(0);
|
||||
for (const [ph, team] of assign) seeds[ph] = team;
|
||||
return seeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill every undecided knockout pairing with the model's preferred side —
|
||||
* the "most likely bracket" baseline the user can then tap to change.
|
||||
* `advHome` returns the model's chance the home slot advances (null while
|
||||
* ratings load); pairings it can't judge stay unpicked.
|
||||
*/
|
||||
export function modelBracket(
|
||||
fixtures: Fixture[],
|
||||
seeds: Seeds,
|
||||
advHome: (f: Fixture, home: string, away: string) => number | null,
|
||||
): Picks {
|
||||
const picks: Picks = {};
|
||||
const ko = fixtures.filter((f) => f.stage !== 'group').sort((a, b) => a.num - b.num);
|
||||
for (const f of ko) {
|
||||
const eff = effectiveBracket(fixtures, picks, seeds);
|
||||
const m = eff.get(f.num);
|
||||
if (!m || m.decided || !m.home || !m.away) continue;
|
||||
const p = advHome(f, m.home, m.away);
|
||||
if (p == null) continue;
|
||||
picks[f.num] = p >= 0.5 ? 'home' : 'away';
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
|
||||
/** The picked champion's path: the matches they were picked to win, in order. */
|
||||
export function championPath(
|
||||
fixtures: Fixture[],
|
||||
|
||||
Reference in New Issue
Block a user