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 Team Ratings

Team Panna ratings aggregated from top player ratings
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

football_data = window.fetchParquet(window.DATA_BASE_URL + "football/ratings.parquet")
Show code
{
  return html`<div class="breadcrumb"><a href="index.html">Football</a> > Team Ratings</div>
  <div class="page-legend">Team Panna ratings (sum of top squad players, relative to league average).
    <span class="legend-tag legend-good">Offense</span> = xG created.
    <span class="legend-tag legend-bad">Defense</span> = xG prevented (negative = better).
  </div>`
}
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)}`,
      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
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer