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

Football Player Stats

Skip to content

Football > Player Stats

Football · Per-match Stats · EPV + Box Score

What did each player actually do?

Box-score stats — goals, shots, key passes, tackles — paired with per-match EPV, the expected-goals each player added across 15 leagues. Filter by league, matchday, team, or position to find the story behind the totals.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
base = window.DATA_BASE_URL

// League mappings (from shared football-maps.js)
leagueCodes = window.footballMaps.clubLeagues
leagueNames = window.footballMaps.leagueNames

// Load ratings (season aggregates — drives the Ratings tab + position fallback)
_ratingsRaw = {
  try { return await window.fetchParquet(base + "football/ratings.parquet") } catch (e) { console.warn("[player-stats] ratings.parquet load failed:", e.message); return null }
}
// Load game-logs (per-player per-match EPV/WPA/PSV — drives Value & EPV components tabs).
// Same structure AFL uses: each row carries epv_total/wpa_*/psv/osv/dsv etc. for one
// (player, match), so player-level aggregation respects matchweek/team/opponent filters
// and Total/Per Game/Per 90 toggles compute correctly.
_gameLogsRaw = {
  try { return await window.fetchParquet(base + "football/game-logs.parquet") } catch (e) { console.warn("[player-stats] game-logs.parquet load failed:", e.message); return null }
}
// Seasons preloaded in _gameLogsRaw (the current-season default file). Used to
// decide whether a selected season is already in memory or needs an on-demand
// fetch. Derived from the data so it self-updates at season rollover.
_gameLogsSeasons = new Set((_gameLogsRaw || []).map(d => String(d.season)))

// On-demand game-logs loader for the Value tab. The current season is preloaded
// in _gameLogsRaw (keeps the default page view light, ~5MB). Historical seasons
// live in per-season R2 files football/game-logs-<season>.parquet and are fetched
// ONLY when that season is actually selected — so we never pull all ~60MB up front.
// Returns null when a season has no game-logs file (e.g. pre-2015-16), which the
// table render below turns into an accurate "not available" message.
_gameLogsActive = {
  if (catDef.source !== "gameLogs") return _gameLogsRaw   // other tabs ignore this
  const eff = seasonFilter === "All Seasons" ? null : String(seasonFilter)

  // Season already in the preloaded current-season file → reuse, no fetch.
  if (eff && _gameLogsSeasons.has(eff)) return _gameLogsRaw

  // Specific historical season → fetch its per-season file on demand.
  if (eff) {
    try {
      return await window.fetchParquet(base + `football/game-logs-${eff}.parquet`)
    } catch (e) {
      console.warn(`[player-stats] game-logs-${eff}.parquet load failed:`, e.message)
      return null
    }
  }

  // "All Seasons" on the Value tab → career aggregates: load every available
  // per-season file and concatenate. Heavy (~60MB) but opt-in — only runs when
  // the user explicitly picks "All Seasons" here, never on default page load.
  // Candidate seasons come from the dropdown (match-stats); the current season
  // reuses the already-loaded _gameLogsRaw, and seasons with no game-logs file
  // (pre-2015-16) resolve to null (fetchParquet swallows 404s) and drop out.
  const candidates = seasonOptions.filter(s => s !== "All Seasons")
  const frames = await Promise.all(candidates.map(s =>
    _gameLogsSeasons.has(String(s))
      ? Promise.resolve(_gameLogsRaw)
      : window.fetchParquet(base + `football/game-logs-${s}.parquet`)
  ))
  const loaded = frames.filter(f => Array.isArray(f) && f.length > 0)
  return loaded.length ? loaded.flat() : _gameLogsRaw
}
// Build player_id → team-name lookup from ratings (game-logs has team_id but no
// team display name; ratings has both, so we use ratings as the authoritative
// player→team mapping for table display).
_playerTeamMap = {
  if (!_ratingsRaw) return new Map()
  const m = new Map()
  for (const r of _ratingsRaw) {
    if (r.player_name && r.team) m.set(r.player_name, r.team)
  }
  return m
}

// Canonical "current" team / position per player_id, derived from matchStats.
// Mirrors AFL's pattern (afl/player-stats.qmd:136-162: latest round wins).
// Why this exists:
//   - _playerTeamMap above is keyed on player_name from ratings. Players who
//     are in matchStats but not in ratings (e.g. a defender on a low-rated
//     team) get a null team. Worse, name-keying loses anyone whose
//     match-stats spelling differs from the ratings spelling ("Virgil van
//     Dijk" vs "Van Dijk").
//   - For position, the matchStats path falls back to ratings.position which
//     is all-null; a cameo Substitute appearance ends up listed as the
//     player's primary position (e.g. Bruno Guimarães → "SUB").
// Solution: walk matchStats once, sorted by date desc. First row per
// player_id sets the latest team and league. Position takes the first
// non-Substitute occurrence we see (cameos don't override the primary).
// Final fallback to the Substitute value only if the player has nothing
// else on record.
_playerCurrentMeta = {
  if (!matchStats) return new Map()
  const sorted = [...matchStats].sort((a, b) => {
    const da = String(a.match_date || "")
    const db = String(b.match_date || "")
    return db.localeCompare(da)  // descending: newest first
  })
  const m = new Map()
  for (const g of sorted) {
    const pid = g.player_id
    if (!pid) continue
    let rec = m.get(pid)
    if (!rec) {
      rec = {
        team: g.team_name || null,
        position: null,
        league: g.league || null,
        lastDate: g.match_date || null
      }
      m.set(pid, rec)
    }
    if (!rec.position && g.position && g.position !== "Substitute" && g.position !== "Sub") {
      rec.position = g.position
    }
  }
  // Backfill Substitute as the last resort for players who never started.
  for (const g of sorted) {
    const pid = g.player_id
    if (!pid) continue
    const rec = m.get(pid)
    if (rec && !rec.position && g.position) rec.position = g.position
  }
  return m
}

// Same Map keyed on player_name, for callers (game-logs path) that only have
// names to look up. Built from _playerCurrentMeta entries by reverse-mapping
// pid → name via matchStats. Two players sharing a name on different teams
// is rare but possible — last-write wins, consistent with the name-keyed
// _playerTeamMap behaviour today.
_playerCurrentMetaByName = {
  if (!matchStats || !_playerCurrentMeta) return new Map()
  const pidToName = new Map()
  for (const g of matchStats) {
    if (g.player_id && g.player_name) pidToName.set(g.player_id, g.player_name)
  }
  const m = new Map()
  for (const [pid, rec] of _playerCurrentMeta) {
    const name = pidToName.get(pid)
    if (name) m.set(name, rec)
  }
  return m
}

// Position fallback map kept name-keyed for callers that only have player_name.
// Now backed by _playerCurrentMetaByName (which already prefers non-Sub) so
// position resolution is consistent across both join paths.
_ratingsPositions = {
  if (!_playerCurrentMetaByName) return {}
  const m = {}
  for (const [name, rec] of _playerCurrentMetaByName) {
    if (rec.position) m[name] = rec.position
  }
  return m
}
Show code
posAbbr = window.footballMaps.posAbbr
posGroups = window.footballMaps.posGroups
footballPosColors = window.footballMaps.posGroupColors
posToGroup = window.footballMaps.posToGroup
Show code
statDefs = {
  if (!window.footballStatDefs) {
    console.error("[player-stats] football/stat-defs.js failed to load")
    return {}
  }
  return window.footballStatDefs
}

catKeys = Object.keys(statDefs).filter(k => !statDefs[k].page)
Show code
// ── Category toggle ──────────────────────────────────────────
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 = document.createElement("div")
  container.className = "stats-category-toggle football"
  for (const key of catKeys) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (key === _default ? " active" : "")
    btn.textContent = statDefs[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
// ── Listed-position filter (simple GK/DEF/MID/FWD) ───────────
// Filters by the player's nominal season position (most-recent non-substitute
// role from match-stats). Distinct from matchPosFilter below, which filters
// by the position the player played in a given match.
viewof posFilter = {
  const positions = ["All", "GK", "DEF", "MID", "FWD"]
  const _key = "_posFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = positions.includes(window[_key]) ? window[_key] : "All"
  const wrap = document.createElement("div")
  wrap.className = "pos-filter-row"
  const label = document.createElement("span")
  label.className = "pos-filter-label"
  label.textContent = "Listed pos"
  label.title = "Filter by the player's nominal season position"
  wrap.appendChild(label)
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const p of positions) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (p === _saved ? " active" : "")
    btn.dataset.pos = p
    btn.textContent = p
    btn.title = p === "All" ? "Show all listed positions" : `Show players listed as ${p}`
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = p
      window[_key] = p
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  wrap.appendChild(container)
  wrap.value = _saved
  return wrap
}
Show code
// ── Match-position filter (detailed LB/CB/AM/DM/LW/...) ──────
// Filters games to those where the player played the selected match position.
// Aggregated stats then reflect only those games. No effect on the Ratings
// tab (season-level RAPM has no per-game position info).
viewof matchPosFilter = {
  const positions = ["All", ...window.footballMaps.detailedPosCodes]
  const _key = "_matchPosFilter_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = positions.includes(window[_key]) ? window[_key] : "All"
  const wrap = document.createElement("div")
  wrap.className = "pos-filter-row"
  const label = document.createElement("span")
  label.className = "pos-filter-label"
  label.textContent = "Match pos"
  label.title = "Filter by the position the player played in matches (aggregates only those games)"
  wrap.appendChild(label)
  const container = document.createElement("div")
  container.className = "pos-pills"
  for (const p of positions) {
    const btn = document.createElement("button")
    btn.className = "pos-pill" + (p === _saved ? " active" : "")
    btn.dataset.pos = p
    btn.textContent = p
    btn.title = p === "All" ? "Show all match positions" : `Only include games where the player played ${p}`
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      wrap.value = p
      window[_key] = p
      wrap.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  wrap.appendChild(container)
  wrap.value = _saved
  return wrap
}
Show code
viewof filters = {
  const makeSelect = window.footballMaps.makeSelect

  const container = document.createElement("div")
  container.className = "player-filter-bar"

  const row = document.createElement("div")
  row.className = "filter-row"

  // League select
  const leagueOpts = ["All Leagues", ...leagueCodes]
  const league = makeSelect(leagueOpts, "ENG", "League", x => x === "All Leagues" ? x : (leagueNames[x] || x))

  // Season select (populated after data loads — starts with placeholder)
  const season = makeSelect(["All Seasons"], "All Seasons", "Season")

  // Matchweek select (populated after data loads — Wed-Tue groupings)
  const matchday = makeSelect(["All Matchweeks"], "All Matchweeks", "Matchweek")

  // Team / Opp / H-A / Day (populated after matchStats loads)
  const team = makeSelect(["All Teams"], "All Teams", "Team")
  const opp = makeSelect(["All Opponents"], "All Opponents", "Vs")
  const haSelect = makeSelect(["All", "Home", "Away"], "All", "H/A")
  const daySelect = makeSelect(["All Days", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], "All Days", "Day")

  row.appendChild(league.wrap)
  row.appendChild(season.wrap)
  row.appendChild(matchday.wrap)
  row.appendChild(team.wrap)
  row.appendChild(opp.wrap)
  row.appendChild(haSelect.wrap)
  row.appendChild(daySelect.wrap)

  // Date range — populated reactively once matchStats loads. Defaults to
  // the played-game window of the selected season (see _updateDates handler).
  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"
  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"
  dateWrap.appendChild(dLbl); dateWrap.appendChild(dMin); dateWrap.appendChild(dSep); dateWrap.appendChild(dMax)
  row.appendChild(dateWrap)

  // Agg toggle. Hidden for ratings-sourced tabs (Value, EPV components, Ratings)
  // because those values come pre-aggregated as per-90 in ratings.parquet — the
  // toggle would do nothing, and a non-functional control reads as a bug.
  const aggWrap = document.createElement("div")
  aggWrap.className = "filter-agg-wrap"
  aggWrap.dataset.role = "agg-toggle"
  const btnAvg = document.createElement("button")
  btnAvg.className = "agg-btn"
  btnAvg.textContent = "Per Game"
  btnAvg.dataset.mode = "avg"
  const btnP90 = document.createElement("button")
  btnP90.className = "agg-btn"
  btnP90.textContent = "Per 90"
  btnP90.dataset.mode = "p90"
  const btnTot = document.createElement("button")
  btnTot.className = "agg-btn active"
  btnTot.textContent = "Total"
  btnTot.dataset.mode = "total"
  aggWrap.appendChild(btnAvg)
  aggWrap.appendChild(btnP90)
  aggWrap.appendChild(btnTot)
  row.appendChild(aggWrap)

  container.appendChild(row)

  // ── State and dispatch ──
  container.value = {
    league: "ENG", season: "All Seasons", matchday: "All Matchweeks",
    team: "All Teams", opponent: "All Opponents", homeAway: "All", day: "All Days",
    dateMin: "", dateMax: "",
    aggMode: "total"
  }

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

  league.sel.addEventListener("change", () => {
    container.value = { ...container.value, league: league.sel.value, team: "All Teams", opponent: "All Opponents" }
    emit()
  })
  season.sel.addEventListener("change", () => {
    container.value = { ...container.value, season: season.sel.value, matchday: "All Matchweeks", team: "All Teams", opponent: "All Opponents" }
    emit()
  })
  matchday.sel.addEventListener("change", () => {
    container.value = { ...container.value, matchday: matchday.sel.value }
    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()
  })
  daySelect.sel.addEventListener("change", () => {
    container.value = { ...container.value, day: daySelect.sel.value }
    emit()
  })

  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)

  for (const btn of [btnAvg, btnP90, btnTot]) {
    btn.addEventListener("click", () => {
      aggWrap.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = { ...container.value, aggMode: btn.dataset.mode }
      emit()
    })
  }

  // One-time flag so we auto-default to the latest real season on first data load,
  // but never clobber the user's explicit choice on subsequent re-renders.
  let _seasonAutoDefaulted = false

  // Expose method to update season options externally
  container._updateSeasons = (options, defaultVal) => {
    const currentVal = container.value.season
    while (season.sel.firstChild) season.sel.removeChild(season.sel.firstChild)
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt
      o.textContent = opt
      season.sel.appendChild(o)
    }
    // Preserve user's current selection if still valid
    if (options.includes(currentVal)) {
      season.sel.value = currentVal
    }
    // One-time: auto-default to latest real season when data first arrives.
    // Subsequent calls respect whatever the user has picked.
    if (!_seasonAutoDefaulted && defaultVal && defaultVal !== "All Seasons" && options.includes(defaultVal)) {
      _seasonAutoDefaulted = true
      container.value = { ...container.value, season: defaultVal, matchday: "All Matchweeks" }
      season.sel.value = defaultVal
      container.dispatchEvent(new Event("input", { bubbles: true }))
    }
  }

  // Expose method to update matchday options externally
  container._updateMatchdays = (options) => {
    const currentVal = container.value.matchday
    while (matchday.sel.firstChild) matchday.sel.removeChild(matchday.sel.firstChild)
    for (const opt of options) {
      const o = document.createElement("option")
      o.value = opt
      o.textContent = opt
      matchday.sel.appendChild(o)
    }
    // Preserve user's current matchweek selection if still valid; otherwise fall
    // back to "All Matchweeks". Never force-reset when the user has an active pick.
    if (options.includes(currentVal)) {
      matchday.sel.value = currentVal
    } else {
      matchday.sel.value = "All Matchweeks"
      container.value = { ...container.value, matchday: "All Matchweeks" }
      container.dispatchEvent(new Event("input", { bubbles: true }))
    }
  }

  // Generic option-list refresher for the new selects (Team, Opp).
  // Day select is static so doesn't need this.
  function makeUpdater(sel, key, allLabel) {
    return (options) => {
      const currentVal = container.value[key]
      while (sel.firstChild) sel.removeChild(sel.firstChild)
      for (const opt of options) {
        const o = document.createElement("option")
        o.value = opt; o.textContent = opt
        sel.appendChild(o)
      }
      if (options.includes(currentVal)) {
        sel.value = currentVal
      } else {
        sel.value = allLabel
        container.value = { ...container.value, [key]: allLabel }
        container.dispatchEvent(new Event("input", { bubbles: true }))
      }
    }
  }
  container._updateTeams = makeUpdater(team.sel, "team", "All Teams")
  container._updateOpps = makeUpdater(opp.sel, "opponent", "All Opponents")

  // Expose method to set date inputs to a season's played-game window.
  // Called from the matchStats data-load cell whenever season changes.
  container._updateDates = (min, max) => {
    dMin.value = min || ""
    dMax.value = max || ""
    container.value = { ...container.value, dateMin: dMin.value, dateMax: dMax.value }
    container.dispatchEvent(new Event("input", { bubbles: true }))
  }

  return container
}

// ── Destructure for downstream reactivity ────────────────────
leagueFilter = filters.league
seasonFilter = filters.season
matchdayFilter = filters.matchday
teamFilter = filters.team
oppFilter = filters.opponent
homeAwayFilter = filters.homeAway
dayFilter = filters.day
dateRange = ({ min: filters.dateMin, max: filters.dateMax })
aggMode = filters.aggMode
Show code
_matchStatsResult = {
  const codes = leagueFilter === "All Leagues" ? leagueCodes : [leagueFilter]
  let results = []
  const failed = []
  for (const code of codes) {
    try {
      const data = await window.fetchParquet(base + `football/match-stats-${code}.parquet`)
      // .concat() is safe on large arrays; spread (results.push(...data)) blows
      // the JS engine's argument-count limit (~32K) for big league files like
      // match-stats-ESP.parquet at 126K rows — RangeError got swallowed and
      // silently nulled the whole filter chain.
      if (data) results = results.concat(data); else failed.push(code)
    } catch (e) {
      console.error(`[player-stats] match-stats-${code} load failed:`, e)
      failed.push(code)
    }
  }
  return { rows: results.length > 0 ? results : null, failed, requestedCodes: codes }
}
matchStats = _matchStatsResult.rows

// ── Detailed position lookup (player_id → LB/CB/RB/LM/CM/RM/LW/RW/etc.) ──
// Lazy-loaded from pannadata's player-positions.parquet only when the Match
// Pos filter is set to something specific (the All-pos case doesn't need the
// derived position). The parquet is small (~few MB, one row per
// player-league-season) so a single fetch is cheap. Cached on window so
// re-triggering the filter doesn't re-fetch.
_playerPositionsAll = {
  if (matchPosFilter === "All") return null
  const url = base + "football/player-positions.parquet"
  const cache = window._playerPositionsCache
  if (cache && cache.url === url && cache.data) return cache.data
  try {
    const data = await window.fetchParquet(url)
    if (Array.isArray(data) && data.length > 0) {
      window._playerPositionsCache = { url, data }
    }
    return data
  } catch (e) {
    console.error("[player-stats] player-positions.parquet load failed:", e)
    return null
  }
}

// Build player_id → detailed_position lookup scoped to the current league
// (multi-league players have one row per league-season, take the row that
// matches leagueFilter). Falls back to optaToPanna[d.position] per-game when
// the parquet has no row for a given player.
_detailedPosMap = {
  if (matchPosFilter === "All" || !_playerPositionsAll) return null
  const m = new Map()
  const effSeason = seasonFilter === "All Seasons" ? null : seasonFilter
  for (const p of _playerPositionsAll) {
    if (!p.detailed_position || !p.player_id) continue
    if (leagueFilter !== "All Leagues" && p.league !== leagueFilter) continue
    if (effSeason && p.season !== effSeason) continue
    // Take first encountered — typical parquet has one row per (player,league,season)
    if (!m.has(p.player_id)) m.set(p.player_id, p.detailed_position)
  }
  return m
}

seasonOptions = {
  if (!matchStats) return ["All Seasons"]
  const seasons = [...new Set(matchStats.map(d => String(d.season)))].sort().reverse()
  return ["All Seasons", ...seasons]
}

// Update season dropdown when options change
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateSeasons) {
    const defaultSeason = seasonOptions[1] || "All Seasons"
    el._updateSeasons(seasonOptions, defaultSeason)
  }
}
Show code
_matchweekRanges = {
  if (!matchStats) return []
  let games = matchStats
  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter
  if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)
  const dates = [...new Set(games.map(d => {
    const md = d.match_date
    return md ? String(md).replace("Z", "").slice(0, 10) : null
  }).filter(Boolean))].sort()
  if (dates.length === 0) return []

  // Group dates into Wed-Tue windows
  const weeks = []
  let currentWeek = null
  for (const dateStr of dates) {
    const d = new Date(dateStr + "T12:00:00Z")
    // Wednesday = day 3. Find the Wednesday on or before this date.
    const day = d.getUTCDay()
    const daysFromWed = (day + 4) % 7  // 0=Wed,1=Thu,...,6=Tue
    const wedDate = new Date(d)
    wedDate.setUTCDate(wedDate.getUTCDate() - daysFromWed)
    const wedKey = wedDate.toISOString().slice(0, 10)

    if (!currentWeek || currentWeek.wedKey !== wedKey) {
      currentWeek = { wedKey, dates: [dateStr] }
      weeks.push(currentWeek)
    } else {
      currentWeek.dates.push(dateStr)
    }
  }

  // Build labels
  const fmt = (s) => {
    const d = new Date(s + "T12:00:00Z")
    const mon = d.toLocaleString("en-GB", { month: "short", timeZone: "UTC" })
    return { day: d.getUTCDate(), mon }
  }
  return weeks.map((w, i) => {
    const first = fmt(w.dates[0])
    const last = fmt(w.dates[w.dates.length - 1])
    const range = w.dates.length === 1
      ? `${first.mon} ${first.day}`
      : first.mon === last.mon
        ? `${first.mon} ${first.day}–${last.day}`
        : `${first.mon} ${first.day} – ${last.mon} ${last.day}`
    return {
      label: `MW ${i + 1} · ${range}`,
      startDate: w.dates[0],
      endDate: w.dates[w.dates.length - 1],
    }
  })
}

matchdayOptions = ["All Matchweeks", ..._matchweekRanges.map(w => w.label)]

// Team / opponent / day options derived from matchStats
teamOptions = {
  if (!matchStats) return ["All Teams"]
  const teams = [...new Set(matchStats.map(d => d.team_name).filter(Boolean))].sort()
  return ["All Teams", ...teams]
}

// Opponent lookup: build (league|match_date) → [team1, team2], then opp = the other team
oppMap = {
  const m = new Map()
  if (!matchStats) return m
  const byMatch = new Map()
  for (const g of matchStats) {
    if (!g.team_name || !g.match_date) continue
    const k = `${g.league}|${g.match_date}`
    if (!byMatch.has(k)) byMatch.set(k, new Set())
    byMatch.get(k).add(g.team_name)
  }
  for (const [k, teams] of byMatch) {
    const arr = [...teams]
    if (arr.length === 2) {
      m.set(`${k}|${arr[0]}`, arr[1])
      m.set(`${k}|${arr[1]}`, arr[0])
    }
  }
  return m
}

oppOptions = {
  if (!oppMap || oppMap.size === 0) return ["All Opponents"]
  const opps = [...new Set(oppMap.values())].sort()
  return ["All Opponents", ...opps]
}

dayOptions = ["All Days", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

// Refresh team / opp dropdowns whenever league/season/matchStats change
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateTeams) el._updateTeams(teamOptions)
  if (el && el._updateOpps) el._updateOpps(oppOptions)
}
Show code
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateMatchdays) {
    el._updateMatchdays(matchdayOptions)
  }
}
Show code
{
  const el = document.querySelector(".player-filter-bar")
  if (!el || !el._updateDates) return
  const _key = "_lastDateSeason_player-stats"
  const currentKey = `${seasonFilter}|${matchStats ? matchStats.length : 0}`
  if (window[_key] === currentKey) return
  window[_key] = currentKey
  if (!matchStats || matchStats.length === 0) { el._updateDates("", ""); return }
  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter
  const games = effectiveSeason ? matchStats.filter(d => String(d.season) === effectiveSeason) : matchStats
  const dates = games.map(d => String(d.match_date || "").replace("Z", "").slice(0, 10)).filter(Boolean).sort()
  if (dates.length === 0) { el._updateDates("", ""); return }
  el._updateDates(dates[0], dates[dates.length - 1])
}
Show code
asAtLabel = {
  if (!matchStats || matchStats.length === 0) return ""
  const dates = matchStats.map(d => d.match_date).filter(Boolean).sort()
  if (dates.length === 0) return ""
  // Strip the trailing "Z" the parquet ships and format as "22 Apr 2026"
  // to match football/player-ratings.qmd convention.
  const latest = dates[dates.length - 1]
  const dt = new Date(String(latest).replace("Z", "") + "T12:00:00Z")
  const formatted = isNaN(dt.getTime())
    ? latest
    : dt.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
  const leagueName = leagueFilter === "All Leagues" ? "All Leagues" : (leagueNames[leagueFilter] || leagueFilter)
  return `${leagueName} · As at ${formatted}`
}

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-panna/">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
catDef = statDefs[category]

// Toggle is always visible — Value/EPV components tabs use per-match game-logs
// (toggle changes the values), and even Ratings tab keeps the toggle for visual
// consistency (its values are already per-90 so the toggle is a visual no-op
// there, but hiding it caused confusion vs other tabs).
{
  const aggWrap = document.querySelector('.player-filter-bar [data-role="agg-toggle"]')
  if (aggWrap) aggWrap.style.display = ""
  return null
}
Show code
tableData = {
  if (category === "custom") return null

  // Ratings tab — season aggregates from ratings.parquet, no per-match aggregation.
  // ratings.parquet has no position field for some players → fall back to matchStats
  // most-common position. Long-form panna league code → short-form blog code via
  // pannaLeagueMap so leagueFilter="ENG" matches "EPL" rows.
  if (catDef.source === "ratings" && _ratingsRaw) {
    let data = _ratingsRaw
    if (leagueFilter !== "All Leagues") {
      const pannaCode = (window.footballMaps.pannaLeagueMap || {})[leagueFilter] || leagueFilter
      data = data.filter(d => d.league === pannaCode)
    }
    const posLookup = _ratingsPositions || {}
    const resolvePos = (d) => d.position || posLookup[d.player_name] || null
    if (posFilter !== "All") {
      const validPositions = window.footballMaps.posGroups[posFilter] || []
      data = data.filter(d => validPositions.includes(resolvePos(d)))
    }
    return data.map(d => {
      const pos = resolvePos(d)
      return {
        player_name: d.player_name, position: pos, team: d.team,
        gp: null, mins: d.total_minutes != null ? Math.round(d.total_minutes) : null,
        panna: d.panna, offense: d.offense, defense: d.defense, spm_overall: d.spm_overall,
        panna_percentile: d.panna_percentile, total_minutes: d.total_minutes,
        pos_group: (window.footballMaps.posToGroup || {})[pos] || "",
      }
    })
  }

  // gameLogs source — Value & EPV components tabs. Per-player per-match rows
  // from game-logs.parquet, aggregated to player-level with full filter support
  // and Total/Per Game/Per 90 toggle. Mirrors AFL's matchStats path.
  if (catDef.source === "gameLogs" && _gameLogsActive) {
    let games = _gameLogsActive
    // game-logs.parquet uses short codes (ENG, ESP) — same as blog convention,
    // so no league-code mapping needed here (unlike ratings.parquet).
    if (leagueFilter !== "All Leagues") games = games.filter(d => d.league === leagueFilter)

    // Season filter — game-logs `season` is a string like "2025-2026"
    const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter
    if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)

    // Matchweek filter (date-range groupings built earlier from matchStats)
    if (matchdayFilter && matchdayFilter !== "All Matchweeks") {
      const mw = _matchweekRanges.find(w => w.label === matchdayFilter)
      if (mw) {
        games = games.filter(d => {
          const md = d.match_date ? String(d.match_date).replace("Z", "").slice(0, 10) : ""
          return md >= mw.startDate && md <= mw.endDate
        })
      }
    }

    // Date range filter — empty bound = no limit on that side
    if (dateRange.min || dateRange.max) {
      games = games.filter(d => {
        if (!d.match_date) return false
        const md = String(d.match_date).replace("Z", "").slice(0, 10)
        if (dateRange.min && md < dateRange.min) return false
        if (dateRange.max && md > dateRange.max) return false
        return true
      })
    }

    // Team / opponent / H-A / day filters require player_id → team-name lookup.
    // game-logs only has team_id; we cross-reference matchStats which has both
    // team_name and the same match_id, so we can build a quick team_id → team_name
    // map from any match's contestant pair.
    if (teamFilter !== "All Teams" || oppFilter !== "All Opponents" || homeAwayFilter !== "All" || dayFilter !== "All Days") {
      // Build match_id → { teams: [{team_id, team_name, position}], date } from matchStats
      const matchInfo = new Map()
      if (matchStats) {
        for (const g of matchStats) {
          if (!g.match_id) continue
          if (!matchInfo.has(g.match_id)) matchInfo.set(g.match_id, { teams: new Map(), date: g.match_date })
          const mi = matchInfo.get(g.match_id)
          if (g.team_id && !mi.teams.has(g.team_id)) {
            mi.teams.set(g.team_id, { team_name: g.team_name, position: g.team_position })
          }
        }
      }
      games = games.filter(d => {
        const mi = matchInfo.get(d.match_id)
        if (!mi) return true  // unknown match → don't drop, just can't apply these filters
        const own = mi.teams.get(d.team_id)
        if (!own) return false
        const oppEntries = [...mi.teams.values()].filter(t => t.team_name !== own.team_name)
        const opp = oppEntries[0]?.team_name || null

        if (teamFilter !== "All Teams" && own.team_name !== teamFilter) return false
        if (oppFilter !== "All Opponents" && opp !== oppFilter) return false
        if (homeAwayFilter !== "All" && own.position !== homeAwayFilter.toLowerCase()) return false
        if (dayFilter !== "All Days") {
          const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
          if (!d.match_date) return false
          const md = String(d.match_date).replace("Z", "").slice(0, 10)
          const dt = new Date(md + "T12:00:00Z")
          if (dayNames[dt.getUTCDay()] !== dayFilter) return false
        }
        return true
      })
    }

    // Listed-pos filter — player's nominal season position (from
    // _playerCurrentMeta, derived from most-recent non-Sub match-stats row).
    // The player_id key is shared between game-logs and match-stats parquets.
    if (posFilter !== "All") {
      games = games.filter(d => {
        const m = _playerCurrentMeta.get(d.player_id)
        const listed = m && m.position
        return listed && posToGroup[listed] === posFilter
      })
    }
    // Match-pos filter — prefers the chain-derived player-positions.parquet
    // lookup (gives granular LB/CB/RB/LM/CM/RM/LW/RW from x/y averages),
    // falls back to the optaToPanna per-game lookup (only resolves to the
    // 7 coarse Opta codes: GK/DEF/WB/DM/MID/AM/ST). Rows whose player has
    // no parquet entry AND whose Opta position doesn't map are silently
    // dropped — intentional, since we can't bucket an unknown player.
    if (matchPosFilter !== "All") {
      const posMap = _detailedPosMap
      const posD = window.footballMaps.posToDetailed || {}
      games = games.filter(d => {
        const derived = posMap && posMap.get(d.player_id)
        const code = derived || posD[d.position]
        return code === matchPosFilter
      })
    }

    // Aggregate per player. statCols are pure value cols that sum naturally
    // across matches (epv_*, wpa_*, psv/osv/dsv) — toggling Total/Per Game/Per 90
    // applies cleanly. Non-sum cols belong on the Ratings tab, not here.
    const statCols = catDef.columns.filter(c => c !== "player_name" && c !== "minsPlayed" && c !== "gp")
    const grouped = new Map()
    for (const g of games) {
      const key = g.player_id || g.player_name
      if (!grouped.has(key)) {
        grouped.set(key, {
          player_name: g.player_name,
          position: g.position,
          count: 0, mins: 0,
          vals: Object.fromEntries(statCols.map(c => [c, 0]))
        })
      }
      const e = grouped.get(key)
      e.count++
      e.mins += Number(g.total_minutes) || 0
      if (g.player_name) e.player_name = g.player_name
      if (g.position) e.position = g.position
      for (const col of statCols) {
        const v = Number(g[col])
        if (!isNaN(v)) e.vals[col] += v
      }
    }

    const out = []
    for (const [pid, e] of grouped) {
      if (e.count === 0) continue
      // Authoritative team comes from matchStats sorted by date desc (_playerCurrentMeta).
      // Falls back to the ratings-derived name map for rows whose pid isn't in
      // matchStats (very rare — game-logs and matchStats are both
      // pannadata-built from the same Opta export), then to e.team (carried
      // through aggregation) so the row never goes empty just because a join missed.
      const meta = _playerCurrentMeta.get(pid)
      const teamFallback = e.player_name ? _playerTeamMap.get(e.player_name) : null
      const effectivePos = (meta && meta.position) || e.position
      const row = {
        player_name: e.player_name || pid,
        team: (meta && meta.team) || teamFallback || null,
        position: effectivePos,
        pos_group: (window.footballMaps.posToGroup || {})[effectivePos] || "",
        gp: e.count,
        mins: e.mins,
      }
      for (const col of statCols) {
        if (aggMode === "avg") {
          row[col] = +(e.vals[col] / e.count).toFixed(3)
        } else if (aggMode === "p90") {
          row[col] = e.mins > 0 ? +(e.vals[col] / e.mins * 90).toFixed(3) : 0
        } else {
          row[col] = +e.vals[col].toFixed(3)
        }
      }
      out.push(row)
    }
    return out
  }

  if (!matchStats) return null
  let games = matchStats
  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter

  // Season filter
  if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)

  // Matchweek filter — filter to games within the selected matchweek date range
  if (matchdayFilter && matchdayFilter !== "All Matchweeks") {
    const mw = _matchweekRanges.find(w => w.label === matchdayFilter)
    if (mw) {
      games = games.filter(d => {
        const md = d.match_date ? String(d.match_date).replace("Z", "").slice(0, 10) : ""
        return md >= mw.startDate && md <= mw.endDate
      })
    }
  }

  // Date range filter — empty bound = no limit on that side
  if (dateRange.min || dateRange.max) {
    games = games.filter(d => {
      if (!d.match_date) return false
      const md = String(d.match_date).replace("Z", "").slice(0, 10)
      if (dateRange.min && md < dateRange.min) return false
      if (dateRange.max && md > dateRange.max) return false
      return true
    })
  }

  // Team filter
  if (teamFilter !== "All Teams") {
    games = games.filter(d => d.team_name === teamFilter)
  }

  // Opponent filter (lookup via oppMap built from same-match team pairing)
  if (oppFilter !== "All Opponents") {
    games = games.filter(d => oppMap.get(`${d.league}|${d.match_date}|${d.team_name}`) === oppFilter)
  }

  // Home/Away filter (uses team_position field directly)
  if (homeAwayFilter !== "All") {
    const target = homeAwayFilter.toLowerCase()
    games = games.filter(d => d.team_position === target)
  }

  // Day-of-week filter
  if (dayFilter !== "All Days") {
    const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
    games = games.filter(d => {
      if (!d.match_date) return false
      const md = String(d.match_date).replace("Z", "").slice(0, 10)
      const dt = new Date(md + "T12:00:00Z")
      return dayNames[dt.getUTCDay()] === dayFilter
    })
  }

  // Listed-pos filter — player's nominal season position
  if (posFilter !== "All") {
    games = games.filter(d => {
      const m = _playerCurrentMeta.get(d.player_id)
      const listed = m && m.position
      return listed && posToGroup[listed] === posFilter
    })
  }
  // Match-pos filter — prefers chain-derived player-positions.parquet lookup
  // (granular LB/CB/RB/...), falls back to optaToPanna per-game lookup.
  if (matchPosFilter !== "All") {
    const posMap = _detailedPosMap
    const posD = window.footballMaps.posToDetailed || {}
    games = games.filter(d => {
      const derived = posMap && posMap.get(d.player_id)
      const code = derived || posD[d.position]
      return code === matchPosFilter
    })
  }

  // Group by player — strip computed % columns so they aren't summed (they're
  // derived from their base counts after aggregation, see "Computed columns" below).
  const statCols = catDef.columns.filter(c => !c.endsWith("_pct"))
  const grouped = new Map()
  for (const g of games) {
    const key = g.player_id || g.player_name
    if (!grouped.has(key)) {
      grouped.set(key, { vals: {}, count: 0, mins: 0, player_name: g.player_name, team: g.team_name, position: g.position, league: g.league })
      for (const col of statCols) grouped.get(key).vals[col] = 0
    }
    const entry = grouped.get(key)
    entry.count++
    entry.mins += g.minsPlayed || 0
    entry.team = g.team_name
    if (g.player_name) entry.player_name = g.player_name
    for (const col of statCols) {
      const v = Number(g[col])
      if (!isNaN(v)) entry.vals[col] += v
    }
  }

  const result = []
  for (const [pid, entry] of grouped) {
    if (entry.count === 0) continue
    // Same authoritative team / position lookup as the gameLogs branch above,
    // so the two paths can't drift. matchStats currently writes the per-game
    // team into entry.team (overwriting on each iteration), which is fine for
    // single-team filter views but breaks for "All Teams" — _playerCurrentMeta
    // gives a stable "current club" per player_id regardless of filter state.
    const meta = _playerCurrentMeta.get(pid)
    const isSubStat = entry.position === "Substitute" || entry.position === "Sub"
    const effectivePos = isSubStat
      ? ((meta && meta.position) || _ratingsPositions[entry.player_name] || entry.position)
      : (entry.position || (meta && meta.position))
    const row = {
      player_name: entry.player_name || pid,
      team: (meta && meta.team) || entry.team,
      position: effectivePos,
      pos_group: posToGroup[effectivePos] || "",
      league: entry.league,
      gp: entry.count,
      mins: entry.mins
    }
    for (const col of statCols) {
      if (aggMode === "avg") {
        row[col] = +(entry.vals[col] / entry.count).toFixed(3)
      } else if (aggMode === "p90") {
        row[col] = entry.mins > 0 ? +(entry.vals[col] / entry.mins * 90).toFixed(3) : 0
      } else {
        row[col] = entry.vals[col]
      }
    }
    // Computed % columns — derived from aggregated counts so the rate is correct
    // under Total/Per Game/Per 90 (division is rate-preserving). Only emit a value
    // if the column is requested AND the denominator is non-zero, otherwise null
    // so the table shows blank rather than a misleading 0%.
    if (catDef.compute) {
      const cols = catDef.columns
      if (cols.includes("pass_pct"))
        row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : null
      if (cols.includes("tackles_won_pct"))
        row.tackles_won_pct = row.tackles > 0 ? Math.round(100 * row.tackles_won / row.tackles) : null
      if (cols.includes("aerials_won_pct")) {
        const aerTot = (row.aerials_won || 0) + (row.aerials_lost || 0)
        row.aerials_won_pct = aerTot > 0 ? Math.round(100 * row.aerials_won / aerTot) : null
      }
      if (cols.includes("duels_won_pct")) {
        const duelTot = (row.duels_won || 0) + (row.duels_lost || 0)
        row.duels_won_pct = duelTot > 0 ? Math.round(100 * row.duels_won / duelTot) : null
      }
      if (cols.includes("shots_on_target_pct"))
        row.shots_on_target_pct = row.shots > 0 ? Math.round(100 * row.shots_on_target / row.shots) : null
    }
    result.push(row)
  }

  return result
}
Show code
// ── Custom column picker ─────────────────────────────────────
viewof customCols = {
  const allMetrics = []
  for (const [key, def] of Object.entries(statDefs)) {
    if (key === "custom" || def.page) continue
    for (const col of def.columns) {
      if (allMetrics.some(m => m.col === col)) continue
      allMetrics.push({ col, label: def.header[col] || col, cat: def.label })
    }
  }

  const MAX = 10
  // Persist user's column choice across reloads via per-page localStorage key.
  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)

  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
      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)
        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]
        try { window.localStorage.setItem(_lsKey, JSON.stringify([...selected])) } catch (e) {}
        container.dispatchEvent(new Event("input", { bubbles: true }))
      })
    }
    panel.appendChild(group)
  }
  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"
  })
  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 = [...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
  const header = {}, heatmap = {}
  for (const col of customCols) {
    for (const [key, def] of Object.entries(statDefs)) {
      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]
        break
      }
    }
  }
  return { label: "Custom", columns: customCols, header, heatmap, sortCol: customCols[0] || null }
}
Show code
// ── Custom tab data ──────────────────────────────────────────
customTableData = {
  if (category !== "custom" || !customCols || customCols.length === 0 || !matchStats) return null

  let games = matchStats
  const effectiveSeason = seasonFilter === "All Seasons" ? null : seasonFilter
  if (effectiveSeason) games = games.filter(d => String(d.season) === effectiveSeason)
  if (matchdayFilter && matchdayFilter !== "All Matchweeks") {
    const mw = _matchweekRanges.find(w => w.label === matchdayFilter)
    if (mw) {
      games = games.filter(d => {
        const md = d.match_date ? String(d.match_date).replace("Z", "").slice(0, 10) : ""
        return md >= mw.startDate && md <= mw.endDate
      })
    }
  }
  if (teamFilter !== "All Teams") games = games.filter(d => d.team_name === teamFilter)
  if (oppFilter !== "All Opponents") {
    games = games.filter(d => oppMap.get(`${d.league}|${d.match_date}|${d.team_name}`) === oppFilter)
  }
  if (homeAwayFilter !== "All") {
    const target = homeAwayFilter.toLowerCase()
    games = games.filter(d => d.team_position === target)
  }
  if (dayFilter !== "All Days") {
    const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
    games = games.filter(d => {
      if (!d.match_date) return false
      const md = String(d.match_date).replace("Z", "").slice(0, 10)
      return dayNames[new Date(md + "T12:00:00Z").getUTCDay()] === dayFilter
    })
  }
  // Listed-pos filter — player's nominal season position (custom tab)
  if (posFilter !== "All") {
    games = games.filter(d => {
      const m = _playerCurrentMeta.get(d.player_id)
      const listed = m && m.position
      return listed && posToGroup[listed] === posFilter
    })
  }
  // Match-pos filter — prefers chain-derived player-positions.parquet lookup
  // (granular LB/CB/RB/...), falls back to optaToPanna per-game lookup.
  if (matchPosFilter !== "All") {
    const posMap = _detailedPosMap
    const posD = window.footballMaps.posToDetailed || {}
    games = games.filter(d => {
      const derived = posMap && posMap.get(d.player_id)
      const code = derived || posD[d.position]
      return code === matchPosFilter
    })
  }

  const grouped = new Map()
  for (const g of games) {
    const key = g.player_id || g.player_name
    if (!grouped.has(key)) {
      grouped.set(key, { vals: {}, count: 0, mins: 0, player_name: g.player_name, team: g.team_name, position: g.position })
      for (const col of customCols) grouped.get(key).vals[col] = 0
    }
    const entry = grouped.get(key)
    entry.count++
    entry.mins += g.minsPlayed || 0
    entry.team = g.team_name
    if (g.player_name) entry.player_name = g.player_name
    for (const col of customCols) {
      const v = Number(g[col])
      if (!isNaN(v)) entry.vals[col] += v
    }
  }

  const result = []
  for (const [pid, entry] of grouped) {
    if (entry.count === 0) continue
    const fixPos = (entry.position === "Substitute" || entry.position === "Sub") ? (_ratingsPositions[entry.player_name] || entry.position) : entry.position
    const row = { player_name: entry.player_name || pid, team: entry.team, position: fixPos, gp: entry.count, mins: entry.mins }
    for (const col of customCols) {
      if (aggMode === "avg") row[col] = +(entry.vals[col] / entry.count).toFixed(3)
      else if (aggMode === "p90") row[col] = entry.mins > 0 ? +(entry.vals[col] / entry.mins * 90).toFixed(3) : 0
      else row[col] = entry.vals[col]
    }
    const cCols = effectiveCatDef.columns
    if (cCols.includes("pass_pct")) {
      row.pass_pct = (row.passes != null && row.passes > 0 && row.passes_accurate != null)
        ? Math.round(100 * row.passes_accurate / row.passes) : null
    }
    if (cCols.includes("tackles_won_pct")) {
      row.tackles_won_pct = (row.tackles > 0 && row.tackles_won != null)
        ? Math.round(100 * row.tackles_won / row.tackles) : null
    }
    if (cCols.includes("aerials_won_pct")) {
      const aerTot = (row.aerials_won || 0) + (row.aerials_lost || 0)
      row.aerials_won_pct = aerTot > 0 ? Math.round(100 * row.aerials_won / aerTot) : null
    }
    if (cCols.includes("duels_won_pct")) {
      const duelTot = (row.duels_won || 0) + (row.duels_lost || 0)
      row.duels_won_pct = duelTot > 0 ? Math.round(100 * row.duels_won / duelTot) : null
    }
    if (cCols.includes("shots_on_target_pct")) {
      row.shots_on_target_pct = (row.shots > 0 && row.shots_on_target != null)
        ? Math.round(100 * row.shots_on_target / row.shots) : null
    }
    result.push(row)
  }
  return result
}
Show code
// ── Partial-failure banner ──────────────────────────────────
// Surfaces league fetches that failed (404 / parse error / network) so
// non-DevTools users actually see when data is incomplete. Reads
// _matchStatsResult.failed, which is populated by the matchStats loader.
{
  const failed = _matchStatsResult && _matchStatsResult.failed
  if (!failed || failed.length === 0) return html``
  const leagueNames = window.footballMaps.leagueNames || {}
  const labels = failed.map(c => leagueNames[c] || c).join(", ")
  const isAllFailed = !_matchStatsResult.rows
  return html`<div class="data-load-warning" role="status">
    <span><strong>${isAllFailed ? "Couldn't load data:" : "Some data didn't load:"}</strong> ${labels}.${isAllFailed ? "" : " Showing partial results."}</span>
    <button class="data-load-warning-btn" onclick="window.location.reload()">Retry</button>
  </div>`
}
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
// ── Search ───────────────────────────────────────────────────
activeData = category === "custom" ? customTableData : tableData

// Search input intentionally dropped — render cell now consumes activeData directly.
// The viewof search + Generators.input pattern silently freezes OJS reactivity
// whenever the data shape transitions in ways that confuse Inputs.search re-creation,
// and removing the indirection is the simplest fix. Re-add via Inputs.text +
// filteredData cell later if needed.
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 colorMap = {}
  for (const [grp, info] of Object.entries(footballPosColors)) colorMap[grp] = info.c || "#888"
  const activePositions = new Set(Object.keys(footballPosColors))

  function drawChart(xCol, yCol) {
    while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
    const filtered = activePositions.size === Object.keys(footballPosColors).length ? data : data.filter(d => activePositions.has(d.pos_group))
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data: filtered,
      xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "player_name",
      colorCol: "pos_group",
      colorMap,
      hrefFn: (row) => `player.html#name=${encodeURIComponent(row.player_name)}`,
      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 crest = window.footballMaps.teamCrest(row.team)
        if (crest) {
          const badge = document.createElement("img")
          badge.className = "scatter-tip-headshot"
          badge.src = crest
          badge.alt = ""
          badge.style.borderRadius = "4px"
          header.appendChild(badge)
        }
        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 grp = posToGroup[row.position] || ""
        const posInfo = footballPosColors[grp] || { a: "?", c: "#9ca3af" }
        teamRow.textContent = (row.team || "") + (row.position ? " · " + posInfo.a : "")
        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 [grp, info] of Object.entries(footballPosColors)) {
    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 || grp))
    item.addEventListener("click", () => {
      if (activePositions.has(grp)) { activePositions.delete(grp); item.style.opacity = "0.3" }
      else { activePositions.add(grp); 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) {
    // Value tab pulls per-season game-logs on demand (_gameLogsActive). Empty data
    // for a specific picked season means that season has no game-logs file at all
    // (EPV/WPA are chain-derived and only backfilled from 2015-16 on), so the
    // generic "select a league and season" copy would mislead — a season IS picked.
    const seasonPicked = seasonFilter && seasonFilter !== "All Seasons"
    if (def.source === "gameLogs" && seasonPicked) {
      const msg = `${def.label} metrics aren't available for ${statsEsc(seasonFilter)} — EPV/WPA value data starts from 2015-16.`
      return html`<p class="text-muted">${msg}</p>`
    }
    return html`<p class="text-muted">No data available. Select a league and season to view player stats.</p>`
  }

  const posBadge = (val) => {
    const grp = posToGroup[val] || ""
    const info = footballPosColors[grp] || { a: posAbbr[val] || val || "?", c: "#9ca3af" }
    const abbr = posAbbr[val] || val || "?"
    return `<span class="pos-badge" style="background:${info.c}18;color:${info.c};border:1px solid ${info.c}35">${statsEsc(abbr)}</span>`
  }

  const statCols = def.columns.filter(c => {
    return data[0] && data[0][c] !== undefined
  })

  const columns = ["player_name", "position", "gp", "mins", ...statCols]

  const header = {
    player_name: "Player",
    position: "Pos",
    gp: "GP",
    mins: "Mins",
    ...def.header,
  }

  const groups = [
    { label: "Player", span: 4 },
    { label: def.label, span: statCols.length },
  ]

  const _pctFmt = x => x != null ? x + "%" : ""
  const format = {
    mins: x => x?.toLocaleString() ?? "",
    pass_pct: _pctFmt,
    tackles_won_pct: _pctFmt,
    aerials_won_pct: _pctFmt,
    duels_won_pct: _pctFmt,
    shots_on_target_pct: _pctFmt
  }
  // Other columns auto-formatted by stats-table.js (adaptive dp)

  const mCols = def.mobileCols ? ["player_name", "position", ...def.mobileCols, "gp"] : null

  const tooltip = { ...def.tooltip, gp: "Games played", mins: "Total minutes played" }

  const tableEl = statsTable(data, {
    columns,
    mobileCols: mCols,
    header,
    groups,
    format,
    tooltip,
    render: {
      player_name: (v, row) => {
        const pos = posToGroup[row.position] || ""
        const posInfo = footballPosColors[pos] || { a: "?", c: "#9ca3af" }
        return `<a href="player.html#name=${encodeURIComponent(v)}" class="player-link"><strong>${statsEsc(v)}</strong></a><span class="player-sub"><a href="team.html#team=${encodeURIComponent(row.team)}" class="team-link">${statsEsc(row.team)}</a> · ${statsEsc(posInfo.a)}</span>`
      },
      position: posBadge
    },
    heatmap: def.heatmap || {},
    heatmapData: data,
    filters: {
      ...(def.sortCol ? { [def.sortCol]: "range" } : {}),
      gp: "range",
      mins: "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/pannadata"; a.target = "_blank"; a.rel = "noopener"
  a.textContent = "pannadata"
  left.appendChild(a)
  left.appendChild(document.createTextNode(" · Opta scrape · CC BY 4.0"))
  const right = document.createElement("span")
  right.textContent = (asAtLabel || "Latest") + " · Filter by league + matchday + 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

  const gl = _gameLogsRaw
  if (!gl || gl.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
  }

  const sortedByEpv = [...gl].sort((a, b) => (b.epv_total ?? -Infinity) - (a.epv_total ?? -Infinity))
  const topGame = sortedByEpv[0]
  const topName = topGame?.player_name || "—"
  const topEpv = topGame?.epv_total != null ? (topGame.epv_total > 0 ? "+" : "") + topGame.epv_total.toFixed(2) : "—"
  const topLeague = topGame?.league ? topGame.league.replace(/_/g, " ") : ""
  const games = gl.length
  const players = new Set(gl.map(g => g.player_id || g.player_name)).size
  const leagues = new Set(gl.map(g => g.league).filter(Boolean)).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 scores + EPV refresh on the daily "))
  const code = document.createElement("code")
  code.style.cssText = "font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--site-body-color)"
  code.textContent = "pannadata"
  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 },
    ...(topLeague ? [{ br: true }, { text: topLeague }] : [])
  ]))
  grid.appendChild(btnTile(games.toLocaleString(), [
    { text: "Match performances", bold: true },
    { text: " · all leagues, this season" }
  ]))
  grid.appendChild(btnTile(players.toLocaleString(), [
    { text: "Unique players", bold: true },
    { text: " · who logged minutes" }
  ]))
  grid.appendChild(btnTile(String(leagues), [
    { text: "Leagues covered", bold: true },
    { text: " · Europe + UCL + UEL" }
  ]))
  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 = "per-match"; p1.appendChild(s1)
  p1.appendChild(document.createTextNode(" view — what happened, game by game, with box-score + EPV side-by-side."))
  about.appendChild(p1)
  const p2 = document.createElement("p")
  p2.appendChild(document.createTextNode("For season-long predictive "))
  const s2 = document.createElement("strong"); s2.textContent = "ratings"; p2.appendChild(s2)
  p2.appendChild(document.createTextNode(" (Panna, Offense, Defense), 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 Panna" },
    { 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