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

AFL Player Ratings

Predictive TORP ratings — EPR and PSR components for every AFL player

AFL > Player Ratings

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
defs = window.aflStatDefs
predToFull = window.aflTeamMaps?.predToFull || {}
fullToPred = window.aflTeamMaps?.fullToPred || {}

base = window.DATA_BASE_URL
Show code
ratings = {
  try {
    return await window.fetchParquet(base + "afl/ratings.parquet")
  } catch (e) {
    console.error("[player-ratings] ratings load failed:", e)
    return null
  }
}

playerDetails = {
  try {
    return await window.fetchParquet(base + "afl/player-details.parquet")
  } catch (e) {
    console.warn("[player-ratings] player-details load failed:", e)
    return null
  }
}

// Eagerly load player-skills in background (starts immediately, doesn't block rendering)
_playerSkillsPromise = window.fetchParquet(base + "afl/player-skills.parquet").catch(e => { console.warn("[player-ratings] skills load failed:", e); return null })

// Resolve skills data — instant if already loaded, waits if still fetching
playerSkills = {
  if (statCategory === "Value") return null
  return await _playerSkillsPromise
}

// Detect column suffix in skills parquet (_rating or _skill)
skillSuffix = {
  if (!playerSkills || playerSkills.length === 0) return "_rating"
  const cols = Object.keys(playerSkills[0])
  return cols.some(c => c.endsWith("_rating") && c !== "cond_tog_rating" && c !== "squad_selection_rating" && c !== "rating_points_rating") ? "_rating" : "_skill"
}

// Use aflStatDefs for full column names, tooltips, and heatmap configs
statRatingCats = {
  const catMap = { Scoring: "scoring", Possession: "possession", Contested: "contested", Midfield: "midfield", Defense: "defense", Ruck: "ruck" }
  const result = {}
  for (const [display, key] of Object.entries(catMap)) {
    const def = defs[key]
    if (def) result[display] = { columns: def.columns, header: def.header, tooltip: def.tooltip || {}, heatmap: def.heatmap || {}, sortCol: def.sortCol }
  }
  return result
}
Show code
ageMap = {
  if (!playerDetails) return new Map()
  const now = Date.now()
  const m = new Map()
  for (const d of playerDetails) {
    if (d.date_of_birth) {
      const dob = new Date(d.date_of_birth)
      if (!isNaN(dob.getTime())) {
        m.set(d.player_id, +((now - dob.getTime()) / 31557600000).toFixed(1))
      }
    }
  }
  return m
}

asAtLabel = {
  if (ratings && ratings.length > 0) {
    let maxSeason = -Infinity; for (const d of ratings) { if (d.season > maxSeason) maxSeason = d.season }
    const latest = ratings.filter(d => d.season === maxSeason)
    let maxRound = 0; for (const d of latest) { const r = d.round || 0; if (r > maxRound) maxRound = r }
    return maxRound > 0 ? `As at Round ${maxRound}, ${maxSeason}` : `Season ${maxSeason}`
  }
  return ""
}
Show code
aflPosColors = window.aflTeamMaps?.posColors || {}
posAbbr = Object.fromEntries(Object.entries(aflPosColors).map(([k, v]) => [k, v.a]))
Show code
// ── Header ───────────────────────────────────────────────────
html`<div class="page-legend" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.5rem">
  <span>${asAtLabel} · <a href="definitions.html">See stat definitions</a></span>
</div>`
Show code
// ── Position filter ──────────────────────────────────────────
viewof posFilter = {
  const positions = ["All", "KD", "MDEF", "MID", "MFWD", "KF", "RK"]
  const _key = "_posFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "All"
  const container = html`<div class="pos-pills">
    ${positions.map(p => `<button class="pos-pill ${p === _saved ? 'active' : ''}" data-pos="${p}">${p}</button>`).join('')}
  </div>`
  container.value = _saved
  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.pos
      window[_key] = container.value
      container.dispatchEvent(new Event('input', {bubbles: true}))
    })
  })
  return container
}
Show code
// ── Stat category toggle ────────────────────────────────────
viewof statCategory = {
  const options = ["Value", "Scoring", "Possession", "Contested", "Midfield", "Defense", "Ruck"]
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "Value"
  const container = html`<div class="pos-pills">
    ${options.map(o => `<button class="pos-pill ${o === _saved ? 'active' : ''}" data-val="${o}">${o}</button>`).join('')}
  </div>`
  container.value = _saved
  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
}
Show code
seasonOptions = {
  if (!ratings) return ["All Seasons"]
  const seasons = new Set()
  ratings.forEach(d => seasons.add(String(d.season)))
  return [...[...seasons].sort((a, b) => b - a)]
}

teamOptions = {
  if (!ratings) return ["All Teams"]
  const teams = new Set()
  ratings.forEach(d => { const f = predToFull[d.team] || d.team; if (f) teams.add(f) })
  return ["All Teams", ...[...teams].sort()]
}

viewof filters = {
  const defaultSeason = seasonOptions[0] || "2026"

  // Get available rounds for a season, filtered by type
  function getRounds(season, type) {
    if (!ratings) return []
    const isFinals = window.aflTeamMaps?.isFinals
    let data = ratings.filter(d => String(d.season) === season)
    if (type === "Regular" && isFinals) data = data.filter(d => !isFinals(d))
    if (type === "Finals" && isFinals) data = data.filter(d => isFinals(d))
    const rounds = new Set()
    data.forEach(d => { if (d.round != null) rounds.add(d.round) })
    return [...rounds].sort((a, b) => a - b)
  }

  function makeSelect(options, defaultVal, label) {
    const wrap = document.createElement("div")
    wrap.className = "filter-select-wrap"
    const lbl = document.createElement("span")
    lbl.className = "filter-label"
    lbl.textContent = label
    const sel = document.createElement("select")
    sel.className = "filter-select"
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt
      o.textContent = opt
      if (opt === defaultVal) o.selected = true
      sel.appendChild(o)
    }
    wrap.appendChild(lbl)
    wrap.appendChild(sel)
    return { wrap, sel }
  }

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

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

  const season = makeSelect(seasonOptions, defaultSeason, "Season")
  const seasonType = makeSelect(["All", "Regular", "Finals"], "All", "Type")
  const team = makeSelect(teamOptions, "All Teams", "Team")

  // Round selector — single round, defaults to latest
  const initialRounds = getRounds(defaultSeason, "All")
  const defaultRound = initialRounds.length > 0 ? String(initialRounds[initialRounds.length - 1]) : "1"
  const round = makeSelect(
    initialRounds.length > 0 ? initialRounds.map(String) : ["1"],
    defaultRound,
    "Round"
  )

  row1.appendChild(season.wrap)
  row1.appendChild(seasonType.wrap)
  row1.appendChild(round.wrap)
  row1.appendChild(team.wrap)

  container.appendChild(row1)

  container.value = {
    season: defaultSeason,
    seasonType: "All",
    round: +defaultRound,
    team: "All Teams"
  }

  function emit() {
    container.dispatchEvent(new Event("input", { bubbles: true }))
  }

  function rebuildRoundOptions() {
    const rounds = getRounds(season.sel.value, seasonType.sel.value)
    while (round.sel.firstChild) round.sel.removeChild(round.sel.firstChild)
    for (const r of rounds) {
      const o = document.createElement("option")
      o.value = String(r)
      o.textContent = String(r)
      round.sel.appendChild(o)
    }
    if (rounds.length > 0) {
      round.sel.value = String(rounds[rounds.length - 1])
    }
  }

  season.sel.addEventListener("change", () => {
    rebuildRoundOptions()
    container.value = {
      ...container.value,
      season: season.sel.value,
      round: round.sel.value ? +round.sel.value : null
    }
    emit()
  })
  seasonType.sel.addEventListener("change", () => {
    rebuildRoundOptions()
    container.value = {
      ...container.value,
      seasonType: seasonType.sel.value,
      round: round.sel.value ? +round.sel.value : null
    }
    emit()
  })
  round.sel.addEventListener("change", () => {
    container.value = { ...container.value, round: round.sel.value ? +round.sel.value : null }
    emit()
  })
  team.sel.addEventListener("change", () => {
    container.value = { ...container.value, team: team.sel.value }
    emit()
  })

  return container
}

seasonFilter = filters.season
seasonTypeFilter = filters.seasonType
roundFilter = filters.round
teamFilter = filters.team
Show code
catDef = defs.ratings

tableData = {
  if (!ratings) return null

  const effectiveSeason = seasonFilter === "All Seasons" ? null : Number(seasonFilter)

  let data = ratings

  // Filter to season
  if (effectiveSeason) {
    data = data.filter(d => d.season === effectiveSeason)
  }

  // Filter by season type (Regular/Finals)
  const isFinals = window.aflTeamMaps?.isFinals
  if (seasonTypeFilter === "Regular" && isFinals) data = data.filter(d => !isFinals(d))
  if (seasonTypeFilter === "Finals" && isFinals) data = data.filter(d => isFinals(d))

  // Filter to selected round (or latest if null)
  if (roundFilter != null) {
    const roundData = data.filter(d => d.round === roundFilter)
    data = roundData.length > 0 ? roundData : window.deduplicateLatest(data)
  } else {
    data = window.deduplicateLatest(data)
  }

  // Position filter
  if (posFilter !== "All") {
    const fullKeys = Object.entries(posAbbr).filter(([k, v]) => v === posFilter).map(([k]) => k)
    data = data.filter(d => fullKeys.includes(d.position_group))
  }

  // Team filter
  if (teamFilter !== "All Teams") {
    const pred = fullToPred[teamFilter] || teamFilter
    data = data.filter(d => d.team === pred || d.team === teamFilter)
  }

  // Enrich with age and join skills data when a stat category is selected
  const skillsMap = new Map()
  if (statCategory !== "Value" && playerSkills) {
    for (const s of playerSkills) skillsMap.set(s.player_id, s)
  }

  return data.map(d => {
    const row = { ...d, age: ageMap.get(d.player_id) ?? null }
    if (statCategory !== "Value") {
      const sk = skillsMap.get(d.player_id)
      if (sk) {
        const cat = statRatingCats[statCategory]
        if (cat) {
          for (const col of cat.columns) {
            row[col + "_r"] = sk[col + skillSuffix] ?? null
          }
        }
      }
    }
    return row
  })
}
Show code
// ── View toggle (Table / Scatter) ───────────────────────────
// Uses pure DOM show/hide to bypass OJS runtime stall bug.
// Stores state on window so cells preserve view across re-renders.
{
  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
// ── Search ───────────────────────────────────────────────────
// Always return a real Inputs.search element — see football/player-stats.qmd for rationale.
viewof search = Inputs.search(tableData || [], { placeholder: "Search players…" })
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
  if (!tableData || tableData.length === 0) return html``

  const isOverview = statCategory === "Value"
  let metricOpts
  if (isOverview) {
    metricOpts = catDef.columns.map(c => ({ value: c, label: catDef.header[c] || c }))
  } else {
    const cat = statRatingCats[statCategory]
    metricOpts = cat.columns.map(c => ({ value: c + "_r", label: (cat.header[c] || c) }))
  }

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

  const headerSrc = isOverview ? catDef.header : (() => {
    const cat = statRatingCats[statCategory]
    const h = {}
    for (const c of cat.columns) h[c + "_r"] = cat.header[c]
    return h
  })()

  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)

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

  const posNorm = window.aflTeamMaps.posCanonical || {}
  const legendPositions = Object.keys(aflPosColors).filter(k => !posNorm[k])
  const colorMap = {}
  for (const [pos, info] of Object.entries(aflPosColors)) colorMap[pos] = info.c || "#888"
  const activePositions = new Set(legendPositions)

  function drawChart(xCol, yCol) {
    chartDiv.innerHTML = ""
    const filtered = activePositions.size === legendPositions.length ? tableData : tableData.filter(d => activePositions.has(posNorm[d.position_group] || d.position_group))
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data: filtered,
      xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "player_name",
      colorCol: "position_group",
      colorMap,
      hrefFn: (row) => `player.html#id=${row.player_id}`,
      format: { [xCol]: v => Number(v).toFixed(2), [yCol]: v => Number(v).toFixed(2) },
      tooltipFn: (tip, row, xC, yC, xL, yL, f) => {
        const esc = window.statsEsc
        const header = document.createElement("div")
        header.className = "scatter-tip-header"
        const img = window.aflTeamMaps.headshotUrl(row.player_id)
        if (img) {
          const hs = document.createElement("img")
          hs.className = "scatter-tip-headshot"
          hs.src = img
          hs.alt = ""
          hs.onerror = function() { this.style.display = "none" }
          header.appendChild(hs)
        }
        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"
        const logo = window.aflTeamMaps.teamLogo(row.team)
        if (logo) {
          const badge = document.createElement("img")
          badge.className = "scatter-tip-badge"
          badge.src = logo
          badge.alt = ""
          teamRow.appendChild(badge)
          teamRow.appendChild(document.createTextNode(" "))
        }
        teamRow.appendChild(document.createTextNode(row.team || ""))
        if (row.position_group) {
          teamRow.appendChild(document.createTextNode(" · " + (aflPosColors[row.position_group]?.a || row.position_group)))
        }
        info.appendChild(teamRow)
        header.appendChild(info)
        tip.appendChild(header)
        const fX = f[xC] ? f[xC](row[xC]) : Number(row[xC]).toFixed(2)
        const fY = f[yC] ? f[yC](row[yC]) : Number(row[yC]).toFixed(2)
        window.chartHelpers.buildFieldTooltip(tip, "", [[xL, fX], [yL, fY]], true)
        // Remove the empty title element buildFieldTooltip creates
        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 of legendPositions) {
    const info = aflPosColors[pos]
    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 (!tableData || tableData.length === 0) {
    return html`<p class="text-muted">No data available. Try adjusting filters or refreshing the page.</p>`
  }

  const posBadge = (val) => window.posBadge(val, aflPosColors)

  const isOverview = statCategory === "Value"

  let statCols, headerMap, heatmapMap, groupLabel, sortCol

  if (isOverview) {
    // Default TORP/EPR/PSR overview
    statCols = catDef.columns.filter(c => tableData[0] && tableData[0][c] !== undefined)
    headerMap = catDef.header
    heatmapMap = catDef.heatmap || {}
    groupLabel = "Ratings"
    sortCol = catDef.sortCol
  } else {
    // Per-stat ratings for the selected category — use full names + tooltips from aflStatDefs
    const cat = statRatingCats[statCategory]
    statCols = cat.columns.map(c => c + "_r").filter(c => tableData[0] && tableData[0][c] !== undefined)
    headerMap = {}
    heatmapMap = {}
    for (const col of cat.columns) {
      headerMap[col + "_r"] = cat.header[col] || col
      heatmapMap[col + "_r"] = cat.heatmap[col] || "high-good"
    }
    groupLabel = statCategory + " Ratings"
    sortCol = statCols.find(c => c === (cat.sortCol + "_r")) || statCols[0] || "torp"
  }

  const columns = ["player_name", "position_group", "age", "gms", ...statCols]

  const header = {
    player_name: "Player",
    position_group: "Pos",
    age: "Age",
    gms: "GP",
    ...headerMap
  }

  const groups = [
    { label: "Player", span: 4 },
    { label: groupLabel, span: statCols.length }
  ]

  const format = { age: x => x?.toFixed(1) ?? "" }
  if (isOverview && catDef.format) {
    for (const [col, dp] of Object.entries(catDef.format)) {
      if (statCols.includes(col)) format[col] = x => x?.toFixed(dp) ?? ""
    }
  } else if (!isOverview) {
    for (const col of statCols) {
      format[col] = x => x?.toFixed(2) ?? ""
    }
  }

  // Mobile columns: identity + top stats from the category
  const mobileStatCols = isOverview
    ? (catDef.mobileCols || []).filter(c => statCols.includes(c))
    : statCols.slice(0, 3)
  const mCols = mobileStatCols.length > 0 ? ["player_name", "position_group", "gms", ...mobileStatCols] : null

  const tableEl = statsTable(search, {
    columns,
    mobileCols: mCols,
    header,
    groups,
    format,
    tooltip: isOverview ? (catDef.tooltip || {}) : (() => {
      const cat = statRatingCats[statCategory]
      if (!cat || !cat.tooltip) return {}
      const t = {}
      for (const col of cat.columns) t[col + "_r"] = cat.tooltip[col]
      return t
    })(),
    render: {
      player_name: (v, row) => window.aflTeamMaps.renderPlayerCell(v, row),
      position_group: posBadge
    },
    heatmap: heatmapMap,
    heatmapData: tableData,
    filters: {
      age: "range",
      [sortCol]: "range",
      gms: "range"
    },
    sort: sortCol,
    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
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer