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

Predictive Panna ratings with offensive and defensive components

Football > Player Ratings

Panna ratings across 15 leagues · See stat definitions
Offense = xG created. Defense = xG prevented (negative = better). Panna = Offense − Defense.

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

football_data = window.fetchParquet(window.DATA_BASE_URL + "football/ratings.parquet")

// Start skills fetch in background (for stat category toggles)
{ window._footballRatingsSkillsPromise = window.fetchParquet(window.DATA_BASE_URL + "football/player-skills.parquet").catch(e => { console.warn("[football-ratings] skills load failed:", e); return null }) }
Show code
leagueOptions = {
  if (!football_data) return []
  const leagues = [...new Set(football_data.map(d => d.league).filter(Boolean))].sort()
  return ["All Leagues", ...leagues]
}

leagueDisplay = {
  const m = new Map(leagueOptions.map(l => [l, l.replace(/_/g, " ")]))
  return m
}

viewof leagueFilter = {
  if (football_data == null) return html`<p></p>`

  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(leagueOptions, "All Leagues", "League", x => leagueDisplay.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
}

leagueFiltered = {
  if (!football_data) return null
  if (leagueFilter === "All Leagues") return football_data
  return football_data.filter(d => d.league === leagueFilter)
}

posAbbrMap = ({
  "Striker": "ST", "Centre Forward": "CF",
  "Attacking Midfielder": "AM", "Central Midfielder": "CM",
  "Defensive Midfielder": "DM", "Left Midfielder": "LM",
  "Right Midfielder": "RM", "Left Winger": "LW",
  "Right Winger": "RW", "Left-Back": "LB",
  "Right-Back": "RB", "Centre-Back": "CB",
  "Goalkeeper": "GK"
})

viewof posFilter = {
  const positions = ["All", "GK", "DEF", "MID", "FWD"]
  const _key = "_posFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = positions.includes(window[_key]) ? window[_key] : "All"
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const p of positions) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (p === _saved ? " active" : "")
    btn.dataset.pos = p
    btn.textContent = p
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = p
      window[_key] = p
      container.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  container.value = _saved
  return container
}

posFiltered = {
  if (!leagueFiltered) return null
  if (posFilter === "All") return leagueFiltered
  const allowed = window.footballMaps.posGroups[posFilter] || []
  return leagueFiltered.filter(d => allowed.includes(d.position))
}
Show code
viewof statCategory = {
  const defs = window.footballStatDefs || {}
  const catKeys = Object.keys(defs).filter(k => !defs[k].compute && k !== "value")
  const options = ["Value", ...catKeys.map(k => defs[k].label)]
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "Value"
  const _default = options.includes(_saved) ? _saved : "Value"
  const container = html`<div class="pos-pills">
    ${options.map(o => `<button class="pos-pill ${o === _default ? 'active' : ''}" data-val="${o}">${o}</button>`).join('')}
  </div>`
  container.value = _default
  container.querySelectorAll('.pos-pill').forEach(btn => {
    btn.addEventListener('click', () => {
      container.querySelectorAll('.pos-pill').forEach(b => b.classList.remove('active'))
      btn.classList.add('active')
      container.value = btn.dataset.val
      window[_key] = container.value
      container.dispatchEvent(new Event('input', {bubbles: true}))
    })
  })
  return container
}

// Join skills data for stat categories
footballSkills = {
  if (statCategory === "Value") return null
  return await window._footballRatingsSkillsPromise
}

statTableData = {
  if (statCategory === "Value" || !footballSkills || !posFiltered) return null

  const defs = window.footballStatDefs || {}
  // Find catDef by label
  const catKey = Object.keys(defs).find(k => defs[k].label === statCategory)
  const catDef = catKey ? defs[catKey] : null
  if (!catDef) return null

  // Build skills lookup
  const skillsMap = new Map()
  for (const s of footballSkills) skillsMap.set(s.player_name, s)

  // Join skills _p90 columns to ratings data
  return posFiltered.map(r => {
    const sk = skillsMap.get(r.player_name)
    const row = { ...r }
    if (sk) {
      for (const col of catDef.columns) {
        // Map stat-defs column to _p90 column in skills parquet
        const p90Key = col + "_p90"
        if (sk[p90Key] != null) row[col + "_r"] = sk[p90Key]
      }
    }
    return row
  })
}
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(".ratings-table-view")
      const scatterView = document.querySelector(".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 football_search = {
  const data = statCategory === "Value" ? posFiltered : statTableData
  if (!data) return html``
  return Inputs.search(data, {placeholder: "Search players…"})
}
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
  const data = statCategory === "Value" ? posFiltered : statTableData
  if (!data || data.length === 0) return html``

  const footballPosColors = window.footballMaps.posColors
  const isValue = statCategory === "Value"
  const defs = window.footballStatDefs || {}

  let metricOpts
  if (isValue) {
    metricOpts = [
      { value: "offense", label: "Offense" },
      { value: "defense", label: "Defense" },
      { value: "panna", label: "Panna" },
      { value: "spm_overall", label: "SPM" }
    ]
  } else {
    const catKey = Object.keys(defs).find(k => defs[k].label === statCategory)
    const catDef = catKey ? defs[catKey] : null
    metricOpts = catDef ? catDef.columns.map(c => ({ value: c + "_r", label: catDef.header[c] || c })) : []
  }

  const defaultX = metricOpts.find(m => m.value === "offense")?.value || metricOpts[0]?.value
  const defaultY = metricOpts.find(m => m.value === "defense")?.value || (metricOpts[1]?.value || metricOpts[0]?.value)

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

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

  // Axis selectors
  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)

  const colorMap = {}
  for (const [pos, info] of Object.entries(footballPosColors)) colorMap[pos] = info.c || "#888"
  const activePositions = new Set(Object.keys(footballPosColors))

  function drawChart(xCol, yCol) {
    while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
    const filtered = activePositions.size === Object.keys(footballPosColors).length ? data : data.filter(d => activePositions.has(d.position))
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data: filtered, xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "player_name",
      colorCol: "position",
      colorMap,
      hrefFn: (row) => `player.html#name=${encodeURIComponent(row.player_name)}`,
      format: { [xCol]: v => Number(v).toFixed(3), [yCol]: v => Number(v).toFixed(3) },
      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.player_name || ""
        info.appendChild(nameEl)
        const teamRow = document.createElement("div")
        teamRow.className = "scatter-tip-team"
        teamRow.textContent = (row.team || "") + (row.position ? " · " + (footballPosColors[row.position]?.a || row.position) : "")
        info.appendChild(teamRow)
        header.appendChild(info)
        tip.appendChild(header)
        const fX = f[xC] ? f[xC](row[xC]) : Number(row[xC]).toFixed(3)
        const fY = f[yC] ? f[yC](row[yC]) : Number(row[yC]).toFixed(3)
        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))

  // Legend
  const legend = document.createElement("div")
  legend.style.cssText = "display:flex;gap:0.75rem;flex-wrap:wrap;margin-top:0.5rem;font-size:0.7rem;font-family:var(--bs-font-monospace)"
  for (const [pos, info] of Object.entries(footballPosColors)) {
    const swatch = document.createElement("span")
    swatch.style.cssText = "width:8px;height:8px;border-radius:50%;background:" + info.c + ";display:inline-block"
    const item = document.createElement("span")
    item.style.cssText = "display:inline-flex;align-items:center;gap:0.25rem;color:#9a9088;cursor:pointer;user-select:none"
    item.appendChild(swatch)
    item.appendChild(document.createTextNode(info.a || pos))
    item.addEventListener("click", () => {
      if (activePositions.has(pos)) { activePositions.delete(pos); item.style.opacity = "0.3" }
      else { activePositions.add(pos); item.style.opacity = "1" }
      drawChart(xSel.value, ySel.value)
    })
    legend.appendChild(item)
  }
  wrapper.appendChild(legend)

  return wrapper
}
Show code
// ── Render table ─────────────────────────────────────────────
{
  if (football_data == null || football_data.length === 0) {
    return html`<p class="text-muted">Ratings data could not be loaded. Try refreshing the page.</p>`
  }

  const footballPosColors = window.footballMaps.posColors
  const posBadge = (val) => window.posBadge(val, footballPosColors)
  const isValue = statCategory === "Value"
  const defs = window.footballStatDefs || {}

  // Stat category view
  if (!isValue) {
    const data = statTableData
    if (!data || data.length === 0) {
      const failed = footballSkills === null
      return html`<p class="text-muted">${failed ? "Rating data could not be loaded. Try refreshing." : "Loading rating data..."}</p>`
    }

    const catKey = Object.keys(defs).find(k => defs[k].label === statCategory)
    const catDef = catKey ? defs[catKey] : null
    if (!catDef) return html`<p class="text-muted">Category not found.</p>`

    const statCols = catDef.columns.map(c => c + "_r").filter(c => data[0] && data[0][c] !== undefined)
    const headerMap = {}; const heatmapMap = {}; const tooltipMap = {}
    for (const col of catDef.columns) {
      headerMap[col + "_r"] = catDef.header[col] || col
      heatmapMap[col + "_r"] = catDef.heatmap?.[col] || "high-good"
      if (catDef.tooltip?.[col]) tooltipMap[col + "_r"] = catDef.tooltip[col]
    }

    const mStatCols = (catDef.mobileCols || catDef.columns.slice(0, 3)).map(c => c + "_r").filter(c => statCols.includes(c))
    const tableEl = statsTable(football_search, {
      columns: ["player_name", "league", "position", ...statCols],
      mobileCols: ["player_name", "position", ...mStatCols],
      header: { player_name: "Player", league: "League", position: "Pos", ...headerMap },
      groups: [{ label: "Player", span: 3 }, { label: statCategory + " (per 90 min)", span: statCols.length }],
      format: Object.fromEntries(statCols.map(c => [c, x => x?.toFixed(2) ?? ""])),
      tooltip: tooltipMap,
      render: {
        player_name: (v, row) => {
          const pos = footballPosColors[row.position] || { a: String(row.position || "").substring(0, 3), c: "#9ca3af" }
          const crest = window.footballMaps.teamCrest(row.team)
          const badge = crest ? `<img src="${statsEsc(crest)}" alt="" style="width:14px;height:14px;object-fit:contain;vertical-align:middle;margin-right:2px">` : ""
          return `<a href="player.html#name=${encodeURIComponent(v)}" class="player-link"><strong>${statsEsc(v)}</strong><span class="player-sub">${badge}<a href="team.html#team=${encodeURIComponent(row.team)}" class="team-link">${statsEsc(row.team)}</a> · ${statsEsc(pos.a)}</span></a>`
        },
        league: (v) => statsEsc(String(v || "").replace(/_/g, " ")),
        position: posBadge
      },
      heatmap: heatmapMap,
      heatmapData: data,
      sort: statCols[0] || "player_name", reverse: true, rows: 25
    })
    const wrap = document.createElement("div")
    wrap.className = "ratings-table-view"
    wrap.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Table" ? "" : "none"
    wrap.appendChild(tableEl)
    return wrap
  }

  // Value view (original)
  const valueEl = statsTable(football_search, {
    columns: ["panna_rank", "player_name", "league", "position", "panna", "offense", "defense", "spm_overall", "total_minutes", "panna_percentile"],
    mobileCols: ["panna_rank", "player_name", "position", "panna", "offense", "defense"],
    header: {
      panna_rank: "#",
      player_name: "Player",
      league: "League",
      position: "Pos",
      panna: "Panna",
      offense: "Off",
      defense: "Def",
      spm_overall: "SPM",
      total_minutes: "Mins",
      panna_percentile: "Pctl"
    },
    groups: [
      { label: "", span: 1 },
      { label: "Player", span: 3 },
      { label: "Rating", span: 1 },
      { label: "Components", span: 3 },
      { label: "", span: 2 }
    ],
    format: {
      panna: x => x?.toFixed(3) ?? "",
      offense: x => x?.toFixed(3) ?? "",
      defense: x => x?.toFixed(3) ?? "",
      spm_overall: x => x?.toFixed(3) ?? "",
      total_minutes: x => x?.toLocaleString() ?? "",
      panna_percentile: x => x != null ? x.toFixed(1) : ""
    },
    render: {
      player_name: (v, row) => {
        const pos = footballPosColors[row.position] || { a: String(row.position || "").substring(0, 3), c: "#9ca3af" }
        const href = `player.html#name=${encodeURIComponent(v)}`
        const crest = window.footballMaps.teamCrest(row.team)
        const badge = crest ? `<img src="${statsEsc(crest)}" alt="" style="width:14px;height:14px;object-fit:contain;vertical-align:middle;margin-right:2px">` : ""
        return `<a href="${href}" class="player-link"><strong>${statsEsc(v)}</strong><span class="player-sub">${badge}<a href="team.html#team=${encodeURIComponent(row.team)}" class="team-link">${statsEsc(row.team)}</a> · ${statsEsc(pos.a)}</span></a>`
      },
      panna_rank: (v) => `<span style="color:#8b929e">${statsEsc(String(v ?? ""))}</span>`,
      league: (v) => statsEsc(String(v || "").replace(/_/g, " ")),
      position: posBadge
    },
    heatmap: {
      panna: "high-good",
      offense: "high-good",
      defense: "low-good",
      spm_overall: "high-good",
      panna_percentile: "high-good"
    },
    heatmapData: football_data,
    filters: {
      panna: "range",
      total_minutes: "range",
      panna_percentile: "range"
    },
    sort: "panna_rank",
    rows: 25
  })

  const wrap = document.createElement("div")
  wrap.className = "ratings-table-view"
  wrap.appendChild(valueEl)
  return wrap
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer