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
Skip to content

Football · The Panna Project · 15 Leagues

Football

Predictive ratings, match picks and league projections across 15 European competitions — powered by Panna, an open-source RAPM+SPM blend that nets xG created against xG conceded for every player on the same scale.

// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>After every matchday</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>Open source · <a href="https://github.com/peteowen1/panna" target="_blank" rel="noopener">panna</a></span>
</div>`
// Navigation cards with football-themed SVG icons
{
  const ns = "http://www.w3.org/2000/svg"
  const accent = "#5a9a7a"
  function svgIcon(children) {
    const s = document.createElementNS(ns, "svg")
    s.setAttribute("viewBox", "0 0 24 24"); s.setAttribute("width", "24"); s.setAttribute("height", "24")
    s.setAttribute("fill", "none"); s.setAttribute("stroke", accent)
    s.setAttribute("stroke-width", "1.5"); s.setAttribute("stroke-linecap", "round"); s.setAttribute("stroke-linejoin", "round")
    for (const c of children) {
      const el = document.createElementNS(ns, c[0])
      for (const [k, v] of Object.entries(c[1])) el.setAttribute(k, v)
      s.appendChild(el)
    }
    return s
  }

  const icons = {
    matches: () => svgIcon([["rect",{x:2,y:5,width:20,height:14,rx:2}],["line",{x1:12,y1:5,x2:12,y2:19}],["line",{x1:7,y1:10,x2:7,y2:14}],["line",{x1:17,y1:10,x2:17,y2:14}]]),
    leagues: () => svgIcon([["circle",{cx:6,cy:6,r:2,fill:accent,stroke:"none",opacity:"0.9"}],["line",{x1:11,y1:6,x2:20,y2:6}],["circle",{cx:6,cy:12,r:2,fill:accent,stroke:"none",opacity:"0.5"}],["line",{x1:11,y1:12,x2:18,y2:12}],["circle",{cx:6,cy:18,r:2,fill:accent,stroke:"none",opacity:"0.25"}],["line",{x1:11,y1:18,x2:16,y2:18}]]),
    sims: () => svgIcon([["polyline",{points:"4 18 8 14 12 16 16 10 20 6"}],["circle",{cx:20,cy:6,r:2,fill:accent,stroke:"none"}],["line",{x1:4,y1:4,x2:4,y2:20}],["line",{x1:4,y1:20,x2:20,y2:20}]]),
    "player-ratings": () => svgIcon([["path",{d:"M12 20a8 8 0 1 1 8-8"}],["path",{d:"M12 20a8 8 0 0 1-8-8"}],["line",{x1:12,y1:12,x2:16,y2:8}],["circle",{cx:12,cy:12,r:1.5,fill:accent,stroke:"none"}]]),
    "player-stats": () => svgIcon([["line",{x1:4,y1:20,x2:20,y2:20}],["rect",{x:5,y:13,width:3,height:7,rx:0.5}],["rect",{x:10.5,y:9,width:3,height:11,rx:0.5}],["rect",{x:16,y:4,width:3,height:16,rx:0.5}]]),
    "team-ratings": () => svgIcon([["path",{d:"M12 2L4 6v5c0 5.25 3.4 10.2 8 12 4.6-1.8 8-6.75 8-12V6l-8-4z"}],["polyline",{points:"9 12 11 14 15 10"}]]),
    "team-stats": () => svgIcon([["rect",{x:3,y:3,width:18,height:18,rx:2}],["line",{x1:3,y1:9,x2:21,y2:9}],["line",{x1:3,y1:15,x2:21,y2:15}],["line",{x1:9,y1:3,x2:9,y2:21}],["line",{x1:15,y1:3,x2:15,y2:21}]]),
    defs: () => svgIcon([["path",{d:"M4 4h16v16H4z",rx:2}],["line",{x1:8,y1:8,x2:16,y2:8}],["line",{x1:8,y1:12,x2:14,y2:12}],["line",{x1:8,y1:16,x2:12,y2:16}]]),
    "world-cup": () => svgIcon([["path",{d:"M7 3h10v6a5 5 0 1 1-10 0V3z"}],["path",{d:"M5 5H3a2 2 0 0 0 0 4h2"}],["path",{d:"M19 5h2a2 2 0 0 1 0 4h-2"}],["path",{d:"M12 14v4"}],["path",{d:"M6 21h12"}]])
  }

  const pages = [
    { title: "Matches", desc: "Results, predictions & win probabilities", href: "matches", icon: "matches" },
    { title: "Leagues", desc: "Standings, form guide, xG & season projections", href: "leagues", icon: "leagues" },
    { title: "World Cup 2026", desc: "Title race, group projections, bracket & team strength", href: "world-cup-2026", icon: "world-cup" },
    { title: "Player Ratings", desc: "Predictive Panna ratings across 15 leagues", href: "player-ratings", icon: "player-ratings" },
    { title: "Player Stats", desc: "Per-match box scores & xG values", href: "player-stats", icon: "player-stats" },
    { title: "Team Ratings", desc: "Squad strength relative to league average", href: "team-ratings", icon: "team-ratings" },
    { title: "Team Stats", desc: "Aggregated team match statistics", href: "team-stats", icon: "team-stats" },
    { title: "Definitions", desc: "Glossary of Panna model concepts & stats", href: "definitions", icon: "defs" }
  ]

  const grid = document.createElement("div")
  grid.className = "afl-dash-nav-grid"
  for (const p of pages) {
    const card = document.createElement("a")
    card.href = p.href
    card.className = "afl-dash-nav-card"
    const iconSpan = document.createElement("span")
    iconSpan.className = "afl-dash-nav-icon"
    iconSpan.appendChild(icons[p.icon]())
    const body = document.createElement("div")
    const title = document.createElement("div")
    title.className = "afl-dash-nav-title"
    title.textContent = p.title
    const desc = document.createElement("div")
    desc.className = "afl-dash-nav-desc"
    desc.textContent = p.desc
    body.appendChild(title)
    body.appendChild(desc)
    card.appendChild(iconSpan)
    card.appendChild(body)
    grid.appendChild(card)
  }
  return grid
}
// Dashboard grid: league table + recent results (single cell to avoid OJS stall)
{
  const statsEsc = window.statsEsc
  const base_url = window.DATA_BASE_URL

  // Load fixtures
  let _fixtureData = []
  try {
    const res = await fetch(base_url + "football/fixtures.json")
    if (!res.ok) { console.warn("[index] Fixture fetch HTTP", res.status); }
    else { const data = await res.json(); _fixtureData = data.matches || [] }
  } catch (e) { console.warn("[index] Fixture load failed:", e) }

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

  // Mini league table (Premier League) moved to side-rail; this cell now
  // returns just matchesPanel for main-rail.
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
  const crest = window.footballMaps.teamCrest
  const shortName = window.footballMaps.normalizeXG

  // engFinished was previously defined inside the ladder-building section that
  // moved to the side-rail; recreate it here for the matches loop below.
  const engFinished = _fixtureData.filter(f => f.league === "ENG" && f.status === "FINISHED" && f.homeScore != null)

  // Recent Results
  const matchesPanel = document.createElement("div")
  matchesPanel.className = "afl-dash-panel afl-dash-matches"

  const matchesH = document.createElement("h2")
  matchesH.className = "afl-dash-panel-title"
  matchesH.textContent = "Recent Results"
  const matchesLink = document.createElement("a")
  matchesLink.href = "matches"
  matchesLink.className = "afl-dash-panel-link"
  matchesLink.textContent = "All matches →"
  matchesH.appendChild(matchesLink)
  matchesPanel.appendChild(matchesH)

  const recent = [...engFinished]
    .sort((a, b) => (b.date || "").localeCompare(a.date || ""))
    .slice(0, 10)

  for (const f of recent) {
    const homeWin = f.homeScore > f.awayScore
    const awayWin = f.awayScore > f.homeScore
    const dateKey = (f.date || "").slice(0, 10)

    const card = document.createElement("a")
    card.href = `match#league=${encodeURIComponent(f.league)}&date=${dateKey}&home=${encodeURIComponent(f.homeTeam)}&away=${encodeURIComponent(f.awayTeam)}`
    card.className = "afl-dash-match-card"

    const homeDiv = document.createElement("div")
    homeDiv.className = "afl-dash-match-team" + (homeWin ? " winner" : "")
    const hCrest = crest(f.homeTeam)
    if (hCrest) {
      const img = document.createElement("img")
      img.src = hCrest
      img.className = "afl-dash-team-badge"
      img.style.cssText = "width:18px;height:18px;object-fit:contain"
      homeDiv.appendChild(img)
    }
    const hName = document.createElement("span"); hName.textContent = shortName(f.homeTeam); homeDiv.appendChild(hName)

    const scoreDiv = document.createElement("div")
    scoreDiv.className = "afl-dash-match-score"
    scoreDiv.textContent = f.homeScore + " – " + f.awayScore

    const awayDiv = document.createElement("div")
    awayDiv.className = "afl-dash-match-team" + (awayWin ? " winner" : "")
    const aCrest = crest(f.awayTeam)
    if (aCrest) {
      const img = document.createElement("img")
      img.src = aCrest
      img.className = "afl-dash-team-badge"
      img.style.cssText = "width:18px;height:18px;object-fit:contain"
      awayDiv.appendChild(img)
    }
    const aName = document.createElement("span"); aName.textContent = shortName(f.awayTeam); awayDiv.appendChild(aName)

    card.appendChild(homeDiv); card.appendChild(scoreDiv); card.appendChild(awayDiv)
    matchesPanel.appendChild(card)
  }

  return matchesPanel
}
// Editorial side rail: mini Premier League table + About + Read Next.
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock } = window.editorial

  // ── Block 1: Mini Premier League standings (was the LEFT half of old dashboard) ──
  const base_url = window.DATA_BASE_URL
  let _fixtureData = []
  try {
    const res = await fetch(base_url + "football/fixtures.json")
    if (res.ok) { const data = await res.json(); _fixtureData = data.matches || [] }
  } catch (e) { console.warn("[index sidebar] Fixture load failed:", e) }

  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
  const crest = window.footballMaps.teamCrest
  const shortName = window.footballMaps.normalizeXG

  const ladderBlock = railBlock("Premier League")
  const engFinished = _fixtureData.filter(f => f.league === "ENG" && f.status === "FINISHED" && f.homeScore != null)

  if (engFinished.length > 0) {
    const standings = {}
    for (const f of engFinished) {
      for (const t of [f.homeTeam, f.awayTeam]) {
        if (!standings[t]) standings[t] = { team: t, w: 0, d: 0, l: 0, gf: 0, ga: 0 }
      }
      const h = standings[f.homeTeam], a = standings[f.awayTeam]
      h.gf += f.homeScore; h.ga += f.awayScore
      a.gf += f.awayScore; a.ga += f.homeScore
      if (f.homeScore > f.awayScore) { h.w++; a.l++ }
      else if (f.homeScore < f.awayScore) { a.w++; h.l++ }
      else { h.d++; a.d++ }
    }
    const sorted = Object.values(standings).sort((a, b) => {
      const ptsA = a.w * 3 + a.d, ptsB = b.w * 3 + b.d
      if (ptsB !== ptsA) return ptsB - ptsA
      return (b.gf - b.ga) - (a.gf - a.ga)
    })

    const table = document.createElement("table")
    table.className = "afl-dash-ladder-table"
    const thead = document.createElement("thead")
    const headRow = document.createElement("tr")
    for (const h of ["Team", "W", "D", "L", "Pts", "GD"]) {
      const th = document.createElement("th"); th.textContent = h; headRow.appendChild(th)
    }
    thead.appendChild(headRow); table.appendChild(thead)

    const tbody = document.createElement("tbody")
    for (const t of sorted) {
      const tr = document.createElement("tr")
      const teamTd = document.createElement("td")
      const teamLink = document.createElement("a")
      teamLink.href = `team#team=${encodeURIComponent(t.team)}`
      teamLink.className = "afl-dash-team-link"
      const crestUrl = crest(t.team)
      if (crestUrl) {
        const img = document.createElement("img")
        img.src = crestUrl
        img.className = "afl-dash-team-badge"
        img.style.cssText = "width:18px;height:18px;object-fit:contain"
        teamLink.appendChild(img)
      }
      const nameSpan = document.createElement("span")
      nameSpan.textContent = shortName(t.team)
      teamLink.appendChild(nameSpan)
      teamTd.appendChild(teamLink)
      const w = document.createElement("td"); w.textContent = t.w
      const d = document.createElement("td"); d.textContent = t.d
      const l = document.createElement("td"); l.textContent = t.l
      const pts = document.createElement("td"); pts.className = "ladder-pts"; pts.textContent = t.w * 3 + t.d
      const gd = document.createElement("td"); gd.textContent = (t.gf - t.ga > 0 ? "+" : "") + (t.gf - t.ga)
      tr.appendChild(teamTd); tr.appendChild(w); tr.appendChild(d); tr.appendChild(l); tr.appendChild(pts); tr.appendChild(gd)
      tbody.appendChild(tr)
    }
    table.appendChild(tbody); ladderBlock.appendChild(table)

    const link = document.createElement("a")
    link.href = "leagues"
    link.className = "afl-dash-panel-link"
    link.style.cssText = "display: block; margin-top: 0.6rem;"
    link.textContent = "All 15 leagues & projections →"
    ladderBlock.appendChild(link)
  }
  inner.appendChild(ladderBlock)

  // ── Block 2: About Panna ──
  const about = railBlock("About Football Coverage"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Independent football analytics built on "))
  const s1 = document.createElement("strong"); s1.textContent = "Panna"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" — a RAPM+SPM blend that nets xG created against xG conceded, controlling for team-mates, opponents, and minutes."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("Ratings refresh after each matchday; live win-probability polls every minute during fixtures."))
  about.appendChild(p2)
  inner.appendChild(about)

  // ── Block 3: Where to start ──
  const read = railBlock("Where to Start")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "player-ratings", title: "Player Ratings", meta: "Who's actually moving the scoreboard" },
    { href: "matches", title: "Matches", meta: "This matchday's picks" },
    { href: "leagues", title: "Leagues & Sims", meta: "Projected final standings" },
    { href: "../blog/2026-04-24-understanding-panna/", title: "Understanding Panna", meta: "Methodology · Blog" }
  ]
  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)

  return inner
}

How Panna works

Traditional football stats tell you what a player did. Panna ratings try to answer a harder question: how much did it actually matter?

The core idea is plus-minus — when a player is on the pitch, does their team create more and concede less than expected? But raw plus-minus is noisy (your numbers look great if you play alongside Messi), so the model controls for the quality of every other player on the field. Think of it as isolating your contribution from everyone else’s.

For players without enough minutes to get a reliable plus-minus estimate, the model falls back on box-score stats — things like key passes, interceptions, and progressive carries — that tend to predict plus-minus well. The final Panna rating blends both signals, leaning more on the box score for bit-part players and more on plus-minus for regulars.

The rating splits into offense and defense. Positive offense means you’re helping create goals. Negative defense means you’re preventing them. Add them together and you get the overall picture.

 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer