AFL Team Game Logs
Team-level game performances — find the best single-game team performances across AFL
AFL > Team Game Logs
Team-level game performances — each row is one team in one match. Sort by any stat column to find the best single-game team performances (e.g., most handballs, 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("[team-game-logs] game-logs load failed:", e)
return null
}
}
gameStats = {
try {
return await window.fetchParquet(base + "afl/game-stats.parquet")
} catch (e) {
console.error("[team-game-logs] game-stats load failed:", e)
return null
}
}
predictions = {
try {
return await window.fetchParquet(base + "afl/predictions.parquet")
} catch (e) {
console.warn("[team-game-logs] predictions load failed:", e)
return null
}
}Show code
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
}
dateMap = {
const m = new Map()
if (predictions) {
for (const p of predictions) {
if (!p.start_time) continue
const d = p.start_time.slice(0, 10)
const h = predToFull[p.home_team] || p.home_team
const a = predToFull[p.away_team] || p.away_team
// Key by team+opponent to handle double-headers (same round, different games)
m.set(`${p.season}-${p.round}-${h}-${a}`, d)
m.set(`${p.season}-${p.round}-${a}-${h}`, d)
}
}
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 = "_teamGameLogCat_" + 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 team 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)
const norm = n => predToFull[n] || n
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
const isFinals = window.aflTeamMaps?.isFinals || (() => false)
if (seasonTypeFilter === "Regular") games = games.filter(d => !isFinals(d))
if (seasonTypeFilter === "Finals") games = games.filter(d => isFinals(d))
// Aggregate player rows into team-game rows (exclude computed cols from sum)
const computedCols = new Set(["score"])
const statCols = catDef.columns
const sumCols = statCols.filter(c => !computedCols.has(c))
const teamGameMap = new Map()
for (const row of games) {
const t = norm(row.team)
if (!t) continue
const round = Number(row.round)
const season = Number(row.season)
const opp = norm(row.opponent || row.opp || "")
// Key by opponent too — handles double-headers (e.g., rescheduled games in same round)
const key = `${season}|||${round}|||${t}|||${opp}`
if (!teamGameMap.has(key)) {
teamGameMap.set(key, { team: t, season, round, opponent: opp, vals: {} })
for (const c of sumCols) teamGameMap.get(key).vals[c] = 0
}
const entry = teamGameMap.get(key)
for (const c of sumCols) {
entry.vals[c] = (entry.vals[c] || 0) + (Number(row[c]) || 0)
}
}
// Apply team filter
let entries = [...teamGameMap.values()]
if (teamFilter !== "All Teams") {
entries = entries.filter(e => e.team === teamFilter)
}
// Home/Away filter
if (homeAwayFilter !== "All") {
const target = homeAwayFilter.toLowerCase()
entries = entries.filter(e => {
return homeAwayMap.get(`${e.season}-${e.round}-${e.team}`) === target
})
}
// Build output rows
const dp = catDef.format ? 3 : 1
return entries.map(e => {
const row = {
team: shortName[e.team] || e.team,
team_full: e.team,
season: e.season,
round: e.round,
date: dateMap.get(`${e.season}-${e.round}-${e.team}-${e.opponent}`) || "",
opponent: shortName[e.opponent] || e.opponent
}
for (const col of sumCols) {
const v = e.vals[col]
row[col] = isNaN(v) ? null : +(v.toFixed(dp))
}
// Computed columns
if (row.goals != null || row.behinds != null) {
row.score = (row.goals || 0) * 6 + (row.behinds || 0)
}
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 renderTeamCell = window.aflTeamMaps?.renderTeamCell
const showSeason = seasonFilter === "All Seasons"
const idCols = showSeason
? ["team", "season", "round", "date", "opponent"]
: ["team", "round", "date", "opponent"]
const columns = [...idCols, ...statCols]
const header = {
team: "Team", season: "Season", round: "Rnd", date: "Date", opponent: "Vs",
...catDef.header
}
const format = {}
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" })
}
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]
}
const render = {}
if (renderTeamCell) {
render.team = (val, row) => renderTeamCell(row.team_full || val)
// Opponent always compact: logo + abbreviation
render.opponent = (val) => {
const esc = window.statsEsc || (s => s)
const full = predToFull[val] || val
const abbr = window.aflTeamMaps?.predToAbbr?.[full] || window.aflTeamMaps?.teamAbbrevMap?.[full] || val
const logo = window.aflTeamMaps?.teamLogo?.(val)
const logoHtml = logo ? '<img src="' + esc(logo) + '" alt="" style="width:18px;height:18px;object-fit:contain;vertical-align:middle">' : ""
return '<span style="display:inline-flex;align-items:center;gap:0.3rem">' + logoHtml + esc(abbr) + '</span>'
}
}
const groups = [
{ label: "", span: idCols.length },
...(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" }
})
}