AFL Player Ratings
Predictive TORP ratings — EPR and PSR components for every AFL player
AFL > Player Ratings
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
Show code
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.teamShow 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
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
}