AFL Player Game Logs
Individual game performances — find the best single-game performances across AFL
AFL > Player Game Logs
Individual game performances — each row is one player in one match. Sort by any stat column to find the best single-game performances (e.g., most disposals, highest EPV). High = above average for that stat. Low = below average.
Show code
gameLogs = {
try {
return await window.fetchParquet(base + "afl/game-logs.parquet")
} catch (e) {
console.error("[game-logs] game-logs load failed:", e)
return null
}
}
gameStats = {
try {
return await window.fetchParquet(base + "afl/game-stats.parquet")
} catch (e) {
console.warn("[game-logs] game-stats load failed:", e)
return null
}
}
playerDetails = {
try {
return await window.fetchParquet(base + "afl/player-details.parquet")
} catch (e) {
console.warn("[game-logs] player-details load failed:", e)
return null
}
}
ratings = {
try {
return await window.fetchParquet(base + "afl/ratings.parquet")
} catch (e) {
console.warn("[game-logs] ratings load failed:", e)
return null
}
}
predictions = {
try {
return await window.fetchParquet(base + "afl/predictions.parquet")
} catch (e) {
console.warn("[game-logs] predictions load failed:", e)
return null
}
}Show code
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
}
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
}
// Home/Away lookup from predictions
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
}Show code
categories = Object.entries(defs)
.filter(([k, v]) => v.source !== "ratings" && k !== "custom")
.map(([k, v]) => ({ key: k, label: v.label }))
viewof category = {
const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
const _saved = window[_key] || categories[0].key
const _default = categories.some(c => c.key === _saved) ? _saved : categories[0].key
const wrap = document.createElement("div")
wrap.className = "epv-toggle"
wrap.value = _default
for (const cat of categories) {
const btn = document.createElement("button")
btn.className = "epv-toggle-btn" + (cat.key === _default ? " active" : "")
btn.textContent = cat.label
btn.addEventListener("click", () => {
wrap.querySelectorAll(".epv-toggle-btn").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
wrap.value = cat.key
window[_key] = cat.key
wrap.dispatchEvent(new Event("input", { bubbles: true }))
})
wrap.appendChild(btn)
}
return wrap
}Show code
seasonOptions = {
const src = gameStats || gameLogs
if (!src) return ["All Seasons"]
const seasons = [...new Set(src.map(d => d.season))].sort((a, b) => b - a)
return ["All Seasons", ...seasons.map(String)]
}
teamOptions = {
const src = gameStats || gameLogs
if (!src) return ["All Teams"]
const teams = [...new Set(src.map(d => predToFull[d.team] || d.team))].filter(Boolean).sort()
return ["All Teams", ...teams]
}Show code
viewof filters = {
const defaultSeason = seasonOptions[1] || "All Seasons"
function getRoundRange(season) {
const src = gameStats || gameLogs
if (!src) return { min: 0, max: 30 }
const allGames = season === "All Seasons" ? src : src.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)
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 row = document.createElement("div")
row.className = "filter-row"
const season = makeSelect(seasonOptions, defaultSeason, "Season")
const team = makeSelect(teamOptions, "All Teams", "Team")
const haSelect = makeSelect(["All", "Home", "Away"], "All", "H/A")
const seasonType = makeSelect(["All", "Regular", "Finals"], "All", "Type")
row.appendChild(season.wrap)
row.appendChild(team.wrap)
row.appendChild(haSelect.wrap)
row.appendChild(seasonType.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)
row.appendChild(roundWrap)
container.appendChild(row)
container.value = {
season: defaultSeason,
team: "All Teams",
homeAway: "All",
seasonType: "All",
roundMin: +rMin.value,
roundMax: +rMax.value
}
function emit() { container.dispatchEvent(new Event("input", { bubbles: true })) }
season.sel.addEventListener("change", () => {
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() })
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() })
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() })
return container
}
seasonFilter = filters.season
teamFilter = filters.team
homeAwayFilter = filters.homeAway
seasonTypeFilter = filters.seasonType
roundRange = ({ min: filters.roundMin, max: filters.roundMax })Show code
// ── Build game log table data ────────────────────────────────
tableData = {
const catDef = defs[category]
if (!catDef) return null
const source = catDef.source
const rawData = source === "gameLogs" ? gameLogs : gameStats
if (!rawData) return null
const effectiveSeason = seasonFilter === "All Seasons" ? null : Number(seasonFilter)
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
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)
}
// Home/Away filter
if (homeAwayFilter !== "All") {
const target = homeAwayFilter.toLowerCase()
games = games.filter(d => {
const teamFull = predToFull[d.team] || d.team
return homeAwayMap.get(`${d.season}-${d.round}-${teamFull}`) === target
})
}
// Build rows — no aggregation, each game is its own row
const statCols = catDef.columns
const dp = catDef.format ? 3 : 1
const posAbbr = {
"Key Defender": "KD", "Medium Defender": "MDEF", "Midfielder": "MID",
"Medium Forward": "MFWD", "Key Forward": "KF", "Ruck": "RK",
"Wingman": "WNG",
"KEY_DEFENDER": "KD", "MEDIUM_DEFENDER": "MDEF", "MIDFIELDER": "MID",
"MEDIUM_FORWARD": "MFWD", "KEY_FORWARD": "KF", "RUCK": "RK",
"MIDFIELDER_FORWARD": "MFWD", "WINGMAN": "WNG"
}
return games.map(g => {
const name = g.player_name || nameMap.get(g.player_id) || g.player_id
const teamFull = predToFull[g.team] || g.team
const opp = g.opponent || g.opp || ""
const rawPos = posMap.get(g.player_id) || g.position_group || ""
const posShort = posAbbr[rawPos] || rawPos
const row = {
player_id: g.player_id,
player_name: name,
team: shortName[teamFull] || teamFull,
position_group: posShort,
round: g.round,
date: g.date || "",
opponent: shortName[opp] || opp,
tog: g.time_on_ground_percentage != null ? Math.round(g.time_on_ground_percentage) : null
}
for (const col of statCols) {
const v = Number(g[col])
row[col] = isNaN(v) ? null : +(v.toFixed(dp))
}
return row
})
}Show code
Show code
// ── Render table ─────────────────────────────────────────────
{
if (tableData == null || tableData.length === 0)
return html`<p class="text-muted">No data available for this category and filter combination.</p>`
const catDef = defs[category]
const statCols = catDef.columns
const posColors = window.aflTeamMaps?.posColors || {}
const renderPlayerCell = window.aflTeamMaps?.renderPlayerCell
const renderTeamCell = window.aflTeamMaps?.renderTeamCell
// Identity columns + stat columns
const columns = ["player_name", "position_group", "team", "round", "date", "opponent", "tog", ...statCols]
const header = {
player_name: "Player", position_group: "POS", team: "Team",
round: "Rnd", date: "Date", opponent: "Vs", tog: "TOG",
...catDef.header
}
const format = {}
for (const col of statCols) {
const dp = catDef.format?.[col] ?? 1
format[col] = v => v != null ? Number(v).toFixed(dp) : ""
}
const heatmap = {}
for (const col of statCols) {
if (catDef.heatmap?.[col]) heatmap[col] = catDef.heatmap[col]
}
format.date = v => {
if (!v) return ""
const d = new Date(v + "T00:00:00")
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })
}
// Abbreviation → color lookup from posColors
const abbrColorMap = {}
for (const [k, v] of Object.entries(posColors)) abbrColorMap[v.a] = v.c
const teamAbbrevMap = window.aflTeamMaps?.teamAbbrevMap || {}
const render = {}
if (renderPlayerCell) {
render.player_name = (val, row) => renderPlayerCell(val, {
player_id: row.player_id,
team: row.team,
position_group: row.position_group
})
}
render.team = (val) => {
const full = predToFull[val] || val
const abbr = teamAbbrevMap[full] || teamAbbrevMap[val] || val
const logo = window.aflTeamMaps?.teamLogo(val)
const logoHtml = logo ? `<img src="${statsEsc(logo)}" alt="" style="width:18px;height:18px;object-fit:contain;vertical-align:middle" onerror="this.style.display='none'"> ` : ""
return `<a href="team#team=${encodeURIComponent(full)}" class="player-link">${logoHtml}${statsEsc(abbr)}</a>`
}
render.opponent = render.team
render.position_group = (val) => {
if (!val) return ""
const col = abbrColorMap[val] || "#888"
return `<span class="pos-badge" style="background:${col}">${statsEsc(val)}</span>`
}
const groups = [
{ label: "", span: 7 },
...(catDef.groups || [{ label: catDef.label, span: statCols.length }])
]
const sortCol = catDef.sortCol || statCols[0]
return statsTable(search, {
columns, header, groups, format, heatmap, render,
sort: sortCol, reverse: true,
pageSize: 25,
filters: { round: "range" }
})
}