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 Player Stats

Skip to content

Football > World Cup 2026 > Player Stats

Football · World Cup 2026 · Player Stats

Who’s delivering at the World Cup?

Per-match tournament box scores — goals, shots, key passes, tackles, duels — for every player at the 2026 World Cup, aggregated across the games they’ve played. The table fills in as matches are played; filter by position or nation, and toggle totals, per-game and per-90 rates.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
base = window.DATA_BASE_URL

// Tournament window — the WC shard runs on league == "WC", which also covers
// qualifier internationals. This page is tournament-only: keep rows from the
// finals window (Jun 11 – Jul 19, 2026) onward.
WC_START = "2026-06-01"

// Three-state load:
//   null → fetch/parse failure (render shows "failed to load")
//   []   → shard not published yet (404) — friendly "tournament just started" state
//   rows → per-player per-match WC box scores
// fetchParquet returns null on ANY failure, which would make "shard not
// published yet" indistinguishable from a real outage — so probe the URL
// first and translate a clean 404 into the empty state.
_wcStatsRaw = {
  const url = base + "football/match-stats-WC.parquet"
  try {
    const probe = await fetch(window.cacheBust(url))
    if (probe.body) probe.body.cancel()  // headers are enough — don't buffer the file
    if (probe.status === 404) return []
  } catch (e) {
    console.error("[wc-player-stats] match-stats-WC probe failed:", e)
    return null
  }
  return await window.fetchParquet(url)
}

// Pre-filtered tournament rows (same shape as the club match-stats shards:
// match_id, team_name, position, minsPlayed, goals, shots, passes, ...).
matchStats = {
  if (_wcStatsRaw == null) return null
  return _wcStatsRaw.filter(d =>
    (d.league == null || d.league === "WC") &&
    String(d.match_date || "").replace("Z", "").slice(0, 10) >= WC_START)
}

// Canonical position per player — first non-Substitute occurrence, newest
// match first, so a cameo sub appearance doesn't become a starter's listed
// position (same pattern as football/player-stats.qmd).
_playerCurrentMeta = {
  if (!matchStats) return new Map()
  const sorted = [...matchStats].sort((a, b) =>
    String(b.match_date || "").localeCompare(String(a.match_date || "")))
  const m = new Map()
  for (const g of sorted) {
    const pid = g.player_id
    if (!pid) continue
    let rec = m.get(pid)
    if (!rec) { rec = { team: g.team_name || null, position: null }; m.set(pid, rec) }
    if (!rec.position && g.position && g.position !== "Substitute" && g.position !== "Sub") {
      rec.position = g.position
    }
  }
  // Backfill Substitute as the last resort for players who never started.
  for (const g of sorted) {
    const rec = g.player_id && m.get(g.player_id)
    if (rec && !rec.position && g.position) rec.position = g.position
  }
  return m
}
Show code
posAbbr = window.footballMaps.posAbbr
footballPosColors = window.footballMaps.posGroupColors
posToGroup = window.footballMaps.posToGroup
Show code
statDefs = {
  if (!window.footballStatDefs) {
    console.error("[wc-player-stats] football/stat-defs.js failed to load")
    return {}
  }
  return window.footballStatDefs
}

catKeys = Object.keys(statDefs).filter(k =>
  !statDefs[k].page && (statDefs[k].source === "matchStats" || k === "custom"))
Show code
// ── Category toggle ──────────────────────────────────────────
viewof category = {
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "scoring"
  const _default = catKeys.includes(_saved) ? _saved : "scoring"
  const container = document.createElement("div")
  container.className = "stats-category-toggle football"
  for (const key of catKeys) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (key === _default ? " active" : "")
    btn.textContent = statDefs[key].label
    btn.dataset.cat = key
    btn.addEventListener("click", () => {
      container.querySelectorAll(".stats-cat-btn").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = key
      window[_key] = key
      container.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  container.value = _default
  return container
}
Show code
// ── Listed-position filter (simple GK/DEF/MID/FWD) ───────────
viewof posFilter = {
  const positions = ["All", "GK", "DEF", "MID", "FWD"]
  const _key = "_posFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = positions.includes(window[_key]) ? window[_key] : "All"
  const wrap = document.createElement("div")
  wrap.className = "pos-filter-row"
  const label = document.createElement("span")
  label.className = "pos-filter-label"
  label.textContent = "Listed pos"
  label.title = "Filter by the player's tournament position"
  wrap.appendChild(label)
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const p of positions) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (p === _saved ? " active" : "")
    btn.dataset.pos = p
    btn.textContent = p
    btn.title = p === "All" ? "Show all listed positions" : `Show players listed as ${p}`
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = p
      window[_key] = p
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  wrap.appendChild(container)
  wrap.value = _saved
  return wrap
}
Show code
viewof filters = {
  const makeSelect = window.footballMaps.makeSelect

  const container = document.createElement("div")
  container.className = "player-filter-bar"
  const row = document.createElement("div")
  row.className = "filter-row"

  // Nation select (populated after data loads)
  const team = makeSelect(["All Nations"], "All Nations", "Nation")
  row.appendChild(team.wrap)

  // Player / nation search
  const searchWrap = document.createElement("div")
  searchWrap.className = "filter-round-wrap"
  const sLbl = document.createElement("span")
  sLbl.className = "filter-label"
  sLbl.textContent = "Search"
  const sInput = document.createElement("input")
  sInput.type = "search"
  sInput.className = "round-input"
  sInput.style.minWidth = "11rem"
  sInput.placeholder = "Player or nation…"
  searchWrap.appendChild(sLbl)
  searchWrap.appendChild(sInput)
  row.appendChild(searchWrap)

  // Agg toggle
  const aggWrap = document.createElement("div")
  aggWrap.className = "filter-agg-wrap"
  const btnAvg = document.createElement("button")
  btnAvg.className = "agg-btn"; btnAvg.textContent = "Per Game"; btnAvg.dataset.mode = "avg"
  const btnP90 = document.createElement("button")
  btnP90.className = "agg-btn"; btnP90.textContent = "Per 90"; btnP90.dataset.mode = "p90"
  const btnTot = document.createElement("button")
  btnTot.className = "agg-btn active"; btnTot.textContent = "Total"; btnTot.dataset.mode = "total"
  aggWrap.appendChild(btnAvg); aggWrap.appendChild(btnP90); aggWrap.appendChild(btnTot)
  row.appendChild(aggWrap)

  container.appendChild(row)

  container.value = { team: "All Nations", search: "", aggMode: "total" }
  function emit() { container.dispatchEvent(new Event("input", { bubbles: true })) }

  team.sel.addEventListener("change", () => {
    container.value = { ...container.value, team: team.sel.value }
    emit()
  })

  let searchTimer
  sInput.addEventListener("input", () => {
    clearTimeout(searchTimer)
    searchTimer = setTimeout(() => {
      container.value = { ...container.value, search: sInput.value.trim() }
      emit()
    }, 250)
  })

  for (const btn of [btnAvg, btnP90, btnTot]) {
    btn.addEventListener("click", () => {
      aggWrap.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = { ...container.value, aggMode: btn.dataset.mode }
      emit()
    })
  }

  // Expose nation-options refresher (same pattern as player-stats.qmd)
  container._updateTeams = (options) => {
    const currentVal = container.value.team
    while (team.sel.firstChild) team.sel.removeChild(team.sel.firstChild)
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt; o.textContent = opt
      team.sel.appendChild(o)
    }
    if (options.includes(currentVal)) {
      team.sel.value = currentVal
    } else {
      team.sel.value = "All Nations"
      container.value = { ...container.value, team: "All Nations" }
      container.dispatchEvent(new Event("input", { bubbles: true }))
    }
  }

  return container
}

teamFilter = filters.team
searchQuery = filters.search
aggMode = filters.aggMode
Show code
teamOptions = {
  if (!matchStats || matchStats.length === 0) return ["All Nations"]
  const teams = [...new Set(matchStats.map(d => d.team_name).filter(Boolean))].sort()
  return ["All Nations", ...teams]
}

{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateTeams) el._updateTeams(teamOptions)
}
Show code
asAtLabel = {
  if (!matchStats || matchStats.length === 0) return "World Cup 2026 · Awaiting the first matches"
  const dates = matchStats.map(d => d.match_date).filter(Boolean).sort()
  const latest = dates[dates.length - 1]
  const dt = new Date(String(latest).replace("Z", "") + "T12:00:00Z")
  const formatted = isNaN(dt.getTime())
    ? latest
    : dt.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
  return `World Cup 2026 · As at ${formatted}`
}

html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>${statsEsc(asAtLabel)}</strong></span>
  <span><a href="../blog/2026-04-24-understanding-panna/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 4 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
Show code
catDef = statDefs[category]
Show code
// ── Custom column picker (box-score metrics only) ────────────
viewof customCols = {
  const allMetrics = []
  for (const [key, def] of Object.entries(statDefs)) {
    if (key === "custom" || def.page || def.source !== "matchStats") continue
    for (const col of def.columns) {
      if (allMetrics.some(m => m.col === col)) continue
      allMetrics.push({ col, label: def.header[col] || col, cat: def.label })
    }
  }

  const MAX = 10
  const _lsKey = "_customCols_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _validCols = new Set(allMetrics.map(m => m.col))
  const _saved = (() => {
    try { const raw = window.localStorage.getItem(_lsKey); return raw ? JSON.parse(raw).filter(c => _validCols.has(c)).slice(0, MAX) : [] }
    catch (e) { return [] }
  })()
  const selected = new Set(_saved)

  const container = document.createElement("div")
  container.className = "custom-col-picker"

  const btn = document.createElement("button")
  btn.className = "custom-col-btn"
  btn.textContent = selected.size === 0 ? "Select columns..." : `${selected.size} column${selected.size > 1 ? "s" : ""} selected`
  container.appendChild(btn)

  const panel = document.createElement("div")
  panel.className = "custom-col-panel"
  panel.style.display = "none"
  container.appendChild(panel)

  const byCat = {}
  for (const m of allMetrics) {
    if (!byCat[m.cat]) byCat[m.cat] = []
    byCat[m.cat].push(m)
  }

  const checkboxes = []
  for (const [cat, metrics] of Object.entries(byCat)) {
    const group = document.createElement("div")
    group.className = "custom-col-group"
    const heading = document.createElement("div")
    heading.className = "custom-col-group-label"
    heading.textContent = cat
    group.appendChild(heading)

    for (const m of metrics) {
      const label = document.createElement("label")
      label.className = "custom-col-item"
      const cb = document.createElement("input")
      cb.type = "checkbox"
      cb.dataset.col = m.col
      if (selected.has(m.col)) cb.checked = true
      const span = document.createElement("span")
      span.textContent = m.label
      label.appendChild(cb)
      label.appendChild(span)
      group.appendChild(label)
      checkboxes.push(cb)

      cb.addEventListener("change", () => {
        if (cb.checked) selected.add(m.col)
        else selected.delete(m.col)
        for (const other of checkboxes) {
          if (!other.checked) other.disabled = selected.size >= MAX
        }
        btn.textContent = selected.size === 0 ? "Select columns..." : `${selected.size} column${selected.size > 1 ? "s" : ""} selected`
        container.value = [...selected]
        try { window.localStorage.setItem(_lsKey, JSON.stringify([...selected])) } catch (e) {}
        container.dispatchEvent(new Event("input", { bubbles: true }))
      })
    }
    panel.appendChild(group)
  }
  if (selected.size >= MAX) {
    for (const cb of checkboxes) if (!cb.checked) cb.disabled = true
  }

  btn.addEventListener("click", (e) => {
    e.stopPropagation()
    panel.style.display = panel.style.display === "none" ? "block" : "none"
  })
  const ac = new AbortController()
  document.addEventListener("click", (e) => {
    if (!container.contains(e.target)) panel.style.display = "none"
  }, { signal: ac.signal })
  invalidation.then(() => ac.abort())

  container.value = [...selected]
  return container
}
Show code
// ── Toggle custom picker visibility ──────────────────────────
{
  const picker = document.querySelector(".custom-col-picker")
  if (picker) picker.style.display = category === "custom" ? "" : "none"
}
Show code
// ── Effective catDef (custom tab builds headers/heatmap from picks) ──
effectiveDef = {
  if (category !== "custom" || !customCols || customCols.length === 0) return catDef
  const header = {}, heatmap = {}
  for (const col of customCols) {
    for (const [key, def] of Object.entries(statDefs)) {
      if (key === "custom" || def.page || def.source !== "matchStats") continue
      if (def.columns.includes(col)) {
        header[col] = def.header[col] || col
        if (def.heatmap[col]) heatmap[col] = def.heatmap[col]
        break
      }
    }
  }
  return { label: "Custom", columns: customCols, header, heatmap, sortCol: customCols[0] || null }
}
Show code
// ── Aggregate tournament rows to player level ────────────────
tableData = {
  if (!matchStats) return null
  if (matchStats.length === 0) return []
  if (category === "custom" && (!customCols || customCols.length === 0)) return []

  let games = matchStats

  if (teamFilter !== "All Nations") {
    games = games.filter(d => d.team_name === teamFilter)
  }

  // Listed-pos filter — player's tournament position from _playerCurrentMeta
  if (posFilter !== "All") {
    games = games.filter(d => {
      const m = _playerCurrentMeta.get(d.player_id)
      const listed = m && m.position
      return listed && posToGroup[listed] === posFilter
    })
  }

  // Group by player — strip computed % columns so they aren't summed (they're
  // derived from their base counts after aggregation, below).
  const statCols = effectiveDef.columns.filter(c => !c.endsWith("_pct"))
  const grouped = new Map()
  for (const g of games) {
    const key = g.player_id || g.player_name
    if (!grouped.has(key)) {
      grouped.set(key, { vals: {}, count: 0, mins: 0, player_name: g.player_name, team: g.team_name, position: g.position })
      for (const col of statCols) grouped.get(key).vals[col] = 0
    }
    const entry = grouped.get(key)
    entry.count++
    entry.mins += g.minsPlayed || 0
    if (g.player_name) entry.player_name = g.player_name
    if (g.team_name) entry.team = g.team_name
    for (const col of statCols) {
      const v = Number(g[col])
      if (!isNaN(v)) entry.vals[col] += v
    }
  }

  const result = []
  for (const [pid, entry] of grouped) {
    if (entry.count === 0) continue
    const meta = _playerCurrentMeta.get(pid)
    const isSubStat = entry.position === "Substitute" || entry.position === "Sub"
    const effectivePos = isSubStat
      ? ((meta && meta.position) || entry.position)
      : (entry.position || (meta && meta.position))
    const row = {
      player_name: entry.player_name || pid,
      team: entry.team,
      position: effectivePos,
      pos_group: posToGroup[effectivePos] || "",
      gp: entry.count,
      mins: entry.mins
    }
    for (const col of statCols) {
      if (aggMode === "avg") {
        row[col] = +(entry.vals[col] / entry.count).toFixed(3)
      } else if (aggMode === "p90") {
        row[col] = entry.mins > 0 ? +(entry.vals[col] / entry.mins * 90).toFixed(3) : 0
      } else {
        row[col] = entry.vals[col]
      }
    }
    // Computed % columns — from aggregated counts so the rate stays correct
    // under Total/Per Game/Per 90 (division is rate-preserving).
    const cols = effectiveDef.columns
    if (cols.includes("pass_pct"))
      row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : null
    if (cols.includes("tackles_won_pct"))
      row.tackles_won_pct = row.tackles > 0 ? Math.round(100 * row.tackles_won / row.tackles) : null
    if (cols.includes("aerials_won_pct")) {
      const aerTot = (row.aerials_won || 0) + (row.aerials_lost || 0)
      row.aerials_won_pct = aerTot > 0 ? Math.round(100 * row.aerials_won / aerTot) : null
    }
    if (cols.includes("duels_won_pct")) {
      const duelTot = (row.duels_won || 0) + (row.duels_lost || 0)
      row.duels_won_pct = duelTot > 0 ? Math.round(100 * row.duels_won / duelTot) : null
    }
    if (cols.includes("shots_on_target_pct"))
      row.shots_on_target_pct = row.shots > 0 ? Math.round(100 * row.shots_on_target / row.shots) : null
    result.push(row)
  }
  return result
}
Show code
// ── Render table ─────────────────────────────────────────────
{
  const maps = window.wcMaps

  // Fetch failure (probe or parse error) — distinct from "no rows yet".
  if (matchStats == null) {
    return html`<p class="text-muted">Data failed to load — try refreshing (see console for details).</p>`
  }
  // Shard not published / no tournament rows yet — the friendly launch state.
  if (matchStats.length === 0) {
    return html`<p class="text-muted">The tournament has just kicked off — per-match player stats appear here
      after the first games are played and the data pipeline runs. Check back after the opening matches.</p>`
  }

  if (category === "custom" && (!customCols || customCols.length === 0)) {
    return html`<p class="text-muted">Select up to 10 columns above to build your custom table.</p>`
  }

  // Search filter (player or nation, case-insensitive)
  let data = tableData || []
  if (searchQuery) {
    const q = searchQuery.toLowerCase()
    data = data.filter(d =>
      (d.player_name || "").toLowerCase().includes(q) ||
      (d.team || "").toLowerCase().includes(q))
  }
  if (data.length === 0) {
    return html`<p class="text-muted">No players match the current filters.</p>`
  }

  const def = effectiveDef

  const posBadge = (val) => {
    const grp = posToGroup[val] || ""
    const info = footballPosColors[grp] || { a: posAbbr[val] || val || "?", c: "#9ca3af" }
    const abbr = posAbbr[val] || val || "?"
    return `<span class="pos-badge" style="background:${info.c}18;color:${info.c};border:1px solid ${info.c}35">${statsEsc(abbr)}</span>`
  }

  const statCols = def.columns.filter(c => data[0] && data[0][c] !== undefined)
  const columns = ["player_name", "position", "gp", "mins", ...statCols]

  const header = {
    player_name: "Player",
    position: "Pos",
    gp: "GP",
    mins: "Mins",
    ...def.header,
  }

  const groups = [
    { label: "Player", span: 4 },
    { label: def.label, span: statCols.length },
  ]

  const _pctFmt = x => x != null ? x + "%" : ""
  const format = {
    mins: x => x?.toLocaleString() ?? "",
    pass_pct: _pctFmt,
    tackles_won_pct: _pctFmt,
    aerials_won_pct: _pctFmt,
    duels_won_pct: _pctFmt,
    shots_on_target_pct: _pctFmt
  }

  const mCols = def.mobileCols ? ["player_name", "position", ...def.mobileCols, "gp"] : null
  const tooltip = { ...def.tooltip, gp: "Games played", mins: "Total minutes played" }

  return statsTable(data, {
    columns,
    mobileCols: mCols,
    header,
    groups,
    format,
    tooltip,
    render: {
      // Player → profile page; nation sub-link (flag + name) → WC team page
      player_name: (v, row) => `<a href="player.html#name=${encodeURIComponent(v)}" class="player-link"><strong>${statsEsc(v)}</strong></a><span class="player-sub">${maps.teamLinkHtml(row.team)}</span>`,
      position: posBadge
    },
    heatmap: def.heatmap || {},
    heatmapData: data,
    filters: {
      ...(def.sortCol ? { [def.sortCol]: "range" } : {}),
      gp: "range",
      mins: "range"
    },
    sort: def.sortCol,
    reverse: true,
    rows: 25
  })
}
Show code
// ── Source attribution row ──────────────────────────────────
{
  const src = document.createElement("div"); src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/pannadata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "pannadata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Opta scrape · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = (asAtLabel || "Latest") + " · Filter by nation + position"
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"
  const { railBlock, btnTile, tableSource } = window.editorial

  // Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = asAtLabel
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Tournament box scores refresh on the daily "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "pannadata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline as games are played."))
  upd.appendChild(updP); inner.appendChild(upd)

  // By the Numbers — only once tournament rows exist
  if (matchStats && matchStats.length > 0) {
    const matches = new Set(matchStats.map(g => g.match_id).filter(Boolean)).size
    const players = new Set(matchStats.map(g => g.player_id || g.player_name)).size
    const nations = new Set(matchStats.map(g => g.team_name).filter(Boolean)).size
    const goals = new Map()
    for (const g of matchStats) {
      const k = g.player_name
      if (!k) continue
      goals.set(k, (goals.get(k) || 0) + (Number(g.goals) || 0))
    }
    const topScorer = [...goals.entries()].sort((a, b) => b[1] - a[1])[0]

    const btn = railBlock("By the Numbers")
    const grid = document.createElement("div"); grid.className = "btn-block"
    if (topScorer && topScorer[1] > 0) {
      grid.appendChild(btnTile(String(topScorer[1]), [
        { text: "Most goals", bold: true },
        { text: " · " + topScorer[0] }
      ]))
    }
    grid.appendChild(btnTile(String(matches), [
      { text: "Matches played", bold: true },
      { text: " · of 104" }
    ]))
    grid.appendChild(btnTile(players.toLocaleString(), [
      { text: "Players", bold: true },
      { text: " · who logged minutes" }
    ]))
    grid.appendChild(btnTile(String(nations), [
      { text: "Nations seen", bold: true },
      { text: " · of 48 qualified" }
    ]))
    btn.appendChild(grid); inner.appendChild(btn)
  }

  // About
  const about = railBlock("Stats vs Strength"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("This is the "))
  const s1 = document.createElement("strong"); s1.textContent = "per-match"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" view — what each player actually did at the tournament, game by game."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("For squad-level ratings going in, see "))
  const a2 = document.createElement("a")
  a2.href = "world-cup-strength.html"; a2.textContent = "Team Strength"
  p2.appendChild(a2); p2.appendChild(document.createTextNode("."))
  about.appendChild(p2)
  inner.appendChild(about)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "world-cup-simulator.html", title: "Simulator", meta: "Lock in results, watch odds shift" },
    { href: "world-cup-strength.html", title: "Team Strength", meta: "Tiento and seven rating systems" },
    { href: "world-cup-matches.html", title: "Match Predictions", meta: "All 72 group fixtures" },
    { href: "world-cup-2026.html", title: "World Cup Hub", meta: "Everything in one place" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  inner.appendChild(tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    sourceNote: "Opta scrape",
    license: "CC BY 4.0",
    hint: "Refreshes daily during the tournament"
  }))

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer