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 Team Stats

Aggregated team-level match stats across 10 European leagues

Football > Team Stats

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

// League code -> display name mapping
leagueCodes = window.footballMaps.domesticLeagues
leagueNames = window.footballMaps.leagueNames

// Load league xG for enriching Results tab
_leagueXg = {
  try { return await window.fetchParquet(base + "football/league-xg.parquet") } catch (e) { console.warn("[team-stats] league-xg.parquet load failed:", e.message); return null }
}
Show code
statDefs = ({
  results: {
    label: "Results",
    columns: ["w", "l", "d", "win_pct", "gf", "ga", "gd", "xgf", "xga", "xgd"],
    mobileCols: ["w", "l", "win_pct", "gf", "ga", "xgd"],
    header: { w: "W", l: "L", d: "D", win_pct: "Win%",
              gf: "GF", ga: "GA", gd: "GD", xgf: "xGF", xga: "xGA", xgd: "xGD" },
    tooltip: { win_pct: "Win percentage", gf: "Goals for", ga: "Goals against", gd: "Goal difference",
               xgf: "Expected goals for (from shot quality)", xga: "Expected goals against", xgd: "Expected goal difference" },
    heatmap: { win_pct: "high-good", gd: "high-good", gf: "high-good", ga: "low-good", xgf: "high-good", xga: "low-good", xgd: "high-good" },
    sortCol: "win_pct"
  },
  scoring: {
    label: "Scoring",
    columns: ["goals", "assists", "shots", "shots_on_target", "big_chances_created", "key_passes"],
    header: {
      goals: "Goals", assists: "Assists", shots: "Shots",
      shots_on_target: "On Target", big_chances_created: "Big Chances", key_passes: "Key Passes"
    },
    heatmap: {
      goals: "high-good", assists: "high-good", shots: "high-good",
      shots_on_target: "high-good", big_chances_created: "high-good", key_passes: "high-good"
    },
    sortCol: "goals"
  },
  passing: {
    label: "Passing",
    columns: ["passes", "passes_accurate", "pass_pct", "key_passes", "big_chances_created"],
    header: {
      passes: "Passes", passes_accurate: "Accurate", pass_pct: "Pass %",
      key_passes: "Key Passes", big_chances_created: "Big Chances"
    },
    heatmap: {
      passes: "high-good", passes_accurate: "high-good", pass_pct: "high-good",
      key_passes: "high-good", big_chances_created: "high-good"
    },
    sortCol: "passes",
    compute: true
  },
  defending: {
    label: "Defending",
    columns: ["tackles", "tackles_won", "interceptions", "clearances", "aerials_won", "aerials_lost"],
    header: {
      tackles: "Tackles", tackles_won: "Tackles Won", interceptions: "Intercepts",
      clearances: "Clearances", aerials_won: "Aerials Won", aerials_lost: "Aerials Lost"
    },
    heatmap: {
      tackles: "high-good", tackles_won: "high-good", interceptions: "high-good",
      clearances: "high-good", aerials_won: "high-good", aerials_lost: "low-good"
    },
    sortCol: "tackles"
  },
  duels: {
    label: "Duels",
    columns: ["duels_won", "duels_lost", "aerials_won", "aerials_lost", "was_fouled", "fouls"],
    header: {
      duels_won: "Duels Won", duels_lost: "Duels Lost",
      aerials_won: "Aerials Won", aerials_lost: "Aerials Lost",
      was_fouled: "Fouled", fouls: "Fouls"
    },
    heatmap: {
      duels_won: "high-good", duels_lost: "low-good",
      aerials_won: "high-good", aerials_lost: "low-good", was_fouled: "high-good", fouls: "low-good"
    },
    sortCol: "duels_won"
  },
  discipline: {
    label: "Discipline",
    columns: ["fouls", "was_fouled", "yellows", "reds", "dispossessed"],
    header: {
      fouls: "Fouls", was_fouled: "Fouled", yellows: "Yellows",
      reds: "Reds", dispossessed: "Dispossessed"
    },
    heatmap: {
      fouls: "low-good", yellows: "low-good", reds: "low-good",
      dispossessed: "low-good", was_fouled: "high-good"
    },
    sortCol: "fouls"
  },
  custom: {
    label: "Custom",
    columns: [],
    header: {},
    heatmap: {},
    sortCol: null
  }
})

catKeys = Object.keys(statDefs)
Show code
// ── Category toggle ──────────────────────────────────────────
viewof category = {
  const _key = "_statCategory_" + window.location.pathname.replace(/[^a-z0-9]/gi, "_")
  const _saved = window[_key] || "results"
  const _default = catKeys.includes(_saved) ? _saved : "results"
  const container = html`<div class="stats-category-toggle football"></div>`
  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
// ── Custom column picker ─────────────────────────────────────
viewof customCols = {
  const allMetrics = []
  for (const [key, def] of Object.entries(statDefs)) {
    if (key === "custom") 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") 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
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)
  const season = makeSelect(["All Seasons"], "All Seasons", "Season")

  // Matchday select (populated after data loads)
  const matchday = makeSelect(["All Matchdays"], "All Matchdays", "Matchday")

  // Home/Away toggle
  const haWrap = document.createElement("div")
  haWrap.className = "filter-agg-wrap"
  for (const label of ["All", "Home", "Away"]) {
    const btn = document.createElement("button")
    btn.className = "agg-btn" + (label === "All" ? " active" : "")
    btn.textContent = label
    btn.dataset.ha = label
    btn.addEventListener("click", () => {
      haWrap.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      container.value = { ...container.value, homeAway: label }
      emit()
    })
    haWrap.appendChild(btn)
  }

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

  // Agg toggle: Per Game / Total only (no Per 90 for teams)
  const aggWrap = document.createElement("div")
  aggWrap.className = "filter-agg-wrap"
  const btnAvg = document.createElement("button")
  btnAvg.className = "agg-btn active"
  btnAvg.textContent = "Per Game"
  btnAvg.dataset.mode = "avg"
  const btnTot = document.createElement("button")
  btnTot.className = "agg-btn"
  btnTot.textContent = "Total"
  btnTot.dataset.mode = "total"
  aggWrap.appendChild(btnAvg)
  aggWrap.appendChild(btnTot)
  row.appendChild(aggWrap)

  container.appendChild(row)

  // ── State and dispatch ──
  container.value = { league: "ENG", season: "All Seasons", matchday: "All Matchdays", homeAway: "All", aggMode: "avg" }

  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 Matchdays" }
    emit()
  })
  matchday.sel.addEventListener("change", () => {
    container.value = { ...container.value, matchday: matchday.sel.value }
    emit()
  })

  for (const btn of [btnAvg, 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()
    })
  }

  // Expose method to update season options externally
  container._updateSeasons = (options, defaultVal) => {
    const oldVal = 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
      if (opt === defaultVal) o.selected = true
      season.sel.appendChild(o)
    }
    if (defaultVal !== oldVal) {
      container.value = { ...container.value, season: defaultVal, matchday: "All Matchdays" }
      container.dispatchEvent(new Event("input", { bubbles: true }))
    }
  }

  // Expose method to update matchday options externally
  container._updateMatchdays = (options) => {
    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
      if (opt === "All Matchdays") o.selected = true
      matchday.sel.appendChild(o)
    }
    container.value = { ...container.value, matchday: "All Matchdays" }
  }

  return container
}

// ── Destructure for downstream reactivity ────────────────────
leagueFilter = filters.league
seasonFilter = filters.season
matchdayFilter = filters.matchday
homeAwayFilter = filters.homeAway
aggMode = filters.aggMode
Show code
fixtureData = {
  try {
    const res = await fetch(window.DATA_BASE_URL + "football/fixtures.json")
    if (res.ok) { const data = await res.json(); return data.matches || null }
  } catch (e) { console.warn("[team-stats] R2 fixture fetch failed, falling back to Worker:", e.message) }
  const data = await window.fetchFixtures("football")
  return data ? (data.matches || null) : null
}

// ── Load match-stats for selected league(s) ──────────────────
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(`[team-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
matchdayOptions = {
  if (!matchStats) return ["All Matchdays"]
  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()
  return ["All Matchdays", ...dates]
}

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

// Collect ALL stat columns across all categories (for custom tab support)
allStatCols = {
  const seen = new Set()
  for (const [key, def] of Object.entries(statDefs)) {
    if (key === "custom" || key === "results") continue
    for (const col of def.columns) {
      if (col !== "pass_pct") seen.add(col)
    }
  }
  return [...seen]
}

teamData = {
  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)

  // Matchday filter
  if (matchdayFilter && matchdayFilter !== "All Matchdays") {
    games = games.filter(d => {
      const md = d.match_date ? String(d.match_date).replace("Z", "").slice(0, 10) : ""
      return md === matchdayFilter
    })
  }

  // Home/Away filter — build fixture lookup for H/A determination
  const homeAwayMode = homeAwayFilter || "All"
  const normalize = s => (s || "").replace(/\b(fc|afc|sfc|sc|cf)\b/gi, "").replace(/\s+/g, " ").trim().toLowerCase()
  const fixtureHAMap = new Map() // "matchdate|teamnorm" → "home"|"away"
  if (fixtureData && homeAwayMode !== "All") {
    for (const f of fixtureData) {
      if (f.status !== "FINISHED" || !f.date) continue
      const dateKey = f.date.slice(0, 10)
      fixtureHAMap.set(`${dateKey}|${normalize(f.homeTeam)}`, "home")
      fixtureHAMap.set(`${dateKey}|${normalize(f.awayTeam)}`, "away")
    }
  }

  // Group by team — sum player stats per match, then aggregate across matches
  // Pass 1: build per-match per-team totals
  const matchTeamMap = new Map()
  for (const g of games) {
    const team = g.team_name
    if (!team) continue
    const matchKey = g.match_id || `${g.match_date}|${g.opponent || ""}`
    const key = `${team}|||${matchKey}`
    if (!matchTeamMap.has(key)) {
      matchTeamMap.set(key, { team, matchKey, opponent: g.opponent, vals: {} })
      for (const col of allStatCols) matchTeamMap.get(key).vals[col] = 0
    }
    const entry = matchTeamMap.get(key)
    for (const col of allStatCols) {
      const v = Number(g[col])
      if (!isNaN(v)) entry.vals[col] += v
    }
  }

  // Build matchKey-based lookup for opponent cross-reference BEFORE H/A filter
  // so goals-against lookups still work when one side is filtered out
  const matchLookup = new Map()  // matchKey -> Map(team -> entry)
  for (const [, entry] of matchTeamMap) {
    if (!matchLookup.has(entry.matchKey)) matchLookup.set(entry.matchKey, new Map())
    matchLookup.get(entry.matchKey).set(entry.team, entry)
  }

  // Apply Home/Away filter: remove match-team entries where H/A doesn't match
  if (homeAwayMode !== "All") {
    for (const [key, entry] of matchTeamMap) {
      const matchDate = games.find(g => (g.match_id || `${g.match_date}|${g.opponent || ""}`) === entry.matchKey)?.match_date
      const dateKey = matchDate ? String(matchDate).replace("Z", "").slice(0, 10) : ""
      const ha = fixtureHAMap.get(`${dateKey}|${normalize(entry.team)}`)
      if (ha && ((homeAwayMode === "Home" && ha !== "home") || (homeAwayMode === "Away" && ha !== "away"))) {
        matchTeamMap.delete(key)
      }
    }
  }

  // Pass 2: aggregate match totals into team totals with results cross-lookup
  const teamMap = new Map()
  for (const [, mEntry] of matchTeamMap) {
    const team = mEntry.team
    if (!teamMap.has(team)) {
      teamMap.set(team, { matchKeys: new Set(), vals: {}, w: 0, l: 0, d: 0, gf: 0, ga: 0 })
      for (const col of allStatCols) teamMap.get(team).vals[col] = 0
    }
    const entry = teamMap.get(team)
    entry.matchKeys.add(mEntry.matchKey)
    for (const col of allStatCols) {
      entry.vals[col] += mEntry.vals[col]
    }

    // Cross-lookup: find the OTHER team in the same match (avoids name matching)
    const teamGoals = mEntry.vals.goals || 0
    const mLookup = matchLookup.get(mEntry.matchKey)
    let oppEntry = null
    if (mLookup) {
      for (const [t, e] of mLookup) {
        if (t !== mEntry.team) { oppEntry = e; break }
      }
    }
    if (!oppEntry) console.warn(`[team-stats] No opponent data for match ${mEntry.matchKey} (${mEntry.team})`)
    const oppGoals = oppEntry ? (oppEntry.vals.goals || 0) : 0

    entry.gf += teamGoals
    entry.ga += oppGoals
    if (teamGoals > oppGoals) entry.w++
    else if (teamGoals < oppGoals) entry.l++
    else entry.d++
  }

  // Pass 3: build output rows
  const isAvg = aggMode === "avg"
  const result = []
  for (const [team, entry] of teamMap) {
    const gp = entry.matchKeys.size
    if (gp === 0) continue
    const row = { team_name: team, gp }
    for (const col of allStatCols) {
      row[col] = isAvg ? +(entry.vals[col] / gp).toFixed(2) : entry.vals[col]
    }
    // Computed columns
    row.pass_pct = row.passes > 0 ? Math.round(100 * row.passes_accurate / row.passes) : null

    // Results columns — W/L/D always totals
    row.w = entry.w
    row.l = entry.l
    row.d = entry.d
    row.win_pct = gp > 0 ? +((entry.w / gp) * 100).toFixed(1) : 0
    row.gf = isAvg ? +(entry.gf / gp).toFixed(2) : entry.gf
    row.ga = isAvg ? +(entry.ga / gp).toFixed(2) : entry.ga
    row.gd = isAvg ? +((entry.gf - entry.ga) / gp).toFixed(2) : entry.gf - entry.ga

    // Join xG from league-xg.parquet
    if (_leagueXg) {
      const xg = _leagueXg.find(x => x.team_name === team && (leagueFilter === "All Leagues" || x.league === leagueFilter))
      if (!xg) console.debug("[team-stats] No xG match for:", team)
      if (xg) {
        row.xgf = isAvg && gp > 0 ? +(xg.xgf / gp).toFixed(2) : xg.xgf != null ? +xg.xgf.toFixed(1) : null
        row.xga = isAvg && gp > 0 ? +(xg.xga / gp).toFixed(2) : xg.xga != null ? +xg.xga.toFixed(1) : null
        row.xgd = isAvg && gp > 0 ? +((xg.xgf - xg.xga) / gp).toFixed(2) : xg.xgd != null ? +xg.xgd.toFixed(1) : null
      }
    }
    result.push(row)
  }

  return result
}
Show code
// ── View toggle (Table / Scatter) ───────────────────────────
{
  if (!window._teamStatsView) window._teamStatsView = "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._teamStatsView ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      container.querySelectorAll(".pos-pill").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      window._teamStatsView = label
      const isTable = label === "Table"
      const tableView = document.querySelector(".team-stats-table-view")
      const scatterView = document.querySelector(".team-stats-scatter-view")
      if (tableView) tableView.style.display = isTable ? "" : "none"
      if (scatterView) scatterView.style.display = isTable ? "none" : ""
    })
    container.appendChild(btn)
  }
  return container
}
Show code
activeData = category === "custom" ? (customCols?.length > 0 ? teamData : null) : teamData

// Always return a real Inputs.search element — see football/player-stats.qmd for rationale.
viewof search = Inputs.search(activeData || [], { placeholder: "Search teams\u2026" })
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 < 1) return html``

  const defaultX = metricOpts[0]?.value
  const defaultY = metricOpts[1]?.value || metricOpts[0]?.value

  const headerSrc = Object.fromEntries(metricOpts.map(m => [m.value, m.label]))

  const wrapper = document.createElement("div")
  wrapper.className = "team-stats-scatter-view"
  wrapper.style.display = window._teamStatsView === "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)

  function drawChart(xCol, yCol) {
    while (chartDiv.firstChild) chartDiv.removeChild(chartDiv.firstChild)
    window.chartHelpers.drawScatterPlot(chartDiv, {
      data,
      xCol, yCol,
      xLabel: headerSrc[xCol] || xCol,
      yLabel: headerSrc[yCol] || yCol,
      labelCol: "team_name",
      format: { [xCol]: v => Number(v).toFixed(2), [yCol]: v => Number(v).toFixed(2) },
      hrefFn: (row) => `team.html#team=${encodeURIComponent(row.team_name)}`,
      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_name)
        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.team_name || ""
        info.appendChild(nameEl)
        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))

  return wrapper
}
Show code
// ── Render table ─────────────────────────────────────────────
{
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
  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 team stats.</p>`
  }

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

  const columns = ["team_name", "gp", ...statCols]

  const header = {
    team_name: "Team",
    gp: "GP",
    ...def.header,
  }

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

  const format = {
    pass_pct: x => x != null ? x + "%" : "",
    win_pct: x => x != null ? x.toFixed(1) + "%" : ""
  }

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

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

  const tableEl = statsTable(search, {
    columns,
    mobileCols: mCols,
    header,
    groups,
    format,
    tooltip,
    render: {
      team_name: window.footballMaps?.renderTeamCell || ((v) => `<strong>${statsEsc(v)}</strong>`)
    },
    heatmap: def.heatmap || {},
    heatmapData: data,
    filters: {
      ...(def.sortCol ? { [def.sortCol]: "range" } : {}),
      gp: "range"
    },
    sort: def.sortCol,
    reverse: true,
    rows: 25
  })

  const wrap = document.createElement("div")
  wrap.className = "team-stats-table-view"
  wrap.style.display = window._teamStatsView === "Table" ? "" : "none"
  wrap.appendChild(tableEl)
  return wrap
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer