In The Game
  • Home
  • Blog
  • AFL
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Matches
    • Ladder
    • Definitions
  • Football
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Leagues
    • Matches
    • Definitions
  • About
Skip to content

Football Leagues

League standings computed from match results across European football

Football > Leagues

Standings and season projections for domestic leagues and UEFA cup competitions. More points = higher in table. Form = last 5 results. xG = expected goals from shot quality. Higher % = more likely (projected view).

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

// Load fixtures from R2 static JSON directly (worker may return incomplete data)
fixtureRaw = {
  try {
    const res = await fetch(window.DATA_BASE_URL + "football/fixtures.json")
    if (!res.ok) throw new Error("HTTP " + res.status)
    return await res.json()
  } catch (e) {
    console.warn("[leagues] R2 fixture load failed, trying worker:", e)
    return await window.fetchFixtures("football")
  }
}
allMatches = fixtureRaw ? (fixtureRaw.matches || []) : []

// Load league-level xG data (pre-aggregated from match-shots pipeline)
leagueXG = {
  try {
    return await window.fetchParquet(window.DATA_BASE_URL + "football/league-xg.parquet")
  } catch (e) {
    console.warn("[leagues] league-xg.parquet not available:", e)
    return null
  }
}

leagueNames = window.footballMaps.leagueNames
leagueOrder = ["ENG", "ESP", "GER", "ITA", "FRA", "NED", "POR", "ENG2", "UCL", "UEL", "UECL"]
cupCodes = new Set(["UCL", "UEL", "UECL"])

// Load simulations for Projected view (domestic + cups)
simData = await window.fetchParquet(window.DATA_BASE_URL + "football/simulations.parquet")
cupSimData = {
  try {
    return await window.fetchParquet(window.DATA_BASE_URL + "football/cup-simulations.parquet")
  } catch (e) {
    console.warn("[leagues] cup-simulations.parquet not available:", e)
    return null
  }
}
Show code
leagueOptions = {
  // Domestic leagues: show if fixture data has finished matches
  const fixtureLeagues = (!allMatches || allMatches.length === 0)
    ? [] : [...new Set(allMatches.filter(m => m.status === "FINISHED").map(m => m.league))]
  // Cup competitions: show if simulation data exists (fixtures may not be available)
  const cupLeagues = cupSimData ? [...new Set(cupSimData.map(d => d.league))] : []
  const available = new Set([...fixtureLeagues, ...cupLeagues])
  return leagueOrder
    .filter(code => available.has(code))
    .map(code => ({ value: code, label: leagueNames[code] || code }))
}

viewof selectedLeague = {
  if (allMatches.length === 0) return html`<p class="text-muted">Loading fixture data…</p>`

  const makeSelect = window.footballMaps.makeSelect

  const defaultCode = (leagueOptions.find(d => d.value === "ENG") || leagueOptions[0] || {}).value || ""

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

  const codes = leagueOptions.map(d => d.value)
  const { wrap, sel } = makeSelect(codes, defaultCode, "League", x => leagueNames[x] || x)
  row.appendChild(wrap)
  bar.appendChild(row)

  // Value is the full {value, label} object to match downstream usage
  const findOpt = (code) => leagueOptions.find(d => d.value === code) || leagueOptions[0]
  bar.value = findOpt(defaultCode)
  sel.addEventListener("change", () => {
    bar.value = findOpt(sel.value)
    bar.dispatchEvent(new Event("input", { bubbles: true }))
  })

  return bar
}
Show code
// Context bar
{
  if (!selectedLeague) return html``
  const leagueName = leagueNames[selectedLeague.value] || selectedLeague.value
  if (!fixtureRaw) {
    return html`<p class="text-muted" style="font-size:0.85rem; margin-bottom:0.5rem">${leagueName}</p>`
  }
  const updated = fixtureRaw.updated ? new Date(fixtureRaw.updated).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : ""
  return html`<p class="text-muted" style="font-size:0.85rem; margin-bottom:0.5rem">
    ${fixtureRaw.season || ""} ${leagueName} · Updated ${updated}
  </p>`
}
Show code
// Current / Projected toggle
viewof leagueView = {
  const wrap = document.createElement("div")
  wrap.className = "epv-toggle"
  wrap.value = "Current"
  for (const label of ["Current", "Projected"]) {
    const btn = document.createElement("button")
    btn.className = "epv-toggle-btn" + (label === "Current" ? " active" : "")
    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
// Compute standings from finished matches
standings = {
  if (!allMatches || !selectedLeague) return null
  const finished = allMatches.filter(m =>
    m.league === selectedLeague.value && m.status === "FINISHED" &&
    m.homeScore != null && m.awayScore != null
  )
  if (finished.length === 0) return null

  const teams = new Map()
  const getTeam = (name) => {
    if (!teams.has(name)) teams.set(name, {
      team: name, p: 0, w: 0, d: 0, l: 0, gf: 0, ga: 0, form: []
    })
    return teams.get(name)
  }

  // Sort by date so form[] is chronological
  const sorted = [...finished].sort((a, b) => (a.date || "").localeCompare(b.date || ""))

  for (const m of sorted) {
    const h = getTeam(m.homeTeam)
    const a = getTeam(m.awayTeam)
    h.p++; a.p++
    h.gf += m.homeScore; h.ga += m.awayScore
    a.gf += m.awayScore; a.ga += m.homeScore

    const matchRef = { league: m.league, date: m.date, home: m.homeTeam, away: m.awayTeam, hs: m.homeScore, as: m.awayScore }
    if (m.homeScore > m.awayScore) {
      h.w++; a.l++; h.form.push({ r: "W", ...matchRef }); a.form.push({ r: "L", ...matchRef })
    } else if (m.homeScore < m.awayScore) {
      a.w++; h.l++; h.form.push({ r: "L", ...matchRef }); a.form.push({ r: "W", ...matchRef })
    } else {
      h.d++; a.d++; h.form.push({ r: "D", ...matchRef }); a.form.push({ r: "D", ...matchRef })
    }
  }

  // Enrich with xG from pre-aggregated league-xg.parquet
  // Opta uses short names ("Arsenal") while fixtures use "Arsenal FC" — normalize by stripping suffixes
  const leagueCode = selectedLeague.value
  const xgData = leagueXG
    ? leagueXG.filter(d => d.league === leagueCode)
    : []

  // Build xG lookup using centralised normalizer from football-maps.js
  const normXG = window.footballMaps.normalizeXG
  const xgMap = new Map()
  for (const d of xgData) xgMap.set(normXG(d.team_name).toLowerCase(), d)

  const rows = [...teams.values()].map(t => {
    const x = xgMap.get(normXG(t.team).toLowerCase()) || {}
    return {
      ...t,
      gd: t.gf - t.ga,
      pts: t.w * 3 + t.d,
      xgf: x.xgf ?? null,
      xga: x.xga ?? null,
      xgd: x.xgd ?? null,
      form5: t.form.slice(-5)
    }
  })

  rows.sort((a, b) => b.pts - a.pts || b.gd - a.gd || b.gf - a.gf)
  rows.forEach((r, i) => r.pos = i + 1)
  return rows
}
Show code
// Standings table (Current view)
{
  if (leagueView !== "Current") return html``
  if (!standings || standings.length === 0) {
    return html`<p class="text-muted">No results available for this league yet.</p>`
  }
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()

  const teamRender = window.footballMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)

  const formRender = (val) => {
    if (!val || !val.length) return ""
    const colors = { W: "#5a9a7a", D: "#fbbf24", L: "#c4734a" }
    return val.map(f => {
      const url = `match.html#league=${encodeURIComponent(f.league)}&date=${encodeURIComponent(f.date)}&home=${encodeURIComponent(f.home)}&away=${encodeURIComponent(f.away)}`
      const tip = `${f.home} ${f.hs}–${f.as} ${f.away}`
      return `<a href="${url}" class="form-badge" style="background:${colors[f.r]}22;color:${colors[f.r]};border-color:${colors[f.r]}44;text-decoration:none" data-tip="${statsEsc(tip)}">${f.r}</a>`
    }).join("")
  }

  const gdRender = (v) => {
    if (v == null) return ""
    return v > 0 ? "+" + v : String(v)
  }

  const hasXG = standings.some(d => d.xgf != null)

  const columns = hasXG
    ? ["pos", "team", "p", "w", "d", "l", "gf", "ga", "gd", "pts", "xgf", "xga", "xgd", "form5"]
    : ["pos", "team", "p", "w", "d", "l", "gf", "ga", "gd", "pts", "form5"]

  const header = hasXG
    ? { pos: "#", team: "Team", p: "P", w: "W", d: "D", l: "L", gf: "GF", ga: "GA", gd: "GD", pts: "Pts", xgf: "xGF", xga: "xGA", xgd: "xGD", form5: "Form" }
    : { pos: "#", team: "Team", p: "P", w: "W", d: "D", l: "L", gf: "GF", ga: "GA", gd: "GD", pts: "Pts", form5: "Form" }

  const groups = hasXG
    ? [{ label: "", span: 2 }, { label: "Results", span: 4 }, { label: "Goals", span: 3 }, { label: "", span: 1 }, { label: "Expected (xG)", span: 3 }, { label: "", span: 1 }]
    : [{ label: "", span: 2 }, { label: "Results", span: 4 }, { label: "Goals", span: 3 }, { label: "", span: 1 }, { label: "", span: 1 }]

  const heatmap = hasXG
    ? { pts: "high-good", gd: "diverging", xgd: "diverging" }
    : { pts: "high-good", gd: "diverging" }

  return statsTable(standings, {
    columns, header, groups, heatmap,
    render: { team: teamRender, form5: formRender, gd: gdRender, xgd: gdRender },
    sort: "pos",
    rows: 25
  })
}
Show code
// Recent results (Current view)
{
  if (leagueView !== "Current") return html``
  if (!allMatches || !selectedLeague) return html``

  const finished = allMatches
    .filter(m => m.league === selectedLeague.value && m.status === "FINISHED" && m.homeScore != null && m.awayScore != null)
    .sort((a, b) => (b.date || "").localeCompare(a.date || ""))
    .slice(0, 15)

  if (finished.length === 0) return html``

  const formatDate = (iso) => {
    if (!iso) return ""
    const d = new Date(iso)
    return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" })
  }

  // Group by date
  const groups = new Map()
  for (const m of finished) {
    const dateKey = (m.date || "").slice(0, 10)
    if (!groups.has(dateKey)) groups.set(dateKey, [])
    groups.get(dateKey).push(m)
  }

  const el = document.createElement("div")
  el.className = "recent-results"

  const h3 = document.createElement("h3")
  h3.textContent = "Recent Results"
  h3.style.marginTop = "1.5rem"
  el.appendChild(h3)

  for (const [dateKey, matches] of groups) {
    const dateHeader = document.createElement("div")
    dateHeader.className = "recent-results-date"
    dateHeader.textContent = formatDate(dateKey)
    el.appendChild(dateHeader)

    for (const m of matches) {
      const dateKey = (m.date || "").slice(0, 10)
      const matchHref = `match.html#league=${encodeURIComponent(m.league)}&date=${dateKey}&home=${encodeURIComponent(m.homeTeam)}&away=${encodeURIComponent(m.awayTeam)}`

      const link = document.createElement("a")
      link.href = matchHref
      link.className = "recent-result-link"
      link.style.textDecoration = "none"
      link.style.color = "inherit"

      const row = document.createElement("div")
      row.className = "recent-result-row"

      const homeWin = m.homeScore > m.awayScore
      const awayWin = m.awayScore > m.homeScore

      const homeName = document.createElement("span")
      homeName.className = homeWin ? "winner" : ""
      homeName.textContent = m.homeTeam
      row.appendChild(homeName)

      const score = document.createElement("span")
      score.className = "result-score"
      score.textContent = ` ${m.homeScore} – ${m.awayScore} `
      row.appendChild(score)

      const awayName = document.createElement("span")
      awayName.className = awayWin ? "winner" : ""
      awayName.textContent = m.awayTeam
      row.appendChild(awayName)

      link.appendChild(row)
      el.appendChild(link)
    }
  }

  return el
}
Show code
// Projected Standings (from simulations)
{
  if (leagueView !== "Projected") return html``

  const isCup = cupCodes.has(selectedLeague.value)
  const sourceData = isCup ? cupSimData : simData
  if (!sourceData || !selectedLeague) return html`<p class="text-muted">Simulation data not available.</p>`

  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()

  const leagueData = sourceData.filter(d => d.league === selectedLeague.value)
  if (leagueData.length === 0) return html`<p class="text-muted">No simulation data for this league.</p>`

  const pctFmt = x => x != null ? (x * 100).toFixed(1) + "%" : ""

  // Join current standings from fixture data (null if no finished matches in this league)
  const currentMap = new Map()
  if (standings) {
    for (const s of standings) currentMap.set(s.team, s)
  }

  // Compute projected totals (current actuals + mean simulated remaining)
  const enriched = leagueData.map(d => {
    const curr = currentMap.get(d.team) || {}
    const curPts = curr.pts ?? d.current_points ?? 0
    const curGd = curr.gd ?? d.current_gd ?? 0
    const curGp = curr.p ?? d.games_played ?? null
    return {
      ...d,
      games_played: curGp,
      current_points: curPts,
      current_gd: curGd,
      proj_total_pts: curPts + (d.avg_points || 0),
      proj_total_gd: curGd + (d.avg_gd || 0),
    }
  })

  const teamRender = window.footballMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)

  if (isCup) {
    // Cup-specific projected view: league phase position + knockout progression
    return statsTable(enriched, {
      columns: [
        "team", "games_played", "current_points", "current_gd",
        "avg_position", "auto_r16_pct", "playoff_pct", "eliminated_league_pct",
        "r16_pct", "qf_pct", "sf_pct", "final_pct", "winner_pct"
      ],
      mobileCols: ["team", "current_points", "avg_position", "r16_pct", "winner_pct"],
      header: {
        team: "Team", games_played: "GP", current_points: "Pts", current_gd: "GD",
        avg_position: "Pos", auto_r16_pct: "Auto", playoff_pct: "Playoff", eliminated_league_pct: "Out",
        r16_pct: "R16", qf_pct: "QF", sf_pct: "SF", final_pct: "Final", winner_pct: "Win"
      },
      groups: [
        { label: "", span: 1 }, { label: "Current", span: 3 },
        { label: "League Phase", span: 4 }, { label: "Knockout Probability", span: 5 }
      ],
      format: {
        current_gd: x => x != null ? (x > 0 ? "+" : "") + x : "",
        avg_position: x => x?.toFixed(1) ?? "",
        auto_r16_pct: pctFmt, playoff_pct: pctFmt, eliminated_league_pct: pctFmt,
        r16_pct: pctFmt, qf_pct: pctFmt, sf_pct: pctFmt, final_pct: pctFmt, winner_pct: pctFmt
      },
      render: { team: teamRender },
      heatmap: {
        avg_position: "low-good",
        auto_r16_pct: "high-good", playoff_pct: "high-good", eliminated_league_pct: "low-good",
        r16_pct: "high-good", qf_pct: "high-good", sf_pct: "high-good", final_pct: "high-good", winner_pct: "high-good"
      },
      tooltip: {
        games_played: "League phase games played", current_points: "Current league phase points", current_gd: "Current goal difference",
        avg_position: "Average league phase finishing position",
        auto_r16_pct: "Probability of finishing top 8 (automatic R16 qualification)",
        playoff_pct: "Probability of finishing 9th-24th (playoff round)",
        eliminated_league_pct: "Probability of elimination in league phase (25th-36th)",
        r16_pct: "Probability of reaching the Round of 16",
        qf_pct: "Probability of reaching the Quarter-Finals",
        sf_pct: "Probability of reaching the Semi-Finals",
        final_pct: "Probability of reaching the Final",
        winner_pct: "Probability of winning the competition"
      },
      sort: "winner_pct",
      reverse: true,
      rows: 36
    })
  }

  // Domestic league projected view
  return statsTable(enriched, {
    columns: [
      "team", "games_played", "current_points", "current_gd",
      "proj_total_pts", "proj_total_gd", "avg_position",
      "title_pct", "top_4_pct", "top_half_pct", "bottom_3_pct"
    ],
    mobileCols: ["team", "current_points", "proj_total_pts", "title_pct", "top_4_pct"],
    header: {
      team: "Team", games_played: "GP", current_points: "Pts", current_gd: "GD",
      proj_total_pts: "Pts", proj_total_gd: "GD", avg_position: "Pos",
      title_pct: "Title", top_4_pct: "Top 4", top_half_pct: "Top Half", bottom_3_pct: "Bottom 3"
    },
    groups: [
      { label: "", span: 1 }, { label: "Current", span: 3 },
      { label: "Projected", span: 3 }, { label: "Probabilities", span: 4 }
    ],
    format: {
      current_gd: x => x != null ? (x > 0 ? "+" : "") + x : "",
      proj_total_pts: x => x?.toFixed(0) ?? "",
      proj_total_gd: x => x != null ? (x > 0 ? "+" : "") + x.toFixed(0) : "",
      avg_position: x => x?.toFixed(1) ?? "",
      title_pct: pctFmt, top_4_pct: pctFmt, top_half_pct: pctFmt, bottom_3_pct: pctFmt
    },
    render: { team: teamRender },
    heatmap: {
      proj_total_pts: "high-good", avg_position: "low-good",
      title_pct: "high-good", top_4_pct: "high-good", top_half_pct: "high-good", bottom_3_pct: "low-good"
    },
    tooltip: {
      games_played: "Games played so far", current_points: "Current points", current_gd: "Current goal difference",
      proj_total_pts: "Projected final points (current + simulated remaining)",
      proj_total_gd: "Projected final goal difference",
      avg_position: "Average finishing position from simulations",
      title_pct: "Probability of winning the league",
      top_4_pct: "Probability of finishing in top 4",
      top_half_pct: "Probability of finishing in top half",
      bottom_3_pct: "Probability of finishing in bottom 3 (relegation zone)"
    },
    sort: "proj_total_pts",
    reverse: true,
    rows: 25
  })
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer