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 ratings are in early development. Data coverage and page features are actively being improved.

Football

Player ratings and match predictions powered by the panna R package.

// 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}]])
  }

  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: "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``

  const el = document.createElement("div")
  el.className = "afl-dash-grid"

  // Ensure crests are loaded
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
  const crest = window.footballMaps.teamCrest
  const shortName = window.footballMaps.normalizeXG

  // ── LEFT: Mini League Table (Premier League) ──
  const ladderPanel = document.createElement("div")
  ladderPanel.className = "afl-dash-panel afl-dash-ladder"

  const ladderH = document.createElement("h2")
  ladderH.className = "afl-dash-panel-title"
  ladderH.textContent = "Premier League"
  const ladderLink = document.createElement("a")
  ladderLink.href = "leagues"
  ladderLink.className = "afl-dash-panel-link"
  ladderLink.textContent = "All leagues →"
  ladderH.appendChild(ladderLink)
  ladderPanel.appendChild(ladderH)

  // Compute standings from finished ENG matches
  const engFinished = _fixtureData.filter(f => f.league === "ENG" && f.status === "FINISHED" && f.homeScore != null)
  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); ladderPanel.appendChild(table)

  // ── RIGHT: 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)
  }

  el.appendChild(ladderPanel); el.appendChild(matchesPanel)
  return el
}

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

Privacy | Disclaimer