World Cup 2026 — Simulator
Football > World Cup 2026 > Simulator
Football · World Cup 2026 · Interactive Simulator
What if? Run the tournament yourself
Every click re-runs 10,000 tournaments in your browser (5,000 on slower devices). Lock a group result (home / draw / away) or force a team through a knockout round, and every probability on the page — group standings, stage odds, likely matchups — recomputes with your results fixed.
Group stage: click 1 / X / 2 next to a fixture to lock home win / draw / away win — click again to unlock. Finished games arrive already locked to their real result; unlock or override them to explore what-ifs. Knockout: click a team in the bracket to force them through that round. The simulator re-runs the full tournament with your results fixed and updates every number. Scorelines are drawn from the model's predicted goals; knockout ties use the published simulation's own pairwise probabilities (all 1,128 possible ties, with Tiento as the fallback), and teams run slightly hot — beating the model's expected scoreline in the groups warms a side's knockout strength (underperforming cools it), and knockout winners keep heating. Baselines track the published Title Race simulation to within a couple of points.
Show code
statsEsc = window.statsEsc
_wcsPreds = {
try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_predictions.parquet") }
catch (e) { console.error("[wc-sim] predictions load failed:", e); return null }
}
_wcsStrength = {
try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_team_strength.parquet") }
catch (e) { console.error("[wc-sim] team_strength load failed:", e); return null }
}
// Official pipeline's full-model pairwise knockout probabilities (1,128
// ties, ~30KB). Optional: null falls back to the Tiento logistic in-engine.
_wcsKoProbs = {
try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_knockout_probs.parquet") }
catch (e) { console.warn("[wc-sim] knockout-probs load failed — Tiento fallback:", e); return null }
}
// Live feed for auto-locking finished results into the sim. Contract from
// wc-maps: null = feed outage (simulator still works, just without reality
// baked in — warned in-console by fetchWcFixtures), [] = no WC rows yet.
_wcsFeed = {
if (!window.wcMaps) { console.error("[wc-sim] wcMaps missing — no real-result locks"); return null }
return await window.wcMaps.fetchWcFixtures()
}Show code
wcsModel = {
if (_wcsPreds == null || _wcsStrength == null) return null
const fixtures = [..._wcsPreds]
.sort((a, b) => String(a.match_date).localeCompare(String(b.match_date)))
.map(p => ({
group: p.group, home: p.home_team, away: p.away_team,
pH: p.prob_home, pD: p.prob_draw, pA: p.prob_away,
gH: p.pred_home_goals, gA: p.pred_away_goals,
date: String(p.match_date || "").replace("Z", "").slice(0, 10)
}))
// Knockout ties use the official pipeline's pairwise lookup when it loads
// (passed as koProbs below); Tiento strengths remain the per-tie fallback:
// Tiento (goals above average, z-blend of panna/EPR/PSR/Elo) scaled by the
// calibrated win-prob slope, so the engine's logistic reproduces the match
// model's own win probabilities at this rating gap.
const agg = window.wcMaps.computeTeamRating(_wcsStrength, _wcsPreds)
if (!agg) console.warn("[wc-sim] Tiento rating unavailable — knockout fallback uses raw BT strengths")
const strengths = agg
? new Map([...agg.ratings].map(([t, r]) => [t, r * agg.winSlope]))
: new Map(_wcsStrength.map(t => [t.team, t.bt]))
return window.wcSim.buildModel({ fixtures, strengths, seeds: window.wcMaps.r32Seeds, koProbs: _wcsKoProbs })
}Show code
// ── The simulator app ─────────────────────────────────────────
{
// wcsModel is null ONLY after a loader failed (OJS doesn't run this cell
// while the parquet fetches are still pending)
if (wcsModel == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
const maps = window.wcMaps
const esc = maps.esc
const KEY = "ig-wc-sim-locks-v1"
const ROUND_NAMES = ["Round of 32", "Round of 16", "Quarter-finals", "Semi-finals", "Final"]
const GROUPS = [...wcsModel.groups.keys()].sort()
const fmtDay = maps.fmtDay
// ── Real results (auto-locks) ──
// FINISHED games lock to their actual outcome by default, so the baseline
// is "reality so far + simulation of the rest". Clicking a finished game's
// active outcome unlocks it (stored as the "OPEN" sentinel) for
// counterfactuals; clicking another outcome rewrites history outright.
// LIVE games show their score but never auto-lock (still in progress).
const realResults = new Map() // fixture key -> {outcome, hs, as, finished, live}
if (_wcsFeed) {
const byKey = new Map(_wcsFeed
.filter(m => m.homeScore != null && m.awayScore != null)
.map(m => [`${String(m.date || "").slice(0, 10)}|${m._h}|${m._a}`, m]))
for (const f of wcsModel.fixtures) {
const m = byKey.get(`${f.date}|${wcsModel.teams[f.h]}|${wcsModel.teams[f.a]}`)
if (!m) continue
const finished = m.status === "FINISHED"
const live = maps.LIVE_STATUSES.has(m.status)
if (!finished && !live) continue
realResults.set(f.key, {
outcome: m.homeScore > m.awayScore ? "H" : m.homeScore < m.awayScore ? "A" : "D",
hs: m.homeScore, as: m.awayScore, finished, live, date: m.date, status: m.status,
minute: m.minute
})
}
}
const realLockFor = (key) => {
const r = realResults.get(key)
return r && r.finished ? r.outcome : null
}
// Live in-game odds: for IN-PROGRESS games, override the model's pre-match
// H/D/A with a score+time-aware estimate (quality-aware footballWinProb), so
// the simulator's baseline matches the live title-race / group pages. PREFER
// the real Opta match minute from the feed (r.minute) — the wall-clock estimate
// from kickoff is unreliable (variable halftime, stoppage, kickoff delays): it
// over-runs, clamps to 95', and footballWinProb then treats any live game as
// decided (100% to whoever leads). The Opta minute is the live truth — the same
// source wc-live-sim.js prefers (its kickoff estimate is the fallback too). Only
// the probabilities are overridden — pred goals (gH/gA) stay pre-match so the
// displayed "predicted" scoreline is unchanged. No auto-tick here: the simulator
// is a what-if tool, so it refreshes on the next interaction.
for (const f of wcsModel.fixtures) {
const r = realResults.get(f.key)
if (!r || !r.live || !window.footballWinProb) continue
let min
if (typeof r.minute === "number" && r.minute > 0) min = r.minute
else if (r.status === "PAUSED") min = 47
else {
const t = Date.parse(r.date)
const e = Number.isFinite(t) ? (Date.now() - t) / 60000 : 1
min = Math.max(1, Math.min(95, e < 47 ? e : e - 15))
}
const wp = window.footballWinProb(min, r.hs, r.as, f.gH, f.gA)
f.pH = wp.home; f.pD = wp.draw; f.pA = wp.away
}
// ── State ──
// locks.match values: "H"/"D"/"A" = user lock (a what-if when it differs
// from reality), "OPEN" = user explicitly unlocked a real result.
let locks = { match: {}, ko: {} }
try {
const s = JSON.parse(localStorage.getItem(KEY) || "null")
if (s && s.match && s.ko) {
const validKeys = new Set(wcsModel.fixtures.map(f => f.key))
for (const [k, v] of Object.entries(s.match)) if (validKeys.has(k) && ["H","D","A","OPEN"].includes(v)) locks.match[k] = v
for (const [t, r] of Object.entries(s.ko)) if (wcsModel.teamIdx.has(t) && r >= 0 && r <= 4) locks.ko[t] = r
}
} catch (e) { /* fresh start */ }
const save = () => { try { localStorage.setItem(KEY, JSON.stringify(locks)) } catch (e) {} }
// Effective lock per fixture: user override wins; OPEN suppresses reality.
const effLock = (key) => {
const u = locks.match[key]
if (u === "OPEN") return null
return u ?? realLockFor(key)
}
const effectiveLocks = () => {
const match = {}
for (const f of wcsModel.fixtures) { const v = effLock(f.key); if (v) match[f.key] = v }
return { match, ko: locks.ko }
}
const realOnlyLocks = () => {
const match = {}
for (const [k, r] of realResults) if (r.finished) match[k] = r.outcome
return { match, ko: {} }
}
const whatIfCount = () => Object.keys(locks.match).length + Object.keys(locks.ko).length
const realCount = () => [...realResults.values()].filter(r => r.finished).length
let nSims = 10000
let selGroup = "A"
let baseline = null, results = null, lastMs = 0, simming = true
// ── Skeleton ──
const wrap = document.createElement("div"); wrap.className = "wcs-wrap"
const bar = document.createElement("div"); bar.className = "wcs-bar"
const groupSec = document.createElement("div"); groupSec.className = "wcs-section"
const koSec = document.createElement("div"); koSec.className = "wcs-section"
const tableSec = document.createElement("div"); tableSec.className = "wcs-section"
const muSec = document.createElement("div"); muSec.className = "wcs-section"
const ptsSec = document.createElement("div"); ptsSec.className = "wcs-section"
// ptsSec sits right under the groups: qualification-by-points reads as
// part of the group story (it follows the group pills), not an appendix
wrap.append(bar, groupSec, ptsSec, koSec, tableSec, muSec)
// ── Sim loop (debounced) ──
let timer = null
function queueSim() {
simming = true
renderBar()
clearTimeout(timer)
timer = setTimeout(run, 90)
}
function run() {
// Baseline = reality only (finished results locked, no what-ifs) — so
// the Δ Champ column reads "your what-ifs vs how things actually stand".
if (!baseline) baseline = window.wcSim.simulate(wcsModel, realOnlyLocks(), { nSims })
// Time ONLY the conditional pass — measuring the baseline pass too would
// double-count on first load (e.g. locks restored from localStorage) and
// spuriously trip the slow-device fallback on capable phones.
const t0 = performance.now()
results = whatIfCount() === 0 ? baseline : window.wcSim.simulate(wcsModel, effectiveLocks(), { nSims })
lastMs = performance.now() - t0
// Slow-device fallback: halve the sim count once if a run crawls, then
// IMMEDIATELY recompute baseline + results at the new count — rendering
// with a cleared baseline would zero the Δ Champ column.
if (lastMs > 1600 && nSims > 5000) {
nSims = 5000
baseline = null
return run()
}
simming = false
render()
}
// ── Interactions ──
function toggleMatchLock(key, outcome) {
const real = realLockFor(key)
const cur = effLock(key)
if (cur === outcome) {
// unlock: real results need the explicit OPEN sentinel, user locks
// just clear
if (real) locks.match[key] = "OPEN"
else delete locks.match[key]
} else if (real && outcome === real) {
// picking reality's own outcome = drop the override, back to real
delete locks.match[key]
} else {
locks.match[key] = outcome
}
save(); renderGroups(); queueSim()
}
function toggleKoLock(team, r) {
const cur = locks.ko[team]
if (cur != null && cur >= r) {
if (r === 0) delete locks.ko[team]
else locks.ko[team] = r - 1
} else locks.ko[team] = r
save(); queueSim()
}
function resetAll() {
locks = { match: {}, ko: {} }
save(); renderGroups(); queueSim()
}
// ── Renders ──
function renderBar() {
bar.replaceChildren()
const status = document.createElement("div"); status.className = "wcs-status"
const w = whatIfCount(), rc = realCount()
const realBit = rc > 0 ? `<b>${rc}</b> real result${rc === 1 ? "" : "s"} · ` : ""
status.innerHTML = simming
? `<span class="wcs-spin"></span> Re-simulating ${nSims.toLocaleString()} tournaments…`
: `${realBit}<b>${w}</b> what-if${w === 1 ? "" : "s"} · ${nSims.toLocaleString()} tournaments in <b>${lastMs.toFixed(0)}ms</b>`
const reset = document.createElement("button")
reset.className = "wcs-btn"
reset.textContent = rc > 0 ? "Clear what-ifs" : "Clear all locks"
reset.disabled = w === 0
reset.onclick = resetAll
bar.append(status, reset)
}
function renderGroups() {
groupSec.replaceChildren()
const h = document.createElement("h2"); h.textContent = "Group stage — call the results"
groupSec.appendChild(h)
if (realCount() > 0) {
const cap = document.createElement("p"); cap.className = "wcs-cap"
cap.textContent = "Finished games arrive locked to their real result. Click the active outcome to unlock it, or another outcome to rewrite history — the whole tournament re-simulates either way."
groupSec.appendChild(cap)
}
const pills = document.createElement("div"); pills.className = "wcs-pills"
for (const g of GROUPS) {
const nLocked = wcsModel.fixtures.filter(f => f.group === g && effLock(f.key)).length
const b = document.createElement("button")
b.className = "wcs-pill" + (g === selGroup ? " active" : "")
b.innerHTML = `Group ${g}` + (nLocked ? `<span class="wcs-pill-n">${nLocked}</span>` : "")
b.onclick = () => { selGroup = g; renderGroups(); renderPoints() }
pills.appendChild(b)
}
groupSec.appendChild(pills)
const row = document.createElement("div"); row.className = "wcs-group-row"
const list = document.createElement("div"); list.className = "wcs-fx-list"
for (const f of wcsModel.fixtures.filter(f => f.group === selGroup)) {
const home = wcsModel.teams[f.h], away = wcsModel.teams[f.a]
const real = realResults.get(f.key)
const lock = effLock(f.key)
// active source = reality (no user override) → styled differently
const isReal = !!(real && real.finished && locks.match[f.key] == null)
const row = document.createElement("div")
row.className = "wcs-fx" + (lock ? " locked" : "") + (isReal ? " real" : "")
const pct = (x) => (x * 100).toFixed(0)
const predTxt = `${f.gH?.toFixed(1) ?? "?"}–${f.gA?.toFixed(1) ?? "?"}`
const scoreHtml = real
? `<span class="wcs-fx-score wcs-fx-final" title="Model predicted ${predTxt}">${real.hs}–${real.as}${real.live ? `<i class="wcs-fx-tag live">live</i>` : `<i class="wcs-fx-tag">FT</i>`}</span>`
: `<span class="wcs-fx-score" title="Predicted goals">${predTxt}</span>`
const btnTitle = (o, label) => {
const rl = realLockFor(f.key)
if (rl === o) return lock === o && isReal ? `Final result — click to unlock for what-ifs` : `Restore the real result`
if (rl) return `What-if: override the real result — ${label}`
return `Lock: ${label}`
}
row.innerHTML = `
<span class="wcs-fx-date">${fmtDay(f.date)}</span>
<span class="wcs-fx-team wcs-fx-home">${maps.teamLinkHtml(home)}</span>
${scoreHtml}
<span class="wcs-fx-team wcs-fx-away">${maps.teamLinkHtml(away)}</span>
<span class="wcs-fx-bar">${maps.probBarHtml(f.pH, f.pD, f.pA, { title: `${home} ${pct(f.pH)}% · Draw ${pct(f.pD)}% · ${away} ${pct(f.pA)}%` })}</span>
<span class="wcs-fx-locks">
<button class="wcs-lock-btn${lock === "H" ? " on" : ""}" data-o="H" title="${esc(btnTitle("H", `${home} win`))}">1</button>
<button class="wcs-lock-btn${lock === "D" ? " on" : ""}" data-o="D" title="${esc(btnTitle("D", "draw"))}">X</button>
<button class="wcs-lock-btn${lock === "A" ? " on" : ""}" data-o="A" title="${esc(btnTitle("A", `${away} win`))}">2</button>
</span>`
row.querySelectorAll(".wcs-lock-btn").forEach(btn => {
btn.onclick = () => toggleMatchLock(f.key, btn.dataset.o)
})
list.appendChild(row)
}
row.appendChild(list)
// Projected group table — expected finishing order under the current
// locks, from the same sim that drives everything else.
if (results) {
const ptsBy = new Map(results.pointsTable.filter(t => t.group === selGroup).map(t => [t.team, t.rows]))
const teams = results.teams
.filter(t => t.group === selGroup)
.map(t => ({
...t,
// a missing pointsTable key would otherwise print a confident 0.0
xPts: (ptsBy.get(t.team) ?? (console.warn("[wc-sim] no pointsTable rows for", t.team), []))
.reduce((s, r) => s + r.points * r.n, 0) / results.nSims,
expPos: (1 * t.p_win_group + 2 * t.p_runner_up + 3 * t.p_third + 4 * t.p_fourth) / 100
}))
.sort((a, b) => a.expPos - b.expPos)
const tbl = document.createElement("div"); tbl.className = "wcs-group-table"
tbl.innerHTML = `
<div class="wcs-gt-cap">Projected table — given your locked results</div>
<table>
<thead><tr><th></th><th>Team</th><th>xPts</th><th>1st</th><th>Adv</th></tr></thead>
<tbody>${teams.map((t, i) => `
<tr>
<td class="wcs-gt-pos">${i + 1}</td>
<td class="wcs-gt-team">${maps.teamLinkHtml(t.team)}</td>
<td>${t.xPts.toFixed(1)}</td>
<td>${t.p_win_group.toFixed(0)}%</td>
<td class="wcs-gt-adv">${t.p_R32.toFixed(0)}%</td>
</tr>`).join("")}
</tbody>
</table>`
row.appendChild(tbl)
}
groupSec.appendChild(row)
}
function renderKo() {
koSec.replaceChildren()
if (!results) return
const h = document.createElement("h2"); h.textContent = "Knockout — force a team through"
const cap = document.createElement("p"); cap.className = "wcs-cap"
cap.textContent = "Each slot shows its most likely team and how often they land there — one appearance per round (hover for the full shortlist). Click a team to lock them through that round; a lock only applies when the team actually reaches the tie, so locked teams can still show under 100%. If both sides are locked, the deeper lock wins."
koSec.append(h, cap)
// FIFA match metadata per bracket slot: r32Seeds is in bracket order, so
// tie i of round r+1 is fed by ties 2i/2i+1 of round r — match numbers
// resolve by walking wcMaps.koSchedule's "W74 v W77" pair strings up the
// tree. Gives every tie its locked date + venue (teams TBD, timing not).
const koMeta = (() => {
const ks = maps.koSchedule || []
const r32 = maps.r32Seeds.map(([a, b]) => {
return ks.find(k => k.round === "Round of 32" &&
(k.pair === `${a} v ${b}` || (b.includes("/") && k.pair === `${a} v 3rd`))) || null
})
const rounds = [r32]
let prev = r32
for (const n of [8, 4, 2, 1]) {
const cur = []
for (let i = 0; i < n; i++) {
const a = prev[2 * i]?.m, b = prev[2 * i + 1]?.m
cur.push(ks.find(k => k.pair === `W${a} v W${b}` || k.pair === `W${b} v W${a}`) || null)
}
rounds.push(cur); prev = cur
}
// Both inputs are static in-repo data, so any miss is hand-edit drift —
// and one R32 mismatch nulls its whole subtree up to the final. Today
// this resolves 31/31; a warn here is always a true positive.
const missing = rounds.flat().filter(x => !x).length
if (missing) console.warn("[wc-sim] koMeta:", missing, "of 31 ties failed to resolve against wcMaps.koSchedule — pair-string drift?")
return rounds
})()
const fmtKoDay = (day) => {
const [y, mo, da] = day.split("-").map(Number)
return new Date(Date.UTC(y, mo - 1, da, 12)).toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
const board = document.createElement("div"); board.className = "wcs-board"
for (let r = 0; r < 5; r++) {
const col = document.createElement("div"); col.className = "wcs-col"
const head = document.createElement("div"); head.className = "wcs-col-head"; head.textContent = ROUND_NAMES[r]
col.appendChild(head)
const ties = document.createElement("div"); ties.className = "wcs-ties"
// Per-round dedupe: each slot carries its top-4 occupants (wc-sim.js);
// a team that tops several same-round slots via different qualification
// routes is shown only in its strongest slot, and the others fall to
// their next-most-likely team. Every % shown is that team's TRUE
// marginal reach probability — dedupe changes which team is shown,
// never the number next to it. Hover shows the slot's real shortlist.
const flat = []
results.bracket[r].forEach((tie, ti) => tie.forEach((slot, si) => {
if (slot && slot.length) flat.push({ ti, si, tops: slot })
}))
flat.sort((a, b) => b.tops[0].pct - a.tops[0].pct)
const used = new Set()
const chosen = results.bracket[r].map(tie => tie.map(() => null))
for (const s of flat) {
// all four candidates already shown (rare, but heavy locking makes it
// likelier): show the favourite anyway rather than invent a fifth —
// and log it, since the caption's one-per-round promise breaks here
const avail = s.tops.find(o => !used.has(o.team))
if (!avail) console.warn("[wc-sim] bracket dedupe exhausted candidates for round", r, "tie", s.ti, "— showing duplicate", s.tops[0].team)
const pick = avail || s.tops[0]
used.add(pick.team)
chosen[s.ti][s.si] = { pick, tops: s.tops }
}
results.bracket[r].forEach((tie, i) => {
const box = document.createElement("div"); box.className = "wcs-tie"
const meta = koMeta[r] && koMeta[r][i]
if (meta) {
const cap2 = document.createElement("div")
cap2.className = "wcs-tie-meta"
const stadium = meta.key.split(",")[0]
cap2.textContent = `${fmtKoDay(meta.day)} · ${stadium}`
cap2.title = `FIFA match ${meta.m} · ${meta.key}`
box.appendChild(cap2)
}
tie.forEach((slot, si) => {
const b = document.createElement("button")
const c = chosen[i][si]
if (!c) { b.className = "wcs-slot tbd"; b.disabled = true; b.textContent = "—" }
else {
const team = c.pick.team, pct = c.pick.pct
const lockedHere = (locks.ko[team] ?? -1) >= r
b.className = "wcs-slot" + (lockedHere ? " locked" : "")
b.innerHTML = maps.flagImg(team) +
`<span class="wcs-slot-name">${esc(team)}</span>` +
`<span class="wcs-slot-pct">${pct.toFixed(0)}%</span>` +
(lockedHere ? `<span class="wcs-slot-lock">locked</span>` : "")
const shortlist = c.tops.slice(0, 3).map(o => `${o.team} ${o.pct.toFixed(0)}%`).join(" · ")
b.title = (lockedHere
? `${team}: locked to win this round — click to unlock`
: `${team} is here in ${pct.toFixed(0)}% of sims — click to lock them through this round`) +
`\nMost likely here: ${shortlist}`
b.onclick = () => toggleKoLock(team, r)
}
box.appendChild(b)
})
ties.appendChild(box)
})
col.appendChild(ties)
board.appendChild(col)
}
// Champion column (display only)
const champCol = document.createElement("div"); champCol.className = "wcs-col wcs-champ-col"
const ch = document.createElement("div"); ch.className = "wcs-col-head"; ch.textContent = "Champion"
champCol.appendChild(ch)
const top = [...results.teams].sort((a, b) => b.p_champ - a.p_champ)[0]
const tile = document.createElement("div"); tile.className = "wcs-champ"
tile.innerHTML = maps.flagImg(top.team) +
`<div class="wcs-champ-name">${esc(top.team)}</div>` +
`<div class="wcs-champ-pct">${top.p_champ.toFixed(1)}%</div>`
champCol.appendChild(tile)
board.appendChild(champCol)
koSec.appendChild(board)
}
function renderTable() {
tableSec.replaceChildren()
if (!results) return
const h = document.createElement("h2"); h.textContent = "Every team's path, given your results"
const cap = document.createElement("p"); cap.className = "wcs-cap"
cap.textContent = "Probability of reaching each stage under your locked results. The last column is the swing in title odds versus the real-results baseline (no what-ifs)."
tableSec.append(h, cap)
// Δ vs baseline derived directly from the baseline run — no separate
// state to keep synchronized when nSims or baseline changes.
const baseChamp = new Map((baseline?.teams ?? []).map(t => [t.team, t.p_champ]))
const rows = results.teams.map(t => ({
team: t.team, group: t.group,
p_win_group: t.p_win_group, p_R32: t.p_R32, p_R16: t.p_R16,
p_QF: t.p_QF, p_SF: t.p_SF, p_final: t.p_final, p_champ: t.p_champ,
d_champ: t.p_champ - (baseChamp.get(t.team) ?? t.p_champ)
}))
const pct1 = x => x == null ? "" : x.toFixed(1) + "%"
const signed = x => x == null ? "" : (x > 0 ? "+" : x < 0 ? "−" : "") + Math.abs(x).toFixed(1)
const table = window.statsTable(rows, {
columns: ["team", "group", "p_win_group", "p_R32", "p_R16", "p_QF", "p_SF", "p_final", "p_champ", "d_champ"],
header: {
team: "Team", group: "Grp", p_win_group: "Win grp",
p_R32: "R32", p_R16: "R16", p_QF: "QF", p_SF: "SF",
p_final: "Final", p_champ: "Champ", d_champ: "Δ Champ"
},
groups: [
{ label: "", span: 2 },
{ label: "Reaches stage (%)", span: 7 },
{ label: "vs baseline", span: 1 }
],
format: {
p_win_group: pct1, p_R32: pct1, p_R16: pct1, p_QF: pct1,
p_SF: pct1, p_final: pct1, p_champ: pct1, d_champ: signed
},
render: { team: (v) => maps.teamLinkHtml(v) },
heatmap: {
p_win_group: "high-good", p_R32: "high-good", p_R16: "high-good",
p_QF: "high-good", p_SF: "high-good", p_final: "high-good",
p_champ: "high-good", d_champ: "diverging"
},
sort: "p_champ", reverse: true, rows: 48
})
tableSec.appendChild(table)
}
function renderMatchups() {
muSec.replaceChildren()
if (!results) return
const h = document.createElement("h2"); h.textContent = "Most likely matchups"
const cap = document.createElement("p"); cap.className = "wcs-cap"
cap.textContent = "The pairings each knockout round serves up most often, given your locked results."
muSec.append(h, cap)
const grid = document.createElement("div"); grid.className = "wcs-mu-grid"
for (let r = 0; r < 5; r++) {
const col = document.createElement("div"); col.className = "wcs-mu-col"
const head = document.createElement("div"); head.className = "wcs-col-head"; head.textContent = ROUND_NAMES[r]
col.appendChild(head)
for (const m of results.matchups[r].slice(0, 6)) {
const row = document.createElement("div"); row.className = "wcs-mu-row"
row.innerHTML = `<span class="wcs-mu-pair">${maps.flagImg(m.a)} ${esc(m.a)} <i>v</i> ${maps.flagImg(m.b)} ${esc(m.b)}</span><span class="wcs-mu-pct">${m.pct.toFixed(1)}%</span>`
col.appendChild(row)
}
grid.appendChild(col)
}
muSec.appendChild(grid)
}
function renderPoints() {
ptsSec.replaceChildren()
if (!results) return
const h = document.createElement("h2"); h.textContent = `Qualification by points — Group ${selGroup}`
const cap = document.createElement("p"); cap.className = "wcs-cap"
// Metric toggle: P(qualify | points) or P(finish 1st..4th | points)
const METRICS = [
{ key: "qualify", label: "Qualify", get: r => r.p_advance,
cap: "How often a team qualifies from each points total — top two automatically, plus the eight best thirds." },
{ key: "pos0", label: "1st", get: r => r.p_pos?.[0] ?? 0, cap: "How often a team wins the group from each points total." },
{ key: "pos1", label: "2nd", get: r => r.p_pos?.[1] ?? 0, cap: "How often a team finishes second from each points total." },
{ key: "pos2", label: "3rd", get: r => r.p_pos?.[2] ?? 0, cap: "How often a team finishes third from each points total — thirds enter the best-8 race." },
{ key: "pos3", label: "4th", get: r => r.p_pos?.[3] ?? 0, cap: "How often a team finishes last from each points total." }
]
const metric = METRICS.find(m => m.key === window._wcsPtsMetric) || METRICS[0]
cap.textContent = metric.cap
const pills = document.createElement("div"); pills.className = "wcs-pills"
for (const m of METRICS) {
const b = document.createElement("button")
b.className = "wcs-pill" + (m.key === metric.key ? " active" : "")
b.textContent = m.label
b.onclick = () => { window._wcsPtsMetric = m.key; renderPoints() }
pills.appendChild(b)
}
ptsSec.append(h, cap, pills)
const POINTS = [0, 1, 2, 3, 4, 5, 6, 7, 9] // 8 points is unreachable from 3 games
const teams = results.pointsTable
.filter(t => t.group === selGroup)
.sort((x, y) => {
const tx = results.teams.find(t => t.team === x.team), ty = results.teams.find(t => t.team === y.team)
return (ty?.p_R32 ?? 0) - (tx?.p_R32 ?? 0)
})
const tbl = document.createElement("table"); tbl.className = "wcs-pts-table"
tbl.innerHTML = `<thead><tr><th>Team</th>${POINTS.map(p => `<th>${p} pts</th>`).join("")}</tr></thead>`
const tb = document.createElement("tbody")
for (const t of teams) {
const byPts = new Map(t.rows.map(r => [r.points, r]))
const cells = POINTS.map(p => {
const r = byPts.get(p)
if (!r || r.n < 30) return `<td class="dim">–</td>`
const v = metric.get(r)
const alpha = (v / 100) * 0.45
return `<td style="background:rgba(90,160,125,${alpha.toFixed(3)})" title="${r.n.toLocaleString()} of ${results.nSims.toLocaleString()} sims">${v.toFixed(0)}%</td>`
}).join("")
const tr = document.createElement("tr")
tr.innerHTML = `<td class="wcs-pts-team">${maps.teamLinkHtml(t.team)}</td>${cells}`
tb.appendChild(tr)
}
tbl.appendChild(tb)
// Wrap in a horizontal-scroll container — the 10-column points grid
// overflows narrow phones, and a bare <table> won't pan (statsTable wraps
// its own tables in .stats-table-scroll; this hand-rolled one needs it too).
const scroll = document.createElement("div"); scroll.className = "wcs-scroll-x"
scroll.appendChild(tbl)
ptsSec.appendChild(scroll)
}
function render() {
// Everything, including the group fixtures — selGroup couples groups and
// points structurally, so no interaction needs to remember a pairing.
renderBar(); renderGroups(); renderKo(); renderTable(); renderMatchups(); renderPoints()
}
// First paint: fixtures are interactive immediately; sim results land ~0.5s later.
renderBar(); renderGroups()
setTimeout(run, 30)
wrap.appendChild(window.editorial.tableSource({
source: "pannadata",
sourceUrl: "https://github.com/peteowen1/pannadata",
license: "CC BY 4.0",
asAt: "Live during play",
hint: "10K-simulation Monte Carlo · re-runs while games are live"
}))
return wrap
}