World Cup 2026 — Match Predictions
Football > World Cup 2026 > Match Predictions
Football · World Cup 2026 · Match Predictions
What’s the model picking?
Win / draw / loss probabilities and predicted scorelines for all 72 group-stage fixtures, from panna’s XGBoost goals + outcome model. The three host nations get a home-ground edge in their own games; every other fixture is treated as neutral-venue.
Show code
statsEsc = window.statsEsc
_wcPredictions = {
try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_predictions.parquet") }
catch (e) { console.error("[wc2026] predictions load failed:", e); return null }
}
// Live fixture feed (kickoff times, scores, status) — same shared loader as
// the hub (wcMaps.fetchWcFixtures: Worker→R2 fallback, feed-side name
// normalisation into _h/_a). NULL = feed unavailable (outage — the cards
// cell shows a muted notice), [] = feed loaded but has no WC rows (silent).
// Either way, consumers must degrade to the prediction-only view when the
// feed has no WC rows. The parquet side is normalised at join time
// (wcMaps.normalizeWcTeam on BOTH sides).
_wcLiveFx = {
return await window.wcMaps.fetchWcFixtures()
}Show code
// ── Filter row: group dropdown + team select + CSV export ────
// Inputs.form composes both selects into one viewof so they sit on one row;
// value is { group, team }. Each filter narrows the 72 fixtures (a team
// select shows that side's 3 group games). The Download CSV button
// serializes the full predictions parquet — not the filtered view — via a
// Blob download.
viewof wcFilters = {
if (_wcPredictions == null) return html``
const groups = [...new Set(_wcPredictions.map(d => String(d.group || "")))]
.filter(Boolean).sort()
const teams = [...new Set(_wcPredictions.flatMap(d => [d.home_team, d.away_team]))]
.filter(Boolean).sort((a, b) => String(a).localeCompare(String(b)))
const csvBtn = document.createElement("button")
csvBtn.type = "button"
csvBtn.className = "wc-csv-btn"
csvBtn.textContent = "Download CSV"
csvBtn.title = "All 72 fixture predictions as CSV"
csvBtn.onclick = () => {
const cols = ["match_date", "group", "home_team", "away_team",
"prob_home", "prob_draw", "prob_away", "pred_home_goals", "pred_away_goals", "predicted"]
const cell = v => {
if (v == null) return ""
const s = String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
const lines = [cols.join(",")]
for (const r of _wcPredictions) {
lines.push(cols.map(c => cell(c === "match_date" ? String(r[c] ?? "").replace("Z", "") : r[c])).join(","))
}
const url = URL.createObjectURL(new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" }))
const a = document.createElement("a")
a.href = url
a.download = "wc2026-predictions.csv"
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
return Inputs.form(
{
group: Inputs.select(["All groups", ...groups], {
label: "Group",
format: g => g === "All groups" ? g : "Group " + g
}),
team: Inputs.select(["All teams", ...teams], { label: "Team" })
},
{ template: (inputs) => htl.html`<div class="wc-filter-row">${Object.values(inputs)}${csvBtn}</div>` }
)
}Show code
// ── Live / All / Fixtures / Results toggle ───────────────────
// Mirrors football/matches.qmd's epv-toggle. Live = in-play, Results =
// finished (newest first), Fixtures = upcoming (incl. the knockout
// schedule), All = everything chronological. Default "All" — most of the
// tournament window has no live game, so Live would land empty.
viewof wcMatchView = {
const wrap = document.createElement("div")
wrap.className = "epv-toggle"
wrap.value = "All"
for (const label of ["Live", "All", "Fixtures", "Results"]) {
const btn = document.createElement("button")
btn.className = "epv-toggle-btn" + (label === "All" ? " active" : "")
btn.dataset.view = label
btn.textContent = label
btn.addEventListener("click", () => {
wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
wrap.value = label
wrap.dispatchEvent(new Event("input", { bubbles: true }))
})
wrap.appendChild(btn)
}
return wrap
}Show code
// ── Match cards, grouped by date ─────────────────────────────
// Mirrors football/matches.qmd's card pattern (match-cards-container /
// match-date-header / match-card.football / prob bars / footer summary).
// Cards join the live fixture feed by normalised home|away pair: kickoff
// time for upcoming games, LIVE/HT badge + score in-play, final score +
// a correct/wrong prediction tag when finished. No fixture row → the card
// must degrade to the prediction-only view. Each card stretch-links to the
// football match page (match.html#league=WC&…) — predictions.parquet carries
// all 104 WC fixtures, so the match page resolves the Opta match_id and pulls
// live xG/shots/chains from the worker even before the parquet rebuild lands.
// Cards can't be wrapped in an <a> (team names + group chip are anchors), so
// the footer .wc-match-link grows a ::after overlay instead (theme.scss).
{
if (_wcPredictions == null) return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
if (_wcPredictions.length === 0) return html`<p class="text-muted">No prediction data available.</p>`
const wc = window.wcMaps
const f = wcFilters || {}
const groupSel = f.group || "All groups"
const teamSel = f.team || "All teams"
// Fixture-feed lookup keyed on UTC day + canonical team pair (hub
// convention). Date is part of the key because from the QF onward a
// knockout tie can be an exact same-orientation rematch of a group fixture
// (plus the 3rd-place game) — pair-only keys let the KO row clobber the
// group row, showing the wrong score/verdict on finished group cards.
// Slice the ISO string (UTC day), never round through a local Date.
const fxByPair = new Map((_wcLiveFx || []).map(m => [`${String(m.date || "").slice(0, 10)}|${m._h}|${m._a}`, m]))
const LIVE = wc.LIVE_STATUSES
const fmtTime = dt => dt.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
// xG comes from the worker live model (not the parquet — it carries no WC
// xG). Awaited up front for EVERY started game so each card paints with its
// Pred+xG bars at once — no deferred patch, no "some cards have xG" flicker.
const xgMap = await wc.fetchWcXgMap(_wcLiveFx)
let rows = [..._wcPredictions]
if (groupSel !== "All groups") rows = rows.filter(r => String(r.group) === groupSel)
if (teamSel !== "All teams") rows = rows.filter(r => r.home_team === teamSel || r.away_team === teamSel)
rows.sort((a, b) => {
const d = String(a.match_date || "").localeCompare(String(b.match_date || ""))
if (d !== 0) return d
return String(a.group || "").localeCompare(String(b.group || ""))
})
// ── Toggle filter (Live / All / Fixtures / Results) ──
// statusOf reads the same live-feed join the cards use below.
const statusOf = (m) => {
const day = String(m.match_date || "").replace("Z", "").slice(0, 10)
const lv = fxByPair.get(`${day}|${wc.normalizeWcTeam(m.home_team)}|${wc.normalizeWcTeam(m.away_team)}`)
if (lv && LIVE.has(lv.status)) return "live"
if (lv && lv.status === "FINISHED" && lv.homeScore != null) return "finished"
return "upcoming"
}
const view = wcMatchView || "All"
if (view === "Live") rows = rows.filter(m => statusOf(m) === "live")
else if (view === "Fixtures") rows = rows.filter(m => statusOf(m) === "upcoming")
else if (view === "Results") {
rows = rows.filter(m => statusOf(m) === "finished")
rows.sort((a, b) => String(b.match_date || "").localeCompare(String(a.match_date || ""))) // newest first
}
// All: keep chronological (already sorted ascending).
// Knockout schedule cards are upcoming fixtures with TBD teams — show them
// under All and Fixtures, but only when not narrowed to a single group/team.
const showKo = (view === "All" || view === "Fixtures") && groupSel === "All groups" && teamSel === "All teams"
if (rows.length === 0 && !showKo) {
const emptyMsg = view === "Live" ? "No World Cup games are in play right now."
: view === "Results" ? "No completed World Cup games yet."
: view === "Fixtures" ? "No upcoming fixtures match the current filters."
: "No fixtures match the current filters."
return html`<p class="text-muted">${emptyMsg}</p>`
}
// Group by calendar date (strip trailing "Z" — keys are plain YYYY-MM-DD)
const byDate = new Map()
for (const m of rows) {
const key = String(m.match_date || "").replace("Z", "").slice(0, 10)
if (!byDate.has(key)) byDate.set(key, [])
byDate.get(key).push(m)
}
// Construct at NOON UTC (Date.UTC(..., 12)) so wcMaps.fmtDateHead — which
// formats in the reader's locale — never shifts the weekday a day in
// negative-offset timezones (match_date strings carry no time component).
const formatDate = (key) => {
const [y, mo, da] = key.split("-").map(Number)
if (!y || !mo || !da) return key
return wc.fmtDateHead(new Date(Date.UTC(y, mo - 1, da, 12)))
}
// Earlier / Upcoming anchor jump (anchor-only hash — data-loader's
// hashchange reload only fires on key=value param changes, so this is safe)
const today = new Date().toISOString().slice(0, 10)
const dateKeys = [...byDate.keys()]
const firstUpcoming = dateKeys.find(d => d >= today)
const hasPast = dateKeys.some(d => d < today)
const jumpHtml = (hasPast && firstUpcoming)
? `<div class="wc-jump"><a href="#wc-upcoming">Jump to upcoming fixtures ↓</a></div>`
: ""
// Home side: wcMaps' standard flag-then-name anchor. Away side mirrors it
// (name then flag) using the same wcMaps teamHref/flagImg primitives.
const teamAnchor = (team, mirrored) => {
if (team == null || team === "") return ""
if (!mirrored) return wc.teamLinkHtml(team)
return `<a class="wc-team-link" href="${wc.teamHref(team)}"><span>${wc.esc(team)}</span>${wc.flagImg(team)}</a>`
}
const sections = []
for (const [dateKey, matches] of byDate) {
const anchorId = (firstUpcoming && dateKey === firstUpcoming && hasPast) ? ` id="wc-upcoming"` : ""
sections.push(`<div class="match-date-header"${anchorId}>${formatDate(dateKey)}</div>`)
for (const m of matches) {
const pH = m.prob_home, pD = m.prob_draw, pA = m.prob_away
const hasPred = pH != null && pD != null && pA != null
// Favoured side: trust the model's pick column, fall back to prob compare
const pick = m.predicted || (hasPred
? (pH >= pA && pH >= pD ? "H" : pA > pH && pA >= pD ? "A" : "D")
: null)
const homeFav = pick === "H"
const awayFav = pick === "A"
// Live fixture join (normalised on both sides; dateKey is the UTC day
// of the parquet's match_date, matching the feed's utcDate day)
const pairKey = `${dateKey}|${wc.normalizeWcTeam(m.home_team)}|${wc.normalizeWcTeam(m.away_team)}`
const live = fxByPair.get(pairKey)
const isLive = live && LIVE.has(live.status)
const finished = live && live.status === "FINISHED" && live.homeScore != null
const hasTime = live && live.date && String(live.date).length > 11
// xG for started games — from the worker live model (xgMap, awaited up
// front), so every started card paints with its Pred+xG bars at once.
const xg = (finished || isLive) ? (xgMap.get(pairKey) || null) : null
const g = String(m.group || "")
const chip = g
? `<a class="wc-group-chip" href="world-cup-group.html#group=${encodeURIComponent(g)}" title="Group ${statsEsc(g)} deep dive">Group ${statsEsc(g)}</a>`
: ""
const infoBits = []
if (isLive) {
// Opta feed carries a real match minute; show "LIVE 69'" when present.
const liveLabel = live.status === "PAUSED" ? "HT"
: (typeof live.minute === "number" && live.minute > 0 ? `LIVE ${live.minute}'` : "LIVE")
infoBits.push(`<span class="live-badge">${statsEsc(liveLabel)}</span>`)
}
if (hasTime && !finished && !isLive) infoBits.push(statsEsc(fmtTime(new Date(live.date))))
// Feed rows don't carry a venue for WC games — fall back to the curated
// group-stage map (same dateKey + canonical pair as the live join above).
const venue = live?.venue || wc.venueFor(dateKey, m.home_team, m.away_team)
if (venue) {
const country = wc.venueCountryOf(venue)
infoBits.push(statsEsc(country ? `${venue} · ${country}` : venue))
}
// With feed content the info row goes flex (bits left, chip right);
// without it only the group chip renders.
const chipHtml = infoBits.length
? `<div class="match-info wc-info-row">${infoBits.join(" · ")}<span class="wc-chip-slot">${chip}</span></div>`
: (chip ? `<div class="match-info">${chip}</div>` : "")
// Centre: real score replaces "vs" for in-play + finished games, with
// the xG line underneath (same .match-xg convention as matches.qmd).
// Cards awaiting the live top-up render a hidden slot the patch fills.
const showScore = (finished || isLive) && live.homeScore != null
const xgLine = xg ? `<div class="match-xg">xG: ${xg.home.toFixed(1)} – ${xg.away.toFixed(1)}</div>` : ""
const centre = showScore
? `<div class="match-score">${live.homeScore} – ${live.awayScore}${xgLine}</div>`
: "vs"
// Prediction verdict once the final whistle has gone
let resultTag = ""
if (finished && pick) {
const actual = live.homeScore > live.awayScore ? "H" : live.homeScore < live.awayScore ? "A" : "D"
resultTag = pick === actual
? `<span class="legend-tag legend-good wc-pred-tag">Correct</span>`
: `<span class="legend-tag legend-bad wc-pred-tag">Wrong</span>`
}
const homeRating = m.pred_home_goals != null ? `<span class="rating">${m.pred_home_goals.toFixed(1)}</span>` : ""
const awayRating = m.pred_away_goals != null ? `<span class="rating">${m.pred_away_goals.toFixed(1)}</span>` : ""
// Dual Pred/xG bars for finished games (matches.qmd convention) — the
// xG bar converts the totals to H/D/A via the shared Poisson model.
const xgProb = ((finished || isLive) && xg && window.footballXgProb) ? window.footballXgProb(xg.home, xg.away) : null
const barHtml = hasPred ? `
<div class="match-prediction${xgProb ? " has-dual-bar" : ""}">
<div class="prob-bars-group">
<div class="prob-bar-row">${xgProb ? '<span class="prob-bar-label">Pred</span>' : ""}${wc.probBarHtml(pH, pD, pA, {
title: `${m.home_team} ${(pH * 100).toFixed(0)}% · Draw ${(pD * 100).toFixed(0)}% · ${m.away_team} ${(pA * 100).toFixed(0)}%`
})}</div>
${xgProb ? `<div class="prob-bar-row"><span class="prob-bar-label">xG</span>${wc.probBarHtml(xgProb.home, xgProb.draw, xgProb.away, {
title: `xG-implied: ${m.home_team} ${(xgProb.home * 100).toFixed(0)}% · Draw ${(xgProb.draw * 100).toFixed(0)}% · ${m.away_team} ${(xgProb.away * 100).toFixed(0)}%`
})}</div>` : ""}
</div>
</div>` : ""
const predSummary = (m.pred_home_goals != null && m.pred_away_goals != null)
? `<div class="pred-summary">Prediction: ${statsEsc(m.home_team)} ${m.pred_home_goals.toFixed(1)} : ${m.pred_away_goals.toFixed(1)} ${statsEsc(m.away_team)}${resultTag}</div>`
: resultTag
// Match-page deep link — home/away must be the PARQUET names (the match
// page resolves its predictions row by league|date|normalized names).
const matchHref = `match.html#league=WC&date=${dateKey}&home=${encodeURIComponent(m.home_team)}&away=${encodeURIComponent(m.away_team)}`
const matchLink = `<a class="wc-match-link" href="${matchHref}">Match centre →</a>`
const footerHtml = `<div class="match-card-footer">${predSummary || ""}${matchLink}</div>`
sections.push(`
<div class="match-card football wc-linked">
${chipHtml}
<div class="match-teams">
<div class="team ${homeFav ? 'favoured' : ''}">${teamAnchor(m.home_team)}${homeRating}</div>
<div class="match-vs">${centre}</div>
<div class="team ${awayFav ? 'favoured' : ''}">${teamAnchor(m.away_team, true)}${awayRating}</div>
</div>
${barHtml}
${footerHtml}
</div>`)
}
}
// ── Knockout schedule (M73–104) ──────────────────────────────
// Teams are TBD until the groups resolve, but the WHEN and WHERE are fixed
// by FIFA's match-number tree (wcMaps.koSchedule) — date, round, bracket
// slot and venue for every knockout game. These cards are static; once
// football-data.org creates the knockout fixtures (teams + kickoff times),
// they surface as normal feed-joined cards in the dated sections above.
// Shown under All / Fixtures only (showKo) — not Live or Results.
if (showKo) {
sections.push(`<div class="match-date-header" id="wc-knockout" style="margin-top:1.6rem">Knockout — Round of 32 to the Final</div>`)
sections.push(`<p class="text-muted" style="font-size:0.82rem;margin:0.3rem 0 0.6rem">Dates, venues and bracket slots are locked; teams fill in as the groups finish. "1A" = Group A winner, "2B" = runner-up, "3rd" = a best-third slot, "W79" = winner of match 79.</p>`)
let lastKoDay = null
for (const k of wc.koSchedule) {
const venueCountry = wc.venueCountryOf(k.key)
const dayLabel = (() => {
const [y, mo, da] = k.day.split("-").map(Number)
return wc.fmtDateHead(new Date(Date.UTC(y, mo - 1, da, 12)))
})()
if (k.day !== lastKoDay) { sections.push(`<div class="wc-ko-day">${statsEsc(dayLabel)}</div>`); lastKoDay = k.day }
sections.push(`
<div class="match-card football wc-ko-card" title="FIFA match ${k.m}">
<div class="match-info wc-info-row">${statsEsc(k.key)}${venueCountry ? ` · ${statsEsc(venueCountry)}` : ""}<span class="wc-chip-slot"><span class="wc-group-chip">${statsEsc(k.round)}</span></span></div>
<div class="match-teams">
<div class="team wc-ko-slot">${statsEsc(k.pair.split(" v ")[0])}</div>
<div class="match-vs">vs</div>
<div class="team wc-ko-slot" style="justify-content:flex-end">${statsEsc(k.pair.split(" v ")[1])}</div>
</div>
</div>`)
}
}
// Feed outage (null, not the no-WC-rows-yet []): one muted line above the
// cards so prediction-only rendering reads as degraded, not as the truth.
const feedNote = _wcLiveFx === null
? `<p class="text-muted" style="font-size:0.82rem;margin:0 0 0.5rem">Live scores and kickoff times are temporarily unavailable — showing model predictions.</p>`
: ""
const root = html`<div>${feedNote}${jumpHtml}<div class="match-cards-container">${sections.join("")}</div></div>`
return root
}Show code
Show code
{
const inner = document.createElement("div")
inner.className = "side-rail-inner"
const { railBlock, btnTile, tableSource } = window.editorial
const asAt = await wcMatchesAsAt
if (_wcPredictions && _wcPredictions.length > 0) {
// Find the closest match between equally-rated teams (smallest gap in prob_home vs prob_away)
const tossUps = [..._wcPredictions]
.map(p => ({ ...p, gap: Math.abs(p.prob_home - p.prob_away) }))
.sort((a, b) => a.gap - b.gap)
const tightest = tossUps[0]
// Heaviest mismatch
const blowouts = [..._wcPredictions].sort((a, b) => Math.max(b.prob_home, b.prob_away) - Math.max(a.prob_home, a.prob_away))
const blow = blowouts[0]
const blowFav = blow.prob_home > blow.prob_away ? blow.home_team : blow.away_team
const blowDog = blow.prob_home > blow.prob_away ? blow.away_team : blow.home_team
const blowPct = (Math.max(blow.prob_home, blow.prob_away) * 100).toFixed(0)
const btn = railBlock("By the numbers")
btn.appendChild(btnTile("72", [{ text: "Group-stage fixtures" }]))
const tightTile = btnTile(`${(tightest.prob_home * 100).toFixed(0)}/${(tightest.prob_draw * 100).toFixed(0)}/${(tightest.prob_away * 100).toFixed(0)}`, [
{ text: "Closest match · " }, { text: `${tightest.home_team} v ${tightest.away_team}`, bold: true }
])
tightTile.title = `Home / Draw / Away win probability — ${(tightest.prob_home * 100).toFixed(0)}% / ${(tightest.prob_draw * 100).toFixed(0)}% / ${(tightest.prob_away * 100).toFixed(0)}%`
btn.appendChild(tightTile)
btn.appendChild(btnTile(`${blowPct}%`, [
{ text: "Heaviest favourite · " }, { text: blowFav, bold: true }, { text: ` v ${blowDog}` }
]))
inner.appendChild(btn)
}
const links = railBlock("Read next")
const l0 = document.createElement("div"); l0.innerHTML = `<a href="world-cup-simulator.html"><strong>Simulator</strong></a><br><span class="text-muted" style="font-size:0.78rem">Disagree? Lock results, re-run the odds</span>`
links.appendChild(l0)
const l1 = document.createElement("div"); l1.style.marginTop = "0.7rem"
l1.innerHTML = `<a href="world-cup-title-race.html"><strong>Title Race</strong></a><br><span class="text-muted" style="font-size:0.78rem">Champion probabilities</span>`
links.appendChild(l1)
const l2 = document.createElement("div"); l2.style.marginTop = "0.7rem"
l2.innerHTML = `<a href="world-cup-strength.html"><strong>Team Strength</strong></a><br><span class="text-muted" style="font-size:0.78rem">48 teams · 7 rating systems</span>`
links.appendChild(l2)
inner.appendChild(links)
inner.appendChild(tableSource({
source: "pannadata",
sourceUrl: "https://github.com/peteowen1/pannadata",
sourceNote: "Opta scrape",
license: "CC BY 4.0",
asAt: asAt || "Refreshed weekly",
hint: "XGBoost outcome + goals model"
}))
return inner
}