AFL Team Ratings
Team ratings aggregated from player ratings, weighted by predicted TOG
AFL > Team Ratings
Show code
ratings = {
try { return await fetchParquet(base_url + "afl/ratings.parquet") }
catch (e) { console.error("[team-ratings] ratings load failed:", e); return null }
}
// Start skills fetch in background (for TOG weights + stat category toggles)
{ window._teamSkillsPromise = fetchParquet(base_url + "afl/player-skills.parquet").catch(e => { console.warn("[team-ratings] skills load failed:", e); return null }) }Show code
Show code
// Stat category toggle — same as player-ratings
viewof teamRatingCategory = {
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
_buildTogWeights = async function() {
const skills = await window._teamSkillsPromise
if (!skills) return new Map()
const m = new Map()
for (const s of skills) {
m.set(s.player_id, (s.squad_selection_rating || 0) * (s.cond_tog_rating || 0))
}
return m
}
// Aggregate ratings per team, weighted by TOG
teamRatings = {
if (!ratings) return null
const togWeights = await _buildTogWeights()
const valueCols = ["torp", "epr", "recv_epr", "disp_epr", "spoil_epr", "hitout_epr", "psr", "osr", "dsr"]
const TARGET_WEIGHT = 18 // normalize TOG weights to 18 on-field players
// Filter to current season only
let currentSeason = -Infinity; for (const r of ratings) { if (r.season > currentSeason) currentSeason = r.season }
const seasonRatings = ratings.filter(r => r.season === currentSeason)
const latestPlayers = window.deduplicateLatest(seasonRatings)
const byTeam = new Map()
for (const r of latestPlayers) {
const fullName = predToFull[r.team] || r.team
if (!byTeam.has(fullName)) byTeam.set(fullName, [])
byTeam.get(fullName).push(r)
}
const teams = []
byTeam.forEach((players, team) => {
// Normalize TOG weights to sum to TARGET_WEIGHT
let rawWeightSum = 0
for (const p of players) rawWeightSum += togWeights.get(p.player_id) || 0
const scale = rawWeightSum > 0 ? TARGET_WEIGHT / rawWeightSum : 0
const row = { team }
for (const col of valueCols) {
let weightedSum = 0
for (const p of players) {
const w = (togWeights.get(p.player_id) || 0) * scale
weightedSum += (p[col] || 0) * w
}
row[col] = weightedSum
}
// Best player by TORP
const best = players.reduce((a, b) => (a.torp || 0) > (b.torp || 0) ? a : b, players[0])
row.top_player = best?.player_name || ""
teams.push(row)
})
const avgs = {}
for (const col of valueCols) {
const sum = teams.reduce((s, t) => s + t[col], 0)
avgs[col] = sum / teams.length
}
for (const t of teams) {
for (const col of valueCols) t[col] = +(t[col] - avgs[col]).toFixed(1)
}
teams.sort((a, b) => b.torp - a.torp)
return teams.map((t, i) => ({ rank: i + 1, ...t }))
}Show code
// Aggregate per-stat ratings by team (for stat category toggles)
teamStatRatings = {
if (teamRatingCategory === "Value") return null
const skills = await window._teamSkillsPromise
if (!skills || !ratings) return null
const defs = window.aflStatDefs || {}
const catKey = teamRatingCategory.toLowerCase()
const catDef = defs[catKey]
if (!catDef) return null
const cols = Object.keys(skills[0] || {})
const suffix = cols.some(c => c.endsWith("_rating") && c !== "cond_tog_rating" && c !== "squad_selection_rating" && c !== "rating_points_rating") ? "_rating" : "_skill"
const TARGET_WEIGHT = 18
const skillsMap = new Map()
const togWeights = new Map()
for (const s of skills) {
skillsMap.set(s.player_id, s)
togWeights.set(s.player_id, (s.squad_selection_rating || 0) * (s.cond_tog_rating || 0))
}
// Filter to current season only
let currentSeason = -Infinity; for (const r of ratings) { if (r.season > currentSeason) currentSeason = r.season }
const seasonRatings = ratings.filter(r => r.season === currentSeason)
const latestByPlayer2 = new Map()
for (const r of seasonRatings) {
const existing = latestByPlayer2.get(r.player_id)
if (!existing || (r.round || 0) > (existing.round || 0)) {
latestByPlayer2.set(r.player_id, r)
}
}
const byTeam = new Map()
for (const r of latestByPlayer2.values()) {
const fullName = predToFull[r.team] || r.team
if (!byTeam.has(fullName)) byTeam.set(fullName, [])
byTeam.get(fullName).push(r)
}
const statCols = catDef.columns
const teams = []
byTeam.forEach((players, team) => {
// Normalize TOG weights to sum to TARGET_WEIGHT
let rawWeightSum = 0
for (const p of players) rawWeightSum += togWeights.get(p.player_id) || 0
const scale = rawWeightSum > 0 ? TARGET_WEIGHT / rawWeightSum : 0
const row = { team }
for (const col of statCols) {
let weightedSum = 0
for (const p of players) {
const sk = skillsMap.get(p.player_id)
if (sk && sk[col + suffix] != null) {
const w = (togWeights.get(p.player_id) || 0) * scale
weightedSum += sk[col + suffix] * w
}
}
row[col] = +weightedSum.toFixed(2)
}
teams.push(row)
})
const sortCol = statCols[0]
teams.sort((a, b) => (b[sortCol] || 0) - (a[sortCol] || 0))
return teams.map((t, i) => ({ rank: i + 1, ...t }))
}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
Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
const isValue = teamRatingCategory === "Value"
const data = isValue ? teamRatings : teamStatRatings
if (!data || data.length === 0) return html``
const defs = window.aflStatDefs || {}
let metricOpts
if (isValue) {
metricOpts = [
{ value: "torp", label: "TORP" }, { value: "epr", label: "EPR" },
{ value: "psr", label: "PSR" }, { value: "osr", label: "OSR" }, { value: "dsr", label: "DSR" }
]
} else {
const catKey = teamRatingCategory.toLowerCase()
const catDef = defs[catKey]
const statCols = catDef ? catDef.columns.filter(c => data[0] && data[0][c] !== undefined) : []
metricOpts = statCols.map(c => ({ value: c, label: catDef.header[c] || c }))
}
if (metricOpts.length === 0) return html``
const defaultX = metricOpts.find(m => m.value === "osr" || m.value === "epr")?.value || metricOpts[0]?.value
const defaultY = metricOpts.find(m => m.value === "dsr" || m.value === "psr")?.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 = "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,
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 logo = window.aflTeamMaps.teamLogo(row.team)
if (logo) {
const badge = document.createElement("img")
badge.className = "scatter-tip-headshot"
badge.src = logo
badge.alt = ""
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)
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
{
const isValue = teamRatingCategory === "Value"
const data = isValue ? teamRatings : teamStatRatings
if (!data || data.length === 0) {
if (!isValue) return html`<p class="text-muted">Loading stat ratings...</p>`
return html`<p class="text-muted">No team ratings data available.</p>`
}
const teamLink = window.aflTeamMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)
const defs = window.aflStatDefs || {}
if (isValue) {
// Same columns as player Value tab: TORP, EPR components, PSR components
const tableEl = statsTable(teamSearch, {
columns: ["rank", "team", "torp", "epr", "recv_epr", "disp_epr", "spoil_epr", "hitout_epr", "psr", "osr", "dsr", "top_player"],
header: { rank: "#", team: "Team", torp: "TORP", epr: "EPR", recv_epr: "Reception", disp_epr: "Disposal", spoil_epr: "Spoil", hitout_epr: "Hitout", psr: "PSR", osr: "OSR", dsr: "DSR", top_player: "Best Player" },
groups: [{ label: "", span: 2 }, { label: "EPR (vs avg)", span: 6 }, { label: "PSR (vs avg)", span: 3 }, { label: "", span: 1 }],
format: Object.fromEntries(["torp","epr","recv_epr","disp_epr","spoil_epr","hitout_epr","psr","osr","dsr"].map(c => [c, x => x != null ? (x > 0 ? "+" : "") + x.toFixed(1) : ""])),
tooltip: { torp: "Total Overall Rating of Players — blended EPR and PSR", epr: "Expected Points Rating — predictive career rating from EPV", recv_epr: "Reception EPR — predictive rating for receiving", disp_epr: "Disposal EPR — predictive rating for kicks and handballs", spoil_epr: "Spoil EPR — predictive rating for defensive acts", hitout_epr: "Hitout EPR — predictive rating for ruck hitouts", psr: "Player Stat Rating — box-score regularised plus-minus", osr: "Offensive Stat Rating — offensive component of PSR", dsr: "Defensive Stat Rating — defensive component of PSR" },
heatmap: { torp: "high-good", epr: "high-good", recv_epr: "diverging", disp_epr: "diverging", spoil_epr: "diverging", hitout_epr: "diverging", psr: "diverging", osr: "diverging", dsr: "diverging" },
render: {
team: teamLink,
top_player: (v) => v ? `<span class="player-link">${statsEsc(v)}</span>` : ""
},
sort: "torp", reverse: true, rows: 20
})
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
}
// Stat category view
const catKey = teamRatingCategory.toLowerCase()
const catDef = defs[catKey]
if (!catDef) return html`<p class="text-muted">Category not found.</p>`
const statCols = catDef.columns.filter(c => data[0] && data[0][c] !== undefined)
const heatmap = {}
for (const c of statCols) heatmap[c] = catDef.heatmap?.[c] || "high-good"
const tableEl2 = statsTable(teamSearch, {
columns: ["rank", "team", ...statCols],
header: { rank: "#", team: "Team", ...catDef.header },
groups: [{ label: "", span: 2 }, { label: teamRatingCategory + " Ratings (TOG-weighted avg)", span: statCols.length }],
format: Object.fromEntries(statCols.map(c => [c, x => x?.toFixed(2) ?? ""])),
tooltip: catDef.tooltip || {},
heatmap,
render: { team: teamLink },
sort: statCols[0] || "rank", reverse: true, rows: 20
})
const wrap2 = document.createElement("div")
wrap2.className = "team-ratings-table-view"
wrap2.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Table" ? "" : "none"
wrap2.appendChild(tableEl2)
return wrap2
}