In The Game
  • Home
  • Blog
  • AFL
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Matches
    • Ladder
    • Definitions
  • Football
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Leagues
    • Matches
    • Definitions
  • About
Skip to content

Football Player Stats

Per-match box-score stats across 10 European leagues

Football > Player Stats

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

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

// Load ratings for position fallback + Value/Ratings tab data
_ratingsRaw = {
  try { return await window.fetchParquet(base + "football/ratings.parquet") } catch (e) { console.warn("[player-stats] ratings.parquet load failed:", e.message); return null }
}
// ratings.parquet position field is all null — derive a player → most-common-position
// map from match-stats instead, so the Value tab's position filter has something to match.
_ratingsPositions = {
  if (!matchStats) return {}
  const counts = {}
  for (const g of matchStats) {
    if (!g.player_name || !g.position || g.position === "Substitute") continue
    if (!counts[g.player_name]) counts[g.player_name] = {}
    counts[g.player_name][g.position] = (counts[g.player_name][g.position] || 0) + 1
  }
  const m = {}
  for (const [name, posMap] of Object.entries(counts)) {
    let best = null, bestN = 0
    for (const [pos, n] of Object.entries(posMap)) {
      if (n > bestN) { best = pos; bestN = n }
    }
    if (best) m[name] = best
  }
  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] || "scoring"
  const _default = catKeys.includes(_saved) ? _saved : "scoring"
  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
// ── Position filter ──────────────────────────────────────────
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 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.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = p
      window[_key] = p
      container.dispatchEvent(new Event("input", { bubbles: true }))
    })
    container.appendChild(btn)
  }
  container.value = _saved
  return container
}
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")

  row.appendChild(league.wrap)
  row.appendChild(season.wrap)
  row.appendChild(matchday.wrap)

  // 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 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", aggMode: "total" }

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

  league.sel.addEventListener("change", () => {
    container.value = { ...container.value, league: league.sel.value }
    emit()
  })
  season.sel.addEventListener("change", () => {
    container.value = { ...container.value, season: season.sel.value, matchday: "All Matchweeks" }
    emit()
  })
  matchday.sel.addEventListener("change", () => {
    container.value = { ...container.value, matchday: matchday.sel.value }
    emit()
  })

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

  return container
}

// ── Destructure for downstream reactivity ────────────────────
leagueFilter = filters.league
seasonFilter = filters.season
matchdayFilter = filters.matchday
aggMode = filters.aggMode
Show code
matchStats = {
  const codes = leagueFilter === "All Leagues" ? leagueCodes : [leagueFilter]
  const results = []
  for (const code of codes) {
    try {
      const data = await window.fetchParquet(base + `football/match-stats-${code}.parquet`)
      if (data) results.push(...data)
    } catch (e) {
      console.warn(`[player-stats] match-stats-${code} load failed:`, e)
    }
  }
  return results.length > 0 ? results : null
}

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

// Update matchday dropdown when season changes
{
  const el = document.querySelector(".player-filter-bar")
  if (el && el._updateMatchdays) {
    el._updateMatchdays(matchdayOptions)
  }
}
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 ""
  const latest = dates[dates.length - 1]
  const leagueName = leagueFilter === "All Leagues" ? "All Leagues" : (leagueNames[leagueFilter] || leagueFilter)
  return `${leagueName} · Updated ${latest}`
}

html`<div class="page-legend" style="display:flex;align-items:center;gap:0.5rem">
  <span>${asAtLabel}</span>
</div>`
Show code
catDef = statDefs[category]

tableData = {
  if (category === "custom") return null

  // Value/Ratings tabs use career ratings (not per-game match stats).
  // ratings.parquet has no position field — fall back to matchStats most-common position per player.
  if ((catDef.source === "gameLogs" || catDef.source === "ratings") && _ratingsRaw) {
    let data = _ratingsRaw
    if (leagueFilter !== "All Leagues") data = data.filter(d => d.league === leagueFilter)
    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,
        // Optional EPV/WPA/PSV columns — present once pannadata ships the enriched ratings.parquet.
        epv_total: d.epv_total, epv_passing: d.epv_passing, epv_shooting: d.epv_shooting,
        epv_dribbling: d.epv_dribbling, epv_defending: d.epv_defending,
        wpa_total: d.wpa_total, wpa_as_actor: d.wpa_as_actor, wpa_as_receiver: d.wpa_as_receiver,
        psv: d.psv, osv: d.osv, dsv: d.dsv, panna_value_p90: d.panna_value_p90,
        pos_group: (window.footballMaps.posToGroup || {})[pos] || "",
      }
    })
  }

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

  // Position filter
  if (posFilter !== "All") {
    const validPositions = posGroups[posFilter] || []
    games = games.filter(d => validPositions.includes(d.position))
  }

  // Group by player
  const statCols = catDef.columns.filter(c => c !== "pass_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
    const row = {
      player_name: entry.player_name || pid,
      team: entry.team,
      position: (entry.position === "Substitute" || entry.position === "Sub") ? (_ratingsPositions[entry.player_name] || entry.position) : entry.position,
      pos_group: posToGroup[(entry.position === "Substitute" || entry.position === "Sub") ? (_ratingsPositions[entry.player_name] || entry.position) : entry.position] || "",
      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
    if (catDef.compute) {
      row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : 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
  const selected = new Set()

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

  const btn = document.createElement("button")
  btn.className = "custom-col-btn"
  btn.textContent = "Select columns..."
  container.appendChild(btn)

  const panel = document.createElement("div")
  panel.className = "custom-col-panel"
  panel.style.display = "none"
  container.appendChild(panel)

  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
      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]
        container.dispatchEvent(new Event("input", { bubbles: true }))
      })
    }
    panel.appendChild(group)
  }

  btn.addEventListener("click", (e) => {
    e.stopPropagation()
    panel.style.display = panel.style.display === "none" ? "block" : "none"
  })
  const ac = new AbortController()
  document.addEventListener("click", (e) => {
    if (!container.contains(e.target)) panel.style.display = "none"
  }, { signal: ac.signal })
  invalidation.then(() => ac.abort())

  container.value = []
  return container
}
Show code
// ── 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 (posFilter !== "All") {
    const validPositions = posGroups[posFilter] || []
    games = games.filter(d => validPositions.includes(d.position))
  }

  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]
    }
    if (effectiveCatDef.columns.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
    }
    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
// ── 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:#9a9088;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) {
    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 format = {
    mins: x => x?.toLocaleString() ?? "",
    pass_pct: x => x != null ? x + "%" : ""
  }
  // 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
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer