In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

AFL Player Stats

Skip to content

AFL > Player Stats

AFL · Per-match Stats · EPV + Box Score

What did each player actually do?

Per-game box-score stats — goals, disposals, tackles, pressure acts — sat alongside per-game EPV, the points-of-expected-score each player added. Filter by season, round, position, or team to find the story behind the numbers.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
defs = window.aflStatDefs
predToFull = window.aflTeamMaps?.predToFull || {}
fullToPred = window.aflTeamMaps?.fullToPred || {}

base = window.DATA_BASE_URL
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 {
    const rs = await window.fetchParquet(base + "afl/game-logs.parquet")
    return window.aflTeamMaps?.renameGameLogsColumns?.(rs) || rs
  } 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
aflPosColors = window.aflTeamMaps?.posColors || {}
posAbbr = Object.fromEntries(Object.entries(aflPosColors).map(([k, v]) => [k, v.a]))
Show code
// ── Byline strip ─────────────────────────────────────────────
html`<div class="byline">
  <span>By <strong>Pete Owen</strong></span>
  <span>Updated · <strong>${statsEsc(asAtLabel)}</strong></span>
  <span><a href="../blog/2026-04-24-understanding-torp/">Methodology &darr;</a></span>
  <span><a href="definitions.html">Definitions &nearr;</a></span>
  <span>&approx; 5 min read</span>
</div>`
Show code
// ── Sidebar collapse toggle ─────────────────────────────────
window.editorial.sidebarToggle()
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. Stream both sources
  // directly into the min/max tracker instead of building a combined array
  // with .push(...) — the spread blows the JS engine's argument-count limit
  // (~32K) on multi-season aggregates, same root cause as the football
  // matchStats stack overflow.
  function getRoundRange(season) {
    let rMin = Infinity, rMax = -Infinity
    const consume = (arr) => {
      if (!arr) return
      for (const d of arr) {
        if (season !== "All Seasons" && String(d.season) !== season) continue
        if (d.round != null) {
          if (d.round < rMin) rMin = d.round
          if (d.round > rMax) rMax = d.round
        }
      }
    }
    consume(gameStats)
    consume(gameLogs)
    return { min: rMin === Infinity ? 0 : rMin, max: rMax === -Infinity ? 30 : rMax }
  }

  let roundBounds = getRoundRange(defaultSeason)

  // Date range = played-game window for the given season (or full played
  // history for "All Seasons"). fixturesHistory carries start_time per fixture;
  // gameStats/gameLogs rows don't have date directly so we derive bounds here.
  function getDateRange(season) {
    if (!fixturesHistory) return { min: "", max: "" }
    const candidates = season === "All Seasons"
      ? fixturesHistory
      : fixturesHistory.filter(f => String(f.season) === season)
    const now = new Date().toISOString()
    const played = candidates
      .map(f => f.start_time)
      .filter(t => t && t <= now)
      .map(t => String(t).slice(0, 10))
      .sort()
    if (played.length === 0) return { min: "", max: "" }
    return { min: played[0], max: played[played.length - 1] }
  }
  let dateBounds = getDateRange(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)

  // Date range — defaults to the played-game window of the selected season.
  // Resets reactively when the season changes.
  const dateWrap = document.createElement("div")
  dateWrap.className = "filter-round-wrap"
  const dLbl = document.createElement("span")
  dLbl.className = "filter-label"
  dLbl.textContent = "Dates"
  const dMin = document.createElement("input")
  dMin.type = "date"; dMin.className = "round-input"; dMin.style.minWidth = "8.5rem"
  dMin.value = dateBounds.min
  const dSep = document.createElement("span")
  dSep.className = "round-sep"; dSep.textContent = "–"
  const dMax = document.createElement("input")
  dMax.type = "date"; dMax.className = "round-input"; dMax.style.minWidth = "8.5rem"
  dMax.value = dateBounds.max
  dateWrap.appendChild(dLbl)
  dateWrap.appendChild(dMin)
  dateWrap.appendChild(dSep)
  dateWrap.appendChild(dMax)
  row1.appendChild(dateWrap)

  // 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,
    dateMin: dateBounds.min,
    dateMax: dateBounds.max,
    aggMode: "total"
  }

  function emit() {
    container.dispatchEvent(new Event("input", { bubbles: true }))
  }

  season.sel.addEventListener("change", () => {
    // Recalculate round + date 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
    dateBounds = getDateRange(season.sel.value)
    dMin.value = dateBounds.min
    dMax.value = dateBounds.max
    container.value = {
      ...container.value,
      season: season.sel.value,
      roundMin: roundBounds.min,
      roundMax: roundBounds.max,
      dateMin: dateBounds.min,
      dateMax: dateBounds.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() })

  let dateTimer
  function updateDate() {
    clearTimeout(dateTimer)
    dateTimer = setTimeout(() => {
      container.value = {
        ...container.value,
        dateMin: dMin.value || "",
        dateMax: dMax.value || ""
      }
      emit()
    }, 300)
  }
  dMin.addEventListener("change", updateDate)
  dMax.addEventListener("change", updateDate)

  ;[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 })
dateRange = ({ min: filters.dateMin, max: filters.dateMax })
aggMode = filters.aggMode
Show code
scoringDerivedDef = ({
  columns: ["accuracy", "conversion", "shot_quality", "score_minus_xscore"],
  header: { accuracy: "Acc%", conversion: "Conv%", shot_quality: "Shot Qual", score_minus_xscore: "Score−xS" },
  heatmap: { accuracy: "high-good", conversion: "high-good", shot_quality: "high-good", score_minus_xscore: "diverging" },
  format: {
    accuracy: x => (x == null ? "" : x.toFixed(0) + "%"),
    conversion: x => (x == null ? "" : x.toFixed(0) + "%"),
    shot_quality: 2,
    score_minus_xscore: x => (x == null ? "" : (x >= 0 ? "+" : "") + x.toFixed(1))
  },
  tooltip: {
    accuracy: "Goal accuracy — goals ÷ total shots at goal",
    conversion: "Conversion — goals ÷ (goals + behinds), i.e. of scoring shots, the goal share",
    shot_quality: "Average shot quality — xG ÷ shots at goal",
    score_minus_xscore: "Score minus expected score (goals×6 + behinds − xScore) — finishing above/below shot quality"
  },
  // Compute from a result row. Ratios mode-invariant; null when inputs missing
  // so the table's presence-filter hides them (e.g. xG-based ones pre-rebuild).
  compute: r => {
    const g = +r.goals || 0, b = +r.behinds || 0, sa = +r.shots_at_goal || 0
    // Return null (not omit) for inapplicable metrics. The generic aggregation
    // loop pre-seeds every catDef column to 0, so the spread must OVERWRITE those
    // — otherwise 0-shot players (~half the rows) would show a fake 0% / +0.0
    // instead of blank. Formatters render null as "", and a non-undefined key
    // keeps the column stably present regardless of which row sorts first.
    // `score` (goals×6+behinds) is always real — it was rendering 0 because it's
    // not stored in game-stats and was never derived.
    return {
      score: g * 6 + b,
      accuracy: sa > 0 ? 100 * g / sa : null,
      conversion: (g + b) > 0 ? 100 * g / (g + b) : null,
      shot_quality: (r.xg != null && sa > 0) ? (+r.xg) / sa : null,
      score_minus_xscore: (r.xscore != null && sa > 0) ? (g * 6 + b) - (+r.xscore) : null
    }
  }
})

// ── Core data computation ────────────────────────────────────
catDef = {
  const base = defs[category]
  if (category !== "scoring") return base
  // Append derived columns after xg (local augmentation — shared defs untouched).
  const cols = [...base.columns]
  cols.splice(cols.indexOf("xg") + 1, 0, ...scoringDerivedDef.columns)
  return {
    ...base,
    columns: cols,
    header: { ...base.header, ...scoringDerivedDef.header },
    heatmap: { ...base.heatmap, ...scoringDerivedDef.heatmap },
    format: { ...(base.format || {}), ...scoringDerivedDef.format },
    tooltip: { ...base.tooltip, ...scoringDerivedDef.tooltip }
  }
}

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
    })
  }

  // Date range filter (from fixtures-history start_time). Empty bound = no
  // limit on that side. Drops games whose fixture has no start_time so a
  // missing fixture entry can't accidentally bypass the filter.
  if (dateRange.min || dateRange.max) {
    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 dStr = String(fix.start_time).slice(0, 10)
      if (dateRange.min && dStr < dateRange.min) return false
      if (dateRange.max && dStr > dateRange.max) return false
      return true
    })
  }

  // 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) {
      // WPA is a probability shift, not a rate — p80 extrapolation produces
      // nonsensical values for low-TOG / small-sample cases. Fall back to
      // per-game avg in p80 mode (see matching logic below for logCols path).
      const isWpa = col === "wpa" || col === "wpa_recv" || col === "wpa_disp"
      if (aggMode === "avg") {
        row[col] = +(entry.vals[col] / entry.count).toFixed(dp)
      } else if (aggMode === "p80") {
        if (isWpa) row[col] = entry.count > 0 ? +(entry.vals[col] / entry.count).toFixed(dp) : 0
        else 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
  // Restore previously selected columns from localStorage so the user's column
  // set persists across reloads + tab visits. Per-page key avoids cross-page
  // bleed (AFL player-stats vs team-stats vs football pages each get their
  // own key). Filtered against allMetrics so renamed/removed cols silently drop
  // rather than break the picker.
  const _lsKey = "_customCols_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _validCols = new Set(allMetrics.map(m => m.col))
  const _saved = (() => {
    try { const raw = window.localStorage.getItem(_lsKey); return raw ? JSON.parse(raw).filter(c => _validCols.has(c)).slice(0, MAX) : [] }
    catch (e) { return [] }
  })()
  const selected = new Set(_saved)

  const container = document.createElement("div")
  container.className = "custom-col-picker"

  const btn = document.createElement("button")
  btn.className = "custom-col-btn"
  btn.textContent = selected.size === 0 ? "Select columns..." : `${selected.size} column${selected.size > 1 ? "s" : ""} selected`
  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
      // Reflect previously-restored selection in the checkbox state
      if (selected.has(m.col)) cb.checked = true
      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]
        // Persist so the user's column set survives reloads
        try { window.localStorage.setItem(_lsKey, JSON.stringify([...selected])) } catch (e) {}
        container.dispatchEvent(new Event("input", { bubbles: true }))
      })
    }
    panel.appendChild(group)
  }
  // Apply MAX-cap state to checkboxes immediately for the restored set
  if (selected.size >= MAX) {
    for (const cb of checkboxes) if (!cb.checked) cb.disabled = true
  }

  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())

  // Initialise container.value from the restored set so downstream cells see
  // the persisted selection on first render (no extra click needed).
  container.value = [...selected]
  return container
}
Show code
// ── Toggle custom picker visibility ──────────────────────────
{
  const picker = document.querySelector(".custom-col-picker")
  if (picker) picker.style.display = category === "custom" ? "" : "none"
}
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
      // WPA is a probability shift, not a rate — per-80 extrapolation is
      // nonsensical (a late-game sub with 5 mins played and +0.15 WPA would
      // scale to +240%). Fall back to per-game avg in p80 mode, which is
      // the most sensible substitute for a bounded probability metric.
      const isWpa = col === "wpa" || col === "wpa_recv" || col === "wpa_disp"
      if (aggMode === "avg") row[col] = +(v / cnt).toFixed(3)
      else if (aggMode === "p80") {
        if (isWpa) row[col] = cnt > 0 ? +(v / cnt).toFixed(3) : 0
        else 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 = {
  const base = category === "custom" ? customTableData : tableData
  // Add derived Scoring metrics (#216) from each row's aggregated values.
  if (category !== "scoring" || !base) return base
  return base.map(r => ({ ...r, ...scoringDerivedDef.compute(r) }))
}

// 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:var(--site-muted-color);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.
  // Values may be integer (dp count) or function (custom formatter).
  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, fmt] of Object.entries(def.format)) {
      if (!statCols.includes(col)) continue
      format[col] = typeof fmt === "function" ? fmt : x => x?.toFixed(fmt) ?? ""
    }
  } 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
}
Show code
// ── Source attribution row ──────────────────────────────────
{
  const src = document.createElement("div"); src.className = "table-source"
  const left = document.createElement("span")
  left.appendChild(document.createTextNode("Source: "))
  const a = document.createElement("a")
  a.href = "https://github.com/peteowen1/torpdata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "torpdata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Pete Owen · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = (asAtLabel || "Latest") + " · Filter by round, position, team"
  src.appendChild(left); src.appendChild(right)
  return src
}
Show code
// ── Editorial side rail ─────────────────────────────────────
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"

  const { railBlock, btnTile } = window.editorial

  if (!gameLogs || gameLogs.length === 0) {
    const lb = railBlock("Loading")
    const p = document.createElement("p")
    p.style.cssText = "color: var(--site-muted-color); font-size: 0.85rem; font-family: 'Source Serif 4', Georgia, serif; margin: 0;"
    p.textContent = "Resolving the latest player stats…"
    lb.appendChild(p); inner.appendChild(lb); return inner
  }

  // Per-game extremes for the current season
  let maxSeason = -Infinity
  for (const g of gameLogs) { if (g.season > maxSeason) maxSeason = g.season }
  const thisSeason = gameLogs.filter(g => g.season === maxSeason)
  const sortedByEpv = [...thisSeason].sort((a, b) => (b.epv ?? -Infinity) - (a.epv ?? -Infinity))
  const topGame = sortedByEpv[0]
  const topName = topGame?.player_name || "—"
  const topEpv = topGame?.epv != null ? (topGame.epv > 0 ? "+" : "") + topGame.epv.toFixed(1) : "—"
  const topRound = topGame?.round != null ? "Round " + topGame.round : ""
  const games = thisSeason.length
  const players = new Set(thisSeason.map(g => g.player_id)).size

  // Last Updated
  const upd = railBlock("Last Updated")
  const stamp = document.createElement("div"); stamp.className = "update-stamp"
  stamp.textContent = asAtLabel || "Latest"
  upd.appendChild(stamp)
  const updP = document.createElement("p")
  updP.style.cssText = "font-family: 'Source Serif 4', Georgia, serif; font-size: 0.85rem; color: var(--site-muted-color); margin: 0.7rem 0 0; line-height: 1.55;"
  updP.appendChild(document.createTextNode("Box-score + EPV data refreshes after each round via the "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "torpdata"
  updP.appendChild(code)
  updP.appendChild(document.createTextNode(" pipeline."))
  upd.appendChild(updP); inner.appendChild(upd)

  // BTN
  const btn = railBlock("By the Numbers")
  const grid = document.createElement("div"); grid.className = "btn-block"
  grid.appendChild(btnTile(topEpv, [
    { text: "Biggest EPV game", bold: true },
    { text: " · " + topName },
    ...(topRound ? [{ br: true }, { text: topRound + ", " + maxSeason }] : [])
  ]))
  grid.appendChild(btnTile(games.toLocaleString(), [
    { text: "Games logged", bold: true },
    { text: " · in " + maxSeason }
  ]))
  grid.appendChild(btnTile(players.toLocaleString(), [
    { text: "Unique players", bold: true },
    { text: " · who played this season" }
  ]))
  grid.appendChild(btnTile("EPV + PSV", [
    { text: "Per-match value", bold: true },
    { text: " · join box score with chain-aware impact" }
  ]))
  btn.appendChild(grid); inner.appendChild(btn)

  // About
  const about = railBlock("Stats vs Ratings"); about.classList.add("about-block")
  const p1 = document.createElement("p")
  p1.appendChild(document.createTextNode("This is the "))
  const s1 = document.createElement("strong"); s1.textContent = "box-score"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" view — what happened, game by game."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("For predictive "))
  const s2 = document.createElement("strong"); s2.textContent = "ratings"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" (TORP, EPR, PSR), see the "))
  const a2 = document.createElement("a")
  a2.href = "player-ratings.html"; a2.textContent = "Player Ratings"
  p2.appendChild(a2); p2.appendChild(document.createTextNode(" page."))
  about.appendChild(p2)
  inner.appendChild(about)

  // Read Next
  const read = railBlock("Read Next")
  const ul = document.createElement("ul"); ul.className = "rail-list"
  const links = [
    { href: "game-logs.html", title: "Player Game Logs", meta: "Per-game stats by player" },
    { href: "team-stats.html", title: "Team Stats", meta: "Aggregated by club" },
    { href: "player-ratings.html", title: "Player Ratings", meta: "Predictive TORP / EPR / PSR" },
    { href: "matches.html", title: "Matches", meta: "Round-by-round results" }
  ]
  for (const l of links) {
    const li = document.createElement("li")
    const ax = document.createElement("a")
    ax.href = l.href; ax.textContent = l.title
    const meta = document.createElement("span"); meta.className = "rail-meta"; meta.textContent = l.meta
    ax.appendChild(meta); li.appendChild(ax); ul.appendChild(li)
  }
  read.appendChild(ul); inner.appendChild(read)

  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer