AFL Team Stats
Aggregated team-level match stats for all AFL teams
Show code
gameStats = {
try { return await fetchParquet(base_url + "afl/game-stats.parquet") }
catch (e) { console.error("[team-stats] game-stats load failed:", e); return null }
}
predictions = {
try { return await fetchParquet(base_url + "afl/predictions.parquet") }
catch (e) { console.error("[team-stats] predictions load failed:", e); return null }
}
fixturesHistory = {
try {
return await window.fetchParquet(base_url + "afl/fixtures-history.parquet")
} catch (e) {
console.warn("[team-stats] fixtures-history load failed:", e)
return null
}
}Show code
viewof teamStatsFilters = {
if (!gameStats) { const e = document.createElement("div"); e.value = { season: null, roundMin: null, roundMax: null, aggMode: "total" }; return e }
const seasons = [...new Set(gameStats.map(d => Number(d.season)))].sort((a, b) => b - a)
const defaultSeason = seasons[0]
function getRoundRange(season) {
const rounds = gameStats.filter(d => Number(d.season) === season).map(d => d.round).filter(r => r != null)
return { min: rounds.length > 0 ? Math.min(...rounds) : 0, max: rounds.length > 0 ? Math.max(...rounds) : 30 }
}
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 (String(opt) === String(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 row = document.createElement("div")
row.className = "filter-row"
const season = makeSelect(seasons, defaultSeason, "Season")
const haSelect = makeSelect(["All", "Home", "Away"], "All", "H/A")
const seasonType = makeSelect(["All", "Regular", "Finals"], "All", "Type")
const venueOpts = fixturesHistory
? ["All Venues", ...[...new Set(fixturesHistory.map(f => f.venue).filter(Boolean))].sort()]
: ["All Venues"]
const venueSelect = makeSelect(venueOpts, "All Venues", "Venue")
const dayOpts = ["All Days", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
const daySelect = makeSelect(dayOpts, "All Days", "Day")
row.appendChild(season.wrap)
row.appendChild(haSelect.wrap)
row.appendChild(seasonType.wrap)
row.appendChild(venueSelect.wrap)
row.appendChild(daySelect.wrap)
// Round range
let roundBounds = getRoundRange(defaultSeason)
const roundWrap = document.createElement("div")
roundWrap.className = "filter-round-wrap"
const rLbl = document.createElement("span")
rLbl.className = "filter-label"
rLbl.textContent = "Rounds"
const rMin = document.createElement("input")
rMin.type = "number"; rMin.className = "round-input"
rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
const rSep = document.createElement("span")
rSep.className = "round-sep"; rSep.textContent = "–"
const rMax = document.createElement("input")
rMax.type = "number"; rMax.className = "round-input"
rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
roundWrap.appendChild(rLbl)
roundWrap.appendChild(rMin)
roundWrap.appendChild(rSep)
roundWrap.appendChild(rMax)
row.appendChild(roundWrap)
// Agg toggle
const aggWrap = document.createElement("div")
aggWrap.className = "filter-agg-wrap"
const btnAvg = document.createElement("button")
btnAvg.className = "agg-btn"; btnAvg.textContent = "Per Game"; btnAvg.dataset.mode = "avg"
const btnTot = document.createElement("button")
btnTot.className = "agg-btn active"; btnTot.textContent = "Total"; btnTot.dataset.mode = "total"
aggWrap.appendChild(btnAvg)
aggWrap.appendChild(btnTot)
row.appendChild(aggWrap)
container.appendChild(row)
container.value = {
season: defaultSeason,
roundMin: roundBounds.min,
roundMax: roundBounds.max,
aggMode: "total",
homeAway: "All",
seasonType: "All",
venue: "All Venues",
day: "All Days"
}
function emit() { container.dispatchEvent(new Event("input", { bubbles: true })) }
season.sel.addEventListener("change", () => {
const s = Number(season.sel.value)
roundBounds = getRoundRange(s)
rMin.min = roundBounds.min; rMin.max = roundBounds.max; rMin.value = roundBounds.min
rMax.min = roundBounds.min; rMax.max = roundBounds.max; rMax.value = roundBounds.max
container.value = { ...container.value, season: s, roundMin: roundBounds.min, roundMax: roundBounds.max }
emit()
})
haSelect.sel.addEventListener("change", () => {
container.value = { ...container.value, homeAway: haSelect.sel.value }
emit()
})
seasonType.sel.addEventListener("change", () => {
container.value = { ...container.value, seasonType: seasonType.sel.value }
emit()
})
venueSelect.sel.addEventListener("change", () => {
container.value = { ...container.value, venue: venueSelect.sel.value }
emit()
})
daySelect.sel.addEventListener("change", () => {
container.value = { ...container.value, day: daySelect.sel.value }
emit()
})
function clampRound(input) {
if (input.value === "") return
const v = +input.value
if (v < roundBounds.min) input.value = roundBounds.min
if (v > roundBounds.max) input.value = roundBounds.max
}
let roundTimer
function updateRound() {
clearTimeout(roundTimer)
roundTimer = setTimeout(() => {
clampRound(rMin); clampRound(rMax)
container.value = { ...container.value, roundMin: +rMin.value, roundMax: +rMax.value }
emit()
}, 500)
}
rMin.addEventListener("input", updateRound)
rMax.addEventListener("input", updateRound)
rMin.addEventListener("blur", () => { clampRound(rMin); updateRound() })
rMax.addEventListener("blur", () => { clampRound(rMax); updateRound() })
for (const btn of [btnAvg, btnTot]) {
btn.addEventListener("click", () => {
aggWrap.querySelectorAll("button").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
container.value = { ...container.value, aggMode: btn.dataset.mode }
emit()
})
}
return container
}
teamStatsSeason = teamStatsFilters.season
teamRoundRange = ({ min: teamStatsFilters.roundMin, max: teamStatsFilters.roundMax })
teamAggMode = teamStatsFilters.aggMode
teamHAFilter = teamStatsFilters.homeAway
teamSeasonType = teamStatsFilters.seasonType
teamVenueFilter = teamStatsFilters.venue
teamDayFilter = teamStatsFilters.day
// Build fixture venue/day lookup
teamFixtureMap = {
const m = new Map()
if (fixturesHistory) {
for (const f of fixturesHistory) {
const h = predToFull[f.home_team] || f.home_team
const a = predToFull[f.away_team] || f.away_team
m.set(`${f.season}-${f.round}-${h}`, { venue: f.venue, start_time: f.start_time })
m.set(`${f.season}-${f.round}-${a}`, { venue: f.venue, start_time: f.start_time })
}
}
return m
}
// Build H/A lookup from predictions
teamHAMap = {
const m = new Map()
if (predictions) {
for (const p of predictions) {
const h = predToFull[p.home_team] || p.home_team
const a = predToFull[p.away_team] || p.away_team
m.set(`${p.season}-${p.round}-${h}`, "home")
m.set(`${p.season}-${p.round}-${a}`, "away")
}
}
return m
}Show code
viewof teamStatsCategory = {
if (!gameStats) { const e = document.createElement("div"); e.value = "Results"; return e }
const categories = ["Results", "Overview", "Possession", "Scoring", "Contested", "Midfield", "Defense", "Custom"]
const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
const _saved = window[_key] || "Results"
const _default = categories.includes(_saved) ? _saved : "Results"
const wrap = document.createElement("div")
wrap.className = "stats-category-toggle"
wrap.value = _default
for (const cat of categories) {
const btn = document.createElement("button")
btn.className = "stats-cat-btn" + (cat === _default ? " active" : "")
btn.textContent = cat
btn.addEventListener("click", () => {
wrap.querySelectorAll(".stats-cat-btn").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
wrap.value = cat
window[_key] = cat
wrap.dispatchEvent(new Event("input", { bubbles: true }))
})
wrap.appendChild(btn)
}
return wrap
}Show code
catConfigs = ({
Results: {
columns: ["team", "gp", "w", "l", "d", "win_pct", "pf", "pa", "pd", "gf", "ga"],
mobileCols: ["team", "w", "l", "win_pct", "pd"],
header: { team: "Team", gp: "GP", w: "W", l: "L", d: "D", win_pct: "Win%",
pf: "PF", pa: "PA", pd: "PD", gf: "GF", ga: "GA" },
groups: [{ label: "", span: 2 }, { label: "Record", span: 4 },
{ label: "Points", span: 3 }, { label: "Goals", span: 2 }],
tooltip: { gp: "Games played", w: "Wins", l: "Losses", d: "Draws", win_pct: "Win percentage", pf: "Points for", pa: "Points against", pd: "Points differential (PF − PA)", gf: "Goals for", ga: "Goals against" },
heatmap: { win_pct: "high-good", pd: "high-good", pf: "high-good", pa: "low-good" },
format: { win_pct: v => v != null ? v.toFixed(1) + "%" : "" },
sortCol: "win_pct"
},
Overview: {
columns: ["team", "gp", "disposals", "kicks", "handballs", "disposal_eff", "marks", "tackles", "clearances", "inside50s", "goals"],
mobileCols: ["team", "disposals", "tackles", "clearances", "goals"],
header: { team: "Team", gp: "GP", disposals: "Disposals", kicks: "Kicks", handballs: "Handballs", disposal_eff: "DE%", marks: "Marks", tackles: "Tackles", clearances: "Clearances", inside50s: "Inside 50s", goals: "Goals" },
groups: [{ label: "", span: 2 }, { label: "", span: 9 }],
tooltip: { gp: "Games played", disposals: "Total disposals (kicks + handballs)", kicks: "Total kicks", handballs: "Total handballs", disposal_eff: "Disposal efficiency percentage", marks: "Total marks", tackles: "Tackles applied", clearances: "Disposals from a stoppage", inside50s: "Entries into the attacking forward 50", goals: "Goals kicked" },
heatmap: { disposals: "high-good", kicks: "high-good", handballs: "high-good", disposal_eff: "high-good", marks: "high-good", tackles: "high-good", clearances: "high-good", inside50s: "high-good", goals: "high-good" },
sortCol: "disposals"
},
Possession: {
columns: ["team", "gp", "disposals", "kicks", "handballs", "marks", "uncontested_possessions", "clangers", "turnovers"],
header: { team: "Team", gp: "GP", disposals: "Disposals", kicks: "Kicks", handballs: "Handballs", marks: "Marks", uncontested_possessions: "Uncontest. Poss.", clangers: "Clangers", turnovers: "Turnovers" },
groups: [{ label: "", span: 2 }, { label: "Possession", span: 7 }],
tooltip: { gp: "Games played", disposals: "Total disposals (kicks + handballs)", kicks: "Total kicks", handballs: "Total handballs", marks: "Total marks", uncontested_possessions: "Possessions gained without direct contest", clangers: "Turnovers from errors", turnovers: "Total turnovers" },
heatmap: { disposals: "high-good", kicks: "high-good", handballs: "high-good", marks: "high-good", uncontested_possessions: "high-good", clangers: "low-good", turnovers: "low-good" },
sortCol: "disposals"
},
Scoring: {
columns: ["team", "gp", "goals", "behinds", "shots_at_goal", "score_involvements", "goal_assists", "marks_inside50"],
header: { team: "Team", gp: "GP", goals: "Goals", behinds: "Behinds", shots_at_goal: "Shots", score_involvements: "Score Inv.", goal_assists: "Goal Assists", marks_inside50: "Marks I50" },
groups: [{ label: "", span: 2 }, { label: "Scoring", span: 6 }],
tooltip: { gp: "Games played", goals: "Goals kicked", behinds: "Behinds kicked", shots_at_goal: "Total shots at goal", score_involvements: "Involvements in a scoring chain", goal_assists: "Disposals directly leading to a goal", marks_inside50: "Marks taken inside the forward 50" },
heatmap: { goals: "high-good", behinds: "high-good", shots_at_goal: "high-good", score_involvements: "high-good", goal_assists: "high-good", marks_inside50: "high-good" },
sortCol: "goals"
},
Contested: {
columns: ["team", "gp", "contested_possessions", "contested_marks", "ground_ball_gets", "frees_for", "frees_against"],
header: { team: "Team", gp: "GP", contested_possessions: "Contested Poss.", contested_marks: "Contested Marks", ground_ball_gets: "Ground Ball Gets", frees_for: "Frees For", frees_against: "Frees Against" },
groups: [{ label: "", span: 2 }, { label: "Contested", span: 5 }],
tooltip: { gp: "Games played", contested_possessions: "Possessions won under direct physical pressure", contested_marks: "Marks taken under direct contest", ground_ball_gets: "Possessions gained from ground-level contests", frees_for: "Free kicks awarded", frees_against: "Free kicks conceded" },
heatmap: { contested_possessions: "high-good", contested_marks: "high-good", ground_ball_gets: "high-good", frees_for: "high-good", frees_against: "low-good" },
sortCol: "contested_possessions"
},
Midfield: {
columns: ["team", "gp", "clearances", "inside50s", "rebound50s", "bounces", "metres_gained"],
header: { team: "Team", gp: "GP", clearances: "Clearances", inside50s: "Inside 50s", rebound50s: "Rebound 50s", bounces: "Bounces", metres_gained: "Metres Gained" },
groups: [{ label: "", span: 2 }, { label: "Midfield", span: 5 }],
tooltip: { gp: "Games played", clearances: "Disposals from a stoppage that exit the congestion", inside50s: "Entries into the attacking forward 50", rebound50s: "Exits from the defensive 50", bounces: "Running bounces while in possession", metres_gained: "Net metres advanced toward goal" },
heatmap: { clearances: "high-good", inside50s: "high-good", rebound50s: "high-good", bounces: "high-good", metres_gained: "high-good" },
sortCol: "clearances"
},
Defense: {
columns: ["team", "gp", "tackles", "intercepts", "one_percenters", "pressure_acts", "def_half_pressure_acts"],
header: { team: "Team", gp: "GP", tackles: "Tackles", intercepts: "Intercepts", one_percenters: "One Pct.", pressure_acts: "Pressure Acts", def_half_pressure_acts: "Def Half PA" },
groups: [{ label: "", span: 2 }, { label: "Defense", span: 5 }],
tooltip: { gp: "Games played", tackles: "Tackles applied to an opponent in possession", intercepts: "Possessions gained from opposition territory", one_percenters: "Selfless acts — shepherds, smothers, spoils", pressure_acts: "Actions that apply physical or territorial pressure", def_half_pressure_acts: "Pressure acts in the defensive half" },
heatmap: { tackles: "high-good", intercepts: "high-good", one_percenters: "high-good", pressure_acts: "high-good", def_half_pressure_acts: "high-good" },
sortCol: "tackles"
},
Custom: null
})Show code
// ── Custom column picker ─────────────────────────────────────
viewof customCols = {
const allMetrics = []
const seen = new Set()
for (const [key, def] of Object.entries(catConfigs)) {
if (key === "Custom" || !def) continue
for (const col of def.columns) {
if (col === "team" || col === "gp" || seen.has(col)) continue
seen.add(col)
allMetrics.push({ col, label: def.header[col] || col, cat: key })
}
}
const MAX = 10
const selected = new Set()
const container = document.createElement("div")
container.className = "custom-col-picker"
const btn = document.createElement("button")
btn.className = "custom-col-btn"
btn.textContent = "Select columns..."
container.appendChild(btn)
const panel = document.createElement("div")
panel.className = "custom-col-panel"
panel.style.display = "none"
container.appendChild(panel)
// Group by category
const byCat = {}
for (const m of allMetrics) {
if (!byCat[m.cat]) byCat[m.cat] = []
byCat[m.cat].push(m)
}
const checkboxes = []
for (const [cat, metrics] of Object.entries(byCat)) {
const group = document.createElement("div")
group.className = "custom-col-group"
const heading = document.createElement("div")
heading.className = "custom-col-group-label"
heading.textContent = cat
group.appendChild(heading)
for (const m of metrics) {
const label = document.createElement("label")
label.className = "custom-col-item"
const cb = document.createElement("input")
cb.type = "checkbox"
cb.dataset.col = m.col
const span = document.createElement("span")
span.textContent = m.label
label.appendChild(cb)
label.appendChild(span)
group.appendChild(label)
checkboxes.push(cb)
cb.addEventListener("change", () => {
if (cb.checked) selected.add(m.col)
else selected.delete(m.col)
// Enforce max
for (const other of checkboxes) {
if (!other.checked) other.disabled = selected.size >= MAX
}
btn.textContent = selected.size === 0 ? "Select columns..." : `${selected.size} column${selected.size > 1 ? "s" : ""} selected`
container.value = [...selected]
container.dispatchEvent(new Event("input", { bubbles: true }))
})
}
panel.appendChild(group)
}
btn.addEventListener("click", (e) => {
e.stopPropagation()
panel.style.display = panel.style.display === "none" ? "block" : "none"
})
// Close panel when clicking outside (use AbortController to clean up on OJS re-evaluation)
const ac = new AbortController()
document.addEventListener("click", (e) => {
if (!container.contains(e.target)) panel.style.display = "none"
}, { signal: ac.signal })
invalidation.then(() => ac.abort())
container.value = []
return container
}Show code
Show code
// ── Build effective category definition for Custom tab ───────
effectiveCatDef = {
if (teamStatsCategory !== "Custom" || !customCols || customCols.length === 0) return null
const header = { team: "Team", gp: "GP" }
const heatmap = {}
const tooltip = { gp: "Games played" }
for (const col of customCols) {
for (const [key, def] of Object.entries(catConfigs)) {
if (key === "Custom" || !def) continue
if (def.columns.includes(col)) {
header[col] = def.header[col] || col
if (def.heatmap[col]) heatmap[col] = def.heatmap[col]
if (def.tooltip?.[col]) tooltip[col] = def.tooltip[col]
break
}
}
}
return {
columns: ["team", "gp", ...customCols],
header,
groups: [{ label: "", span: 2 }, { label: "Custom", span: customCols.length }],
heatmap,
tooltip,
sortCol: customCols[0]
}
}Show code
// Sum player-level stats per team with opponent cross-lookup for results
teamGameStats = {
if (!gameStats || !teamStatsSeason) return null
let seasonData = gameStats.filter(d => Number(d.season) === teamStatsSeason)
// Round range filter
if (teamRoundRange.min != null) seasonData = seasonData.filter(d => d.round >= teamRoundRange.min)
if (teamRoundRange.max != null) seasonData = seasonData.filter(d => d.round <= teamRoundRange.max)
// Season type filter (Regular / Finals)
if (teamSeasonType === "Regular") seasonData = seasonData.filter(d => !window.aflTeamMaps.isFinals(d))
if (teamSeasonType === "Finals") seasonData = seasonData.filter(d => window.aflTeamMaps.isFinals(d))
if (seasonData.length === 0) return null
const statCols = ["disposals", "kicks", "handballs", "marks", "tackles", "clearances",
"inside50s", "rebound50s", "contested_possessions", "uncontested_possessions",
"clangers", "turnovers", "metres_gained", "goals", "behinds", "shots_at_goal",
"score_involvements", "goal_assists", "marks_inside50", "contested_marks",
"ground_ball_gets", "frees_for", "frees_against", "intercepts", "one_percenters",
"pressure_acts", "def_half_pressure_acts", "hitouts", "hitouts_to_advantage", "ruck_contests", "bounces"]
// Normalize team/opponent names to display format so cross-lookup works
const norm = n => predToFull[n] || n
// Pass 1: per-match per-team totals, keyed by normalized team + round
const matchTeamMap = new Map() // normalizedTeam|||round -> entry
for (const row of seasonData) {
const t = norm(row.team)
if (!t) continue
const round = Number(row.round)
const key = t + "|||" + round
if (!matchTeamMap.has(key)) {
matchTeamMap.set(key, { team: t, round, opponent: norm(row.opponent), vals: {} })
for (const c of statCols) matchTeamMap.get(key).vals[c] = 0
}
const entry = matchTeamMap.get(key)
for (const c of statCols) {
entry.vals[c] = (entry.vals[c] || 0) + (Number(row[c]) || 0)
}
}
// Collect keys to remove, then delete after — avoids mutating Map during iteration
// which would break opponent cross-lookup in Pass 2
const keysToDelete = new Set()
// Home/Away filter
if (teamHAFilter !== "All") {
const target = teamHAFilter.toLowerCase()
for (const [key, mEntry] of matchTeamMap) {
const ha = teamHAMap.get(`${teamStatsSeason}-${mEntry.round}-${mEntry.team}`)
if (ha !== target) keysToDelete.add(key)
}
}
// Venue filter
if (teamVenueFilter !== "All Venues") {
for (const [key, mEntry] of matchTeamMap) {
if (keysToDelete.has(key)) continue
const fix = teamFixtureMap.get(`${teamStatsSeason}-${mEntry.round}-${mEntry.team}`)
if (!fix || fix.venue !== teamVenueFilter) keysToDelete.add(key)
}
}
// Day of week filter
if (teamDayFilter !== "All Days") {
const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
for (const [key, mEntry] of matchTeamMap) {
if (keysToDelete.has(key)) continue
const fix = teamFixtureMap.get(`${teamStatsSeason}-${mEntry.round}-${mEntry.team}`)
if (!fix || !fix.start_time) { keysToDelete.add(key); continue }
const dt = new Date(fix.start_time)
if (dayNames[dt.getDay()] !== teamDayFilter) keysToDelete.add(key)
}
}
for (const key of keysToDelete) matchTeamMap.delete(key)
// Pass 2: aggregate into team totals with results cross-lookup
const byTeam = new Map()
for (const [, mEntry] of matchTeamMap) {
const team = mEntry.team
const matchId = mEntry.round + "|" + mEntry.opponent
if (!byTeam.has(team)) {
byTeam.set(team, { games: new Set(), totals: {}, w: 0, l: 0, d: 0, pf: 0, pa: 0, gf: 0, ga: 0 })
for (const c of statCols) byTeam.get(team).totals[c] = 0
}
const entry = byTeam.get(team)
entry.games.add(matchId)
for (const c of statCols) entry.totals[c] += mEntry.vals[c]
// Cross-lookup: opponent's entry uses same normalized names
const oppKey = mEntry.opponent + "|||" + mEntry.round
const oppEntry = matchTeamMap.get(oppKey)
if (!oppEntry) console.warn(`[team-stats] No opponent data for ${mEntry.opponent} in round ${mEntry.round}`)
const teamScore = (mEntry.vals.goals || 0) * 6 + (mEntry.vals.behinds || 0)
const oppScore = oppEntry ? ((oppEntry.vals.goals || 0) * 6 + (oppEntry.vals.behinds || 0)) : 0
entry.pf += teamScore
entry.pa += oppScore
entry.gf += mEntry.vals.goals || 0
entry.ga += oppEntry ? (oppEntry.vals.goals || 0) : 0
if (teamScore > oppScore) entry.w++
else if (teamScore < oppScore) entry.l++
else entry.d++
}
// Pass 3: build output rows
const isAvg = teamAggMode === "avg"
const teams = []
for (const [team, entry] of byTeam) {
const gp = entry.games.size
if (gp === 0) continue
const row = { team, gp }
for (const c of statCols) {
row[c] = isAvg ? +(entry.totals[c] / gp).toFixed(3) : entry.totals[c]
}
// Disposal efficiency (always a percentage)
const totalDisp = entry.totals.disposals
const totalClang = entry.totals.clangers
row.disposal_eff = totalDisp > 0 ? +((totalDisp - totalClang) / totalDisp * 100).toFixed(1) : 0
// Results columns — W/L/D always totals, PF/PA/GF/GA respect aggMode
row.w = entry.w
row.l = entry.l
row.d = entry.d
row.win_pct = gp > 0 ? +((entry.w / gp) * 100).toFixed(1) : 0
row.pf = isAvg ? +(entry.pf / gp).toFixed(1) : entry.pf
row.pa = isAvg ? +(entry.pa / gp).toFixed(1) : entry.pa
row.pd = isAvg ? +((entry.pf - entry.pa) / gp).toFixed(1) : entry.pf - entry.pa
row.gf = isAvg ? +(entry.gf / gp).toFixed(1) : entry.gf
row.ga = isAvg ? +(entry.ga / gp).toFixed(1) : entry.ga
teams.push(row)
}
return teams
}Show code
teamAsAtLabel = {
if (!gameStats) return ""
const seasonData = gameStats.filter(d => Number(d.season) === teamStatsSeason)
if (seasonData.length === 0) return ""
const maxRound = Math.max(...seasonData.map(d => d.round).filter(r => r != null))
return isFinite(maxRound) ? `As at Round ${maxRound}, ${teamStatsSeason}` : ""
}
html`${teamAsAtLabel ? `<div class="page-legend"><span>${teamAsAtLabel}</span></div>` : ""}`Show code
// ── View toggle (Table / Scatter) ───────────────────────────
{
if (!window._teamStatsView) window._teamStatsView = "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._teamStatsView ? " active" : "")
btn.textContent = label
btn.addEventListener("click", () => {
container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
window._teamStatsView = label
const isTable = label === "Table"
const tableView = document.querySelector(".team-stats-table-view")
const scatterView = document.querySelector(".team-stats-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 (!teamGameStats || teamGameStats.length === 0) return html``
const config = teamStatsCategory === "Custom" ? effectiveCatDef : catConfigs[teamStatsCategory]
if (!config) return html``
const statCols = config.columns.filter(c => c !== "team" && c !== "gp" && teamGameStats[0] && teamGameStats[0][c] !== undefined)
const metricOpts = statCols.map(c => ({ value: c, label: config.header[c] || c }))
if (metricOpts.length < 1) return html``
const defaultX = metricOpts[0]?.value
const defaultY = 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-stats-scatter-view"
wrapper.style.display = window._teamStatsView === "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: teamGameStats,
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
{
if (!teamGameStats) return html``
const teamLink = window.aflTeamMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)
// Handle Custom tab
if (teamStatsCategory === "Custom") {
if (!effectiveCatDef) return html`<p class="text-muted">Select up to 10 columns above to build your custom table.</p>`
const customEl = statsTable(teamSearch, {
...effectiveCatDef,
tooltip: effectiveCatDef.tooltip || {},
render: { team: teamLink },
heatmapData: teamGameStats,
filters: {
...(effectiveCatDef.sortCol ? { [effectiveCatDef.sortCol]: "range" } : {}),
gp: "range"
},
sort: effectiveCatDef.sortCol,
reverse: true,
rows: 20
})
const wrapC = document.createElement("div")
wrapC.className = "team-stats-table-view"
wrapC.style.display = window._teamStatsView === "Table" ? "" : "none"
wrapC.appendChild(customEl)
return wrapC
}
const config = catConfigs[teamStatsCategory]
if (!config) return html``
const tableEl = statsTable(teamSearch, {
...config,
format: config.format || {},
tooltip: config.tooltip || {},
render: { team: teamLink },
heatmapData: teamGameStats,
filters: {
...(config.sortCol ? { [config.sortCol]: "range" } : {}),
gp: "range"
},
sort: config.sortCol,
reverse: true,
rows: 20
})
const wrap = document.createElement("div")
wrap.className = "team-stats-table-view"
wrap.style.display = window._teamStatsView === "Table" ? "" : "none"
wrap.appendChild(tableEl)
return wrap
}