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

Football Team Ratings

Skip to content

Football > Team Ratings

Football · The Panna Project · Squad Strength

Which clubs have the deepest squads?

Team Panna sums the top 20 players’ ratings per club, then centres the result inside each league. The headline: who’s stacked, who’s thin, and where the gulf between top-of-table and relegation lives.

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

football_data = window.fetchParquet(window.DATA_BASE_URL + "football/ratings.parquet")
Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>Daily</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
allTeamRatings = {
  if (!football_data) return null
  const byTeam = new Map()
  for (const p of football_data) {
    const k = p.league + "|" + p.team
    if (!byTeam.has(k)) byTeam.set(k, { team: p.team, league: p.league, players: [] })
    byTeam.get(k).players.push(p)
  }
  const TOP_N = 20
  const teams = []
  for (const [, g] of byTeam) {
    g.players.sort((a, b) => (b.panna ?? 0) - (a.panna ?? 0))
    const top = g.players.slice(0, TOP_N)
    const panna = top.reduce((s, p) => s + (p.panna ?? 0), 0)
    const offense = top.reduce((s, p) => s + (p.offense ?? 0), 0)
    const defense = top.reduce((s, p) => s + (p.defense ?? 0), 0)
    const best = g.players[0]
    teams.push({
      team: g.team, league: g.league, panna, offense, defense,
      n_players: g.players.length,
      top_player: best?.player_name ?? "",
      top_panna: best?.panna ?? 0
    })
  }
  // Relative to league average within each league
  const byLeague = new Map()
  for (const t of teams) {
    if (!byLeague.has(t.league)) byLeague.set(t.league, [])
    byLeague.get(t.league).push(t)
  }
  for (const leagueTeams of byLeague.values()) {
    const cols = ["panna", "offense", "defense"]
    const n = leagueTeams.length
    if (n === 0) continue
    const avgs = {}
    for (const c of cols) avgs[c] = leagueTeams.reduce((s, t) => s + t[c], 0) / n
    for (const t of leagueTeams) {
      for (const c of cols) t[c] = t[c] - avgs[c]
    }
  }
  return teams.sort((a, b) => b.panna - a.panna).map((t, i) => ({ rank: i + 1, ...t }))
}
Show code
viewof leagueFilter = {
  if (!football_data) return html`<p></p>`
  const leagues = [...new Set(football_data.map(d => d.league).filter(Boolean))].sort()
  const options = ["All Leagues", ...leagues]
  const display = new Map(options.map(l => [l, l.replace(/_/g, " ")]))

  const makeSelect = window.footballMaps.makeSelect

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

  const { wrap, sel } = makeSelect(options, "All Leagues", "League", x => display.get(x) || x)
  row.appendChild(wrap)
  bar.appendChild(row)

  bar.value = "All Leagues"
  sel.addEventListener("change", () => {
    bar.value = sel.value
    bar.dispatchEvent(new Event("input", { bubbles: true }))
  })

  return bar
}
Show code
footballTeamData = {
  if (!allTeamRatings || allTeamRatings.length === 0) return null
  return leagueFilter === "All Leagues"
    ? allTeamRatings
    : allTeamRatings.filter(d => d.league === leagueFilter)
}
Show code
// ── View toggle (Table / Scatter) ───────────────────────────
{
  const _key = "_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  if (!window[_key]) window[_key] = "Table"
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const label of ["Table", "Scatter"]) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (label === window[_key] ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      window[_key] = label
      const isTable = label === "Table"
      const tableView = document.querySelector(".team-ratings-table-view")
      const scatterView = document.querySelector(".team-ratings-scatter-view")
      if (tableView) tableView.style.display = isTable ? "" : "none"
      if (scatterView) scatterView.style.display = isTable ? "none" : ""
    })
    container.appendChild(btn)
  }
  return container
}
Show code
viewof footballTeamSearch = footballTeamData ? Inputs.search(footballTeamData, { placeholder: "Search teams…" }) : html``
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
  if (!footballTeamData || footballTeamData.length === 0) return html``

  const metricOpts = [
    { value: "panna", label: "Panna" },
    { value: "offense", label: "Offense" },
    { value: "defense", label: "Defense" }
  ]

  const defaultX = "offense"
  const defaultY = "defense"

  const headerSrc = Object.fromEntries(metricOpts.map(m => [m.value, m.label]))

  const wrapper = document.createElement("div")
  wrapper.className = "team-ratings-scatter-view"
  wrapper.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Scatter" ? "" : "none"

  const axisBar = document.createElement("div")
  axisBar.className = "scatter-axis-bar"

  const xLabel = document.createElement("label")
  xLabel.textContent = "X: "
  const xSel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; xSel.appendChild(o)
  }
  xSel.value = defaultX
  xLabel.appendChild(xSel)

  const yLabel = document.createElement("label")
  yLabel.textContent = "Y: "
  const ySel = document.createElement("select")
  for (const opt of metricOpts) {
    const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; ySel.appendChild(o)
  }
  ySel.value = defaultY
  yLabel.appendChild(ySel)

  axisBar.appendChild(xLabel)
  axisBar.appendChild(yLabel)
  wrapper.appendChild(axisBar)

  const chartDiv = document.createElement("div")
  wrapper.appendChild(chartDiv)

  function drawChart(xCol, yCol) {
    while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data: footballTeamData,
      xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "team",
      format: { [xCol]: v => Number(v).toFixed(1), [yCol]: v => Number(v).toFixed(1) },
      hrefFn: (row) => `team.html#team=${encodeURIComponent(row.team)}`,
      imageFn: (row) => window.footballMaps?.teamCrest(row.team) || null,
      tooltipFn: (tip, row, xC, yC, xL, yL, f) => {
        const header = document.createElement("div")
        header.className = "scatter-tip-header"
        const crest = window.footballMaps.teamCrest(row.team)
        if (crest) {
          const badge = document.createElement("img")
          badge.className = "scatter-tip-headshot"
          badge.src = crest
          badge.alt = ""
          badge.style.borderRadius = "4px"
          header.appendChild(badge)
        }
        const info = document.createElement("div")
        const nameEl = document.createElement("div")
        nameEl.className = "scatter-tip-name"
        nameEl.textContent = row.team || ""
        info.appendChild(nameEl)
        if (row.league) {
          const leagueEl = document.createElement("div")
          leagueEl.className = "scatter-tip-team"
          leagueEl.textContent = String(row.league || "").replace(/_/g, " ")
          info.appendChild(leagueEl)
        }
        header.appendChild(info)
        tip.appendChild(header)
        const fX = f[xC] ? f[xC](row[xC]) : Number(row[xC]).toFixed(1)
        const fY = f[yC] ? f[yC](row[yC]) : Number(row[yC]).toFixed(1)
        window.chartHelpers.buildFieldTooltip(tip, "", [[xL, fX], [yL, fY]], true)
        const title = tip.querySelector(".ft-title")
        if (title && !title.textContent) title.remove()
      }
    })
  }

  drawChart(defaultX, defaultY)

  xSel.addEventListener("change", () => drawChart(xSel.value, ySel.value))
  ySel.addEventListener("change", () => drawChart(xSel.value, ySel.value))

  return wrapper
}
Show code
{
  if (!footballTeamData || footballTeamData.length === 0) {
    return html`<p class="text-muted">No team data available.</p>`
  }
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()

  const showLeague = leagueFilter === "All Leagues"
  const columns = showLeague
    ? ["rank", "team", "league", "panna", "offense", "defense", "top_player"]
    : ["rank", "team", "panna", "offense", "defense", "top_player"]
  const header = { rank: "#", team: "Team", league: "League", panna: "Panna", offense: "Off", defense: "Def", top_player: "Best Player" }
  const groups = showLeague
    ? [{ label: "", span: 3 }, { label: "Team Panna (vs league avg)", span: 3 }, { label: "", span: 1 }]
    : [{ label: "", span: 2 }, { label: "Team Panna (vs league avg)", span: 3 }, { label: "", span: 1 }]

  const tableEl = statsTable(footballTeamSearch, {
    columns, mobileCols: ["rank", "team", "panna", "offense", "defense"], header, groups,
    heatmap: { panna: "high-good", offense: "high-good", defense: "low-good" },
    heatmapData: footballTeamData,
    render: {
      team: window.footballMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`),
      league: (v) => statsEsc(String(v || "").replace(/_/g, " ")),
      top_player: (v) => v ? `<a href="player.html#name=${encodeURIComponent(v)}" class="player-link">${statsEsc(v)}</a>` : ""
    },
    filters: {
      panna: "range"
    },
    sort: "panna",
    reverse: true,
    rows: 25
  })

  const wrap = document.createElement("div")
  wrap.className = "team-ratings-table-view"
  wrap.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Table" ? "" : "none"
  wrap.appendChild(tableEl)
  return wrap
}
Show code
trAsAt = window.editorial.dataUpdated(window.DATA_BASE_URL + "football/ratings.parquet")
Show code
// ── Source attribution row ──────────────────────────────────
{
  const asAt = await trAsAt
  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(" · Top 20 squad players summed · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = [asAt, "Centred within each league · 15 leagues"].filter(Boolean).join(" · ")
  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 } = window.editorial

  if (!allTeamRatings || allTeamRatings.length === 0) {
    const lb = railBlock("Loading")
    const p = document.createElement("p")
    p.style.cssText = "color: var(--site-muted-color); font-size: 0.85rem; font-family: 'Source Serif 4', Georgia, serif; margin: 0;"
    p.textContent = "Resolving team ratings…"
    lb.appendChild(p); inner.appendChild(lb); return inner
  }

  const sorted = [...allTeamRatings].sort((a, b) => b.panna - a.panna)
  const topTeam = sorted[0]
  const bottomTeam = sorted[sorted.length - 1]
  const leagues = new Set(allTeamRatings.map(t => t.league)).size
  // Best per-league: find max spread league
  const leagueSpreads = new Map()
  for (const t of allTeamRatings) {
    if (!leagueSpreads.has(t.league)) leagueSpreads.set(t.league, { min: Infinity, max: -Infinity })
    const s = leagueSpreads.get(t.league)
    if (t.panna < s.min) s.min = t.panna
    if (t.panna > s.max) s.max = t.panna
  }
  let widestLeague = "", widestSpread = 0
  for (const [lg, s] of leagueSpreads) {
    if (s.max - s.min > widestSpread) { widestSpread = s.max - s.min; widestLeague = lg }
  }

  // Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = "Daily refresh"
  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("Ratings 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."))
  upd.appendChild(updP); inner.appendChild(upd)

  // BTN
  const btn = railBlock("By the Numbers")
  const grid = document.createElement("div"); grid.className = "btn-block"
  grid.appendChild(btnTile("+" + topTeam.panna.toFixed(1), [
    { text: "Deepest squad", bold: true },
    { text: " · " + topTeam.team },
    { br: true }, { text: topTeam.league.replace(/_/g, " ") }
  ]))
  grid.appendChild(btnTile(bottomTeam.panna.toFixed(1), [
    { text: "Thinnest squad", bold: true },
    { text: " · " + bottomTeam.team },
    { br: true }, { text: bottomTeam.league.replace(/_/g, " ") }
  ]))
  grid.appendChild(btnTile(widestSpread.toFixed(1), [
    { text: "Widest league", bold: true },
    { text: " · " + widestLeague.replace(/_/g, " ") },
    { br: true }, { text: "biggest top-to-bottom Panna gap" }
  ]))
  grid.appendChild(btnTile(String(leagues), [
    { text: "Leagues covered", bold: true },
    { text: " · all clubs aggregated" }
  ]))
  btn.appendChild(grid); inner.appendChild(btn)

  // About
  const about = railBlock("About Team Panna"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("Each club's rating is the sum of its top 20 players' Panna scores, then centred at the league average so values compare within (not across) competitions."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  const s = document.createElement("strong"); s.textContent = "Offense"; p2.appendChild(s)
  p2.appendChild(document.createTextNode(" / "))
  const s2 = document.createElement("strong"); s2.textContent = "Defense"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" decompose the headline rating into xG created vs xG prevented."))
  about.appendChild(p2)
  const p3 = document.createElement("p")
  p3.appendChild(document.createTextNode("Open source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/panna"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "panna"
  p3.appendChild(a); p3.appendChild(document.createTextNode(" on GitHub."))
  about.appendChild(p3); inner.appendChild(about)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "leagues.html", title: "Leagues & Sims", meta: "Projected final standings" },
    { href: "team-stats.html", title: "Team Stats", meta: "Aggregated match statistics" },
    { href: "player-ratings.html", title: "Player Ratings", meta: "Player-level Panna" },
    { href: "../blog/2026-04-24-understanding-panna/", title: "Understanding Panna", meta: "Blog · Methodology" }
  ]
  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
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer