In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

World Cup 2026 — Wall Chart

Skip to content
Show code
statsEsc = window.statsEsc

// Group advance + champion odds from the LIVE in-browser sim (wc-live-sim.js):
// real + in-progress results baked in, re-run every minute while games are live.
_wcGroups = window.wcLiveSim.groupsStream()
_wcSimulation = window.wcLiveSim.simStream()
_wcPredictions = {
  try { return await window.fetchParquet(window.DATA_BASE_URL + "football/wc2026_predictions.parquet") }
  catch (e) { console.error("[wallchart] predictions load failed:", e); return null }
}
wcwAsAt = window.editorial.dataUpdated(window.DATA_BASE_URL + "football/wc2026_predictions.parquet")
// Live results: WC rows from the shared fixture feed (wcMaps fetches
// Worker-first, pre-normalizes team names onto _h/_a). NULL = feed
// unavailable (outage), [] = feed loaded but no WC rows yet. The poster
// has no room for status copy, so an outage degrades to pencil mode like
// the pre-tournament [] — console.warn is the only trace.
_wcResults = {
  const rows = await window.wcMaps.fetchWcFixtures()
  if (rows == null) {
    console.warn("[wallchart] fixture feed unavailable — rendering in pencil (no inked results)")
    return []
  }
  // BOTH scores required — a half-null FINISHED row would otherwise ink a
  // phantom home shutout (2 > null coerces to a 2-0 win)
  return rows.filter(m => m.status === "FINISHED" && m.homeScore != null && m.awayScore != null)
}
Show code
// Flag lookup + markup come from the shared wcMaps helper (wc-maps.js); the
// wallchart keeps its own .wcw-flag sizing CSS, which reserves an explicit
// width per context (default 28px, bracket mini 20px, champion 40px) so the
// layout holds pre-load and on flagcdn failure. Unmapped names render as a
// pencilled blank; callers pass the matching slot width for the blank span.
wcwFlagImg = (team, size) => {
  if (!window.wcMaps.flag[team]) return `<span class="wcw-flag wc-flag-blank" style="width:${size || 28}px" title="${statsEsc(team)}"></span>`
  return window.wcMaps.flagImg(team, "wcw-flag")
}
Show code
// Short display name so long names (Bosnia-Herzegovina, United States) don't
// blow out the fixed-width group panels.
wcwShort = name => {
  const map = {
    "Bosnia-Herzegovina":"Bosnia","United States":"USA","Korea Republic":"S. Korea",
    "Saudi Arabia":"Saudi Arabia","Côte d'Ivoire":"Côte d'Ivoire","South Africa":"S. Africa",
    "New Zealand":"N. Zealand"
  }
  return map[name] || name
}
Show code
// ── Poster header band ────────────────────────────────────────────
{
  const wrap = document.createElement("header")
  wrap.className = "wcw-head"

  // Kickoff countdown from the earliest fixture.
  let days = null, kickoffStr = ""
  if (_wcPredictions && _wcPredictions.length) {
    const parse = d => {
      const m = String(d || "").slice(0, 10).match(/^(\d{4})-(\d{2})-(\d{2})$/)
      return m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null
    }
    const dates = _wcPredictions.map(p => parse(p.match_date)).filter(Boolean).sort((a, b) => a - b)
    if (dates.length) {
      const k = dates[0]
      days = Math.ceil((k - new Date()) / 86400000)
      const mo = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
      kickoffStr = `${mo[k.getUTCMonth()]} ${k.getUTCDate()}, ${k.getUTCFullYear()}`
    }
  }

  wrap.innerHTML = `
    <div class="wcw-head-rule"></div>
    <div class="wcw-head-main">
      <div class="wcw-head-kicker">FIFA World Cup · United States · Canada · Mexico</div>
      <h1 class="wcw-head-title">WORLD CUP <span>2026</span></h1>
      <div class="wcw-head-sub">The wall chart — every group, the whole bracket, the road to the final</div>
    </div>
    <div class="wcw-head-count">
      ${days != null && days > 0
        ? `<span class="wcw-count-num">${days}</span><span class="wcw-count-lbl">days to kickoff<br>${kickoffStr}</span>`
        : `<span class="wcw-count-num">●</span><span class="wcw-count-lbl">tournament<br>underway</span>`}
    </div>
    <div class="wcw-head-rule"></div>`
  return wrap
}
Show code
// ── Compute live standings per group from finished WC results ──────
// Returns Map<group, Map<team, {p,w,d,l,gf,ga,pts}>>. Empty until results flow.
wcwStandings = {
  const byGroup = new Map()
  if (!_wcResults || !_wcResults.length || !_wcGroups) return byGroup

  // Build a team → group index from the (authoritative) groups parquet, keyed
  // by canonical name (wcMaps.normalizeWcTeam) so feed rows — whose _h/_a are
  // pre-normalized by fetchWcFixtures — match parquet names ("USA" arrives as
  // "United States").
  const norm = window.wcMaps.normalizeWcTeam
  const teamGroup = new Map()
  for (const r of _wcGroups) teamGroup.set(norm(r.team), { group: r.group, team: r.team })

  const ensure = (g, team) => {
    if (!byGroup.has(g)) byGroup.set(g, new Map())
    const m = byGroup.get(g)
    if (!m.has(team)) m.set(team, { p:0, w:0, d:0, l:0, gf:0, ga:0, pts:0 })
    return m.get(team)
  }

  // Whitelist of the 72 actual group fixtures (UTC day + canonical pair).
  // Same-group filtering alone is NOT enough: from the QF onward a knockout
  // tie can be an exact rematch of a group pairing (and the 3rd-place game
  // can too) — without the whitelist it would ink in as a phantom 4th group
  // game. Falls back to the same-group check if predictions failed to load.
  const fxWhitelist = _wcPredictions
    ? new Set(_wcPredictions.map(p =>
        `${String(p.match_date || "").replace("Z", "").slice(0, 10)}|${norm(p.home_team)}|${norm(p.away_team)}`))
    : null

  let matched = 0, dropped = 0
  for (const f of _wcResults) {
    const hg = teamGroup.get(f._h), ag = teamGroup.get(f._a)
    if (!hg || !ag || hg.group !== ag.group) { dropped++; continue }   // not a recognised group game
    if (fxWhitelist && !fxWhitelist.has(`${String(f.date || "").slice(0, 10)}|${f._h}|${f._a}`)) { dropped++; continue }
    matched++
    const g = hg.group
    const H = ensure(g, hg.team), A = ensure(g, ag.team)
    const hs = f.homeScore, as = f.awayScore
    H.p++; A.p++; H.gf += hs; H.ga += as; A.gf += as; A.ga += hs
    if (hs > as) { H.w++; A.l++; H.pts += 3 }
    else if (hs < as) { A.w++; H.l++; A.pts += 3 }
    else { H.d++; A.d++; H.pts++; A.pts++ }
  }
  // All-rows-dropped = name/date drift silently un-inking the poster
  const logFn = _wcResults.length > 0 && matched === 0 ? console.warn : console.info
  logFn(`[wc-wallchart] ledger: matched ${matched} of ${_wcResults.length} feed rows (${dropped} dropped)`)
  return byGroup
}
Show code
wcwGroupRow = (t) => {
  const s = t._standing
  const cell = v => `<span class="wcw-gcell">${v}</span>`
  if (s) {
    return cell(s.p) + cell(s.w) + cell(s.d) + cell(s.l) +
           `<span class="wcw-gcell wcw-gpts">${s.pts}</span>`
  }
  // pencil (no games yet): blank fill-in lines + faint advance%
  return cell("·") + cell("·") + cell("·") + cell("·") +
         `<span class="wcw-gcell wcw-gpts wcw-pencil" title="panna advance ${t.advance.toFixed(0)}%">${t.advance.toFixed(0)}%</span>`
}

{
  if (_wcGroups == null) return html`<p class="wcw-loading">Data failed to load — try refreshing (see console for details).</p>`
  if (_wcGroups.length === 0) return html`<p class="wcw-loading">No group data available.</p>`

  const byGroup = new Map()
  for (const r of _wcGroups) {
    if (!byGroup.has(r.group)) byGroup.set(r.group, [])
    byGroup.get(r.group).push(r)
  }
  const groups = [...byGroup.keys()].sort()

  const grid = document.createElement("div")
  grid.className = "wcw-groups"

  for (const g of groups) {
    const live = wcwStandings.get(g)
    // Predicted finish order (pencil): by advance%. If live standings exist,
    // order by points then GD then GF (real ledger).
    let teams = byGroup.get(g).map((r, i) => ({ ...r }))
    if (live && live.size) {
      // once a group has started, unplayed teams get a true zero ledger so
      // the Pts column stays uniformly points (no pencil-% mixed in)
      teams.forEach(t => {
        t._standing = live.get(t.team) || { p: 0, w: 0, d: 0, l: 0, gf: 0, ga: 0, pts: 0 }
        t._played = t._standing.p > 0
      })
      teams.sort((a, b) => {
        const A = a._standing || {pts:0,gf:0,ga:0}, B = b._standing || {pts:0,gf:0,ga:0}
        return B.pts - A.pts || (B.gf - B.ga) - (A.gf - A.ga) || B.gf - A.gf || a.team.localeCompare(b.team)
      })
    } else {
      teams.forEach(t => { t._standing = null; t._played = false })
      teams.sort((a, b) => b.advance - a.advance)
    }
    teams.forEach((t, i) => { t._rank = i + 1 })

    const panel = document.createElement("div")
    panel.className = "wcw-group"
    // before any result the last column carries the model's advance%, not
    // points — label it honestly and flip to Pts once the group starts
    const lastCol = (live && live.size)
      ? `<span>Pts</span>`
      : `<span title="Model probability of a top-two finish (best-third route to the round of 32 not included)">Top2</span>`
    let inner = `<div class="wcw-group-tab">GROUP ${statsEsc(g)}</div>
      <div class="wcw-group-cols"><span></span><span>P</span><span>W</span><span>D</span><span>L</span>${lastCol}</div>`
    for (const t of teams) {
      const qual = t._rank <= 2 ? " is-qual" : ""
      inner += `<div class="wcw-group-row${qual}">
        <a class="wcw-group-team" href="world-cup-group.html#group=${statsEsc(g)}" title="Group ${statsEsc(g)} deep dive — ${statsEsc(t.team)}">
          ${wcwFlagImg(t.team)}
          <span class="wcw-group-name">${statsEsc(wcwShort(t.team))}</span>
        </a>
        ${wcwGroupRow(t)}
      </div>`
    }
    panel.innerHTML = inner
    grid.appendChild(panel)
  }
  return grid
}
Show code
wcwBracketSeeds = window.wcMaps.r32Seeds

{
  if (_wcGroups == null || _wcSimulation == null) return html`<p class="wcw-loading">Data failed to load — try refreshing (see console for details).</p>`

  const statsEscL = window.statsEsc
  // Seed → predicted team (pencil), via group projections.
  const byGroup = new Map()
  const teamToGroup = new Map()   // for bracket team click-throughs
  for (const r of _wcGroups) {
    if (!byGroup.has(r.group)) byGroup.set(r.group, [])
    byGroup.get(r.group).push(r)
    teamToGroup.set(r.team, r.group)
  }
  const seedToTeam = new Map()
  // 1X / 2X: per-group predicted winner / runner-up. The 2X pick EXCLUDES the
  // 1X team — a dominant favourite can top both win_group and runner_up, and
  // without the exclusion it would occupy both seeds (same one-slot-per-team
  // rule as the title-race bracket). 3rd excludes both.
  const groupThird = new Map()
  for (const [g, rows] of byGroup.entries()) {
    const w = [...rows].sort((a, b) => b.win_group - a.win_group)[0]?.team || "?"
    const r = [...rows].filter(x => x.team !== w)
      .sort((a, b) => b.runner_up - a.runner_up)[0]?.team || "?"
    seedToTeam.set("1" + g, w)
    seedToTeam.set("2" + g, r)
    const third = [...rows].filter(x => x.team !== w && x.team !== r)
      .sort((a, b) => (b.third ?? 0) - (a.third ?? 0))[0]
    if (third) groupThird.set(g, { g, team: third.team, score: third.third ?? 0 })
  }

  // Best-third qualification + slot assignment. FIFA fields the 8 best
  // 3rd-placed teams into fixed composite slots ("3A/B/C/D/F" = a 3rd from
  // one of those five groups — FIFA's published possibility sets). Take the
  // 8 best thirds (by model `third`), then let wcMaps.assignThirdSlots place
  // DISTINCT teams into the slots via maximum matching — a perfect
  // assignment exists for every qualifying combination by FIFA's design.
  const qualifiers = [...groupThird.values()].sort((a, b) => b.score - a.score).slice(0, 8)
  {
    const assigned = window.wcMaps.assignThirdSlots(qualifiers)
    for (const s of wcwBracketSeeds.flat()) {
      if (s.startsWith("3") && s.includes("/")) seedToTeam.set(s, assigned.get(s)?.team || "?")
    }
  }
  const champByTeam = new Map(_wcSimulation.map(t => [t.team, t.p_champ]))
  const pick = (a, b) => {
    if (a === "?" ) return b
    if (b === "?") return a
    return (champByTeam.get(b) ?? 0) > (champByTeam.get(a) ?? 0) ? b : a
  }

  // Build the rounds bottom-up. R32 (16 matches → 32 boxes), then derive.
  const R32 = wcwBracketSeeds.map(([s1, s2]) => ({ a: seedToTeam.get(s1) || s1, b: seedToTeam.get(s2) || s2 }))
  const advance = arr => {
    const out = []
    for (let i = 0; i < arr.length; i += 2) {
      const w1 = arr[i].w ?? pick(arr[i].a, arr[i].b)
      const w2 = arr[i+1].w ?? pick(arr[i+1].a, arr[i+1].b)
      out.push({ a: w1, b: w2 })
    }
    return out
  }
  R32.forEach(m => m.w = pick(m.a, m.b))
  const R16 = advance(R32); R16.forEach(m => m.w = pick(m.a, m.b))
  const QF  = advance(R16); QF.forEach(m => m.w = pick(m.a, m.b))
  const SF  = advance(QF);  SF.forEach(m => m.w = pick(m.a, m.b))
  const FIN = advance(SF);  FIN.forEach(m => m.w = pick(m.a, m.b))
  const champion = FIN.length ? pick(FIN[0].a, FIN[0].b) : "?"

  // Render: 5 columns of match cards + a champion plinth. CSS flex/grid
  // handles the vertical centring between rounds (cleaner than SVG geometry
  // for a poster, and prints as real text).
  const flagMini = t => t && t !== "?" ? wcwFlagImg(t, 20) : `<span class="wcw-flag wc-flag-blank" style="width:20px"></span>`
  const teamLine = (t, isWinner) => {
    const known = t && t !== "?"
    const g = known ? teamToGroup.get(t) : null
    const nameInner = known ? statsEscL(wcwShort(t)) : "—"
    const name = g
      ? `<a href="world-cup-group.html#group=${g}" class="wcw-bk-link" title="Group ${g} — ${statsEscL(t)}">${nameInner}</a>`
      : `<span>${nameInner}</span>`
    return `<div class="wcw-bk-team${isWinner ? " is-win" : ""}${known ? "" : " is-tbd"}">${flagMini(t)}${name}</div>`
  }
  const matchCard = m =>
    `<div class="wcw-bk-match">${teamLine(m.a, m.w === m.a)}${teamLine(m.b, m.w === m.b)}</div>`

  const col = (label, matches) => {
    const c = document.createElement("div")
    c.className = "wcw-bk-col"
    c.innerHTML = `<div class="wcw-bk-colhead">${label}</div>` + matches.map(matchCard).join("")
    return c
  }

  const wrap = document.createElement("div")
  const h = document.createElement("div")
  h.className = "wcw-bk-title"
  h.innerHTML = `<span>The Knockouts</span><em>model's most-likely path in pencil · results ink in as they land</em>`
  wrap.appendChild(h)

  const board = document.createElement("div")
  board.className = "wcw-bracket"
  board.appendChild(col("Round of 32", R32))
  board.appendChild(col("Round of 16", R16))
  board.appendChild(col("Quarter-finals", QF))
  board.appendChild(col("Semi-finals", SF))
  board.appendChild(col("Final", FIN))

  const champCol = document.createElement("div")
  champCol.className = "wcw-bk-col wcw-bk-champcol"
  champCol.innerHTML = `<div class="wcw-bk-colhead">Champion</div>
    <div class="wcw-champ">
      <div class="wcw-trophy">${wcwTrophySvg()}</div>
      <div class="wcw-champ-flag">${champion !== "?" ? wcwFlagImg(champion, 40) : ""}</div>
      <div class="wcw-champ-name">${champion !== "?" ? statsEscL(champion) : "— — —"}</div>
      <div class="wcw-champ-tag">predicted</div>
    </div>`
  board.appendChild(champCol)
  wrap.appendChild(board)

  const note = document.createElement("p")
  note.className = "wcw-bk-note"
  note.innerHTML = `Bracket structure is FIFA's official 2026 bracket (12 group winners + 12 runners-up + 8 best third-placed, matches 73–104); <em>3X/Y/Z</em> slots show FIFA's allowed source groups until the thirds settle. Predicted advancers use champion probability from the 10,000-tournament simulation.`
  wrap.appendChild(note)

  const asAt = await wcwAsAt
  wrap.appendChild(window.editorial.tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    license: "CC BY 4.0",
    asAt: asAt || "Refreshed weekly",
    hint: "Predicted advancers · FIFA 2026 bracket"
  }))

  return wrap
}
Show code
// Hand-drawn-ish trophy mark (SVG, no emoji — keeps the no-emoji site rule and
// renders identically on Windows).
wcwTrophySvg = () => `
<svg viewBox="0 0 48 56" width="44" height="52" fill="none" aria-hidden="true">
  <path d="M14 6h20v10a10 10 0 0 1-20 0V6z" fill="#caa53a" stroke="#7c5e15" stroke-width="1.5"/>
  <path d="M14 8H8a4 4 0 0 0 0 8h6M34 8h6a4 4 0 0 1 0 8h-6" stroke="#7c5e15" stroke-width="1.5"/>
  <path d="M24 26v8M17 40h14l-2-6H19l-2 6zM15 40h18v4H15z" stroke="#7c5e15" stroke-width="1.5" fill="#caa53a"/>
  <rect x="13" y="44" width="22" height="5" rx="1" fill="#7c5e15"/>
</svg>`
Show code
// ── Legend + source footer ────────────────────────────────────────
{
  const f = document.createElement("footer")
  f.className = "wcw-foot"
  f.innerHTML = `
    <div class="wcw-legend">
      <span class="wcw-leg"><i class="wcw-swatch wcw-sw-qual"></i> Qualifies (top 2)</span>
      <span class="wcw-leg"><i class="wcw-swatch wcw-sw-pencil"></i> Model pick (pencil)</span>
      <span class="wcw-leg"><i class="wcw-swatch wcw-sw-ink"></i> Real result (inked)</span>
    </div>
    <div class="wcw-source">
      Source: <a href="https://github.com/peteowen1/pannadata" target="_blank" rel="noopener">pannadata</a>
      · panna model · flags: flagcdn.com · Pete Owen · CC BY 4.0
      · <a href="world-cup-2026.html">World Cup hub →</a>
    </div>`
  return f
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer