AFL Player Stats
Interactive AFL player stats explorer with EPV values and box-score stats
AFL > Player Stats
Show code
predictions = {
try {
return await window.fetchParquet(base + "afl/predictions.parquet")
} catch (e) {
console.warn("[players] predictions load failed:", e)
return null
}
}
fixturesHistory = {
try {
return await window.fetchParquet(base + "afl/fixtures-history.parquet")
} catch (e) {
console.warn("[players] fixtures-history load failed:", e)
return null
}
}
gameLogs = {
try {
return await window.fetchParquet(base + "afl/game-logs.parquet")
} catch (e) {
console.error("[players] game-logs load failed:", e)
return null
}
}
gameStats = {
try {
return await window.fetchParquet(base + "afl/game-stats.parquet")
} catch (e) {
console.warn("[players] game-stats load failed:", e)
return null
}
}
playerDetails = {
try {
return await window.fetchParquet(base + "afl/player-details.parquet")
} catch (e) {
console.warn("[players] player-details load failed:", e)
return null
}
}
ratings = {
try {
return await window.fetchParquet(base + "afl/ratings.parquet")
} catch (e) {
console.warn("[players] ratings load failed:", e)
return null
}
}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
}
// Name map from details (fallback for game-stats which may not have names)
nameMap = {
const m = new Map()
if (playerDetails) {
for (const d of playerDetails) m.set(d.player_id, d.player_name)
}
if (ratings) {
for (const r of ratings) m.set(r.player_id, r.player_name)
}
return m
}
// Position map from ratings — sort so latest round wins
posMap = {
const m = new Map()
if (ratings) {
const sorted = [...ratings].sort((a, b) => (a.season - b.season) || (a.round - b.round))
for (const r of sorted) m.set(r.player_id, r.position_group)
}
return m
}
// Team map (most recent) from ratings, then game-stats
teamMap = {
const m = new Map()
if (gameStats) {
const sorted = [...gameStats].sort((a, b) => (a.season - b.season) || (a.round - b.round))
for (const g of sorted) m.set(g.player_id, g.team)
}
if (ratings) {
// Latest round ratings override
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 }
const curr = maxRound > 0 ? latest.filter(d => d.round === maxRound) : latest
for (const r of curr) m.set(r.player_id, r.team)
}
return m
}
// "As at" label
asAtLabel = {
// Use game-logs for EPV data range
const sources = [gameLogs, gameStats].filter(Boolean)
for (const src of sources) {
if (src.length > 0) {
let maxSeason = -Infinity; for (const d of src) { if (d.season > maxSeason) maxSeason = d.season }
let maxRound = -Infinity; for (const d of src) { if (d.season === maxSeason && d.round > maxRound) maxRound = d.round }
return `As at Round ${maxRound}, ${maxSeason}`
}
}
if (ratings && ratings.length > 0) {
let maxSeason = -Infinity; for (const d of ratings) { if (d.season > maxSeason) maxSeason = d.season }
return `Season ${maxSeason}`
}
return ""
}Show code
Show code
Show code
catKeys = Object.keys(defs).filter(k => !defs[k].page)
viewof category = {
const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
const _saved = window[_key] || "value"
const _default = catKeys.includes(_saved) ? _saved : "value"
const container = html`<div class="stats-category-toggle"></div>`
for (const key of catKeys) {
const btn = document.createElement("button")
btn.className = "stats-cat-btn" + (key === _default ? " active" : "")
btn.textContent = defs[key].label
btn.dataset.cat = key
btn.addEventListener("click", () => {
container.querySelectorAll(".stats-cat-btn").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
container.value = key
window[_key] = key
container.dispatchEvent(new Event("input", { bubbles: true }))
})
container.appendChild(btn)
}
container.value = _default
return container
}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
seasonOptions = {
const seasons = new Set()
if (gameLogs) gameLogs.forEach(d => seasons.add(String(d.season)))
if (gameStats) gameStats.forEach(d => seasons.add(String(d.season)))
return ["All Seasons", ...[...seasons].sort((a, b) => b - a)]
}
teamOptions = {
const teams = new Set()
if (ratings) ratings.forEach(d => { const f = predToFull[d.team] || d.team; if (f) teams.add(f) })
if (gameStats) gameStats.forEach(d => { const f = predToFull[d.team] || d.team; if (f) teams.add(f) })
if (gameLogs) gameLogs.forEach(d => { const f = predToFull[d.team] || d.team; if (f) teams.add(f) })
return ["All Teams", ...[...teams].sort()]
}
oppOptions = {
const opps = new Set()
if (gameStats) gameStats.forEach(d => { if (d.opponent) opps.add(d.opponent) })
if (gameLogs) gameLogs.forEach(d => { if (d.opp) opps.add(d.opp) })
return ["All Opponents", ...[...opps].sort()]
}
// Build home/away lookup: "season-round-team" → "home"|"away"
homeAwayMap = {
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
}
// Build venue + day-of-week lookup from fixtures-history
fixtureVenueMap = {
const m = new Map()
if (fixturesHistory) {
for (const f of fixturesHistory) {
// Key by both home and away team
m.set(`${f.season}-${f.round}-${f.home_team}`, { venue: f.venue, start_time: f.start_time })
m.set(`${f.season}-${f.round}-${f.away_team}`, { venue: f.venue, start_time: f.start_time })
}
}
return m
}
venueOptions = {
if (!fixturesHistory) return ["All Venues"]
const venues = [...new Set(fixturesHistory.map(f => f.venue).filter(Boolean))].sort()
return ["All Venues", ...venues]
}
dayOptions = ["All Days", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]Show code
viewof filters = {
const defaultSeason = seasonOptions[1] || "All Seasons"
// Compute round range from data for a given season
function getRoundRange(season) {
const allGames = []
if (gameStats) allGames.push(...(season === "All Seasons" ? gameStats : gameStats.filter(d => String(d.season) === season)))
if (gameLogs) allGames.push(...(season === "All Seasons" ? gameLogs : gameLogs.filter(d => String(d.season) === season)))
let rMin = Infinity, rMax = -Infinity
for (const d of allGames) { if (d.round != null) { if (d.round < rMin) rMin = d.round; if (d.round > rMax) rMax = d.round } }
return { min: rMin === Infinity ? 0 : rMin, max: rMax === -Infinity ? 30 : rMax }
}
let roundBounds = getRoundRange(defaultSeason)
// ── Build custom select ──
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"
// Row 1: Season, Team, Opponent
const row1 = document.createElement("div")
row1.className = "filter-row"
const season = makeSelect(seasonOptions, defaultSeason, "Season")
const team = makeSelect(teamOptions, "All Teams", "Team")
const opp = makeSelect(oppOptions, "All Opponents", "Vs")
const haSelect = makeSelect(["All", "Home", "Away"], "All", "H/A")
const seasonType = makeSelect(["All", "Regular", "Finals"], "All", "Type")
const venueSelect = makeSelect(venueOptions, "All Venues", "Venue")
const daySelect = makeSelect(dayOptions, "All Days", "Day")
row1.appendChild(season.wrap)
row1.appendChild(team.wrap)
row1.appendChild(opp.wrap)
row1.appendChild(haSelect.wrap)
row1.appendChild(seasonType.wrap)
row1.appendChild(venueSelect.wrap)
row1.appendChild(daySelect.wrap)
// Round range
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)
row1.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 btnP80 = document.createElement("button")
btnP80.className = "agg-btn"
btnP80.textContent = "Per 80"
btnP80.dataset.mode = "p80"
const btnTot = document.createElement("button")
btnTot.className = "agg-btn active"
btnTot.textContent = "Total"
btnTot.dataset.mode = "total"
aggWrap.appendChild(btnAvg)
aggWrap.appendChild(btnP80)
aggWrap.appendChild(btnTot)
row1.appendChild(aggWrap)
container.appendChild(row1)
// ── State and dispatch ──
container.value = {
season: defaultSeason,
team: "All Teams",
opponent: "All Opponents",
homeAway: "All",
seasonType: "All",
venue: "All Venues",
day: "All Days",
roundMin: +rMin.value,
roundMax: +rMax.value,
aggMode: "total"
}
function emit() {
container.dispatchEvent(new Event("input", { bubbles: true }))
}
season.sel.addEventListener("change", () => {
// Recalculate round bounds for new season
roundBounds = getRoundRange(season.sel.value)
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: season.sel.value,
roundMin: roundBounds.min,
roundMax: roundBounds.max
}
emit()
})
team.sel.addEventListener("change", () => {
container.value = { ...container.value, team: team.sel.value }
emit()
})
opp.sel.addEventListener("change", () => {
container.value = { ...container.value, opponent: opp.sel.value }
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()
})
let roundTimer
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
}
function updateRound() {
clearTimeout(roundTimer)
roundTimer = setTimeout(() => {
clampRound(rMin)
clampRound(rMax)
container.value = {
...container.value,
roundMin: rMin.value === "" ? null : +rMin.value,
roundMax: rMax.value === "" ? null : +rMax.value
}
emit()
}, 500)
}
rMin.addEventListener("input", updateRound)
rMax.addEventListener("input", updateRound)
rMin.addEventListener("blur", () => { clampRound(rMin); updateRound() })
rMax.addEventListener("blur", () => { clampRound(rMax); updateRound() })
;[btnAvg, btnP80, btnTot].forEach(btn => {
btn.addEventListener("click", () => {
aggWrap.querySelectorAll(".agg-btn").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
container.value = { ...container.value, aggMode: btn.dataset.mode }
emit()
})
})
return container
}
// ── Destructure for downstream reactivity ──
seasonFilter = filters.season
teamFilter = filters.team
oppFilter = filters.opponent
homeAwayFilter = filters.homeAway
venueFilter = filters.venue
dayFilter = filters.day
seasonTypeFilter = filters.seasonType
roundRange = ({ min: filters.roundMin, max: filters.roundMax })
aggMode = filters.aggModeShow code
catDef = defs[category]
tableData = {
// Custom tab has its own data pipeline (customTableData)
if (category === "custom") return null
const effectiveSeason = seasonFilter === "All Seasons" ? null : Number(seasonFilter)
const source = catDef.source
// Pick the right data source
const rawData = source === "gameLogs" ? gameLogs : gameStats
if (!rawData) return null
let games = rawData
// Season filter
if (effectiveSeason) games = games.filter(d => d.season === effectiveSeason)
// Round range filter
if (roundRange.min != null) games = games.filter(d => d.round >= roundRange.min)
if (roundRange.max != null) games = games.filter(d => d.round <= roundRange.max)
// Season type filter (Regular / Finals)
if (seasonTypeFilter === "Regular") games = games.filter(d => !window.aflTeamMaps.isFinals(d))
if (seasonTypeFilter === "Finals") games = games.filter(d => window.aflTeamMaps.isFinals(d))
// Team filter
if (teamFilter !== "All Teams") {
const pred = fullToPred[teamFilter] || teamFilter
games = games.filter(d => d.team === pred || (predToFull[d.team] || d.team) === teamFilter)
}
// Opponent filter (game-stats uses "opponent", game-logs uses "opp")
if (oppFilter !== "All Opponents") {
games = games.filter(d => (d.opponent || d.opp) === oppFilter)
}
// Home/Away filter (derived from predictions data)
// Normalize d.team to full name since homeAwayMap/fixtureVenueMap use full names
if (homeAwayFilter !== "All") {
const target = homeAwayFilter.toLowerCase()
games = games.filter(d => {
const teamFull = predToFull[d.team] || d.team
const ha = homeAwayMap.get(`${d.season}-${d.round}-${teamFull}`)
return ha === target
})
}
// Venue filter (from fixtures-history)
if (venueFilter !== "All Venues") {
games = games.filter(d => {
const teamFull = predToFull[d.team] || d.team
const fix = fixtureVenueMap.get(`${d.season}-${d.round}-${teamFull}`)
return fix && fix.venue === venueFilter
})
}
// Day of week filter (from fixtures-history start_time)
if (dayFilter !== "All Days") {
const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
games = games.filter(d => {
const teamFull = predToFull[d.team] || d.team
const fix = fixtureVenueMap.get(`${d.season}-${d.round}-${teamFull}`)
if (!fix || !fix.start_time) return false
const dt = new Date(fix.start_time)
return dayNames[dt.getDay()] === dayFilter
})
}
// Position filter (use posMap from ratings)
if (posFilter !== "All") {
const fullKeys = Object.entries(posAbbr).filter(([k, v]) => v === posFilter).map(([k]) => k)
games = games.filter(d => fullKeys.includes(posMap.get(d.player_id)))
}
// Group by player_id, compute avg or total
const statCols = catDef.columns
const grouped = new Map()
for (const g of games) {
if (!grouped.has(g.player_id)) {
grouped.set(g.player_id, { vals: {}, count: 0, togSum: 0, team: g.team, player_name: g.player_name || nameMap.get(g.player_id) })
for (const col of statCols) grouped.get(g.player_id).vals[col] = 0
}
const entry = grouped.get(g.player_id)
entry.count++
entry.team = g.team
if (g.player_name) entry.player_name = g.player_name
if (g.time_on_ground_percentage != null) entry.togSum += g.time_on_ground_percentage
for (const col of statCols) {
const v = Number(g[col])
if (!isNaN(v)) entry.vals[col] += v
}
}
// If source is gameLogs, build TOG lookup from game-stats (gameLogs has no TOG)
const togLookup = new Map()
if (source === "gameLogs" && gameStats) {
let togGames = gameStats
if (effectiveSeason) togGames = togGames.filter(d => d.season === effectiveSeason)
if (roundRange.min != null) togGames = togGames.filter(d => d.round >= roundRange.min)
if (roundRange.max != null) togGames = togGames.filter(d => d.round <= roundRange.max)
if (seasonTypeFilter === "Regular") togGames = togGames.filter(d => !window.aflTeamMaps.isFinals(d))
if (seasonTypeFilter === "Finals") togGames = togGames.filter(d => window.aflTeamMaps.isFinals(d))
for (const g of togGames) {
if (g.time_on_ground_percentage == null) continue
if (!togLookup.has(g.player_id)) togLookup.set(g.player_id, { sum: 0, n: 0 })
const t = togLookup.get(g.player_id)
t.sum += g.time_on_ground_percentage
t.n++
}
}
const dp = catDef.format ? 3 : 2
const result = []
for (const [pid, entry] of grouped) {
// TOG: use togSum from grouped data (game-stats source), or togLookup (game-logs source)
let avgTog = null
if (entry.togSum > 0 && entry.count > 0) {
avgTog = Math.round(entry.togSum / entry.count)
} else if (togLookup.has(pid)) {
const t = togLookup.get(pid)
avgTog = t.n > 0 ? Math.round(t.sum / t.n) : null
}
const row = {
player_id: pid,
player_name: entry.player_name || nameMap.get(pid) || pid,
team: entry.team,
position_group: posMap.get(pid) || "",
age: ageMap.get(pid) ?? null,
gp: entry.count,
avg_tog: avgTog
}
const togFrac = avgTog != null && avgTog > 0 ? avgTog / 100 : 1
const estMins = entry.count * 80 * togFrac
for (const col of statCols) {
if (aggMode === "avg") {
row[col] = +(entry.vals[col] / entry.count).toFixed(dp)
} else if (aggMode === "p80") {
row[col] = estMins > 0 ? +(entry.vals[col] / estMins * 80).toFixed(dp) : 0
} else {
row[col] = +entry.vals[col].toFixed(dp)
}
}
result.push(row)
}
return result
}Show code
// ── Custom column picker (shown only for Custom tab) ─────────
viewof customCols = {
// Always build the picker (OJS caches viewof results).
// Visibility controlled by CSS class.
// Build all available metrics from non-custom, non-ratings categories
const allMetrics = []
for (const [key, def] of Object.entries(defs)) {
if (key === "custom" || def.page) continue
for (const col of def.columns) {
allMetrics.push({ col, label: def.header[col] || col, cat: def.label, catKey: key, source: def.source })
}
}
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
cb.dataset.source = m.source
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 catDef for custom tab ────────────────────
effectiveCatDef = {
if (category !== "custom" || !customCols || customCols.length === 0) return catDef
// Build a virtual catDef from selected columns
const header = {}
const heatmap = {}
const format = {}
const tooltip = {}
const sources = new Set()
for (const col of customCols) {
for (const [key, def] of Object.entries(defs)) {
if (key === "custom" || def.page) continue
if (def.columns.includes(col)) {
header[col] = def.header[col] || col
if (def.heatmap[col]) heatmap[col] = def.heatmap[col]
if (def.format && def.format[col]) format[col] = def.format[col]
if (def.tooltip && def.tooltip[col]) tooltip[col] = def.tooltip[col]
sources.add(def.source)
break
}
}
}
return {
label: "Custom",
source: sources.size === 1 ? [...sources][0] : "mixed",
columns: customCols,
header,
heatmap,
format,
tooltip,
sortCol: customCols[0] || null
}
}Show code
// ── Custom tab mixed-source aggregation ──────────────────────
customTableData = {
if (category !== "custom" || !customCols || customCols.length === 0) return null
const effectiveSeason = seasonFilter === "All Seasons" ? null : Number(seasonFilter)
// Determine which columns come from which source
const logCols = []
const statCols = []
for (const col of customCols) {
for (const [key, def] of Object.entries(defs)) {
if (key === "custom" || def.page) continue
if (def.columns.includes(col)) {
if (def.source === "gameLogs") logCols.push(col)
else statCols.push(col)
break
}
}
}
function filterGames(src) {
if (!src) return []
let games = src
if (effectiveSeason) games = games.filter(d => d.season === effectiveSeason)
if (roundRange.min != null) games = games.filter(d => d.round >= roundRange.min)
if (roundRange.max != null) games = games.filter(d => d.round <= roundRange.max)
if (seasonTypeFilter === "Regular") games = games.filter(d => !window.aflTeamMaps.isFinals(d))
if (seasonTypeFilter === "Finals") games = games.filter(d => window.aflTeamMaps.isFinals(d))
if (teamFilter !== "All Teams") {
const pred = fullToPred[teamFilter] || teamFilter
games = games.filter(d => d.team === pred || (predToFull[d.team] || d.team) === teamFilter)
}
if (oppFilter !== "All Opponents") {
games = games.filter(d => (d.opponent || d.opp) === oppFilter)
}
if (posFilter !== "All") {
const fullKeys = Object.entries(posAbbr).filter(([k, v]) => v === posFilter).map(([k]) => k)
games = games.filter(d => fullKeys.includes(posMap.get(d.player_id)))
}
return games
}
function aggregate(games, cols) {
const grouped = new Map()
for (const g of games) {
if (!grouped.has(g.player_id)) {
grouped.set(g.player_id, { vals: {}, count: 0, togSum: 0, team: g.team, player_name: g.player_name || nameMap.get(g.player_id) })
for (const col of cols) grouped.get(g.player_id).vals[col] = 0
}
const entry = grouped.get(g.player_id)
entry.count++
entry.team = g.team
if (g.player_name) entry.player_name = g.player_name
if (g.time_on_ground_percentage != null) entry.togSum += g.time_on_ground_percentage
for (const col of cols) {
const v = Number(g[col])
if (!isNaN(v)) entry.vals[col] += v
}
}
return grouped
}
const isAvg = aggMode === "avg"
const logAgg = logCols.length > 0 ? aggregate(filterGames(gameLogs), logCols) : new Map()
const statAgg = statCols.length > 0 ? aggregate(filterGames(gameStats), statCols) : new Map()
// Always aggregate game-stats for TOG (even when stat columns are empty, e.g. Value tab)
const togAgg = statAgg.size > 0 ? statAgg : (gameStats ? aggregate(filterGames(gameStats), []) : new Map())
// Merge player IDs from both sources
const allIds = new Set([...logAgg.keys(), ...statAgg.keys()])
const result = []
for (const pid of allIds) {
const logEntry = logAgg.get(pid)
const statEntry = statAgg.get(pid)
const entry = logEntry || statEntry
const gpLog = logEntry ? logEntry.count : 0
const gpStat = statEntry ? statEntry.count : 0
const gp = Math.max(gpLog, gpStat)
// TOG from game-stats (togAgg always has time_on_ground_percentage)
const togEntry = togAgg.get(pid)
const avgTog = togEntry && togEntry.count > 0 ? Math.round(togEntry.togSum / togEntry.count) : null
const row = {
player_id: pid,
player_name: entry.player_name || nameMap.get(pid) || pid,
team: entry.team,
position_group: posMap.get(pid) || "",
age: ageMap.get(pid) ?? null,
gp,
avg_tog: avgTog
}
const togFrac = avgTog != null && avgTog > 0 ? avgTog / 100 : 1
const estMinsLog = gpLog * 80 * togFrac
for (const col of logCols) {
const v = logEntry ? logEntry.vals[col] : 0
const cnt = logEntry ? logEntry.count : 1
if (aggMode === "avg") row[col] = +(v / cnt).toFixed(3)
else if (aggMode === "p80") row[col] = estMinsLog > 0 ? +(v / estMinsLog * 80).toFixed(3) : 0
else row[col] = +v.toFixed(2)
}
const estMinsStat = gpStat * 80 * togFrac
for (const col of statCols) {
const v = statEntry ? statEntry.vals[col] : 0
const cnt = statEntry ? statEntry.count : 1
if (aggMode === "avg") row[col] = +(v / cnt).toFixed(3)
else if (aggMode === "p80") row[col] = estMinsStat > 0 ? +(v / estMinsStat * 80).toFixed(3) : 0
else row[col] = +v.toFixed(1)
}
result.push(row)
}
return result
}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(".stats-table-view")
const scatterView = document.querySelector(".stats-scatter-view")
if (tableView) tableView.style.display = isTable ? "" : "none"
if (scatterView) scatterView.style.display = isTable ? "none" : ""
})
container.appendChild(btn)
}
return container
}Show code
activeData = category === "custom" ? customTableData : tableData
// Always return a real Inputs.search element — see football/player-stats.qmd for the
// long-form rationale (empty html`` span has no `.value`, freezes downstream cells).
viewof search = Inputs.search(activeData || [], { placeholder: "Search players…" })Show code
// ── Scatter plot (always renders, starts hidden) ─────────────
{
const data = activeData
const def = category === "custom" ? effectiveCatDef : catDef
if (!data || data.length === 0 || !def) return html``
const statCols = def.columns.filter(c => data[0] && data[0][c] !== undefined)
const metricOpts = statCols.map(c => ({ value: c, label: def.header[c] || c }))
if (metricOpts.length === 0) return html``
const defaultX = metricOpts[0]?.value
const defaultY = metricOpts[1]?.value || metricOpts[0]?.value
const headerSrc = {}
for (const m of metricOpts) headerSrc[m.value] = m.label
const wrapper = document.createElement("div")
wrapper.className = "stats-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)
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) {
while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
const filtered = activePositions.size === legendPositions.length ? data : data.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 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)
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))
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 ─────────────────────────────────────────────
{
const data = activeData
const def = category === "custom" ? effectiveCatDef : catDef
if (category === "custom" && (!customCols || customCols.length === 0)) {
return html`<p class="text-muted">Select up to 10 columns above to build your custom table.</p>`
}
if (!data || data.length === 0) {
const source = def?.source
const effectiveSeason = seasonFilter === "All Seasons" ? null : Number(seasonFilter)
const gsSeasons = gameStats ? [...new Set(gameStats.map(d => d.season))] : []
const noDataForSeason = source === "gameStats" && effectiveSeason && !gsSeasons.includes(effectiveSeason)
if (noDataForSeason) {
const availYears = gsSeasons.sort((a, b) => b - a).join(", ")
return html`<p class="text-muted">Box-score stats are only available for seasons ${availYears}. Try the <strong>Value</strong> tab for all seasons (2021+).</p>`
}
return html`<p class="text-muted">No data available. Try adjusting filters or refreshing the page.</p>`
}
const posBadge = (val) => window.posBadge(val, aflPosColors)
// Build column list: player info + stat columns
const statCols = def.columns.filter(c => {
return data[0] && data[0][c] !== undefined
})
const columns = ["player_name", "position_group", "age", "gp", "avg_tog", ...statCols]
const header = {
player_name: "Player",
position_group: "Pos",
age: "Age",
gp: "GP",
avg_tog: "TOG",
...def.header
}
// Use catDef.groups if defined (e.g. merged Value tab), else single group
const groups = def.groups
? [{ label: "", span: 5 }, ...def.groups]
: [
{ label: "", span: 5 },
{ label: def.label, span: statCols.length }
]
// Format: use catDef.format for decimal places, default 1dp
const format = { age: x => x?.toFixed(1) ?? "", avg_tog: x => x != null ? x + "%" : "" }
if (def.format && Object.keys(def.format).length > 0) {
for (const [col, dp] of Object.entries(def.format)) {
if (statCols.includes(col)) format[col] = x => x?.toFixed(dp) ?? ""
}
} else {
for (const col of statCols) {
format[col] = x => x?.toFixed(1) ?? ""
}
}
const mCols = def.mobileCols ? ["player_name", "position_group", "gp", ...def.mobileCols] : null
const tableEl = statsTable(search, {
columns,
mobileCols: mCols,
header,
groups,
format,
tooltip: { avg_tog: "Average time on ground percentage", ...(def.tooltip || {}) },
render: {
player_name: (v, row) => window.aflTeamMaps.renderPlayerCell(v, row),
position_group: posBadge
},
heatmap: def.heatmap || {},
heatmapData: data,
filters: {
age: "range",
...(def.sortCol ? { [def.sortCol]: "range" } : {}),
gp: "range"
},
sort: def.sortCol,
reverse: true,
rows: 25
})
const wrap = document.createElement("div")
wrap.className = "stats-table-view"
wrap.style.display = window["_viewMode_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")] === "Table" ? "" : "none"
wrap.appendChild(tableEl)
return wrap
}