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 Match Detail

Skip to content
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL

// Shared xG estimation from Opta coordinates (distance + angle model)
// Used by header xG, timeline, match stats, and shot map
_estimateXg = (x, y) => {
  const mX = (100 - (x ?? 50)) * 1.05
  const mY = ((y ?? 50) - 50) * 0.68
  const dist = Math.sqrt(mX * mX + mY * mY)
  const base = dist < 2 ? 0.60 : 1.15 * Math.exp(-0.12 * dist)
  const ao = Math.abs(mY) / Math.max(mX, 1)
  return Math.max(0.01, Math.min(0.85, base / (1 + 1.5 * ao * ao)))
}
Show code
paramLeague = window._getHashParam("league") || ""
paramDate = window._getHashParam("date") || ""
// Some upstream callers (matches.qmd:285) emit the full ISO timestamp
// ("2026-05-03T14:30:00Z"), others emit just the date ("2026-05-03"). The
// predictions and fixtures lookups key on the date-only form, so derive that
// once and reuse — without it, ISO-timestamped URLs were silently bypassing
// match resolution and the page rendered the "Match not found" banner even
// when timeline/xG/chains had already loaded.
paramDateKey = (paramDate || "").slice(0, 10)
paramHome = {
  const raw = window._getHashParam("home")
  return raw ? raw.replace(/\+/g, " ") : ""
}
paramAway = {
  const raw = window._getHashParam("away")
  return raw ? raw.replace(/\+/g, " ") : ""
}
paramOptaId = window._getHashParam("optaId") || ""
Show code
predictions = fetchParquet(base_url + "football/predictions.parquet")
ratings = fetchParquet(base_url + "football/ratings.parquet")
matchStatsRaw = paramLeague ? fetchParquet(base_url + "football/match-stats-" + paramLeague + ".parquet") : null
matchShotsRaw = fetchParquet(base_url + "football/match-shots.parquet")
chainsRaw = {
  const rows = paramLeague ? await fetchParquet(base_url + "football/chains-" + paramLeague + ".parquet") : null
  // Forward-compat: pannadata may rename the per-action EPV CREDIT column
  // `equity`→`epv_credit` (it holds credit, not a state — see CLAUDE.md). Alias
  // it so every downstream `.equity` reader keeps working through the rename
  // without a coordinated cross-repo deploy.
  if (rows) for (const r of rows) if (r.equity == null && r.epv_credit != null) r.equity = r.epv_credit
  return rows
}

// Batch per-player WPA for this match from game-logs.parquet — the SAME values
// the season Value tab shows. The worker's LIVE WPA over-credits (~2× the batch)
// because it scores raw Opta events without SPADL receiver attribution, so for
// FINISHED matches the Value tab prefers these batch values (the worker value is
// only used while a match is in progress, before game-logs is built). Returns a
// Map(player_name → row) or an empty Map. The default game-logs.parquet covers the
// current season; older matches fall back to football/game-logs-<season>.parquet.
_matchGameLogs = {
  const mid = match && match.match_id
  if (!mid) return new Map()
  const pick = (rows) => (rows || []).filter(d => d.match_id === mid)
  let rows = pick(await fetchParquet(base_url + "football/game-logs.parquet")
    .catch(e => { console.warn("[match] game-logs.parquet load failed:", e.message); return null }))
  if (rows.length === 0 && paramDate) {
    const d = new Date(paramDate), y = d.getUTCFullYear(), m = d.getUTCMonth() + 1
    const start = m >= 7 ? y : y - 1
    const season = `${start}-${String(start + 1).slice(2)}`  // e.g. "2025-26"
    rows = pick(await fetchParquet(base_url + `football/game-logs-${season}.parquet`)
      .catch(e => { console.warn(`[match] game-logs-${season}.parquet unavailable:`, e.message); return null }))
  }
  const map = new Map()
  for (const r of rows) if (r.player_name != null) map.set(r.player_name, r)
  if (map.size > 0) console.log(`[match] batch WPA loaded for ${map.size} players (game-logs)`)
  return map
}

fixtures = {
  // Load from R2 first (worker currently returns incomplete data)
  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("[match] R2 fixture fetch failed, falling back to Worker:", e.message) }
  const data = await window.fetchFixtures("football")
  return data ? (data.matches || null) : null
}
Show code
leagueNames = window.footballMaps.leagueNames
Show code
teamForm = {
  if (!fixtures) return {}
  const form = {}
  const finished = fixtures
    .filter(f => f.status === "FINISHED" && f.homeScore != null && f.awayScore != null)
    .sort((a, b) => (a.date || "").localeCompare(b.date || ""))
  for (const f of finished) {
    const league = f.league
    const hKey = `${league}|${f.homeTeam}`
    const aKey = `${league}|${f.awayTeam}`
    if (!form[hKey]) form[hKey] = []
    if (!form[aKey]) form[aKey] = []
    const url = `match.html#league=${encodeURIComponent(league)}&date=${f.date}&home=${encodeURIComponent(f.homeTeam)}&away=${encodeURIComponent(f.awayTeam)}`
    const hResult = f.homeScore > f.awayScore ? "W" : f.homeScore < f.awayScore ? "L" : "D"
    const aResult = f.homeScore > f.awayScore ? "L" : f.homeScore < f.awayScore ? "W" : "D"
    const scoreStr = ` (${f.homeScore}–${f.awayScore})`
    form[hKey].push({ result: hResult, url, tip: `${f.homeTeam} v ${f.awayTeam}${scoreStr} ${hResult}` })
    form[aKey].push({ result: aResult, url, tip: `${f.homeTeam} v ${f.awayTeam}${scoreStr} ${aResult}` })
  }
  for (const k of Object.keys(form)) form[k] = form[k].slice(-5)
  return form
}

formDotsHtml = (league, team) => {
  const results = teamForm[`${league}|${team}`]
  if (!results || results.length === 0) return ""
  return `<span class="form-dots">${results.map(r =>
    `<span class="form-dot form-${statsEsc(String(r.result || "").toLowerCase())}" data-tip="${statsEsc(r.tip || "")}" aria-label="${statsEsc(r.tip || r.result || "match")}" data-nav-url="${statsEsc(r.url || "")}" role="link" tabindex="0"></span>`
  ).join("")}</span>`
}
Show code
match = {
  if (!predictions || !paramLeague || !paramDateKey || !paramHome || !paramAway) return null
  const norm = window.normalizeTeam
  const nHome = norm(paramHome)
  const nAway = norm(paramAway)
  return predictions.find(d =>
    d.league === paramLeague &&
    (d.match_date || "").replace("Z", "").slice(0, 10) === paramDateKey &&
    norm(d.home_team) === nHome &&
    norm(d.away_team) === nAway
  ) || null
}

// Build fixture lookup and find this match's fixture.
// Normalized keys so URLs using either team-name form resolve correctly.
fixtureMap = {
  if (!fixtures) return {}
  const norm = window.normalizeTeam
  const map = {}
  for (const f of fixtures) {
    const dateKey = f.date ? f.date.slice(0, 10) : ""
    map[`${f.league}|${dateKey}|${norm(f.homeTeam)}|${norm(f.awayTeam)}`] = f
  }
  return map
}

fixture = fixtureMap[`${paramLeague}|${paramDateKey}|${window.normalizeTeam(paramHome)}|${window.normalizeTeam(paramAway)}`] || null
Show code
// Editorial header — breadcrumb, kicker, dynamic display headline, byline.
// Mirrors the world-cup pages' treatment (world-cup-team.qmd is the dynamic-
// headline reference); the team names come from the URL hash, so the whole
// block is one OJS cell instead of static markdown.
{
  if (!paramHome || !paramAway) {
    return html`<div class="breadcrumb"><a href="index.html">Football</a> > <a href="matches.html">Matches</a></div>
    <div class="kicker football">Football · Match Centre</div>
    <h1 class="match-hero-name">Match Centre</h1>
    <p class="text-muted">No match selected. Go to <a href="matches.html">Matches</a> and click a match card.</p>`
  }

  const leagueLabel = leagueNames[paramLeague] || paramLeague
  return html`<div class="breadcrumb"><a href="index.html">Football</a> > <a href="matches.html">Matches</a> > ${statsEsc(paramHome)} vs ${statsEsc(paramAway)}</div>
    <div class="kicker football">Football · ${statsEsc(leagueLabel)} · Match Centre</div>
    <h1 class="match-hero-name">${statsEsc(paramHome)} <span class="match-hero-vs">vs</span> ${statsEsc(paramAway)}</h1>
    <div class="dek">One match in full — win probability and xG as they moved, every player's
    value contribution, the box score, the shot map and how each attack was built.</div>
    <div class="byline">
      <span>By <strong>Pete Owen</strong></span>
      <span>Updated · <strong>Live during play</strong></span>
      <span><a href="matches.html">All matches &uarr;</a></span>
    </div>`
}
Show code
window.editorial.sidebarToggle()
Show code
formatMatchDate = (dateStr) => window.formatMatchDate(dateStr, "long")

// Best-available live score: per-team max across Opta _liveEvents.scores,
// matchChains-derived goal count (type_id 16 with own-goal flip), and
// football-data.org fixture. Exposed as a top-level OJS cell so the header
// card AND the Match Timeline summary cards reference the same value
// (otherwise they drift, because fixtures.json lags by ~30 min while the
// chains feed updates in seconds). Returns { home, away } where each can
// be null if no source has a score yet (pre-match).
liveScoresBest = {
  // Opta (via worker) exposes a structured score breakdown: ht / ft / et / pen /
  // total. `total` folds the shootout INTO the scoreline (the "5–4" bug on a
  // 1–1 a.e.t. match), so we never use it for display. The main scoreline is the
  // result AFTER extra time (et → ft); the shootout surfaces separately via .pens.
  const s = _liveEvents?.scores
  // Chains fallback for parquet-only / historical matches: split goals by period
  // so shootout pens (period_id 5) don't inflate the regulation/ET scoreline.
  const chainSplit = (() => {
    if (!matchChains || matchChains.length === 0) return null
    const homeName = matchChains[0]?.home_team || paramHome
    let rh = 0, ra = 0, ph = 0, pa = 0
    for (const d of matchChains) {
      if (d.type_id !== 16) continue
      const isOG = d.x != null && d.x < 50
      const rawHome = d.team_name === homeName
      const creditedHome = isOG ? !rawHome : rawHome
      if (d.period_id >= 5) { if (creditedHome) ph++; else pa++ }
      else { if (creditedHome) rh++; else ra++ }
    }
    return { reg: { home: rh, away: ra }, pen: (ph || pa) ? { home: ph, away: pa } : null }
  })()
  // Priority: worker (Opta, authoritative + fastest) → chains → fixture.
  const reg = (s && (s.et || s.ft)) || chainSplit?.reg || null
  const pen = (s && s.pen) || chainSplit?.pen || null
  const homeMain = Number.isFinite(reg?.home) ? reg.home
    : (Number.isFinite(fixture?.homeScore) ? fixture.homeScore : null)
  const awayMain = Number.isFinite(reg?.away) ? reg.away
    : (Number.isFinite(fixture?.awayScore) ? fixture.awayScore : null)
  return {
    home: homeMain,
    away: awayMain,
    // Shootout result {home, away}, or null when the match didn't go to pens.
    pens: (pen && pen.home != null && pen.away != null) ? { home: pen.home, away: pen.away } : null
  }
}

{
  if (!paramHome || !paramAway) return html``

  // Wait for predictions and fixtures to finish loading before deciding a match is "missing".
  // Without this we'd flash the not-found card on every page load.
  if (predictions == null || fixtures == null) return html``

  // If neither the predictions parquet nor the fixture list knows about this match,
  // the URL is mistyped, truncated, or points at a match we have no data for.
  // Rendering the header card with raw paramHome/paramAway in that case produces
  // a real-looking match card for nonsense team names, which is worse than saying
  // "not found". Prefer explicit failure.
  if (!match && !fixture) {
    return html`<div class="match-card match-detail-header football" style="text-align:center;padding:2rem 1.5rem">
      <div style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem">Match not found</div>
      <div class="text-muted" style="font-size:0.9rem;line-height:1.5">
        No prediction or fixture data for <strong>${statsEsc(paramHome)}</strong> vs <strong>${statsEsc(paramAway)}</strong> on ${statsEsc(paramDateKey || paramDate)}.<br>
        Check the URL — team names are case-sensitive and must match the form used on the <a href="matches.html">Matches</a> page.
      </div>
    </div>`
  }

  // Ensure crests are loaded
  if (window.footballMaps.loadCrests) await window.footballMaps.loadCrests()
  const crest = window.footballMaps.teamCrest
  const hCrest = crest(paramHome)
  const aCrest = crest(paramAway)
  const badgeHtml = (url) => url ? `<img src="${url}" alt="" class="team-badge" style="width:28px;height:28px;object-fit:contain;vertical-align:middle;margin-right:6px">` : ""

  // Date, venue, league info
  const leagueLabel = leagueNames[paramLeague] || paramLeague
  const dateStr = fixture ? formatMatchDate(fixture.date) : ""
  const venue = fixture?.venue || ""
  const infoParts = [leagueLabel, dateStr, venue].filter(Boolean)
  const infoLine = infoParts.join(" · ")

  // Match xG, shootout-excluded. Prefer the real model xG from match-shots.parquet
  // (xgShots now ships a populated xg column from panna's xG model), matching the
  // timeline; fall back to the coordinate estimate on live chain shots for an
  // in-progress match not yet in the parquet.
  const liveXg = (() => {
    if (xgShots && xgShots.length > 0 && xgHomeTeamId != null) {
      let hXg = 0, aXg = 0
      for (const s of xgShots) {
        if (s.minute != null && s.minute >= 120) continue  // drop shootout
        const xg = Number.isFinite(s.xg) ? s.xg : _estimateXg(s.x, s.y)
        const isOG = s.is_goal && s.x != null && s.x < 50
        const isHome = isOG ? s.team_id !== xgHomeTeamId : s.team_id === xgHomeTeamId
        if (isHome) hXg += xg; else aXg += xg
      }
      return { home: Math.round(hXg * 10) / 10, away: Math.round(aXg * 10) / 10 }
    }
    // Worker-computed model xG totals (same model the batch parquets use,
    // shipped on the live-events response) — preferred over the coordinate
    // estimate below so this header agrees with the match cards during the
    // window before the parquet rebuild lands.
    if (_liveEvents?.xg && typeof _liveEvents.xg.home === "number") {
      return { home: Math.round(_liveEvents.xg.home * 10) / 10, away: Math.round(_liveEvents.xg.away * 10) / 10 }
    }
    if (matchChains.length === 0) return null
    const shots = matchChains.filter(d => [13, 14, 15, 16].includes(d.type_id) && (d.period_id == null || d.period_id < 5))
    if (shots.length === 0) return null
    const homeTeam = matchChains[0]?.home_team || paramHome
    let hXg = 0, aXg = 0
    for (const s of shots) {
      // Same per-shot resolution as the timeline + shot map: model xG, estimate fallback.
      const xg = Number.isFinite(s.xg) ? s.xg : _estimateXg(s.x, s.y)
      const isOG = s.type_id === 16 && s.x != null && s.x < 50
      const isHome = isOG ? s.team_name !== homeTeam : s.team_name === homeTeam
      if (isHome) hXg += xg; else aXg += xg
    }
    return { home: Math.round(hXg * 10) / 10, away: Math.round(aXg * 10) / 10 }
  })()

  // Live match status from Worker
  const isLive = _liveEvents && _liveEvents.status === "Playing"
  const liveScore = _liveEvents?.scores?.total
  const liveTime = _liveEvents?.matchTime

  // Best-available score (see liveScoresBest top-level cell above for sources).
  // liveHomeScore/liveAwayScore are the result AFTER extra time (never the
  // shootout). `pens` is the shootout result, or null for non-pens matches.
  const liveHomeScore = liveScoresBest.home
  const liveAwayScore = liveScoresBest.away
  const pens = liveScoresBest.pens
  const pensWinner = pens ? (pens.home > pens.away ? paramHome : paramAway) : null
  const pensLine = pens
    ? `${statsEsc(pensWinner)} won ${Math.max(pens.home, pens.away)}–${Math.min(pens.home, pens.away)} on penalties`
    : null

  if (!match) {
    // No prediction data — show fixture-only card (with live score if available)
    const finished = fixture && fixture.status === "FINISHED" && fixture.homeScore != null
    const hasScore = finished || liveHomeScore != null
    const hScore = finished ? fixture.homeScore : liveHomeScore
    const aScore = finished ? fixture.awayScore : liveAwayScore

    const homeUrl = `team.html#team=${encodeURIComponent(paramHome)}`
    const awayUrl = `team.html#team=${encodeURIComponent(paramAway)}`
    return html`<div class="match-card match-detail-header football">
      ${infoLine ? html`<div class="match-info">${infoLine}${isLive ? html` · <span class="live-badge">LIVE ${liveTime ? liveTime + "'" : ""}</span>` : ""}</div>` : html``}
      <div class="match-teams">
        <div class="team"><a href="${homeUrl}" class="team-link">${html([badgeHtml(hCrest)])}<strong>${statsEsc(paramHome)}</strong></a></div>
        <div class="match-vs">${hasScore ? html`<div class="match-score">${hScore} – ${aScore}${pensLine ? html`<div class="match-pens">${pensLine}</div>` : ""}${liveXg ? html`<div class="match-xg">xG: ${liveXg.home} – ${liveXg.away}</div>` : ""}</div>` : "vs"}</div>
        <div class="team"><a href="${awayUrl}" class="team-link"><strong>${statsEsc(paramAway)}</strong>${html([badgeHtml(aCrest)])}</a></div>
      </div>
      ${!hasScore ? html`<p class="text-muted" style="margin-top:0.5rem">No prediction data available for this match.</p>` : html``}
    </div>`
  }

  const homeWin = match.prob_H >= match.prob_A && match.prob_H >= match.prob_D
  const awayWin = match.prob_A > match.prob_H && match.prob_A >= match.prob_D
  const favTeam = homeWin ? paramHome : awayWin ? paramAway : null
  const favProb = homeWin ? match.prob_H : awayWin ? match.prob_A : match.prob_D
  const favPct = (favProb * 100).toFixed(0)

  const predLine = favTeam
    ? `${statsEsc(favTeam)} win (${favPct}%)`
    : `Draw (${favPct}%)`

  // Score + result display.
  // hasScore fires whenever ANY source (live poller / chains / fixtures.json)
  // has a score for the match — covers finished, in-progress, and games stuck
  // on IN_PLAY because football-data.org hasn't flipped status to FINISHED yet
  // (common for an hour or two post-FT on free tier). `isLive` was already
  // declared at the top of this cell from _liveEvents.status, which is the
  // authoritative Opta-sourced live signal — reusing that here.
  const hasScore = liveHomeScore != null
  const todayIso = new Date().toISOString().slice(0, 10)
  const isPast = paramDate && paramDate < todayIso
  const finished = (fixture && fixture.status === "FINISHED" && fixture.homeScore != null) || (hasScore && isPast)

  // xG — ALWAYS prefer liveXg (the live model xG, summed from the same shots the
  // timeline + shot map use) so this header can never disagree with them. liveXg
  // excludes shootout kicks, so it's also correct for pens. Fall back to
  // predictions.parquet's xg_home/xg_away only when no shot data is loaded at all
  // (and that parquet value includes shootout kicks for pens matches — inflated —
  // but it's a last resort only reached when liveXg is null).
  const xgH = liveXg?.home ?? match.xg_home ?? null
  const xgA = liveXg?.away ?? match.xg_away ?? null
  const hasXg = xgH != null && xgA != null

  let resultHtml = ""
  if (finished) {
    const actualHome = liveHomeScore > liveAwayScore
    const actualDraw = liveHomeScore === liveAwayScore
    const predCorrect = (homeWin && actualHome) || (awayWin && !actualHome && !actualDraw) || (!homeWin && !awayWin && actualDraw)
    resultHtml = `<span class="actual-result ${predCorrect ? 'correct' : 'incorrect'}">Result: ${liveHomeScore}–${liveAwayScore}</span>`
  }

  const homeUrl = `team.html#team=${encodeURIComponent(paramHome)}`
  const awayUrl = `team.html#team=${encodeURIComponent(paramAway)}`

  const homeForm = formDotsHtml(paramLeague, paramHome)
  const awayForm = formDotsHtml(paramLeague, paramAway)

  // AFL-style dual probability bars: Pred + xG, both shown whenever we have a score + xG
  const xgWp = hasXg && window.footballXgProb ? window.footballXgProb(xgH, xgA) : null
  const dualBar = hasScore && hasXg && xgWp

  return html`<div class="match-card match-detail-header football">
    ${infoLine ? html`<div class="match-info">${infoLine}${isLive ? html` · <span class="live-badge">LIVE</span>` : ""}</div>` : html``}
    <div class="match-teams">
      <div class="team ${homeWin ? 'favoured' : ''}">
        <a href="${homeUrl}" class="team-link">${html([badgeHtml(hCrest)])}<strong>${statsEsc(paramHome)}</strong></a>
        <span class="rating">${match.pred_home_goals?.toFixed(1) ?? "—"}</span>
        ${homeForm ? html`<div class="match-form">${html([homeForm])}</div>` : ""}
      </div>
      <div class="match-vs">${hasScore ? html`<div class="match-score">${liveHomeScore} – ${liveAwayScore}${pensLine ? html`<div class="match-pens">${pensLine}</div>` : ""}${hasXg ? html`<div class="match-xg">xG: ${xgH.toFixed(1)} – ${xgA.toFixed(1)}</div>` : ""}</div>` : "vs"}</div>
      <div class="team ${awayWin ? 'favoured' : ''}">
        <a href="${awayUrl}" class="team-link"><strong>${statsEsc(paramAway)}</strong>${html([badgeHtml(aCrest)])}</a>
        <span class="rating">${match.pred_away_goals?.toFixed(1) ?? "—"}</span>
        ${awayForm ? html`<div class="match-form">${html([awayForm])}</div>` : ""}
      </div>
    </div>
    <div class="match-prediction ${dualBar ? 'has-dual-bar' : ''}">
      <div class="prob-bars-group">
        <div class="prob-bar-row">
          ${dualBar ? html`<span class="prob-bar-label">Pred</span>` : ""}
          <div class="prob-bar-container">
            <div class="prob-bar prob-home" style="width: ${match.prob_H * 100}%">
              ${match.prob_H >= 0.05 ? (match.prob_H * 100).toFixed(0) + '%' : ''}
            </div>
            <div class="prob-bar prob-draw" style="width: ${match.prob_D * 100}%">
              ${match.prob_D >= 0.05 ? (match.prob_D * 100).toFixed(0) + '%' : ''}
            </div>
            <div class="prob-bar prob-away" style="width: ${match.prob_A * 100}%">
              ${match.prob_A >= 0.05 ? (match.prob_A * 100).toFixed(0) + '%' : ''}
            </div>
          </div>
        </div>
        ${dualBar ? html`<div class="prob-bar-row">
          <span class="prob-bar-label">xG</span>
          <div class="prob-bar-container">
            <div class="prob-bar prob-home" style="width: ${xgWp.home * 100}%">
              ${xgWp.home >= 0.05 ? (xgWp.home * 100).toFixed(0) + '%' : ''}
            </div>
            <div class="prob-bar prob-draw" style="width: ${xgWp.draw * 100}%">
              ${xgWp.draw >= 0.05 ? (xgWp.draw * 100).toFixed(0) + '%' : ''}
            </div>
            <div class="prob-bar prob-away" style="width: ${xgWp.away * 100}%">
              ${xgWp.away >= 0.05 ? (xgWp.away * 100).toFixed(0) + '%' : ''}
            </div>
          </div>
        </div>` : ""}
        ${isLive && liveScore && liveTime ? (() => {
          const wp = window.footballWinProb
            ? window.footballWinProb(parseInt(liveTime) || 0, liveScore.home || 0, liveScore.away || 0, match?.pred_home_goals, match?.pred_away_goals)
            : null
          if (!wp) return ""
          return html`<div class="prob-bar-row" data-wp-bar>
            <span class="prob-bar-label">Live</span>
            <div class="prob-bar-container">
              <div class="prob-bar prob-home" style="width: ${wp.home * 100}%">
                ${wp.home >= 0.05 ? (wp.home * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-draw" style="width: ${wp.draw * 100}%">
                ${wp.draw >= 0.05 ? (wp.draw * 100).toFixed(0) + '%' : ''}
              </div>
              <div class="prob-bar prob-away" style="width: ${wp.away * 100}%">
                ${wp.away >= 0.05 ? (wp.away * 100).toFixed(0) + '%' : ''}
              </div>
            </div>
          </div>`
        })() : ""}
      </div>
    </div>
  </div>`
}
Show code
footballPosColors = window.footballMaps.posColors

// Prediction team names ("Arsenal FC") differ from rating team names ("Arsenal").
// Build a lookup from all rating team names, then try exact match → strip " FC" suffix.
findTeamInRatings = {
  if (!ratings) return () => null
  const ratingTeams = new Set(ratings.map(d => d.team).filter(Boolean))
  return (predName) => {
    if (ratingTeams.has(predName)) return predName
    const stripped = predName.replace(/ FC$/, "").replace(/ AFC$/, "")
    if (ratingTeams.has(stripped)) return stripped
    return null
  }
}

homeRatingTeam = findTeamInRatings(paramHome)
awayRatingTeam = findTeamInRatings(paramAway)
Show code
homeRoster = {
  if (!ratings || !homeRatingTeam) return []
  return ratings
    .filter(d => d.team === homeRatingTeam)
    .sort((a, b) => (b.panna ?? 0) - (a.panna ?? 0))
}

awayRoster = {
  if (!ratings || !awayRatingTeam) return []
  return ratings
    .filter(d => d.team === awayRatingTeam)
    .sort((a, b) => (b.panna ?? 0) - (a.panna ?? 0))
}
Show code
xgShots = {
  if (!matchShotsRaw || !paramHome || !paramAway || !paramLeague) return []

  let targetMatchId = null
  if (match && match.match_id) targetMatchId = match.match_id
  else if (matchStats && matchStats.length > 0) targetMatchId = matchStats[0].match_id

  if (!targetMatchId) return []

  return matchShotsRaw.filter(d => d.match_id === targetMatchId)
    .sort((a, b) => a.minute - b.minute || a.second - b.second)
}

// Determine home/away team_ids from shots
xgHomeTeamId = {
  if (xgShots.length === 0 || !matchStats || matchStats.length === 0) return null
  const homeStats = matchStats.filter(d => d.team_name === statsHomeTeam)
  return homeStats.length > 0 ? homeStats[0].team_id : null
}
Show code
// Live-events fallback banner. Renders only when:
//   - we resolved an Opta match_id (so we attempted to fetch),
//   - the worker fetch returned null (fetch failed — Opta CDN may 403 if their
//     referer/UA check tightens; worker forges headers to mitigate, but it's
//     a known fragility),
//   - the fixture indicates the match is in progress (live windows are when
//     the missing live data actually matters; finished matches use parquet).
//
// For finished matches, the parquet path (matchChains via chainsRaw) is the
// authoritative source and the live-events outage is invisible. Only the gap
// between match-finish and next pannadata pipeline rebuild loses live-only
// fields (timeline, per-event WP/EPV) — which is what this banner signals.
{
  if (!_optaId) return html``
  if (_liveEvents) return html``
  if (!fixture) return html``
  const liveStatuses = new Set(["IN_PLAY", "PAUSED", "LIVE", "SUSPENDED"])
  if (!liveStatuses.has(fixture.status)) return html``
  return html`<div class="alert-banner" style="margin: 1rem 0; padding: 0.75rem 1rem; background: rgba(255, 152, 0, 0.08); border-left: 3px solid #ff9800; border-radius: 4px; color: var(--site-body-color); font-size: 0.9rem; line-height: 1.4;">
    <strong style="color:#ff9800">Live event data unavailable.</strong>
    The match score and prediction below are still accurate, but the live xG timeline, win-probability chart, and chain-derived stats may be missing or stale until the upstream feed is available again.
  </div>`
}
Show code
// Match Timeline with xG / xG Margin / Win Prob toggles. Unified across parquet and live
// data sources — uses xgShots from match-shots parquet when available, otherwise derives
// shot events from matchChains (live Opta feed). Each shot row carries a pre-computed
// `_isHome` flag (with own-goal flip) so downstream code doesn't need to know which
// source it came from. This mirrors the AFL Match Timeline that behaves identically
// whether a game is mid-match or finished.
{
  if (!paramHome || !paramAway) return html``

  const NS = "http://www.w3.org/2000/svg"
  function svgEl(tag, attrs) {
    const el = document.createElementNS(NS, tag)
    if (attrs) Object.keys(attrs).forEach(k => el.setAttribute(k, attrs[k]))
    return el
  }

  // Build unified shots source — parquet takes priority when its required keys exist,
  // falls back to live chain data from the Opta feed.
  const useParquetShots = xgShots.length > 0 && xgHomeTeamId != null

  // This match's stoppage lengths — max minute in each period minus the nominal boundary.
  // matchStop1 is used to offset 2H events for effectiveMinute. Both are passed to the
  // WP model so the piecewise integral uses this specific match's stoppage rather than
  // the league average. Falls back to league-specific STOPPAGE_TIME defaults (from
  // win-prob.js, so hardcoded literals don't drift from the fit script output) when
  // the chain data is too sparse to infer a value — common for pre-match or very-early
  // in-progress matches where no stoppage events exist yet.
  const _stoppageDefault = window.footballStoppage
    ? window.footballStoppage(paramLeague)
    : { stop1: 2.39, stop2: 5.37 }
  const matchStop1 = (() => {
    let max1h = 0
    for (const d of matchChains) {
      if (d.period_id === 1 && d.minute != null && d.minute > max1h) max1h = d.minute
    }
    return max1h >= 45 ? max1h - 45 : _stoppageDefault.stop1
  })()
  const matchStop2 = (() => {
    let max2h = 0
    for (const d of matchChains) {
      if (d.period_id === 2 && d.minute != null && d.minute > max2h) max2h = d.minute
    }
    return max2h >= 90 ? max2h - 90 : _stoppageDefault.stop2
  })()
  // Effective minute = real elapsed time from kickoff. 2H events get offset by this match's
  // 1H stoppage so "minute 47 in 2H" becomes "effective minute 49" when 1H had 2 min stoppage.
  const effMin = (d) => (d.period_id === 1 || d.period_id == null)
    ? (d.minute || 0)
    : (d.minute || 0) + matchStop1

  // Exclude penalty-shootout kicks from the timeline/xG. match-shots.parquet has
  // no period_id, but Opta keeps the clock running past the end of ET, so shootout
  // kicks are logged at minute ≥ 120 — a minute cutoff drops them reliably while
  // keeping all open-play + ET shots (the latest ET shots sit at ~118–119').
  // (Proper fix: add period_id to match-shots.parquet upstream in pannadata.)
  const timelineShots = useParquetShots ? xgShots.filter(s => s.minute == null || s.minute < 120).map(s => {
    const isOG = s.is_goal && s.x != null && s.x < 50
    const rawHome = s.team_id === xgHomeTeamId
    // Upstream match-shots.parquet currently writes xg null on ~99% of rows. Fall back
    // to coordinate-based _estimateXg (same estimator the header card and chains-path
    // already use) so the timeline, margin series, and xG Win Prob card don't collapse
    // to zero. Once the pannadata build fills xg properly, this no-op's.
    return Object.assign({}, s, {
      xg: Number.isFinite(s.xg) ? s.xg : _estimateXg(s.x, s.y),
      _isHome: isOG ? !rawHome : rawHome,
      _effMin: effMin(s),
    })
  }) : (matchChains.length > 0 ? (() => {
    const homeName = chainHomeTeam || matchChains[0]?.home_team || paramHome
    return matchChains
      // Chains carry period_id, so exclude shootout (period 5) precisely here.
      .filter(d => [13, 14, 15, 16].includes(d.type_id) && (d.period_id == null || d.period_id < 5))
      .map(d => {
        const isOG = d.type_id === 16 && d.x != null && d.x < 50
        const rawHome = d.team_name === homeName
        return {
          minute: d.minute || 0,
          second: d.second || 0,
          period_id: d.period_id,
          // model xG from the worker (now mapped through on live rows), estimate fallback
          xg: Number.isFinite(d.xg) ? d.xg : _estimateXg(d.x, d.y),
          is_goal: d.type_id === 16,
          x: d.x, y: d.y,
          type_id: d.type_id,
          player_name: d.player_name,
          player_id: d.player_id,
          _isHome: isOG ? !rawHome : rawHome,
          _effMin: effMin(d),
        }
      })
  })() : [])

  // Dismissals (red / second yellow) from the live rows. The chains parquet
  // KEEPS type-17 card rows but exports no card-colour qualifier, so `card`
  // is absent on the parquet path and this series is empty — historical
  // markers need pannadata to export the colour, not new data. Yellows are
  // deliberately not marked (clutter).
  const timelineCards = matchChains
    .filter(d => (d.card === "red" || d.card === "second_yellow") && (d.period_id == null || d.period_id < 5))
    .map(d => ({
      minute: d.minute || 0, second: d.second || 0, _effMin: effMin(d),
      player_name: d.player_name,
      _isHome: d.team_name === (chainHomeTeam || matchChains[0]?.home_team || paramHome),
    }))

  if (timelineShots.length === 0) return html``

  // Team display names for tooltips and end labels
  const tlHomeTeam = useParquetShots ? statsHomeTeam : (chainHomeTeam || matchChains[0]?.home_team || paramHome)
  const tlAwayTeam = useParquetShots ? statsAwayTeam : (chainAwayTeam || matchChains[0]?.away_team || paramAway)

  // Poisson PMF — used by xG timeline WP calculation
  const _poissonFact = [1]; for (let i = 1; i <= 10; i++) _poissonFact[i] = _poissonFact[i-1] * i
  function poissonPmf(k, lambda) {
    if (lambda <= 0) return k === 0 ? 1 : 0
    return Math.exp(-lambda) * Math.pow(lambda, k) / (_poissonFact[k] || 1)
  }

  // ── Pre-compute all data series ──────────────────────────────
  // _isHome is already own-goal-flipped upstream; _effMin is real elapsed time from kickoff.
  // All downstream timeline code uses _effMin as the x-axis so 1H stoppage and early 2H
  // play don't collide at the same raw minute value.
  const homeShots = timelineShots.filter(d => d._isHome)
  const awayShots = timelineShots.filter(d => !d._isHome)
  // X-axis stretches to cover: (a) all observed shots in effective-minute space,
  // (b) this match's effective end (90 + matchStop1 + matchStop2). Uses per-match
  // stoppages so the axis ends exactly where the match did, not at league avg.
  const _matchEndMin = window.footballMatchEndMinute ? window.footballMatchEndMinute(paramLeague, matchStop1, matchStop2) : 95
  const maxMinute = Math.max(Math.max(...timelineShots.map(d => d._effMin || 0), 90) + 2, _matchEndMin)
  const homeColor = "#3b82f6", awayColor = "#ef4444"

  // Live-match data clip. For an in-play match, fixture.status is IN_PLAY/PAUSED
  // or _liveEvents.status is "Playing"/"Paused"; in that state we want the xG /
  // WP / Margin lines to STOP at the latest observed event minute rather than
  // project flat to 90' (which made the summary card show "100% home" at HT and
  // drew lines across un-played minutes). Axis stays full-width so the viewer
  // still sees the proportion of the match played.
  const _isLiveMatch =
    (typeof fixture !== "undefined" && fixture && (fixture.status === "IN_PLAY" || fixture.status === "PAUSED")) ||
    (typeof _liveEvents !== "undefined" && _liveEvents && (_liveEvents.status === "Playing" || _liveEvents.status === "Paused"))
  const _chainLatestMin = matchChains.reduce((acc, d) => {
    if (d.minute == null) return acc
    const em = effMin(d)
    return em > acc ? em : acc
  }, 0)
  const _latestEventMin = Math.max(
    _chainLatestMin,
    ...timelineShots.map(d => (d._effMin || 0) + (d.second || 0) / 60),
    0
  )
  // Where data series should end. For finished matches, extend to nominal match end
  // (so the line carries forward through any post-final-shot minutes). For live
  // matches, clip just past the latest event so the chart doesn't extrapolate.
  const _seriesEndMin = _isLiveMatch ? Math.max(_latestEventMin + 0.25, 1) : maxMinute

  // Cumulative xG — uses effective minute so 1H stoppage and 2H early events don't stack
  function buildCum(shots, key) {
    const pts = [{ minute: 0, val: 0 }]
    let cum = 0
    for (const s of shots) {
      cum += key === "xg" ? (s.xg || 0) : (s.is_goal ? 1 : 0)
      pts.push({ minute: (s._effMin || 0) + (s.second || 0) / 60, val: cum, shot: s })
    }
    pts.push({ minute: _seriesEndMin, val: cum })
    return pts
  }
  const homeXgCum = buildCum(homeShots, "xg")
  const awayXgCum = buildCum(awayShots, "xg")
  const homeXgTotal = homeXgCum[homeXgCum.length - 1].val
  const awayXgTotal = awayXgCum[awayXgCum.length - 1].val

  // xG Margin series (home - away differential at each event)
  function buildMargin(homePts, awayPts) {
    const events = []
    for (const p of homePts) events.push({ minute: p.minute, team: "home", val: p.val, shot: p.shot })
    for (const p of awayPts) events.push({ minute: p.minute, team: "away", val: p.val, shot: p.shot })
    events.sort((a, b) => a.minute - b.minute)
    const pts = []
    let hv = 0, av = 0
    for (const e of events) {
      if (e.team === "home") hv = e.val; else av = e.val
      pts.push({ minute: e.minute, val: hv - av, shot: e.shot })
    }
    // Dedupe consecutive same-minute entries (keep last)
    const deduped = [pts[0]]
    for (let i = 1; i < pts.length; i++) {
      if (pts[i].minute === deduped[deduped.length - 1].minute) deduped[deduped.length - 1] = pts[i]
      else deduped.push(pts[i])
    }
    return deduped
  }
  const xgMargin = buildMargin(homeXgCum, awayXgCum)

  // Win probability series — AFL-style live model driven by footballLiveWp().
  // Uses a league-specific goal-rate model with a stoppage-time fixed effect,
  // fit on historical chains data (football/win-prob.js). Per-match stoppage
  // lengths (matchStop1, matchStop2) are passed through so the piecewise
  // integral respects this specific match's structure.
  const wpPoints = []
  {
    // Quality-weighted: pass the match's pre-match expected goals so the WP line
    // reflects team strength (a stronger side is favoured even at 0-0) instead of
    // a league-average prior. Falls back to league-average if preds are absent.
    const computeWp = (minute, hG, aG) =>
      window.footballLiveWp ? window.footballLiveWp(minute, hG, aG, paramLeague, matchStop1, matchStop2, match?.pred_home_goals, match?.pred_away_goals)
                            : { home: 0.5, draw: 0, away: 0.5 }
    // For live matches the WP series stops at the current minute (matches the xG
    // line). For finished matches it covers the full nominal end (90 + stoppage).
    // For a match that reached extra time, extend across ET to the (shootout-free)
    // axis end so the line doesn't stop short at ~95'. footballLiveWp clamps
    // fromMinute to regulation end, so ET samples return the score-only WP — a
    // 1–1 reads as a strong draw, which is correct (it's why it went to pens).
    const wentToET = maxMinute > 100
    const wpEndMin = _isLiveMatch ? _seriesEndMin : (wentToET ? maxMinute : _matchEndMin)
    const wpMax = wpEndMin

    // Collect goal events (sorted by effective minute, not raw Opta minute)
    const goalEvents = timelineShots
      .filter(s => s.is_goal)
      .map(s => ({ minute: (s._effMin || 0) + (s.second || 0) / 60, isHome: s._isHome, shot: s }))
      .sort((a, b) => a.minute - b.minute)

    // Sample every minute, plus insert exact goal moments + series-end boundary
    const sampleSet = new Set()
    for (let m = 0; m <= wpMax; m++) sampleSet.add(m)
    for (const g of goalEvents) sampleSet.add(g.minute)
    sampleSet.add(wpEndMin)
    const samples = [...sampleSet].sort((a, b) => a - b)

    let hGoals = 0, aGoals = 0
    let goalIdx = 0
    // Gate the invariant warn so a genuinely-broken model only logs once per
    // render (not once per minute = ~95 lines per cell evaluation).
    let _wpInvariantWarned = false
    for (const m of samples) {
      // Apply any goals that happen at or before this sample
      let goalJustHappened = null
      while (goalIdx < goalEvents.length && goalEvents[goalIdx].minute <= m) {
        const g = goalEvents[goalIdx]
        if (g.isHome) hGoals++
        else aGoals++
        goalJustHappened = g
        goalIdx++
      }
      const p = computeWp(m, hGoals, aGoals)
      // Sanity: home+draw+away should sum to ~1 (Poisson distribution over a
      // finite grid). Drift catches schema breakage, NaN scores, or a missing
      // league falling through to malformed defaults. One-shot per render.
      if (!_wpInvariantWarned) {
        const _sum = (p.home || 0) + (p.draw || 0) + (p.away || 0)
        if (Math.abs(_sum - 1) > 0.02) {
          console.warn("[match] wpPoints invariant broken at minute " + m, p)
          _wpInvariantWarned = true
        }
      }
      wpPoints.push({
        minute: m,
        home: p.home,
        draw: p.draw,
        away: p.away,
        // Score-at-minute snapshot so the WP tooltip can show the live score
        // without re-walking goalEvents on every hover (matches AFL parity).
        hGoals,
        aGoals,
        // Legacy `val` used by valAtMinute in non-WP modes; for WP we read home/draw/away directly.
        val: p.home + p.draw * 0.5,
        isGoal: !!(goalJustHappened && goalJustHappened.minute === m),
        isHome: goalJustHappened ? goalJustHappened.isHome : undefined,
        shot: goalJustHappened ? goalJustHappened.shot : undefined
      })
    }
  }

  // ── Summary stat cards ───────────────────────────────────────
  const container = document.createElement("div")
  const h2 = document.createElement("h2")
  h2.textContent = "Match Timeline"
  container.appendChild(h2)

  // Score card uses the same liveScoresBest top-level cell as the header card,
  // so they can't drift. Per-team max across Opta liveScore / chain-derived
  // goals / football-data.org fixture.
  const hasScore = liveScoresBest.home != null
  const scoreIsFinal = hasScore && fixture && (fixture.status === "FINISHED" || (paramDate && paramDate < new Date().toISOString().slice(0, 10)))
  const summaryDiv = document.createElement("div")
  summaryDiv.className = "shot-stats"
  const cards = []
  if (hasScore) cards.push({ v: liveScoresBest.home + " – " + liveScoresBest.away + (liveScoresBest.pens ? " (" + liveScoresBest.pens.home + "–" + liveScoresBest.pens.away + " pens)" : ""), l: scoreIsFinal ? "Final Score" : "Live Score" })
  cards.push({ v: homeXgTotal.toFixed(2) + " – " + awayXgTotal.toFixed(2), l: "xG" })
  // Live-aware Win Prob card — reads directly from the last wpPoints sample which
  // holds { home, draw, away } at the final evaluated minute. For a finished match
  // the timeline converges to 100/0/0 or 0/0/100 on its own.
  if (wpPoints.length > 0) {
    const lastWp = wpPoints[wpPoints.length - 1]
    cards.push({
      v: (lastWp.home * 100).toFixed(0) + "% – " + (lastWp.draw * 100).toFixed(0) + "% – " + (lastWp.away * 100).toFixed(0) + "%",
      l: "Win Prob (H-D-A)"
    })
  }
  // xG Win Prob card — "who deserved to win" view, derived purely from total xG
  // without any time/score awareness. Different from the live Win Prob card which
  // converges to the actual final result. This one tells you whether the result
  // was deserved based on shot quality.
  if ((homeXgTotal > 0 || awayXgTotal > 0) && window.footballXgProb) {
    const xgWp = window.footballXgProb(homeXgTotal, awayXgTotal)
    if (xgWp) {
      cards.push({
        v: (xgWp.home * 100).toFixed(0) + "% – " + (xgWp.draw * 100).toFixed(0) + "% – " + (xgWp.away * 100).toFixed(0) + "%",
        l: "xG Win Prob (H-D-A)"
      })
    }
  }
  for (const s of cards) {
    const card = document.createElement("div")
    card.className = "shot-stat"
    const valDiv = document.createElement("div")
    valDiv.className = "shot-stat-value"
    valDiv.textContent = s.v
    const lblDiv = document.createElement("div")
    lblDiv.className = "shot-stat-label"
    lblDiv.textContent = s.l
    card.appendChild(valDiv)
    card.appendChild(lblDiv)
    summaryDiv.appendChild(card)
  }
  container.appendChild(summaryDiv)

  // ── Toggle buttons ───────────────────────────────────────────
  const toggleRow = document.createElement("div")
  toggleRow.className = "epv-toggle"
  const modes = [
    { id: "xG", label: "xG" },
    { id: "WinProb", label: "Win Prob" },
    { id: "xGMargin", label: "xG Margin" }
  ]
  const btns = []
  for (const m of modes) {
    const btn = document.createElement("button")
    btn.textContent = m.label
    btn.className = "epv-toggle-btn" + (m.id === "xG" ? " active" : "")
    if (m.disabled) btn.disabled = true
    btns.push({ btn, id: m.id })
    toggleRow.appendChild(btn)
  }
  container.appendChild(toggleRow)

  // ── Chart SVG (shared, redrawn by drawMode) ──────────────────
  const W = 800, H = 200, PAD = { l: 40, r: 45, t: 30, b: 30 }
  const plotW = W - PAD.l - PAD.r, plotH = H - PAD.t - PAD.b

  const chartWrap = document.createElement("div")
  chartWrap.className = "xg-timeline-wrap"
  const svg = svgEl("svg", { viewBox: "0 0 " + W + " " + H, class: "xg-timeline-svg" })
  chartWrap.appendChild(svg)
  container.appendChild(chartWrap)

  // Tooltip (outside SVG, positioned absolutely)
  const tooltip = document.createElement("div")
  tooltip.className = "field-tooltip"
  chartWrap.appendChild(tooltip)

  // Note below chart
  const note = document.createElement("div")
  note.className = "xg-note"
  note.textContent = "xG estimated from shot distance and angle"
  chartWrap.appendChild(note)

  // ── drawMode() — redraws SVG content for selected mode ───────
  function drawMode(mode) {
    svg.textContent = ""

    // Determine data and scales for this mode
    let homePts, awayPts, marginPts, isMargin = false, isWP = false
    let yMax, yMin = 0, yFmt

    if (mode === "xG") {
      homePts = homeXgCum; awayPts = awayXgCum
      yMax = Math.max(homePts[homePts.length - 1].val, awayPts[awayPts.length - 1].val, 0.5) * 1.15
      yFmt = v => v.toFixed(v === Math.round(v) ? 0 : 1)
    } else if (mode === "WinProb") {
      isWP = true
      yMax = 1.05; yMin = -0.05 // buffer so 0%/100% aren't clipped
      yFmt = v => (v * 100).toFixed(0) + "%"
    } else if (mode === "xGMargin") {
      isMargin = true; marginPts = xgMargin
      const ext = Math.max(Math.abs(Math.min(...marginPts.map(p => p.val))), Math.abs(Math.max(...marginPts.map(p => p.val))), 0.5)
      yMax = ext * 1.3; yMin = -yMax
      yFmt = v => (v > 0 ? "+" : "") + v.toFixed(1)
    }

    const xScale = (min) => PAD.l + (min / maxMinute) * plotW
    const yScale = (v) => {
      const range = yMax - yMin
      return PAD.t + plotH - ((v - yMin) / range) * plotH
    }

    // Defs: clip paths
    const defs = svgEl("defs")
    const clip = svgEl("clipPath"); clip.setAttribute("id", "fb-tl-clip")
    clip.appendChild(svgEl("rect", { x: PAD.l, y: PAD.t, width: plotW, height: plotH }))
    defs.appendChild(clip)
    if (isMargin || isWP) {
      const y0 = isWP ? yScale(0.5) : yScale(0)
      const aboveClip = svgEl("clipPath"); aboveClip.setAttribute("id", "fb-tl-above")
      aboveClip.appendChild(svgEl("rect", { x: PAD.l, y: PAD.t, width: plotW, height: Math.max(y0 - PAD.t, 0) }))
      defs.appendChild(aboveClip)
      const belowClip = svgEl("clipPath"); belowClip.setAttribute("id", "fb-tl-below")
      belowClip.appendChild(svgEl("rect", { x: PAD.l, y: y0, width: plotW, height: Math.max(PAD.t + plotH - y0, 0) }))
      defs.appendChild(belowClip)
    }
    svg.appendChild(defs)

    // Background
    svg.appendChild(svgEl("rect", { x: PAD.l, y: PAD.t, width: plotW, height: plotH, fill: "var(--bs-body-bg)", rx: "4" }))

    // Grid lines (horizontal)
    const yTicks = []
    if (isWP) {
      yTicks.push(0, 0.25, 0.5, 0.75, 1)
    } else if (isMargin) {
      const step = yMax > 3 ? 1 : 0.5
      for (let v = -Math.floor(yMax / step) * step; v <= yMax; v += step) yTicks.push(v)
    } else {
      const step = yMax > 4 ? 1 : (yMax > 2 ? 0.5 : 0.25)
      for (let v = 0; v <= yMax; v += step) yTicks.push(v)
    }
    for (const v of yTicks) {
      const y = yScale(v)
      if (y < PAD.t - 1 || y > PAD.t + plotH + 1) continue
      const isZero = (isMargin && v === 0) || (isWP && v === 0.5)
      svg.appendChild(svgEl("line", {
        x1: PAD.l, y1: y, x2: PAD.l + plotW, y2: y,
        stroke: "var(--bs-border-color)", "stroke-width": isZero ? "1" : "0.5",
        "stroke-dasharray": isZero ? "4,4" : "none"
      }))
      const label = svgEl("text", { x: PAD.l - 5, y: y + 3, fill: "var(--bs-secondary-color)", "font-size": "9", "text-anchor": "end", "font-family": "var(--bs-font-monospace)" })
      label.textContent = yFmt(v)
      svg.appendChild(label)
    }

    // X-axis labels (105'/120' only render when the axis reaches extra time)
    for (const min of [0, 15, 30, 45, 60, 75, 90, 105, 120]) {
      if (min > maxMinute) break
      const x = xScale(min)
      svg.appendChild(svgEl("line", { x1: x, y1: PAD.t + plotH, x2: x, y2: PAD.t + plotH + 4, stroke: "var(--bs-border-color)", "stroke-width": "0.5" }))
      const label = svgEl("text", { x: x, y: PAD.t + plotH + 16, fill: "var(--bs-secondary-color)", "font-size": "9", "text-anchor": "middle", "font-family": "var(--bs-font-monospace)" })
      label.textContent = min + "'"
      svg.appendChild(label)
    }

    // Half-time line at the actual half-time moment in effective-minute space
    // (= 45 + this match's 1H stoppage length, since the x-axis is effectiveMinute)
    const htX = xScale(45 + matchStop1)
    svg.appendChild(svgEl("line", { x1: htX, y1: PAD.t, x2: htX, y2: PAD.t + plotH, stroke: "var(--bs-border-color)", "stroke-width": "0.5", "stroke-dasharray": "3,3" }))
    const htLabel = svgEl("text", { x: htX, y: PAD.t - 5, fill: "var(--bs-secondary-color)", "font-size": "8", "text-anchor": "middle" })
    htLabel.textContent = "HT"
    svg.appendChild(htLabel)

    // Extra-time marker — start of ET (90' whistle) in effective-minute space.
    // Gate on ACTUAL ET evidence (period 3/4 events), not axis length — this
    // World Cup's stoppage time pushes regulation matches past 100 effective
    // minutes, which the old maxMinute>100 heuristic mislabelled as ET.
    // Chains rows (parquet AND live) carry period_id; only the pure
    // match-shots-parquet path lacks it, so that path alone falls back to a
    // >105' axis (90 + 15' of stoppage is implausible in regulation).
    const _reachedET = matchChains.length > 0
      ? matchChains.some(d => d.period_id === 3 || d.period_id === 4)
      : maxMinute > 105
    if (_reachedET) {
      const etX = xScale(90 + matchStop1)
      svg.appendChild(svgEl("line", { x1: etX, y1: PAD.t, x2: etX, y2: PAD.t + plotH, stroke: "var(--bs-border-color)", "stroke-width": "0.5", "stroke-dasharray": "3,3" }))
      const etLabel = svgEl("text", { x: etX, y: PAD.t - 5, fill: "var(--bs-secondary-color)", "font-size": "8", "text-anchor": "middle" })
      etLabel.textContent = "ET"
      svg.appendChild(etLabel)
    }

    // ── Draw data ──────────────────────────────────────────────
    // Render order: fills → lines → goal dots (dots always on top)
    const plotGroup = svgEl("g", { "clip-path": "url(#fb-tl-clip)" })
    const goalDots = [] // collected, rendered last

    function buildStepPath(pts) {
      let d = "M" + xScale(pts[0].minute).toFixed(1) + "," + yScale(pts[0].val).toFixed(1)
      for (let i = 1; i < pts.length; i++) {
        d += "H" + xScale(pts[i].minute).toFixed(1) + "V" + yScale(pts[i].val).toFixed(1)
      }
      return d
    }

    if (isWP) {
      // AFL-style three-way stacked chart:
      //   - Home area fills from 0% baseline up to the home probability line
      //   - Away area fills from 100% baseline down to the (1 - away) line
      //   - The gap between the two lines is the draw probability
      // Two step lines are drawn on top of the areas, plus end-point % labels.
      function stepAreaToBaseline(pts, yFn, baselineY) {
        if (pts.length < 2) return ""
        let d = "M" + xScale(pts[0].minute).toFixed(1) + "," + baselineY.toFixed(1)
        d += "V" + yFn(pts[0]).toFixed(1)
        for (let i = 1; i < pts.length; i++) {
          d += "H" + xScale(pts[i].minute).toFixed(1) + "V" + yFn(pts[i]).toFixed(1)
        }
        d += "V" + baselineY.toFixed(1) + "Z"
        return d
      }
      function stepLine(pts, yFn) {
        let d = "M" + xScale(pts[0].minute).toFixed(1) + "," + yFn(pts[0]).toFixed(1)
        for (let i = 1; i < pts.length; i++) {
          d += "H" + xScale(pts[i].minute).toFixed(1) + "V" + yFn(pts[i]).toFixed(1)
        }
        return d
      }

      const homeY = p => yScale(p.home)
      const awayTopY = p => yScale(1 - p.away)
      const bottomBaseline = yScale(0)
      const topBaseline = yScale(1)

      // Band between the two lines = draw probability. Fill with muted off-white so
      // the user can actually see the draw component of the three-way split.
      function stepBandPath(pts, upperYFn, lowerYFn) {
        const n = pts.length
        if (n < 2) return ""
        const upperVerts = [[xScale(pts[0].minute), upperYFn(pts[0])]]
        for (let i = 1; i < n; i++) {
          upperVerts.push([xScale(pts[i].minute), upperYFn(pts[i-1])])
          upperVerts.push([xScale(pts[i].minute), upperYFn(pts[i])])
        }
        const lowerVerts = [[xScale(pts[0].minute), lowerYFn(pts[0])]]
        for (let i = 1; i < n; i++) {
          lowerVerts.push([xScale(pts[i].minute), lowerYFn(pts[i-1])])
          lowerVerts.push([xScale(pts[i].minute), lowerYFn(pts[i])])
        }
        // Walk upper forward, then lower reversed, close
        const all = upperVerts.concat(lowerVerts.reverse())
        let d = "M" + all[0][0].toFixed(1) + "," + all[0][1].toFixed(1)
        for (let i = 1; i < all.length; i++) d += "L" + all[i][0].toFixed(1) + "," + all[i][1].toFixed(1)
        return d + "Z"
      }

      const homeArea = stepAreaToBaseline(wpPoints, homeY, bottomBaseline)
      const awayArea = stepAreaToBaseline(wpPoints, awayTopY, topBaseline)
      const drawBand = stepBandPath(wpPoints, awayTopY, homeY) // upper = 1-away (smaller y), lower = home (larger y)
      const homeLineD = stepLine(wpPoints, homeY)
      const awayLineD = stepLine(wpPoints, awayTopY)

      plotGroup.appendChild(svgEl("path", { d: homeArea, fill: homeColor, opacity: "0.22" }))
      plotGroup.appendChild(svgEl("path", { d: awayArea, fill: awayColor, opacity: "0.22" }))
      plotGroup.appendChild(svgEl("path", { d: drawBand, fill: "rgba(255,255,255,0.2)" }))
      plotGroup.appendChild(svgEl("path", { d: homeLineD, fill: "none", stroke: homeColor, "stroke-width": "2" }))
      plotGroup.appendChild(svgEl("path", { d: awayLineD, fill: "none", stroke: awayColor, "stroke-width": "2" }))

      // Goal dots: place on the scoring team's line so the step jump is visible
      for (const p of wpPoints) {
        if (p.isGoal) {
          const goalY = p.isHome ? yScale(p.home) : yScale(1 - p.away)
          goalDots.push({ cx: xScale(p.minute), cy: goalY, color: p.isHome ? homeColor : awayColor, r: "4" })
        }
      }

      // End-of-line labels — anchored to the actual line endpoint (matches AFL).
      // For finished matches that endpoint is the right edge; for live matches
      // it's the current minute so labels sit next to where the line stops.
      const last = wpPoints[wpPoints.length - 1]
      const labelX = xScale(last.minute) + 5
      const homeEndY = yScale(last.home) + 3
      const awayEndY = yScale(1 - last.away) + 3
      // If labels would overlap, nudge them apart
      let hY = homeEndY, aY = awayEndY
      const minGap = 12
      if (Math.abs(hY - aY) < minGap) {
        const nudge = (minGap - Math.abs(hY - aY)) / 2
        if (hY > aY) { hY += nudge; aY -= nudge } else { hY -= nudge; aY += nudge }
      }
      const homeLabel = svgEl("text", {
        x: labelX, y: hY,
        fill: homeColor, "font-size": "10", "font-weight": "700", "font-family": "var(--bs-font-monospace)"
      })
      homeLabel.textContent = (last.home * 100).toFixed(0) + "%"
      svg.appendChild(homeLabel)
      const awayLabel = svgEl("text", {
        x: labelX, y: aY,
        fill: awayColor, "font-size": "10", "font-weight": "700", "font-family": "var(--bs-font-monospace)"
      })
      awayLabel.textContent = (last.away * 100).toFixed(0) + "%"
      svg.appendChild(awayLabel)
    } else if (isMargin) {
      // xG Margin: single line, split fill above/below zero
      const stepD = buildStepPath(marginPts)
      const y0 = yScale(0)
      const areaD = stepD + "V" + y0 + "H" + xScale(0) + "Z"
      plotGroup.appendChild(svgEl("path", { d: areaD, fill: "rgba(59,130,246,0.15)", "clip-path": "url(#fb-tl-above)" }))
      plotGroup.appendChild(svgEl("path", { d: areaD, fill: "rgba(239,68,68,0.15)", "clip-path": "url(#fb-tl-below)" }))
      plotGroup.appendChild(svgEl("path", { d: stepD, fill: "none", stroke: "rgba(255,255,255,0.6)", "stroke-width": "2" }))
      for (const p of marginPts) {
        if (p.shot && p.shot.is_goal) {
          const isHome = p.shot._isHome
          goalDots.push({ cx: xScale(p.minute), cy: yScale(p.val), color: isHome ? homeColor : awayColor, r: "4" })
        }
      }
      // End-of-line label (anchored to line endpoint so it sits next to the
      // line in live matches that stop mid-axis).
      const last = marginPts[marginPts.length - 1]
      const endVal = last.val
      const endLabel = svgEl("text", {
        x: xScale(last.minute) + 5, y: yScale(endVal) + 3,
        fill: endVal > 0 ? homeColor : endVal < 0 ? awayColor : "var(--bs-body-color)",
        "font-size": "10", "font-weight": "600", "font-family": "var(--bs-font-monospace)"
      })
      endLabel.textContent = (endVal > 0 ? "+" : "") + endVal.toFixed(2)
      svg.appendChild(endLabel)
    } else {
      // xG: two-line mode — draw losing team first so winning team's line is on top
      const homeEnd = homePts[homePts.length - 1].val
      const awayEnd = awayPts[awayPts.length - 1].val
      const drawOrder = homeEnd >= awayEnd ? [{ pts: awayPts, color: awayColor }, { pts: homePts, color: homeColor }]
                                            : [{ pts: homePts, color: homeColor }, { pts: awayPts, color: awayColor }]
      const endLabels = []
      for (const { pts, color } of drawOrder) {
        if (pts.length < 2) continue
        const stepD = buildStepPath(pts)
        // Fill
        plotGroup.appendChild(svgEl("path", {
          d: stepD + "V" + yScale(0) + "H" + xScale(0) + "Z",
          fill: color, "fill-opacity": "0.08"
        }))
      }
      for (const { pts, color } of drawOrder) {
        if (pts.length < 2) continue
        const stepD = buildStepPath(pts)
        // Line
        plotGroup.appendChild(svgEl("path", { d: stepD, fill: "none", stroke: color, "stroke-width": "2" }))
        // Collect goal dots
        for (const p of pts) {
          if (p.shot && p.shot.is_goal) goalDots.push({ cx: xScale(p.minute), cy: yScale(p.val), color, r: "5" })
        }
        const last = pts[pts.length - 1]
        endLabels.push({ x: xScale(last.minute) + 5, y: yScale(last.val), color, text: last.val.toFixed(2) })
      }
      // Anti-overlap: push end labels apart if too close
      if (endLabels.length === 2) {
        const minGap = 12
        const dy = Math.abs(endLabels[0].y - endLabels[1].y)
        if (dy < minGap) {
          const mid = (endLabels[0].y + endLabels[1].y) / 2
          const sign = endLabels[0].y < endLabels[1].y ? -1 : 1
          endLabels[0].y = mid + sign * minGap / 2
          endLabels[1].y = mid - sign * minGap / 2
        }
      }
      for (const lbl of endLabels) {
        const labelEl = svgEl("text", {
          x: lbl.x, y: lbl.y + 3,
          fill: lbl.color, "font-size": "10", "font-weight": "600", "font-family": "var(--bs-font-monospace)"
        })
        labelEl.textContent = lbl.text
        svg.appendChild(labelEl)
      }
    }

    // Goal dots — always rendered last so they sit on top of all lines
    for (const dot of goalDots) {
      plotGroup.appendChild(svgEl("circle", {
        cx: dot.cx, cy: dot.cy, r: dot.r,
        fill: dot.color, stroke: "#fbbf24", "stroke-width": "1.5"
      }))
    }
    // Dismissal markers — a literal red card drawn at the top of the plot at
    // the sending-off minute (no emoji: a 5×8 rounded rect IS the iconography),
    // centred on a dashed drop-line.
    for (const c of timelineCards) {
      const ccx = xScale(c._effMin)
      plotGroup.appendChild(svgEl("line", {
        x1: ccx, y1: PAD.t, x2: ccx, y2: PAD.t + plotH,
        stroke: "#ef4444", "stroke-width": "1", "stroke-dasharray": "2,3", opacity: "0.6",
      }))
      plotGroup.appendChild(svgEl("rect", {
        x: ccx - 2.5, y: PAD.t + 2, width: 5, height: 8, rx: 1,
        fill: "#ef4444", stroke: "#1a1918", "stroke-width": "0.75",
        transform: `rotate(8 ${ccx} ${PAD.t + 6})`,
      }))
    }
    svg.appendChild(plotGroup)

    // Team name labels (top of chart)
    if (!isMargin && !isWP) {
      const hLabel = svgEl("text", { x: PAD.l + 5, y: PAD.t - 8, fill: homeColor, "font-size": "10", "font-weight": "600" })
      hLabel.textContent = tlHomeTeam
      svg.appendChild(hLabel)
      const aLabel = svgEl("text", { x: PAD.l + plotW - 5, y: PAD.t - 8, fill: awayColor, "font-size": "10", "font-weight": "600", "text-anchor": "end" })
      aLabel.textContent = tlAwayTeam
      svg.appendChild(aLabel)
    } else {
      // Margin/WP: labels at top-left and bottom-right
      const hLabel = svgEl("text", { x: PAD.l + 5, y: PAD.t + 12, fill: homeColor, "font-size": "9", "font-weight": "600" })
      hLabel.textContent = tlHomeTeam
      svg.appendChild(hLabel)
      const aLabel = svgEl("text", { x: PAD.l + plotW - 5, y: PAD.t + plotH - 5, fill: awayColor, "font-size": "9", "font-weight": "600", "text-anchor": "end" })
      aLabel.textContent = tlAwayTeam
      svg.appendChild(aLabel)
    }

    // ── Crosshair hover ──────────────────────────────────────────
    const crosshair = svgEl("line", { x1: 0, y1: PAD.t, x2: 0, y2: PAD.t + plotH, stroke: "rgba(255,255,255,0.5)", "stroke-width": "1", "stroke-dasharray": "4,3", visibility: "hidden", "pointer-events": "none" })
    svg.appendChild(crosshair)
    const hoverHome = svgEl("circle", { r: "3.5", fill: homeColor, stroke: "#1a1918", "stroke-width": "1.5", visibility: "hidden", "pointer-events": "none" })
    const hoverAway = svgEl("circle", { r: "3.5", fill: awayColor, stroke: "#1a1918", "stroke-width": "1.5", visibility: "hidden", "pointer-events": "none" })
    svg.appendChild(hoverHome); svg.appendChild(hoverAway)

    // Build lookup: minute → value(s) for interpolation
    // For step functions, value at minute M = last point with minute <= M
    function valAtMinute(pts, m) {
      let val = pts[0].val
      for (const p of pts) { if (p.minute <= m) val = p.val; else break }
      return val
    }
    // Variant returning the full point object (used for WP mode where we need home/draw/away)
    function ptAtMinute(pts, m) {
      let pt = pts[0]
      for (const p of pts) { if (p.minute <= m) pt = p; else break }
      return pt
    }

    const overlay = svgEl("rect", { x: PAD.l, y: PAD.t, width: plotW, height: plotH, fill: "transparent", style: "cursor:crosshair" })
    svg.appendChild(overlay)

    overlay.addEventListener("mousemove", (e) => {
      const rect = svg.getBoundingClientRect()
      const svgX = (e.clientX - rect.left) / rect.width * W
      const minute = Math.max(0, Math.min(maxMinute, (svgX - PAD.l) / plotW * maxMinute))
      const cx = xScale(minute)

      crosshair.setAttribute("x1", cx); crosshair.setAttribute("x2", cx)
      crosshair.setAttribute("visibility", "visible")

      let tipTitle = Math.floor(minute) + "'"
      let tipRows = []

      // Find nearby shot events (within ±1 effective minute)
      const nearbyShots = timelineShots.filter(s => Math.abs(((s._effMin || 0) + (s.second || 0) / 60) - minute) < 1)
      const shotLabels = { 16: "Goal", 15: "Saved", 14: "Post", 13: "Miss" }

      if (isWP) {
        const wp = ptAtMinute(wpPoints, minute)
        hoverHome.setAttribute("cx", cx); hoverHome.setAttribute("cy", yScale(wp.home))
        hoverHome.setAttribute("fill", homeColor)
        hoverHome.setAttribute("visibility", "visible")
        hoverAway.setAttribute("cx", cx); hoverAway.setAttribute("cy", yScale(1 - wp.away))
        hoverAway.setAttribute("fill", awayColor)
        hoverAway.setAttribute("visibility", "visible")
        tipRows = [
          ["Score", (wp.hGoals ?? 0) + " – " + (wp.aGoals ?? 0)],
          [tlHomeTeam, (wp.home * 100).toFixed(0) + "%"],
          ["Draw", (wp.draw * 100).toFixed(0) + "%"],
          [tlAwayTeam, (wp.away * 100).toFixed(0) + "%"]
        ]
      } else if (isMargin) {
        const mv = valAtMinute(marginPts, minute)
        hoverHome.setAttribute("cx", cx); hoverHome.setAttribute("cy", yScale(mv))
        hoverHome.setAttribute("fill", mv >= 0 ? homeColor : awayColor)
        hoverHome.setAttribute("visibility", "visible")
        hoverAway.setAttribute("visibility", "hidden")
        tipRows = [["xG Margin", (mv > 0 ? "+" : "") + mv.toFixed(2)]]
      } else {
        const hv = valAtMinute(homePts, minute)
        const av = valAtMinute(awayPts, minute)
        hoverHome.setAttribute("cx", cx); hoverHome.setAttribute("cy", yScale(hv))
        hoverHome.setAttribute("visibility", "visible")
        hoverAway.setAttribute("cx", cx); hoverAway.setAttribute("cy", yScale(av))
        hoverAway.setAttribute("visibility", "visible")
        tipRows = [[tlHomeTeam, hv.toFixed(2)], [tlAwayTeam, av.toFixed(2)]]
      }

      // Position tooltip; shots/cards get richer custom rows appended after
      // the shared helper renders the base label/value pairs.
      const nearbyCards = timelineCards.filter(c => Math.abs((c._effMin || 0) + (c.second || 0) / 60 - minute) < 1)
      const _tip = window.chartHelpers?.buildFieldTooltip
      if (_tip) {
        _tip(tooltip, tipTitle, tipRows)
        for (const s of nearbyShots) {
          const label = shotLabels[s.type_id] || "Shot"
          const row = document.createElement("div")
          row.className = "ft-row mt-evt" + (s.is_goal ? " mt-evt-goal" : "")
          const teamColor = s._isHome ? homeColor : awayColor
          // Headshot rule: photo OR initials circle, never an empty gap.
          const initial = statsEsc((s.player_name || "?").trim().charAt(0).toUpperCase())
          const hs = `<span class="mp-hs-wrap mp-hs-tip"><span class="mp-hs-init">${initial}</span>${s.player_id
            ? `<img class="mp-hs-img" src="${base_url}football/headshots/${statsEsc(s.player_id)}.webp" alt="" onerror="this.style.display='none'">`
            : ""}</span>`
          row.innerHTML = `${hs}<span class="ft-label" style="color:${teamColor}">${s.is_goal ? "GOAL" : statsEsc(label)}</span>` +
            `<span class="ft-value">${statsEsc(s.player_name || "")}${s.xg != null ? ` <span class="mt-tip-xg">xG ${s.xg.toFixed(2)}</span>` : ""}</span>`
          tooltip.appendChild(row)
        }
        for (const c of nearbyCards) {
          const row = document.createElement("div")
          row.className = "ft-row mt-evt mt-evt-red"
          row.innerHTML = `<span class="mt-tip-card"></span><span class="ft-label" style="color:${c._isHome ? homeColor : awayColor}">Sent off</span><span class="ft-value">${statsEsc(c.player_name || "")}</span>`
          tooltip.appendChild(row)
        }
        const wrapRect = chartWrap.getBoundingClientRect()
        tooltip.style.left = ((cx / W) * rect.width + rect.left - wrapRect.left) + "px"
        tooltip.style.top = ((PAD.t / H) * rect.height + rect.top - wrapRect.top - 10) + "px"
        tooltip.classList.add("visible")
      }
    })

    overlay.addEventListener("mouseleave", () => {
      crosshair.setAttribute("visibility", "hidden")
      hoverHome.setAttribute("visibility", "hidden")
      hoverAway.setAttribute("visibility", "hidden")
      tooltip.classList.remove("visible")
    })
  }

  // ── Toggle button handlers ───────────────────────────────────
  for (const { btn, id } of btns) {
    btn.addEventListener("click", () => {
      if (btn.disabled) return
      btns.forEach(b => b.btn.classList.remove("active"))
      btn.classList.add("active")
      drawMode(id)
    })
  }

  // Initial draw
  drawMode("xG")
  return container
}
Show code
// ── Source / updated row beneath the Match Timeline ─────────
{
  if (!paramHome || !paramAway) return html``
  const _md = paramDateKey ? new Date(paramDateKey + "T12:00:00Z") : null
  const matchAsAt = (_md && !isNaN(_md.getTime()))
    ? "Match " + _md.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
    : "Live during play"
  return window.editorial.tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    asAt: matchAsAt,
    hint: "Win prob + xG, live during play"
  })
}
Show code
// Chain Visualizer feature banner — the page's best deep-dive used to live
// behind a footer button below Match Stats; surface it right under the
// timeline where the reader has just watched the match's shape.
{
  if (!paramHome || !paramAway || matchChains.length === 0) return html``
  const optaParam = _optaId ? `&optaId=${encodeURIComponent(_optaId)}` : ""
  const href = `match-chains.html#league=${encodeURIComponent(paramLeague)}&date=${encodeURIComponent(paramDate)}&home=${encodeURIComponent(paramHome)}&away=${encodeURIComponent(paramAway)}${optaParam}`
  return html`<a class="chain-cta" href="${href}">
    <svg class="chain-cta-pitch" viewBox="0 0 64 40" aria-hidden="true">
      <rect x="1" y="1" width="62" height="38" rx="3" fill="rgba(90,154,122,0.18)" stroke="rgba(90,154,122,0.55)" stroke-width="1.5"/>
      <line x1="32" y1="1" x2="32" y2="39" stroke="rgba(90,154,122,0.4)" stroke-width="1"/>
      <polyline points="8,30 20,24 30,28 42,14 54,18" fill="none" stroke="#5a9a7a" stroke-width="2" stroke-linejoin="round"/>
      <circle cx="8" cy="30" r="2.5" fill="#5a9a7a"/><circle cx="20" cy="24" r="2.5" fill="#5a9a7a"/>
      <circle cx="30" cy="28" r="2.5" fill="#5a9a7a"/><circle cx="42" cy="14" r="2.5" fill="#5a9a7a"/>
      <circle cx="54" cy="18" r="3.5" fill="#fbbf24"/>
    </svg>
    <span class="chain-cta-text">
      <strong>Chain Visualizer</strong>
      <span>Every possession in this match, drawn on the pitch — watch each attack build, pass by pass</span>
    </span>
    <span class="chain-cta-arrow">&rarr;</span>
  </a>`
}
Show code
// ── Lineups pitch map ────────────────────────────────────────────────────
// Starting XIs from the worker lineup feed. Layout uses the granular Opta
// matchstats fields when the worker ships them (pos "Defender" + pos_side
// "Left/Centre" → classifyRole lines, side-ordered within each line), and
// degrades to the coarse q44 groups in feed order on an older worker.
// Orientation matches the chain map's TV world frame (match-chains.qmd
// chainGroups: home rows (x, 100−y), away = the 180° rotation): home
// attacks left→right with their left flank at the TOP. Names prefer the
// feed's matchName, falling back to ratings.parquet. Heading lives inside
// the cell so nothing renders when lineups haven't been published.
{
  if (!paramHome || !paramAway) return html``
  const lu = _liveEvents?.lineups
  // Consume the worker's status fields: drift states would otherwise render
  // byte-identically to "lineups not published yet" (the #228 dead-feature
  // failure mode), and a degraded matchstats merge renders identically to an
  // old worker. Neither blocks the render — they make the cause debuggable.
  const _ls = _liveEvents?.lineups_status
  if (_ls === "no_player_ids" || _ls === "side_unresolved") {
    console.warn("[match] lineups_status:", _ls, "— lineup feed schema/id drift, pitch map suppressed")
  }
  const _gs = _liveEvents?.granular_status
  if (_gs && _gs !== "ok") {
    console.warn("[match] matchstats granular positions degraded (" + _gs + ") — coarse lineup layout, no position pills")
  }
  if (!lu || (!(lu.home || []).some(p => p.starter) && !(lu.away || []).some(p => p.starter))) return html``

  const nameById = new Map()
  for (const r of (ratings || [])) if (r.player_id && !nameById.has(r.player_id)) nameById.set(r.player_id, r.player_name)
  const dispName = (p) => p.name || nameById.get(p.player_id) || null

  const wrap = document.createElement("div")
  wrap.innerHTML = `<h2 class="anchored">Lineups</h2>`

  // Per-player involvement ledger for the hover card, counted off the same
  // chain rows the stats tables use. Empty pre-kickoff (or when chains
  // haven't published) — the tooltip then shows identity only.
  const statByPid = new Map()
  for (const e of (matchChains || [])) {
    if (!e.player_id || e._duelLoser) continue
    if (e.period_id != null && e.period_id >= 5) continue
    const t = e.type_id
    let s = statByPid.get(e.player_id)
    if (!s) { s = { touches: 0, passes: 0, shots: 0, goals: 0 }; statByPid.set(e.player_id, s) }
    s.touches++
    if (t === 1 || t === 2) s.passes++
    if (t >= 13 && t <= 16) s.shots++
    if (t === 16 && !(e.x != null && e.x < 50)) s.goals++  // x<50 goal = own goal, not theirs
  }

  const W = 105, H = 68
  const ns = "http://www.w3.org/2000/svg"
  const svg = document.createElementNS(ns, "svg")
  svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
  svg.setAttribute("class", "lineup-pitch")

  // Tooltip lives in a positioned wrapper around the SVG; player dots show
  // it on hover (pointerenter) and tap (click) — touch has no hover. The
  // headshot keeps the initials circle behind the img (revealed on 404),
  // never a bare gap.
  const pitchWrap = document.createElement("div")
  pitchWrap.className = "lineup-wrap"
  const tip = document.createElement("div")
  tip.className = "lineup-tip"
  tip.style.display = "none"
  const hideTip = () => { tip.style.display = "none" }
  const showTip = (p, color, cx, cy) => {
    const nm = dispName(p) || "Unknown"
    const initial = statsEsc(nm.trim().charAt(0).toUpperCase())
    const img = p.player_id
      ? `<img class="mp-hs-img" src="${base_url}football/headshots/${statsEsc(p.player_id)}.webp" alt="" loading="lazy" onerror="this.style.display='none'">`
      : ""
    const role = window.footballMaps?.classifyRole ? window.footballMaps.classifyRole(p.pos, p.pos_side) : null
    const pill = role ? `<span class="mp-pos" style="background:${window.footballMaps?.pannaBadge?.[role] || "#9ca3af"}">${statsEsc(role)}</span>` : ""
    const s = statByPid.get(p.player_id)
    const row = (lbl, val) => `<div class="lt-row"><span>${lbl}</span><span class="lt-lead"></span><b>${val}</b></div>`
    tip.innerHTML = `
      <div class="lt-head">
        <span class="mp-hs-wrap mp-hs-tip"><span class="mp-hs-init">${initial}</span>${img}</span>
        <span class="lt-id">
          <span class="lt-name">${statsEsc(nm)}</span>
          <span class="lt-meta">${p.jersey ? `<span class="lt-shirt">No. ${statsEsc(String(p.jersey))}</span>` : ""}${pill}</span>
        </span>
      </div>` +
      (s ? `<div class="lt-stats">${row("Involvements", s.touches)}${row("Passes", s.passes)}${s.shots ? row("Shots", s.shots) : ""}${s.goals ? row("Goals", s.goals) : ""}</div>` : "")
    tip.style.borderLeftColor = color
    tip.style.display = "block"
    // SVG user units → wrapper px; prefer above the dot, flip below when
    // cropped at the top; clamp horizontally inside the wrapper.
    const r = pitchWrap.getBoundingClientRect()
    const px = cx * (r.width / W), py = cy * (r.height / H)
    const tw = tip.offsetWidth, th = tip.offsetHeight
    tip.style.left = Math.max(4, Math.min(px - tw / 2, r.width - tw - 4)) + "px"
    const above = py - th - 10
    tip.style.top = (above < 4 ? py + 10 : above) + "px"
  }
  svg.addEventListener("click", hideTip)
  pitchWrap.addEventListener("pointerleave", hideTip)
  const el = (tag, attrs) => { const n = document.createElementNS(ns, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); return n }
  svg.appendChild(el("rect", { x: 0.5, y: 0.5, width: W - 1, height: H - 1, rx: 1.5, fill: "rgba(40,80,55,0.55)", stroke: "rgba(255,255,255,0.25)", "stroke-width": 0.4 }))
  svg.appendChild(el("line", { x1: W / 2, y1: 0.5, x2: W / 2, y2: H - 0.5, stroke: "rgba(255,255,255,0.2)", "stroke-width": 0.35 }))
  svg.appendChild(el("circle", { cx: W / 2, cy: H / 2, r: 7.5, fill: "none", stroke: "rgba(255,255,255,0.2)", "stroke-width": 0.35 }))
  svg.appendChild(el("rect", { x: 0.5, y: H / 2 - 14.5, width: 12.5, height: 29, fill: "none", stroke: "rgba(255,255,255,0.2)", "stroke-width": 0.35 }))
  svg.appendChild(el("rect", { x: W - 13, y: H / 2 - 14.5, width: 12.5, height: 29, fill: "none", stroke: "rgba(255,255,255,0.2)", "stroke-width": 0.35 }))

  // Line index per player: granular role when the feed shipped pos/pos_side
  // (six tactical bands: GK / back line / WB+DM band / midfield / AM band /
  // strikers), else the coarse q44 group (four bands).
  const ROLE_LINE = { GK: 0, LB: 1, CB: 1, RB: 1, LWB: 2, RWB: 2, DM: 2, LM: 3, CM: 3, RM: 3, CAM: 4, LW: 4, RW: 4, LF: 5, CF: 5, RF: 5 }
  const COARSE_LINE = { "1": 0, "2": 1, "3": 3, "4": 5 }
  // pos_side → top-to-bottom order for the HOME team (left flank at top,
  // matching the chain map's home transform y_world = 100 − y_team). Away
  // is mirrored. Compound sides slot between their anchors.
  const SIDE_ORD = { "Left": 0, "Left/Centre": 1, "Centre": 2, "Centre/Right": 3, "Right": 4 }

  const allLabels = []  // filled by drawTeam, placed by the solver below

  const drawTeam = (entries, isHome, color) => {
    const starters = (entries || []).filter(p => p.starter)
    if (!starters.length) return null
    const role = (p) => window.footballMaps?.classifyRole ? window.footballMaps.classifyRole(p.pos, p.pos_side) : null
    const byLine = new Map()
    for (const p of starters) {
      const r = role(p)
      const li = r != null ? ROLE_LINE[r] : COARSE_LINE[String(p.position)]
      const key = li != null ? li : 6  // unmappable → extra line rather than dropped
      if (!byLine.has(key)) byLine.set(key, [])
      byLine.get(key).push(p)
    }
    const lines = [...byLine.keys()].sort((a, b) => a - b).map(k => byLine.get(k))
    for (const line of lines) {
      line.sort((a, b) => {
        const sa = SIDE_ORD[a.pos_side], sb = SIDE_ORD[b.pos_side]
        if (sa != null && sb != null && sa !== sb) return isHome ? sa - sb : sb - sa
        // No side info: formation place (matchstats), then q131 slot, else feed order
        const pa = parseInt(a.place ?? a.slot) || 99, pb = parseInt(b.place ?? b.slot) || 99
        return pa - pb
      })
    }
    // x columns: GK near the team's goal, lines spread toward halfway. Dots
    // and listeners attach now; name labels are only COLLECTED here — they
    // get placed by the global collision solver after both teams are drawn,
    // because collisions cross team boundaries at halfway and cross columns
    // whenever a high dot's below-label meets a low dot's above-label.
    const n = lines.length
    lines.forEach((line, li) => {
      const t = n === 1 ? 0.5 : li / (n - 1)
      const xHome = 6.5 + t * 41.5
      const x = isHome ? xHome : W - xHome
      line.forEach((p, pi) => {
        const y = (H * (pi + 1)) / (line.length + 1)
        const g = el("g", { class: "lineup-dot" })
        g.appendChild(el("circle", { cx: x, cy: y, r: 2.7, fill: color, stroke: "rgba(0,0,0,0.45)", "stroke-width": 0.35 }))
        const num = el("text", { x, y: y + 0.9, "text-anchor": "middle", "font-size": 2.5, "font-weight": 700, fill: "#fff", "font-family": "var(--font-family-data, monospace)" })
        num.textContent = p.jersey || ""
        g.appendChild(num)
        const nm = dispName(p)
        if (nm) allLabels.push({ g, x, y, nm })
        g.addEventListener("pointerenter", () => showTip(p, color, x, y))
        g.addEventListener("pointerleave", hideTip)
        g.addEventListener("click", (ev) => { ev.stopPropagation(); showTip(p, color, x, y) })
        svg.appendChild(g)
      })
    })
    // Formation: prefer the feed's formationUsed digits ("4141" → "4-1-4-1").
    // formationUsed excludes the GK, so its digits always sum to the 10
    // outfield players; length 3–5 rejects a leftover 1-2-digit q130 numeric
    // id. Else derive from the outfield line counts.
    const fu = String((_liveEvents?.formations || {})[isHome ? "home" : "away"] || "")
    if (/^\d{3,5}$/.test(fu) && fu.split("").reduce((s, d) => s + +d, 0) === 10) return fu.split("").join("-")
    const outfield = lines.filter(l => {
      const p0 = l[0]
      return !(role(p0) === "GK" || String(p0?.position) === "1")
    })
    return outfield.length >= 2 ? outfield.map(l => l.length).join("-") : null
  }

  const homeForm = drawTeam(lu.home, true, "#3b82f6")
  const awayForm = drawTeam(lu.away, false, "#ef4444")

  // Greedy label placement over BOTH teams: prefer below the dot, flip
  // above when that collides with an already-placed label, nudge downward
  // as a last resort. Label x clamps inside the pitch so the GK's name
  // can't clip the goal-line edge. Deterministic (left-to-right sweep),
  // no measurement needed — widths estimate from the mono font's fixed
  // advance (~1.15 units/char at font-size 2.05).
  allLabels.sort((a, b) => a.x - b.x || a.y - b.y)
  const placed = []
  for (const L of allLabels) {
    const txt = L.nm.length > 13 ? L.nm.slice(0, 12) + "…" : L.nm
    const w = txt.length * 1.15 + 1, h = 2.7
    const tx = Math.max(w / 2 + 1.5, Math.min(L.x, W - w / 2 - 1.5))
    const collides = (y) => placed.some(q => Math.abs(q.tx - tx) < (q.w + w) / 2 && Math.abs(q.ty - y) < h)
    let ty = L.y + 5.8
    if (collides(ty)) {
      const above = L.y - 4.3
      if (!collides(above)) ty = above
      else { let k = 0; while (collides(ty) && k++ < 4) ty += 2.8 }
    }
    ty = Math.max(3, Math.min(ty, H - 1.5))
    placed.push({ tx, ty, w })
    const label = el("text", { x: tx, y: ty, "text-anchor": "middle", "font-size": 2.05, fill: "rgba(255,255,255,0.85)", "font-family": "var(--font-family-data, monospace)" })
    label.textContent = txt
    L.g.appendChild(label)
  }

  const luMark = (team) => {
    if (paramLeague === "WC" && window.wcMaps) return window.wcMaps.flagImg(team, "ms-team-mark")
    const crest = window.footballMaps.teamCrest && window.footballMaps.teamCrest(team)
    return crest ? `<img class="ms-team-mark" src="${statsEsc(crest)}" alt="" loading="lazy" onerror="this.style.display='none'">` : ""
  }
  const head = document.createElement("div")
  head.className = "lineup-head"
  head.innerHTML = `
    <span class="lineup-team" style="color:#3b82f6">${luMark(paramHome)}${statsEsc(paramHome)}${homeForm ? ` <span class="lineup-form">${statsEsc(homeForm)}</span>` : ""}</span>
    <span class="lineup-team" style="color:#ef4444">${awayForm ? `<span class="lineup-form">${statsEsc(awayForm)}</span> ` : ""}${statsEsc(paramAway)}${luMark(paramAway)}</span>`
  wrap.appendChild(head)
  pitchWrap.appendChild(svg)
  pitchWrap.appendChild(tip)
  wrap.appendChild(pitchWrap)

  // Bench chips (named subs only)
  const bench = document.createElement("div")
  bench.className = "lineup-bench"
  for (const [entries, color] of [[lu.home, "#3b82f6"], [lu.away, "#ef4444"]]) {
    const subs = (entries || []).filter(p => !p.starter).map(p => ({ nm: dispName(p), j: p.jersey })).filter(s => s.nm)
    if (!subs.length) continue
    const row = document.createElement("div")
    row.className = "lineup-bench-row"
    row.innerHTML = `<span class="lineup-bench-lbl" style="color:${color}">Bench</span>` +
      subs.map(s => `<span class="lineup-bench-chip">${s.j ? `<b>${statsEsc(String(s.j))}</b> ` : ""}${statsEsc(s.nm)}</span>`).join("")
    bench.appendChild(row)
  }
  wrap.appendChild(bench)
  return wrap
}

Match Stats

Show code
viewof matchPhase = {
  const isKnockout = matchChains.some(d => d.period_id != null && d.period_id >= 3)
  if (!isKnockout) {
    const hidden = html`<span style="display:none"></span>`
    hidden.value = "All"
    return hidden
  }
  const outer = document.createElement("div")
  outer.className = "stats-quarter-toggle"
  outer.style.cssText = "display:flex;gap:0.25rem;margin:0 0 0.75rem;font-size:0.85rem;align-items:center;flex-wrap:wrap"
  outer.value = "All"
  const lbl = document.createElement("span")
  lbl.className = "text-muted"
  lbl.style.cssText = "margin-right:0.4rem"
  lbl.textContent = "Phase:"
  outer.appendChild(lbl)
  // Reg + ET / Regular / Extra Time only. The shootout isn't shown as a phase
  // here — box-score stats (passes, possession…) are meaningless for penalties,
  // and the result is already on the header + Final Score tile. A dedicated
  // taker-by-taker shootout panel would be the right way to surface pens.
  const opts = [["All", "Reg + ET"], ["RT", "Regular"], ["ET", "Extra Time"]]
  for (const [value, label] of opts) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (value === "All" ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      outer.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      outer.value = value
      outer.dispatchEvent(new Event("input", { bubbles: true }))
    })
    outer.appendChild(btn)
  }
  return outer
}

// Predicate selecting chain events for the active phase. "All" = Reg + ET (pens
// excluded, the default everywhere else); RT = 1H/2H; ET = both ET halves;
// Pens = the shootout. Shared by the team and player stats cells below.
_matchPhasePred = {
  const set = matchPhase === "RT" ? new Set([1, 2])
            : matchPhase === "ET" ? new Set([3, 4])
            : matchPhase === "Pens" ? new Set([5])
            : null
  return (d) => set ? set.has(d.period_id) : (d.period_id == null || d.period_id < 5)
}
Show code
// Team-level stats comparison (parquet or live events)
{
  if (!paramHome || !paramAway) return html``

  // The match-stats parquet is pre-aggregated over the whole match, so it can't
  // honour a phase filter. When a specific phase is selected, fall back to the
  // live chain path (which carries period_id) so the table actually filters.
  const hasParquetStats = matchStats && matchStats.length > 0 && matchPhase === "All"
  const hasLiveChains = matchChains.length > 0 && !hasParquetStats

  if (!hasParquetStats && !hasLiveChains) {
    return html`<p class="text-muted">No match stats available for this game.</p>`
  }

  function sum(arr, col) { return arr.reduce((s, d) => s + (d[col] || 0), 0) }
  function pct(num, den) { return den > 0 ? Math.round(num / den * 100) : 0 }
  function countType(events, typeIds) { return events.filter(d => typeIds.includes(d.type_id)).length }
  function countTypeOk(events, typeIds) { return events.filter(d => typeIds.includes(d.type_id) && d.outcome === 1).length }

  let metrics
  if (hasParquetStats) {
    const homeStats = matchStats.filter(d => d.team_name === statsHomeTeam)
    const awayStats = matchStats.filter(d => d.team_name === statsAwayTeam)

    const _isOG = (s) => s.is_goal && s.x != null && s.x < 50
    const _isHome = (s) => _isOG(s) ? s.team_id !== xgHomeTeamId : s.team_id === xgHomeTeamId
    // match-shots.parquet's xg column is NaN on ~99% of rows; fall back to coordinate
    // estimator to mirror the Match Timeline fix above.
    const _shotXg = (d) => Number.isFinite(d.xg) ? d.xg : _estimateXg(d.x, d.y)
    // Exclude shootout kicks (logged at minute ≥ 120; match-shots.parquet has no period_id).
    const _openPlay = (d) => d.minute == null || d.minute < 120
    const homeXG = xgShots.filter(d => _isHome(d) && _openPlay(d)).reduce((s, d) => s + _shotXg(d), 0)
    const awayXG = xgShots.filter(d => !_isHome(d) && _openPlay(d)).reduce((s, d) => s + _shotXg(d), 0)

    metrics = [
      // Goals from the authoritative after-ET score (liveScoresBest), so a pens
      // match shows the 1–1 result, not a shootout-inflated total.
      { label: "Goals", home: Number.isFinite(liveScoresBest.home) ? liveScoresBest.home : sum(homeStats, "goals"), away: Number.isFinite(liveScoresBest.away) ? liveScoresBest.away : sum(awayStats, "goals"), higherBetter: true },
      { label: "xG", home: homeXG.toFixed(2), away: awayXG.toFixed(2), higherBetter: true },
      { label: "Possession %", home: pct(sum(homeStats, "touches"), sum(homeStats, "touches") + sum(awayStats, "touches")), away: pct(sum(awayStats, "touches"), sum(homeStats, "touches") + sum(awayStats, "touches")), suffix: "%", higherBetter: true },
      { label: "Shots", home: sum(homeStats, "shots"), away: sum(awayStats, "shots"), higherBetter: true },
      { label: "Shots on Target", home: sum(homeStats, "shots_on_target"), away: sum(awayStats, "shots_on_target"), higherBetter: true },
      { label: "Passes", home: sum(homeStats, "passes"), away: sum(awayStats, "passes"), higherBetter: true },
      { label: "Pass Accuracy", home: pct(sum(homeStats, "passes_accurate"), sum(homeStats, "passes")), away: pct(sum(awayStats, "passes_accurate"), sum(awayStats, "passes")), suffix: "%", higherBetter: true },
      { label: "Tackles", home: sum(homeStats, "tackles"), away: sum(awayStats, "tackles"), higherBetter: true },
      { label: "Interceptions", home: sum(homeStats, "interceptions"), away: sum(awayStats, "interceptions"), higherBetter: true },
      { label: "Clearances", home: sum(homeStats, "clearances"), away: sum(awayStats, "clearances") },
      { label: "Fouls", home: sum(homeStats, "fouls"), away: sum(awayStats, "fouls"), higherBetter: false },
      { label: "Duels Won", home: sum(homeStats, "duels_won"), away: sum(awayStats, "duels_won"), higherBetter: true },
      { label: "Aerials Won", home: sum(homeStats, "aerials_won"), away: sum(awayStats, "aerials_won"), higherBetter: true },
      { label: "Big Chances", home: sum(homeStats, "big_chances_created"), away: sum(awayStats, "big_chances_created"), higherBetter: true },
    ]
  } else {
    // Aggregate from live chain events, filtered to the selected phase
    // (default "All" = Reg + ET, shootout excluded). The Phase toggle above
    // narrows this to Regular / Extra Time / Penalties on knockout matches.
    const homeTeam = matchChains[0]?.home_team || paramHome
    const inMatch = matchChains.filter(_matchPhasePred)
    const hEvts = inMatch.filter(d => d.team_name === homeTeam)
    const aEvts = inMatch.filter(d => d.team_name !== homeTeam)

    // Live xG
    const shotIds = [13, 14, 15, 16]
    const hShots = hEvts.filter(d => shotIds.includes(d.type_id))
    const aShots = aEvts.filter(d => shotIds.includes(d.type_id))
    // Prefer the worker's AUTHORITATIVE xG total (_liveEvents.xg — same number the
    // header shows, with OG-flip + shootout exclusion already applied) so the box
    // score can't disagree with the header. Fall back to summing per-shot model xG
    // (d.xg), then the coordinate estimate, when the worker total isn't present.
    // ONLY when the whole match is shown (matchPhase "All"): the worker total is
    // whole-match, so under a Regular/ET phase filter it would sit beside
    // phase-filtered Shots/Goals — sum the phase-filtered shots there instead.
    const _sumXg = shots => shots.reduce((sum, d) => sum + (Number.isFinite(d.xg) ? d.xg : _estimateXg(d.x, d.y)), 0)
    const _useWorkerXg = matchPhase === "All" && _liveEvents?.xg
    const hXg = (_useWorkerXg && Number.isFinite(_liveEvents.xg.home)) ? _liveEvents.xg.home : _sumXg(hShots)
    const aXg = (_useWorkerXg && Number.isFinite(_liveEvents.xg.away)) ? _liveEvents.xg.away : _sumXg(aShots)

    const hPass = countType(hEvts, [1, 2])
    const aPass = countType(aEvts, [1, 2])
    const hPassOk = countTypeOk(hEvts, [1, 2])
    const aPassOk = countTypeOk(aEvts, [1, 2])

    metrics = [
      // Authoritative score (liveScoresBest = Opta matchDetails.scores, OG/VAR-
      // aware) so the box score never disagrees with the header; the raw type-16
      // event count is only a fallback when the structured score is absent.
      { label: "Goals", home: Number.isFinite(liveScoresBest.home) ? liveScoresBest.home : countType(hEvts, [16]), away: Number.isFinite(liveScoresBest.away) ? liveScoresBest.away : countType(aEvts, [16]), higherBetter: true },
      { label: "xG", home: hXg.toFixed(2), away: aXg.toFixed(2), higherBetter: true },
      { label: "Possession %", home: pct(hEvts.length, hEvts.length + aEvts.length), away: pct(aEvts.length, hEvts.length + aEvts.length), suffix: "%", higherBetter: true },
      { label: "Shots", home: hShots.length, away: aShots.length, higherBetter: true },
      { label: "Shots on Target", home: countType(hEvts, [15, 16]), away: countType(aEvts, [15, 16]), higherBetter: true },
      { label: "Passes", home: hPass, away: aPass, higherBetter: true },
      { label: "Pass Accuracy", home: pct(hPassOk, hPass), away: pct(aPassOk, aPass), suffix: "%", higherBetter: true },
      { label: "Tackles", home: countType(hEvts, [7]), away: countType(aEvts, [7]), higherBetter: true },
      { label: "Interceptions", home: countType(hEvts, [8]), away: countType(aEvts, [8]), higherBetter: true },
      { label: "Clearances", home: countType(hEvts, [12]), away: countType(aEvts, [12]) },
      { label: "Fouls", home: countType(hEvts, [4]), away: countType(aEvts, [4]), higherBetter: false },
      { label: "Aerials Won", home: countTypeOk(hEvts, [44]), away: countTypeOk(aEvts, [44]), higherBetter: true },
      { label: "Ball Recoveries", home: countType(hEvts, [49, 80]), away: countType(aEvts, [49, 80]), higherBetter: true },
    ]
  }

  // ── Render: centre-out proportional bars per stat, team identity in the
  // header (crest for clubs, flag for internationals), category chips.
  const CAT_OF = {
    "Goals": "Attack", "xG": "Attack", "Shots": "Attack", "Shots on Target": "Attack", "Big Chances": "Attack",
    "Possession %": "Control", "Passes": "Control", "Pass Accuracy": "Control", "Ball Recoveries": "Control",
    "Tackles": "Defence", "Interceptions": "Defence", "Clearances": "Defence", "Duels Won": "Defence",
    "Aerials Won": "Defence", "Fouls": "Defence",
  }
  const homeName = statsHomeTeam || (matchChains[0]?.home_team) || paramHome
  const awayName = statsAwayTeam || (matchChains[0]?.away_team) || paramAway
  const teamMark = (team) => {
    if (paramLeague === "WC" && window.wcMaps) return window.wcMaps.flagImg(team, "ms-team-mark")
    const crest = window.footballMaps.teamCrest && window.footballMaps.teamCrest(team)
    return crest ? `<img class="ms-team-mark" src="${statsEsc(crest)}" alt="" loading="lazy" onerror="this.style.display='none'">` : ""
  }

  const el = document.createElement("div")
  el.className = "ms-compare"
  el.innerHTML = `
    <div class="ms-head">
      <span class="ms-team ms-team-home">${teamMark(homeName)}<span>${statsEsc(homeName)}</span></span>
      <span class="ms-cats"></span>
      <span class="ms-team ms-team-away"><span>${statsEsc(awayName)}</span>${teamMark(awayName)}</span>
    </div>
    <div class="ms-rows"></div>`

  const rowsEl = el.querySelector(".ms-rows")
  const renderRows = (cat) => {
    rowsEl.textContent = ""
    for (const m of metrics) {
      if (cat !== "All" && CAT_OF[m.label] !== cat) continue
      const h = parseFloat(m.home), a = parseFloat(m.away)
      const tot = (h || 0) + (a || 0)
      const hShare = tot > 0 ? (h / tot) * 100 : 0
      const aShare = tot > 0 ? (a / tot) * 100 : 0
      const homeWins = m.higherBetter !== undefined ? (m.higherBetter ? h > a : h < a) : false
      const awayWins = m.higherBetter !== undefined ? (m.higherBetter ? a > h : a < h) : false
      const row = document.createElement("div")
      row.className = "ms-row"
      row.innerHTML = `
        <span class="ms-val${homeWins ? " ms-leads" : ""}">${statsEsc(String(m.home))}${m.suffix || ""}</span>
        <div class="ms-mid">
          <div class="ms-label">${statsEsc(m.label)}</div>
          <div class="ms-bars">
            <div class="ms-bar-half ms-left"><div class="ms-bar ms-bar-home${homeWins ? " ms-bar-leads" : ""}" style="width:${hShare}%"></div></div>
            <div class="ms-bar-half ms-right"><div class="ms-bar ms-bar-away${awayWins ? " ms-bar-leads" : ""}" style="width:${aShare}%"></div></div>
          </div>
        </div>
        <span class="ms-val${awayWins ? " ms-leads" : ""}">${statsEsc(String(m.away))}${m.suffix || ""}</span>`
      rowsEl.appendChild(row)
    }
  }

  const catsEl = el.querySelector(".ms-cats")
  for (const cat of ["All", "Attack", "Control", "Defence"]) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (cat === "All" ? " active" : "")
    btn.textContent = cat
    btn.addEventListener("click", () => {
      catsEl.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      renderRows(cat)
    })
    catsEl.appendChild(btn)
  }
  renderRows("All")
  return el
}
Show code
// Shot map uses the same match shots as the xG timeline
matchShots = {
  if (!xgShots || xgShots.length === 0) return []
  // xgShots already filtered to this match from matchShotsRaw
  return xgShots.filter(d => [13, 14, 15, 16].includes(d.type_id))
}
Show code
// Squad comparison heading
{
  if (!paramHome || !paramAway) return html``
  if (homeRoster.length === 0 || awayRoster.length === 0) return html``
  return html`<h2>Squad Comparison</h2>`
}
Show code
// Team stat cards — aggregate panna/offense/defense for top 11 players each
{
  if (!paramHome || !paramAway) return html``
  if (homeRoster.length === 0 || awayRoster.length === 0) {
    if (homeRoster.length === 0 && awayRoster.length === 0) return html``
    return html`<p class="text-muted">Ratings comparison not available — team not yet rated.</p>`
  }

  const agg = (roster, key) => {
    const top = roster.slice(0, 11)
    if (top.length === 0) return null
    const sum = top.reduce((s, d) => s + (d[key] ?? 0), 0)
    return sum
  }

  const homePanna = agg(homeRoster, "panna")
  const awayPanna = agg(awayRoster, "panna")
  const homeOff = agg(homeRoster, "offense")
  const awayOff = agg(awayRoster, "offense")
  const homeDef = agg(homeRoster, "defense")
  const awayDef = agg(awayRoster, "defense")

  const fmt = (v) => v != null ? v.toFixed(2) : "—"

  const stats = [
    { label: statsEsc(paramHome), values: [fmt(homePanna), fmt(homeOff), fmt(homeDef)] },
    { label: "Metric", values: ["Panna", "Offense", "Defense"] },
    { label: statsEsc(paramAway), values: [fmt(awayPanna), fmt(awayOff), fmt(awayDef)] }
  ]

  const better = (h, a, lowerIsBetter) => {
    if (h == null || a == null) return ""
    if (lowerIsBetter) return h < a ? "home-better" : a < h ? "away-better" : ""
    return h > a ? "home-better" : a > h ? "away-better" : ""
  }

  return html`<div class="team-comparison-table">
    <table class="comparison-tbl">
      <thead>
        <tr>
          <th class="comp-home">${statsEsc(paramHome)}</th>
          <th class="comp-metric">Top 11</th>
          <th class="comp-away">${statsEsc(paramAway)}</th>
        </tr>
      </thead>
      <tbody>
        <tr class="${better(homePanna, awayPanna)}">
          <td class="comp-home">${fmt(homePanna)}</td>
          <td class="comp-metric">Panna</td>
          <td class="comp-away">${fmt(awayPanna)}</td>
        </tr>
        <tr class="${better(homeOff, awayOff)}">
          <td class="comp-home">${fmt(homeOff)}</td>
          <td class="comp-metric">Offense</td>
          <td class="comp-away">${fmt(awayOff)}</td>
        </tr>
        <tr class="${better(homeDef, awayDef, true)}">
          <td class="comp-home">${fmt(homeDef)}</td>
          <td class="comp-metric">Defense</td>
          <td class="comp-away">${fmt(awayDef)}</td>
        </tr>
      </tbody>
    </table>
  </div>`
}
Show code
// Panna pre-match ratings — shown ONLY when there's no match-play data from any source.
// Per-player match stats (both parquet-backed and live-chain-aggregated) are rendered by
// the Player Match Stats cell below, so this cell defers to it whenever any data is present.
{
  if (!paramHome || !paramAway) return html``

  // Any data source that means "the match has been played / is being played" → defer
  const hasMatchData = (matchStats && matchStats.length > 0) || matchChains.length > 0
  if (hasMatchData) return html``

  // For upcoming fixtures (no match data yet), show Panna ratings
  if (homeRoster.length === 0 && awayRoster.length === 0) {
    return html`<p class="text-muted">No player ratings available for these teams.</p>`
  }

  const tableCfg = (teamName) => ({
    columns: ["player_name", "position", "panna", "offense", "defense"],
    header: {
      player_name: "Player",
      position: "Pos",
      panna: "Panna",
      offense: "Off",
      defense: "Def"
    },
    format: {
      panna: x => x?.toFixed(3) ?? "",
      offense: x => x?.toFixed(3) ?? "",
      defense: x => x?.toFixed(3) ?? ""
    },
    render: {
      player_name: (v) => {
        const href = `player.html#name=${encodeURIComponent(v)}`
        return `<a href="${href}" class="player-link" title="${statsEsc(v)}"><strong>${statsEsc(v)}</strong></a>`
      },
      position: (v) => {
        const info = footballPosColors[v] || { a: String(v || "").substring(0, 3), c: "#9ca3af" }
        return `<span class="pos-badge" style="background:${info.c}18;color:${info.c};border:1px solid ${info.c}35">${info.a}</span>`
      }
    },
    heatmap: { panna: "high-good" },
    sort: "panna",
    reverse: true,
    rows: 15
  })

  const el = document.createElement("div")
  el.className = "match-squad-grid"

  for (const [roster, name] of [[homeRoster, paramHome], [awayRoster, paramAway]]) {
    const col = document.createElement("div")
    col.className = "match-squad-col"
    const title = document.createElement("div")
    title.className = "squad-team-name"
    title.textContent = name
    col.appendChild(title)
    if (roster.length > 0) {
      col.appendChild(statsTable(roster, tableCfg(name)))
    } else {
      const p = document.createElement("p")
      p.className = "text-muted"
      p.textContent = "No rated players found."
      col.appendChild(p)
    }
    el.appendChild(col)
  }
  return el
}
Show code
matchStats = {
  if (!matchStatsRaw || !paramHome || !paramAway || !paramLeague) return null

  // Primary path — strict match_id lookup via predictions.
  if (match && match.match_id) {
    const strict = matchStatsRaw.filter(d => d.match_id === match.match_id)
    if (strict.length > 0) return strict
  }

  // Team name matching: use all significant words (not just the first)
  const homeLower = paramHome.toLowerCase()
  const awayLower = paramAway.toLowerCase()
  // Split into words, drop common suffixes like "fc" for matching
  const stopWords = new Set(["fc", "cf", "sc", "ac", "afc", "united", "city", "real"])
  const homeWords = homeLower.split(/\s+/).filter(w => w.length > 2)
  const awayWords = awayLower.split(/\s+/).filter(w => w.length > 2)

  function teamMatches(opta, words) {
    const tn = opta.toLowerCase()
    // Best: full name match
    if (tn === words.join(" ")) return 3
    // Good: all non-stop words present in opta name
    const sigWords = words.filter(w => !stopWords.has(w))
    if (sigWords.length > 0 && sigWords.every(w => tn.includes(w))) return 2
    // Fallback: any non-stop word matches (but only if team name also contains it)
    if (sigWords.some(w => tn.includes(w))) return 1
    return 0
  }

  // Find matches that have BOTH teams — group by match_id
  const matchTeams = new Map() // match_id → Set of team_names
  for (const d of matchStatsRaw) {
    if (d.league !== paramLeague || !d.team_name) continue
    if (!matchTeams.has(d.match_id)) matchTeams.set(d.match_id, new Set())
    matchTeams.get(d.match_id).add(d.team_name)
  }

  // Score each match by how well its teams match our params
  let bestMatch = null
  let bestScore = 0
  const targetDate = paramDate || ""

  for (const [mid, teams] of matchTeams) {
    const teamArr = [...teams]
    let homeScore = 0, awayScore = 0
    for (const t of teamArr) {
      const hs = teamMatches(t, homeWords)
      const as = teamMatches(t, awayWords)
      if (hs > homeScore) homeScore = hs
      if (as > awayScore) awayScore = as
    }
    if (homeScore === 0 || awayScore === 0) continue

    let score = homeScore + awayScore
    // Bonus for date match
    if (targetDate) {
      const sample = matchStatsRaw.find(d => d.match_id === mid)
      const rowDate = String(sample?.match_date || "").replace("Z", "").slice(0, 10)
      if (rowDate === targetDate) score += 10
    }
    if (score > bestScore) { bestScore = score; bestMatch = mid }
  }

  // Only show stats if we matched on date — otherwise we'd show data from a different fixture
  if (!bestMatch || bestScore < 10) return null
  return matchStatsRaw.filter(d => d.match_id === bestMatch)
}

// Get team names from match stats data
_matchTeamScore = {
  const stopWords = new Set(["fc", "cf", "sc", "ac", "afc", "united", "city", "real"])
  return function(opta, param) {
    const tn = opta.toLowerCase()
    const pn = param.toLowerCase()
    if (tn === pn) return 3
    const words = pn.split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w))
    if (words.length > 0 && words.every(w => tn.includes(w))) return 2
    if (words.some(w => tn.includes(w))) return 1
    return 0
  }
}
statsHomeTeam = {
  if (!matchStats) return paramHome
  const teams = [...new Set(matchStats.map(d => d.team_name))]
  let best = null, bestScore = 0
  for (const t of teams) {
    const s = _matchTeamScore(t, paramHome)
    if (s > bestScore) { best = t; bestScore = s }
  }
  return best || teams[0] || paramHome
}
statsAwayTeam = {
  if (!matchStats) return paramAway
  const teams = [...new Set(matchStats.map(d => d.team_name))]
  let best = null, bestScore = 0
  for (const t of teams) {
    if (t === statsHomeTeam) continue
    const s = _matchTeamScore(t, paramAway)
    if (s > bestScore) { best = t; bestScore = s }
  }
  return best || teams.find(t => t !== statsHomeTeam) || paramAway
}

Player Match Stats

Show code
// Player-level match stats with category toggle.
// Uses parquet matchStats when available, falls back to live event aggregation from
// matchChains. This cell is the single source of truth for rendering per-player match
// data — the Panna pre-match ratings cell above defers to this one whenever any match
// data exists from either source.
{
  if (!paramHome || !paramAway) return html``

  // Parquet box scores are whole-match; for a specific phase, use the chain
  // path (period_id-aware) so the per-player table filters to that phase too.
  let useParquet = matchStats && matchStats.length > 0 && matchPhase === "All"

  // ── Live per-player WPA from SCORE-STATE win probability ────────────────
  // footballLiveWp prices time + scoreline + league with NO possession term.
  // The worker's per-event wpa inherits the WP model's ~8pp possession
  // premium (its is_home feature — panna#92), so every turnover swung the
  // players involved by ±8% and a 0-0 at 3' displayed ±25% "value" with
  // nothing having happened. Score-state deltas move only with goals and the
  // clock. Event-level credit: each non-goal event contributes its worker
  // EPV credit (epv_disp + epv_recv — REAL threat changes; equity carries no
  // possession premium) converted to WP units via "what a goal is worth at
  // this game state"; goal rows realize the actual score-state jump on the
  // scorer. This keeps per-action WPA (passes, tackles, chances, turnovers
  // all move it) while killing the flat ~8pp possession-flip swing the
  // worker's raw per-event wpa carries (its WP model prices possession via
  // is_home — panna#92). Finished matches with game-logs coverage still
  // override with panna's batch values below; the worker per-event wpa
  // remains the fallback when win-prob.js is absent.
  const neutralWpa = (() => {
    const lwp = window.footballLiveWp
    if (!lwp) {
      // Falling back to the worker per-event WPA — the numbers this change
      // exists to replace. Loud, because the fallback looks plausible.
      if (matchChains.length > 0) console.warn("[match] win-prob.js not loaded — falling back to worker per-event WPA (possession-noise scale)")
      return null
    }
    if (matchChains.length === 0) return null
    const rows = matchChains.filter(e => typeof e.wp === "number" && e.minute != null && _matchPhasePred(e))
    if (rows.length < 2) return null
    if (typeof rows[0].home_score !== "number") {
      // Expected on the chains-parquet path (no score columns; batch override
      // handles finished matches) — a worker regression on LIVE rows is not.
      if (isLiveData) console.warn("[match] live rows missing home_score — score-state WPA unavailable, check worker attachFootballScores")
      return null
    }
    const eh = (e) => {
      const p = lwp(Math.max(1, e.minute), e.home_score || 0, e.away_score || 0, paramLeague)
      return p.home + 0.5 * p.draw
    }
    // EPV credit is computed independently of wp/scores in the worker — an
    // EPV-pipeline outage ships rows that pass every gate above but carry no
    // epv_disp/epv_recv, degrading the hybrid to goals-only WPA that renders
    // exactly like a quiet game. Say so.
    const hasEpvCredit = rows.some(e => typeof e.epv_disp === "number" || typeof e.epv_recv === "number")
    if (!hasEpvCredit && isLiveData) {
      console.warn("[match] live rows carry wp but no epv_disp/epv_recv (epv_status:", _liveEvents?.epv_status, ") — WPA degraded to goals-only")
    }
    const out = new Map()
    const credit = (name, v) => { if (name) out.set(name, (out.get(name) || 0) + v) }
    // "What is a goal worth right now" in win-probability terms, from the
    // actor's side — converts per-event EPV threat credit into WP units, so
    // a chance in a tight 85th minute is worth more than one at 0-3.
    const goalWorth = (e) => {
      const h = e.home_score || 0, a = e.away_score || 0, m = Math.max(1, e.minute)
      const cur = lwp(m, h, a, paramLeague)
      const plus = e.is_home ? lwp(m, h + 1, a, paramLeague) : lwp(m, h, a + 1, paramLeague)
      const ehCur = cur.home + 0.5 * cur.draw, ehPlus = plus.home + 0.5 * plus.draw
      return Math.abs(ehPlus - ehCur)
    }
    // NB: the final wp-carrying row is never credited (no next row to diff
    // against) — a just-scored goal on a live poll shows 0 for the scorer
    // until the next poll's restart events arrive. Transient by design;
    // finished matches anchor via the batch override anyway.
    let prevEh = eh(rows[0])
    for (let i = 0; i < rows.length - 1; i++) {
      const e = rows[i]
      const nextEh = eh(rows[i + 1])
      const isGoal = e.type_id === 16 && (e.period_id == null || e.period_id < 5)
      if (isGoal) {
        // Realization: the actual score-state WP jump lands on the goal row's
        // actor (negative on an own-goal scorer via the actor-side sign). The
        // threat component is skipped here — the jump IS the realized threat,
        // and the build-up chain already earned its share below.
        credit(e.player_name, e.is_home ? (nextEh - prevEh) : -(nextEh - prevEh))
      } else if (typeof e.epv_disp === "number" || typeof e.epv_recv === "number") {
        // Event-level threat: the worker's per-event EPV credit (real threat
        // changes — equity carries no possession premium, unlike the WP
        // model's is_home feature, panna#92) converted to WP units at the
        // live game state. Passes, tackles, chances created and turnovers
        // all move WPA by what they changed, not by a flat ~8pp possession
        // swing.
        credit(e.player_name, ((e.epv_disp || 0) + (e.epv_recv || 0)) * goalWorth(e))
      }
      prevEh = nextEh
    }
    for (const [k, v] of out) out.set(k, Math.round(v * 10000) / 10000)
    return out
  })()

  let allPlayerStats = []
  if (useParquet) {
    allPlayerStats = matchStats
  } else if (matchChains.length > 0) {
    // Build player stats from chain/event data
    const byPlayer = new Map()
    // WPA per event: possession-POV wp delta from prev event, credited 100% to
    // the acting player. Unlike AFL chains (where a disposal is followed by a
    // separate reception row and 50/50 disp/recv applies), football events are
    // atomic per-actor — the pass and its receipt are distinct events — so each
    // event's full wp shift belongs to its actor. Sign handling mirrors AFL:
    // same-POV → delta as-is; opposite-POV → 1 - next (flip to current frame).
    const hasWp = matchChains.some(e => typeof e.wp === "number")
    for (let i = 0; i < matchChains.length; i++) {
      const e = matchChains[i]
      if (!e.player_name) continue
      // Restrict to the selected phase (default "All" = Reg + ET, pens excluded).
      if (!_matchPhasePred(e)) continue
      const key = e.player_name
      if (!byPlayer.has(key)) {
        byPlayer.set(key, {
          player_name: e.player_name, player_id: e.player_id, team_name: e.team_name,
          involvements: 0, passes: 0, passes_accurate: 0, key_passes: 0,
          shots: 0, shots_on_target: 0, goals: 0, assists: 0,
          tackles: 0, tackles_won: 0, interceptions: 0, clearances: 0,
          aerials_won: 0, aerials_lost: 0, duels_won: 0, duels_lost: 0,
          fouls: 0, was_fouled: 0, ball_recoveries: 0, take_ons: 0,
          take_ons_won: 0, touches: 0, saves: 0,
          big_chances_created: 0, minsPlayed: 0,
          equity: 0, epv_delta: 0,
          wpa_total: hasWp ? 0 : null,
        })
      }
      const p = byPlayer.get(key)
      p.involvements++
      const tid = e.type_id
      const ok = e.outcome === 1
      if (tid === 1 || tid === 2) { p.passes++; if (ok) p.passes_accurate++ }
      if (tid === 13 || tid === 14 || tid === 15 || tid === 16) { p.shots++; if (tid === 15 || tid === 16) p.shots_on_target++ }
      // Own goals (type 16 at x<50, the site-wide detector) are not the
      // scorer's goal — same rule as the xG totals, scorecard, and the
      // lineup hover card, which this count must agree with.
      if (tid === 16 && !(e.x != null && e.x < 50)) p.goals++
      if (tid === 7) { p.tackles++; if (ok) p.tackles_won++ }
      if (tid === 8) p.interceptions++
      if (tid === 12) p.clearances++
      if (tid === 44) { if (ok) { p.aerials_won++; p.duels_won++ } else { p.aerials_lost++; p.duels_lost++ } }
      if (tid === 45) { if (ok) p.duels_won++; else p.duels_lost++ }
      if (tid === 4) p.fouls++
      if (tid === 49 || tid === 80) p.ball_recoveries++
      if (tid === 3) { p.take_ons++; if (ok) p.take_ons_won++ }
      if (tid === 61 || tid === 58) p.touches++
      if (tid === 10 || tid === 11) p.saves++
      if (tid === 83 && ok) p.key_passes++ // successful cross as key pass proxy
      if (e.equity != null) p.equity += e.equity
      // EPV credit: prefer worker-supplied epv_disp + epv_recv (AFL-pattern
      // parity: cross-team-flipped delta_ep, 50/50 disp/recv split). Falls
      // back to worker's epv_delta (legacy live-events shape) or chain-derived
      // path in valueByPlayer block below.
      if (typeof e.epv_disp === "number" || typeof e.epv_recv === "number") {
        p.epv_delta += (e.epv_disp || 0) + (e.epv_recv || 0)
      } else if (e.epv_delta != null) {
        p.epv_delta += e.epv_delta
      } else if (e.equity != null) {
        // Chains `equity` = panna per-action EPV CREDIT (player_credit), not an
        // EPV state — sum it (same as p.equity above), never diff it.
        p.epv_delta += e.equity
      }
      // WPA: prefer worker-supplied wpa_actor + wpa_receiver (panna-pipeline
      // parity for finished matches: sign-flipped, outcome-anchored, mean-
      // centred, 50/50 actor/receiver split). Falls back to same-frame delta
      // only when the worker didn't ship them.
      if (typeof e.wpa_actor === "number" || typeof e.wpa_receiver === "number") {
        p.wpa_total += (e.wpa_actor || 0) + (e.wpa_receiver || 0)
      } else if (hasWp && typeof e.wp === "number") {
        let next = null
        for (let k = i + 1; k < matchChains.length; k++) {
          if (typeof matchChains[k].wp === "number") { next = matchChains[k]; break }
        }
        if (next && e.is_home === next.is_home) {
          p.wpa_total += (next.wp - e.wp)
        }
      }
    }
    // Round WPA accumulator; score-state WPA replaces the possession-noise
    // worker accumulation wholesale when win-prob.js is available.
    for (const p of byPlayer.values()) {
      if (neutralWpa) p.wpa_total = neutralWpa.get(p.player_name) ?? 0
      else if (typeof p.wpa_total === "number") p.wpa_total = Math.round(p.wpa_total * 10000) / 10000
    }
    // Estimate minutes from event timestamps
    const maxMin = matchChains.reduce((m, e) => Math.max(m, e.minute || 0), 0)
    for (const p of byPlayer.values()) p.minsPlayed = maxMin
    allPlayerStats = [...byPlayer.values()]
    console.log("[match] Built live player stats from", matchChains.length, "events for", allPlayerStats.length, "players")
  }

  if (allPlayerStats.length === 0) return html``

  // Always derive per-player Value metrics from matchChains when available —
  // even on completed matches where the parquet branch populated allPlayerStats
  // above. matchStats is box-score only; EPV/WPA aggregation lives on chain
  // events. We layer the values on by player_name after the base rows exist.
  //
  // Live-events path supplies r.epv_disp/r.epv_recv (or legacy r.epv_delta) and
  // r.wp per row (worker-scored). Parquet-chain path carries only r.equity =
  // panna's per-action EPV CREDIT (player_credit, already a signed per-event
  // value) — so it is SUMMED, not diffed. (It is NOT an EPV state; diffing it
  // would be a delta-of-a-delta. For finished matches the exact batch epv_total
  // from game-logs overrides this below anyway.)
  if (matchChains.length > 0) {
    const hasWp = matchChains.some(e => typeof e.wp === "number")
    const hasEquity = matchChains.some(e => typeof e.equity === "number")
    const valueByPlayer = new Map()
    for (let i = 0; i < matchChains.length; i++) {
      const e = matchChains[i]
      if (!e.player_name) continue
      // Restrict per-player EPV/WPA to the selected phase (default Reg + ET).
      if (!_matchPhasePred(e)) continue
      let v = valueByPlayer.get(e.player_name)
      if (!v) {
        v = { epv_delta: 0, wpa_total: hasWp ? 0 : null, involvements: 0, xg: 0, xa: 0, assists_derived: 0 }
        valueByPlayer.set(e.player_name, v)
      }
      v.involvements++
      // Per-player xG, and xA via _findAssistIdx — the last same-team
      // chance-creating action (Pete's broad assist #287: pass / won duel /
      // rebound / deliberate touch within 25s), since neither the chains parquet
      // nor the live rows carry the Opta assist qualifier. Use the SAME
      // per-shot xG the shot map + timeline use — the worker's model `xg` when
      // present, coordinate estimate otherwise — NOT `equity` (which is the EPV
      // state on non-shot rows, and gave this table a different number than the
      // shot map). Own goals (x<50) are excluded — their geometry produces
      // nonsense xG and nobody assists an OG.
      const _isShot = e.type_id === 13 || e.type_id === 14 || e.type_id === 15 || e.type_id === 16
      if (_isShot && !(e.type_id === 16 && e.x != null && e.x < 50)) {
        const sxg = Number.isFinite(e.xg) ? e.xg : _estimateXg(e.x, e.y)
        v.xg += sxg
        const aIdx = _findAssistIdx(matchChains, i)
        if (aIdx >= 0) {
          const pr = matchChains[aIdx]
          let pv = valueByPlayer.get(pr.player_name)
          if (!pv) {
            pv = { epv_delta: 0, wpa_total: hasWp ? 0 : null, involvements: 0, xg: 0, xa: 0, assists_derived: 0 }
            valueByPlayer.set(pr.player_name, pv)
          }
          pv.xa += sxg
          if (e.type_id === 16) pv.assists_derived++
        }
      }
      // Prefer worker-supplied epv_disp + epv_recv (AFL-pattern parity:
      // delta_ep is cross-team-flipped, 50/50 split with sign-flip on recv).
      // Falls back to worker epv_delta, then to summing chains `equity` (the
      // per-action EPV credit) when the worker didn't ship per-event credit.
      if (typeof e.epv_disp === "number" || typeof e.epv_recv === "number") {
        v.epv_delta += (e.epv_disp || 0) + (e.epv_recv || 0)
      } else if (typeof e.epv_delta === "number") {
        v.epv_delta += e.epv_delta
      } else if (hasEquity && typeof e.equity === "number") {
        // Chains `equity` is panna's per-action EPV CREDIT (player_credit:
        // signed, centred ~0, ±1 range) — NOT an EPV state value. So SUM it,
        // like any other per-event credit. Earlier code diffed consecutive
        // equity (next.equity - e.equity) on the assumption it was a state, but
        // that produced a delta-of-a-delta ≈ noise. Validated: corr(Σ equity,
        // game-logs epv_total) = 0.95; diffing gives ~0. (pannaverse confirmed
        // the column holds credit, not state, 2026-06-03.)
        v.epv_delta += e.equity
      }
      // Prefer worker-supplied wpa_actor + wpa_receiver — see WPA comment
      // in byPlayer aggregation above. Fall back to same-frame delta.
      if (typeof e.wpa_actor === "number" || typeof e.wpa_receiver === "number") {
        v.wpa_total += (e.wpa_actor || 0) + (e.wpa_receiver || 0)
      } else if (hasWp && typeof e.wp === "number") {
        let next = null
        for (let k = i + 1; k < matchChains.length; k++) {
          if (typeof matchChains[k].wp === "number") { next = matchChains[k]; break }
        }
        if (next && e.is_home === next.is_home) {
          v.wpa_total += (next.wp - e.wp)
        }
      }
    }
    for (const v of valueByPlayer.values()) {
      v.epv_delta = Math.round(v.epv_delta * 1000) / 1000
      if (typeof v.wpa_total === "number") v.wpa_total = Math.round(v.wpa_total * 10000) / 10000
    }
    // Only overwrite when the base source genuinely has no value. Once
    // pannadata starts emitting per-event epv_delta on the chain parquet, real
    // zeros must not be clobbered by chain-derived estimates.
    for (const p of allPlayerStats) {
      const v = valueByPlayer.get(p.player_name)
      if (!v) continue
      if (p.epv_delta == null) p.epv_delta = v.epv_delta
      if (p.wpa_total == null) p.wpa_total = neutralWpa ? (neutralWpa.get(p.player_name) ?? 0) : v.wpa_total
      if (p.involvements == null) p.involvements = v.involvements
      p.xg = Math.round(v.xg * 100) / 100
      p.xa = Math.round(v.xa * 100) / 100
      // Parquet box scores carry real Opta assists; the chain paths can't,
      // so the derived final-pass count fills in for live/chain-built rows.
      if (!useParquet) p.assists = v.assists_derived
    }
  }

  // For FINISHED matches in the default (full-match) phase view, override
  // per-player WPA with the batch game-logs value (the exact figure the season
  // Value tab shows). The worker's live computation over-credits ~2× — it scores
  // raw Opta events without SPADL receiver attribution — so the match page and
  // season tab disagreed. game-logs only exists once a match is built, so its
  // presence means the match is final; live matches keep the worker value.
  // Skipped for RT/ET/Pens phase views, where game-logs (a whole-match total)
  // doesn't apply and the phase-summed worker WPA is the only meaningful figure.
  if (matchPhase === "All" && _matchGameLogs && _matchGameLogs.size > 0) {
    let matched = 0
    for (const p of allPlayerStats) {
      const g = _matchGameLogs.get(p.player_name)
      if (g && typeof g.wpa_total === "number") { p.wpa_total = g.wpa_total; matched++ }
      // EPV column: same deal — show the exact batch epv_total the season Value
      // tab shows. The chain-derived Σequity above (corr 0.95 with epv_total) is
      // the live/fallback path; once a match is built, prefer the batch figure.
      if (g && typeof g.epv_total === "number") p.epv_delta = g.epv_total
    }
    // Override is keyed on exact player_name. If chains↔game-logs name drift
    // leaves some players unmatched, they keep the (un-reconciled) live worker
    // WPA/EPV while teammates show the batch value — surface that rather than
    // silently mixing two scales in one column.
    if (matched < _matchGameLogs.size) {
      console.warn(`[match] batch WPA matched ${matched}/${_matchGameLogs.size} game-logs players — name drift may leave some rows on the live value`)
    }
  }

  const homeTeamName = useParquet ? statsHomeTeam : (matchChains[0]?.home_team || paramHome)
  const awayTeamName = useParquet ? statsAwayTeam : (matchChains[0]?.away_team || paramAway)

  // Full lineups: players named in the Opta team-setup event but with no
  // recorded events yet (unused subs, anyone early in a live match) get
  // zero rows so the whole squad is visible from kickoff. Names prefer the
  // feed's matchName (worker matchstats merge); ratings.parquet is the
  // fallback for older workers whose lineups carry ids only — there,
  // players outside panna's tracked leagues stay hidden until their first
  // event creates a named row.
  if (_liveEvents?.lineups && allPlayerStats.length > 0 && ratings) {
    const seen = new Set(allPlayerStats.map(p => p.player_id).filter(Boolean))
    const nameById = new Map()
    for (const r of ratings) if (r.player_id && !nameById.has(r.player_id)) nameById.set(r.player_id, r.player_name)
    const lineupMaxMin = matchChains.reduce((m, e) => Math.max(m, e.minute || 0), 0)
    const zeroRow = (name, pid, teamName, starter) => ({
      player_name: name, player_id: pid, team_name: teamName,
      minsPlayed: starter ? lineupMaxMin : 0,
      involvements: 0, passes: 0, passes_accurate: 0, key_passes: 0,
      shots: 0, shots_on_target: 0, goals: 0, assists: 0,
      tackles: 0, tackles_won: 0, interceptions: 0, clearances: 0,
      aerials_won: 0, aerials_lost: 0, duels_won: 0, duels_lost: 0,
      fouls: 0, was_fouled: 0, ball_recoveries: 0, take_ons: 0,
      take_ons_won: 0, touches: 0, saves: 0, big_chances_created: 0,
      equity: 0, epv_delta: null, wpa_total: null, xg: 0, xa: 0,
    })
    let attempted = 0, added = 0
    for (const [side, teamName] of [["home", homeTeamName], ["away", awayTeamName]]) {
      for (const lp of (_liveEvents.lineups[side] || [])) {
        if (!lp.player_id || seen.has(lp.player_id)) continue
        attempted++
        const nm = lp.name || nameById.get(lp.player_id)
        if (!nm) continue
        added++
        allPlayerStats.push(zeroRow(nm, lp.player_id, teamName, !!lp.starter))
      }
    }
    // Per-player misses are expected (untracked leagues, old worker); a
    // TOTAL miss means both name sources failed (precedent: the shot-map
    // headshot id mismatch, #228) — a permanently dead feature with no other
    // signal. Note: a name-shipping worker masks ratings.parquet id-drift
    // here; the headshot path is the remaining canary for that.
    if (attempted >= 5 && added === 0) {
      console.warn("[match] lineups present but 0 of", attempted, "players resolved via feed matchName or ratings.parquet — feed/id drift?")
    }
  }

  const homeStats = allPlayerStats.filter(d => d.team_name === homeTeamName).sort((a, b) => (b.minsPlayed || 0) - (a.minsPlayed || 0) || (b.involvements || 0) - (a.involvements || 0))
  const awayStats = allPlayerStats.filter(d => d.team_name === awayTeamName).sort((a, b) => (b.minsPlayed || 0) - (a.minsPlayed || 0) || (b.involvements || 0) - (a.involvements || 0))

  // Shirt numbers from the lineup feed (worker qualifier 59, keyed by
  // player_id) — the number you see on the broadcast is how you find the
  // player. Empty map until the lineups ship on the live-events response.
  const jerseyById = new Map()
  if (_liveEvents?.lineups) {
    for (const side of ["home", "away"]) {
      for (const lp of (_liveEvents.lineups[side] || [])) {
        if (lp.player_id && lp.jersey) jerseyById.set(lp.player_id, lp.jersey)
      }
    }
  }

  // Player cell: shirt number + headshot (initial-circle fallback behind the
  // img, revealed when the photo 404s — same convention as
  // world-cup-player-ratings) + the match-events deep link. One renderer
  // shared by every category tab.
  const playerRender = (v, r) => {
    const optaParam = _optaId ? `&optaId=${encodeURIComponent(_optaId)}` : ""
    const eventsHref = `match-events/#league=${encodeURIComponent(paramLeague)}&date=${encodeURIComponent(paramDate)}&player=${encodeURIComponent(v)}${optaParam}`
    const lbl = `View ${v}'s match events`
    const initial = statsEsc((v || "?").trim().charAt(0).toUpperCase())
    const pid = r && r.player_id
    const img = pid
      ? `<img class="mp-hs-img" src="${base_url}football/headshots/${statsEsc(pid)}.webp" alt="" loading="lazy" onerror="this.style.display='none'">`
      : ""
    const hs = `<span class="mp-hs-wrap"><span class="mp-hs-init">${initial}</span>${img}</span>`
    const jersey = pid && jerseyById.get(pid)
    const num = `<span class="mp-shirt">${jersey ? statsEsc(String(jersey)) : ""}</span>`
    // Game-position pill (LB / CM / RW …) from the Opta lineup's position +
    // positionSide via classifyRole — where they lined up THIS match, not
    // their career listed position. Absent (no pill) for ALL subs — the feed
    // keeps them as "Substitute" even after they come on — and on matches
    // the worker hasn't enriched.
    const role = pid && _roleById.get(pid)
    const pill = role ? ` <span class="mp-pos" style="background:${window.footballMaps?.pannaBadge?.[role] || "#9ca3af"}">${statsEsc(role)}</span>` : ""
    return `${num}${hs}<a href="${eventsHref}" class="player-link" title="${statsEsc(lbl)}" aria-label="${statsEsc(lbl)}"><strong>${statsEsc(v)}</strong></a>${pill}`
  }

  // Value tab shows whenever we have chain data — even if EPV/WPA happen to
  // sum to exactly zero on this match. Individual EPV/WPA columns are still
  // gated below so a tab with literally-no-signal still gives the user the
  // involvements baseline rather than disappearing entirely (matches the AFL
  // convention — the Value tab is always present once chains are loaded).
  const hasEpv = allPlayerStats.some(p => p.epv_delta != null && p.epv_delta !== 0)
  // Score-state WPA is legitimately ~0 for everyone at 0-0 (nothing has
  // happened) — keep the column visible whenever the computation ran rather
  // than vanishing until the first goal.
  const hasWpa = !!neutralWpa || allPlayerStats.some(p => p.wpa_total != null && p.wpa_total !== 0)
  const hasValueData = matchChains.length > 0 || hasEpv || hasWpa

  // Guard: stats populated but no EPV/WPA → surface notice. Trigger uses `!= null` not `!== 0` so all-zero matches don't false-positive.
  const hasAnyEpv = allPlayerStats.some(p => p.epv_delta != null)
  const hasAnyWpa = allPlayerStats.some(p => p.wpa_total != null)
  let valueDataWarning = null
  if (allPlayerStats.length >= 2 && !hasAnyEpv && !hasAnyWpa) {
    if (matchChains.length === 0) {
      console.warn("[match] Value signal missing: matchStats populated but no chain overlay", {
        paramLeague, paramDate, useParquet, matchStatsLen: allPlayerStats.length
      })
      valueDataWarning = "Per-event values are still publishing — try refreshing in a few minutes."
    } else {
      console.warn("[match] Value signal zero despite chains present", {
        paramLeague, paramDate, chainRows: matchChains.length, useParquet
      })
      valueDataWarning = "Per-event values for this match are unavailable due to incomplete possession data."
    }
  }
  const epFmt = x => x == null ? "" : (x >= 0 ? "+" : "") + x.toFixed(2)
  const wpaFmt = x => x == null ? "" : (x >= 0 ? "+" : "") + (x * 100).toFixed(1) + "%"

  const hasXg = allPlayerStats.some(p => p.xg != null)
  const valueCols = ["player_name", "minsPlayed", "involvements", "goals"]
  if (hasXg) valueCols.push("xg")
  valueCols.push("assists")
  if (hasXg) valueCols.push("xa")
  if (hasEpv) valueCols.push("epv_delta")
  if (hasWpa) valueCols.push("wpa_total")
  const valueHeatmap = { goals: "high-good", assists: "high-good" }
  if (hasXg) { valueHeatmap.xg = "high-good"; valueHeatmap.xa = "high-good" }
  if (hasEpv) valueHeatmap.epv_delta = "diverging"
  if (hasWpa) valueHeatmap.wpa_total = "diverging"
  const numFmt2 = x => x == null || x === 0 ? "" : x.toFixed(2)
  const valueFormat = { xg: numFmt2, xa: numFmt2 }
  if (hasEpv) valueFormat.epv_delta = epFmt
  if (hasWpa) valueFormat.wpa_total = wpaFmt

  // Every abbreviated column header carries a hover definition (standing
  // rule: stat tables always define their abbreviations via tooltips).
  const baseTips = { minsPlayed: "Minutes played" }
  const categories = {
    Overview: {
      columns: ["player_name", "minsPlayed", "goals", "assists", "shots", "passes_accurate", "tackles", "interceptions"],
      header: { player_name: "Player", minsPlayed: "Min", goals: "G", assists: "A", shots: "Sh", passes_accurate: "Pas", tackles: "Tkl", interceptions: "Int" },
      heatmap: { goals: "high-good", assists: "high-good", shots: "high-good", passes_accurate: "high-good", tackles: "high-good", interceptions: "high-good" },
      tooltip: { ...baseTips, goals: "Goals scored", assists: "Assists", shots: "Total shots", passes_accurate: "Accurate passes", tackles: "Tackles", interceptions: "Interceptions" }
    },
    ...(hasValueData ? {
      Value: {
        columns: valueCols,
        header: { player_name: "Player", minsPlayed: "Min", involvements: "Inv", goals: "G", xg: "xG", assists: "A", xa: "xA", epv_delta: "EPV", wpa_total: "WPA" },
        heatmap: valueHeatmap,
        format: valueFormat,
        tooltip: {
          ...baseTips,
          involvements: "Total event involvements",
          goals: "Goals scored",
          assists: "Assists",
          xg: "Expected goals — sum of this player's shot xG (own goals excluded)",
          xa: "Expected assists — total xG of shots created by this player's last action (pass, won duel, rebound or deliberate touch)",
          epv_delta: "Expected Possession Value — sum of per-event EPV shifts this player was involved in",
          wpa_total: "Win Probability Added — every action's threat change (EPV credit) converted to win probability at the live game state, plus the actual score swing on goals. Finished matches show panna's batch game-logs values where available."
        }
      }
    } : {}),
    Passing: {
      columns: ["player_name", "minsPlayed", "passes", "passes_accurate", "key_passes", "big_chances_created", "touches"],
      header: { player_name: "Player", minsPlayed: "Min", passes: "Pas", passes_accurate: "Acc", key_passes: "Key", big_chances_created: "BigC", touches: "Tch" },
      heatmap: { passes: "high-good", passes_accurate: "high-good", key_passes: "high-good", big_chances_created: "high-good", touches: "high-good" },
      tooltip: { ...baseTips, passes: "Passes attempted", passes_accurate: "Accurate passes", key_passes: "Key passes — passes leading directly to a shot", big_chances_created: "Big chances created", touches: "Touches" }
    },
    Shooting: {
      columns: ["player_name", "minsPlayed", "goals", "shots", "shots_on_target", "assists", "big_chances_created"],
      header: { player_name: "Player", minsPlayed: "Min", goals: "G", shots: "Sh", shots_on_target: "SoT", assists: "A", big_chances_created: "BigC" },
      heatmap: { goals: "high-good", shots: "high-good", shots_on_target: "high-good", assists: "high-good", big_chances_created: "high-good" },
      tooltip: { ...baseTips, goals: "Goals scored", shots: "Total shots", shots_on_target: "Shots on target", assists: "Assists", big_chances_created: "Big chances created" }
    },
    Defending: {
      columns: ["player_name", "minsPlayed", "tackles", "tackles_won", "interceptions", "clearances", "saves"],
      header: { player_name: "Player", minsPlayed: "Min", tackles: "Tkl", tackles_won: "TklW", interceptions: "Int", clearances: "Clr", saves: "Sav" },
      heatmap: { tackles: "high-good", tackles_won: "high-good", interceptions: "high-good", clearances: "high-good", saves: "high-good" },
      tooltip: { ...baseTips, tackles: "Tackles attempted", tackles_won: "Tackles won", interceptions: "Interceptions", clearances: "Clearances", saves: "Goalkeeper saves" }
    },
    Duels: {
      columns: ["player_name", "minsPlayed", "duels_won", "duels_lost", "aerials_won", "aerials_lost", "fouls", "was_fouled"],
      header: { player_name: "Player", minsPlayed: "Min", duels_won: "DW", duels_lost: "DL", aerials_won: "AW", aerials_lost: "AL", fouls: "Fls", was_fouled: "Fld" },
      heatmap: { duels_won: "high-good", aerials_won: "high-good", was_fouled: "high-good", fouls: "low-good" },
      tooltip: { ...baseTips, duels_won: "Duels won (ground + aerial)", duels_lost: "Duels lost", aerials_won: "Aerial duels won", aerials_lost: "Aerial duels lost", fouls: "Fouls committed", was_fouled: "Fouls won (was fouled)" }
    }
  }

  const el = document.createElement("div")

  // Show amber notice above the toggle when Value signal is missing.
  if (valueDataWarning) {
    const banner = document.createElement("div")
    banner.className = "text-muted"
    banner.style.cssText = "margin:0 0 0.5rem;padding:0.5rem 0.75rem;background:rgba(255,193,7,0.08);border-left:3px solid #ffc107;font-size:0.85rem;border-radius:3px"
    banner.textContent = valueDataWarning
    el.appendChild(banner)
  }

  // Toggle buttons
  const toggleRow = document.createElement("div")
  toggleRow.className = "epv-toggle"
  const catNames = Object.keys(categories)
  const btns = []
  for (const cat of catNames) {
    const btn = document.createElement("button")
    btn.textContent = cat
    btn.className = "epv-toggle-btn" + (cat === "Overview" ? " active" : "")
    btns.push({ btn, cat })
    toggleRow.appendChild(btn)
  }
  el.appendChild(toggleRow)

  const tableContainer = document.createElement("div")
  el.appendChild(tableContainer)

  function renderCategory(cat) {
    tableContainer.textContent = ""
    const cfg = categories[cat]
    const tableCfg = {
      ...cfg,
      render: { player_name: playerRender },
      sort: "minsPlayed",
      reverse: true,
      rows: 20
    }

    const grid = document.createElement("div")
    grid.className = "match-squad-grid"

    for (const [teamName, teamStats] of [[homeTeamName, homeStats], [awayTeamName, awayStats]]) {
      const col = document.createElement("div")
      col.className = "match-squad-col"
      const title = document.createElement("div")
      title.className = "squad-team-name"
      // Flag (WC) / crest (club) next to the squad header — same sources as
      // the Match Stats comparison header above.
      const mark = paramLeague === "WC" && window.wcMaps
        ? window.wcMaps.flagImg(teamName, "ms-team-mark")
        : (() => { const c = window.footballMaps.teamCrest && window.footballMaps.teamCrest(teamName); return c ? `<img class="ms-team-mark" src="${statsEsc(c)}" alt="" loading="lazy" onerror="this.style.display='none'">` : "" })()
      title.innerHTML = `${mark}<span>${statsEsc(teamName)}</span>`
      col.appendChild(title)
      if (teamStats.length > 0) {
        col.appendChild(statsTable(teamStats, tableCfg))
      } else {
        const p = document.createElement("p")
        p.className = "text-muted"
        p.textContent = "No match stats available."
        col.appendChild(p)
      }
      grid.appendChild(col)
    }

    tableContainer.appendChild(grid)
  }

  for (const { btn, cat } of btns) {
    btn.addEventListener("click", () => {
      btns.forEach(b => b.btn.classList.remove("active"))
      btn.classList.add("active")
      renderCategory(cat)
    })
  }

  renderCategory("Overview")
  return el
}

Shot Map

Show code
// Shot map visualization — half-pitch with shot positions
{
  if (!paramHome || !paramAway) return html``

  // Use parquet shots or build from live chain data
  let allShots = matchShots && matchShots.length > 0 ? matchShots : null
  // Single source of truth for assists (#287): match-shots.parquet carries no
  // assist column, so when this match's chain events are available we link each
  // shot to its chain row (by player+minute+second+type — the parquet has no
  // event_id, but that composite key matches the chains ~100% in practice) and
  // stamp the SAME broad-rule assist the chain visualizer shows. Keeps "who
  // assisted" identical across the shot map and the chain page.
  if (allShots && matchChains.length > 0) {
    const _ak = (r) => ((r.player_name || "").trim()) + "|" + r.minute + "|" + r.second + "|" + r.type_id
    const _chainShotByKey = new Map()
    for (let i = 0; i < matchChains.length; i++) {
      const t = matchChains[i].type_id
      if (t === 13 || t === 14 || t === 15 || t === 16) _chainShotByKey.set(_ak(matchChains[i]), i)
    }
    allShots = allShots.map(s => {
      if (s.assist_player !== undefined) return s
      const ci = _chainShotByKey.get(_ak(s))
      const aIdx = ci != null ? _findAssistIdx(matchChains, ci) : -1
      return { ...s, assist_player: aIdx >= 0 ? (matchChains[aIdx].player_name ?? matchChains[aIdx].player) : null }
    })
  }
  if (!allShots && matchChains.length > 0) {
    const homeTeam = matchChains[0]?.home_team || paramHome
    allShots = []
    for (let i = 0; i < matchChains.length; i++) {
      const d = matchChains[i]
      if (![13, 14, 15, 16].includes(d.type_id)) continue
      // Assister via the shared final-pass linkage (tooltip row; the parquet
      // path has no assist column, so the row simply doesn't render there)
      const aIdx = _findAssistIdx(matchChains, i)
      allShots.push({
        ...d,
        // Compute xG for live shots from distance+angle so the dot size and data-xg
        // tooltip match the parquet-mode behavior. Number.isFinite (not != null)
        // because match-shots.parquet writes NaN for missing values, and NaN != null
        // is true — would otherwise stamp NaN onto the row and break dot scaling.
        xg: Number.isFinite(d.xg) ? d.xg : _estimateXg(d.x, d.y),
        team_id: d.team_name === homeTeam ? "home" : "away",
        is_home: d.team_name === homeTeam,
        assist_player: aIdx >= 0 ? matchChains[aIdx].player_name : null,
      })
    }
  }
  if (!allShots || allShots.length === 0) {
    return html`<p class="text-muted">No shot data available for this match.</p>`
  }

  // Resolve team names
  const shotHome = statsHomeTeam || (matchChains[0]?.home_team) || paramHome
  const shotAway = statsAwayTeam || (matchChains[0]?.away_team) || paramAway

  // Split shots by team
  const homeShots = allShots.filter(d => d.is_home || d.team_id === xgHomeTeamId || d.team_name === shotHome)
  const awayShots = allShots.filter(d => !homeShots.includes(d))

  // Summary stats
  const homeGoals = homeShots.filter(d => d.type_id === 16).length
  const awayGoals = awayShots.filter(d => d.type_id === 16).length
  const homeTotalShots = homeShots.length
  const awayTotalShots = awayShots.length
  const homeOnTarget = homeShots.filter(d => [15, 16].includes(d.type_id)).length
  const awayOnTarget = awayShots.filter(d => [15, 16].includes(d.type_id)).length

  // xG per shot — prefer the per-shot xg already populated above (parquet xg
  // when available, _estimateXg fallback otherwise) rather than re-estimating
  // from coords. Was silently overriding real Opta xg values; finite-check
  // matches the convention used at lines 264, 490, 1180.
  const _shotXg = d => Number.isFinite(d.xg) ? d.xg : _estimateXg(d.x, d.y)
  const homeXgTotal = homeShots.reduce((s, d) => s + _shotXg(d), 0)
  const awayXgTotal = awayShots.reduce((s, d) => s + _shotXg(d), 0)

  // SVG setup — cropped half-pitch (goal at top), same pattern as player.qmd
  const W = 460, H = 375
  const ns = "http://www.w3.org/2000/svg"
  const svg = document.createElementNS(ns, "svg")
  svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
  svg.setAttribute("class", "shot-chart-svg")

  // Opta: x 0-100, y 0-100. Crop to x >= 62 (~40 yards from goal)
  const xMin = 62
  const padTop = 25, padBot = 15, padLR = 20
  const plotW = W - 2 * padLR
  const plotH = H - padTop - padBot

  // Transform Opta coords to SVG pixel coords (goal at top).
  // Mirror Opta y across the pitch centreline so shots render on the
  // correct side of goal (attacker's right at Opta y=0 lands on viewer's right).
  function toSVG(optaX, optaY) {
    const sx = padLR + ((100 - optaY) / 100) * plotW
    const sy = padTop + (1 - (optaX - xMin) / (100 - xMin)) * plotH
    return [sx, sy]
  }

  // Distance and angle to goal centre
  function shotMetrics(optaX, optaY) {
    const dx = (100 - optaX) / 100 * 105
    const dy = (optaY - 50) / 100 * 68
    const dist = Math.sqrt(dx * dx + dy * dy)
    const postY1 = (50 - 3.66 / 68 * 100)
    const postY2 = (50 + 3.66 / 68 * 100)
    const dy1 = (optaY - postY1) / 100 * 68
    const dy2 = (optaY - postY2) / 100 * 68
    const a1 = Math.atan2(dx, dy1)
    const a2 = Math.atan2(dx, dy2)
    const angle = Math.abs(a1 - a2) * (180 / Math.PI)
    return { dist: dist.toFixed(1), angle: angle.toFixed(1) }
  }

  // SVG defs: grass, net, vignette
  const defs = document.createElementNS(ns, "defs")
  defs.appendChild(window.chartHelpers.createGrassPattern("match-grass", 40, 20))

  const netPat = document.createElementNS(ns, "pattern")
  netPat.setAttribute("id", "match-net")
  netPat.setAttribute("patternUnits", "userSpaceOnUse")
  netPat.setAttribute("width", "6")
  netPat.setAttribute("height", "6")
  const netBg = document.createElementNS(ns, "rect")
  netBg.setAttribute("width", "6")
  netBg.setAttribute("height", "6")
  netBg.setAttribute("fill", "rgba(255,255,255,0.03)")
  netPat.appendChild(netBg)
  const netL1 = document.createElementNS(ns, "line")
  netL1.setAttribute("x1", "0"); netL1.setAttribute("y1", "0")
  netL1.setAttribute("x2", "6"); netL1.setAttribute("y2", "6")
  netL1.setAttribute("stroke", "rgba(255,255,255,0.08)")
  netL1.setAttribute("stroke-width", "0.5")
  netPat.appendChild(netL1)
  const netL2 = document.createElementNS(ns, "line")
  netL2.setAttribute("x1", "6"); netL2.setAttribute("y1", "0")
  netL2.setAttribute("x2", "0"); netL2.setAttribute("y2", "6")
  netL2.setAttribute("stroke", "rgba(255,255,255,0.08)")
  netL2.setAttribute("stroke-width", "0.5")
  netPat.appendChild(netL2)
  defs.appendChild(netPat)

  defs.appendChild(window.chartHelpers.createVignette("match-vignette", { cy: "25%", opacity: 0.3 }))

  // Glow filter for goals
  const glowFilter = document.createElementNS(ns, "filter")
  glowFilter.setAttribute("id", "goal-glow")
  glowFilter.setAttribute("x", "-50%")
  glowFilter.setAttribute("y", "-50%")
  glowFilter.setAttribute("width", "200%")
  glowFilter.setAttribute("height", "200%")
  const blur = document.createElementNS(ns, "feGaussianBlur")
  blur.setAttribute("stdDeviation", "3")
  blur.setAttribute("result", "blur")
  glowFilter.appendChild(blur)
  const merge = document.createElementNS(ns, "feMerge")
  const mn1 = document.createElementNS(ns, "feMergeNode")
  mn1.setAttribute("in", "blur")
  merge.appendChild(mn1)
  const mn2 = document.createElementNS(ns, "feMergeNode")
  mn2.setAttribute("in", "SourceGraphic")
  merge.appendChild(mn2)
  glowFilter.appendChild(merge)
  defs.appendChild(glowFilter)

  svg.appendChild(defs)

  // Pitch background
  const pitch = document.createElementNS(ns, "rect")
  pitch.setAttribute("x", padLR)
  pitch.setAttribute("y", padTop)
  pitch.setAttribute("width", plotW)
  pitch.setAttribute("height", plotH)
  pitch.setAttribute("rx", "4")
  pitch.setAttribute("fill", "url(#match-grass)")
  pitch.setAttribute("stroke", "rgba(255,255,255,0.2)")
  pitch.setAttribute("stroke-width", "1.5")
  svg.appendChild(pitch)

  // Vignette
  const vigOverlay = document.createElementNS(ns, "rect")
  vigOverlay.setAttribute("x", padLR)
  vigOverlay.setAttribute("y", padTop)
  vigOverlay.setAttribute("width", plotW)
  vigOverlay.setAttribute("height", plotH)
  vigOverlay.setAttribute("rx", "4")
  vigOverlay.setAttribute("fill", "url(#match-vignette)")
  svg.appendChild(vigOverlay)

  // Draw rect helper
  function drawRect(x1, y1, x2, y2, stroke) {
    const [sx1, sy1] = toSVG(x1, y1)
    const [sx2, sy2] = toSVG(x2, y2)
    const rect = document.createElementNS(ns, "rect")
    rect.setAttribute("x", Math.min(sx1, sx2))
    rect.setAttribute("y", Math.min(sy1, sy2))
    rect.setAttribute("width", Math.abs(sx2 - sx1))
    rect.setAttribute("height", Math.abs(sy2 - sy1))
    rect.setAttribute("fill", "none")
    rect.setAttribute("stroke", stroke || "rgba(255,255,255,0.2)")
    rect.setAttribute("stroke-width", "1")
    svg.appendChild(rect)
  }

  // Pitch markings
  drawRect(83, 21.1, 100, 78.9)      // Penalty box
  drawRect(94.2, 36.8, 100, 63.2)    // 6-yard box

  // Penalty spot
  const [penX, penY] = toSVG(88.5, 50)
  const penSpot = document.createElementNS(ns, "circle")
  penSpot.setAttribute("cx", penX)
  penSpot.setAttribute("cy", penY)
  penSpot.setAttribute("r", 3)
  penSpot.setAttribute("fill", "rgba(255,255,255,0.45)")
  svg.appendChild(penSpot)

  // D-arc
  const arcPts = []
  for (let a = -0.9; a <= 0.9; a += 0.02) {
    const ax = 88.5 + 10 * Math.cos(a)
    const ay = 50 + 10 * Math.sin(a)
    if (ax < 83) arcPts.push(toSVG(ax, ay))
  }
  if (arcPts.length > 1) {
    const dArc = document.createElementNS(ns, "polyline")
    dArc.setAttribute("points", arcPts.map(p => p.join(",")).join(" "))
    dArc.setAttribute("fill", "none")
    dArc.setAttribute("stroke", "rgba(255,255,255,0.2)")
    dArc.setAttribute("stroke-width", "1")
    svg.appendChild(dArc)
  }

  // Goal mouth with net
  const [glL, glLy] = toSVG(100, 44.2)
  const [glR, glRy] = toSVG(100, 55.8)
  const goalDepth = 8
  const goalMouth = document.createElementNS(ns, "rect")
  goalMouth.setAttribute("x", Math.min(glL, glR))
  goalMouth.setAttribute("y", glLy - goalDepth)
  goalMouth.setAttribute("width", Math.abs(glR - glL))
  goalMouth.setAttribute("height", goalDepth)
  goalMouth.setAttribute("fill", "url(#match-net)")
  goalMouth.setAttribute("stroke", "rgba(255,255,255,0.3)")
  goalMouth.setAttribute("stroke-width", "1")
  svg.appendChild(goalMouth)

  // Goal posts
  for (const gx of [glL, glR]) {
    const gPost = document.createElementNS(ns, "circle")
    gPost.setAttribute("cx", gx)
    gPost.setAttribute("cy", glLy)
    gPost.setAttribute("r", 2.5)
    gPost.setAttribute("fill", "rgba(255,255,255,0.7)")
    svg.appendChild(gPost)
  }

  // Corner arcs
  for (const [cx, cy] of [toSVG(100, 0), toSVG(100, 100)]) {
    const corner = document.createElementNS(ns, "circle")
    corner.setAttribute("cx", cx)
    corner.setAttribute("cy", cy)
    corner.setAttribute("r", 6)
    corner.setAttribute("fill", "none")
    corner.setAttribute("stroke", "rgba(255,255,255,0.15)")
    corner.setAttribute("stroke-width", "0.8")
    svg.appendChild(corner)
  }

  // Team colors for shots
  const homeColor = "#6ba09a"  // blue
  const awayColor = "#c4734a"  // red
  const typeLabels = { 16: "Goal", 15: "Saved", 14: "Post", 13: "Miss" }

  // Opta `situation` / `body_part` come as camelCase codes. prettify spaces them
  // for the tooltip ("FromCorner" \u2192 "From Corner"); the *Group helpers collapse
  // them to the filter buckets Pete wants (Open Play / Set Piece / Penalty,
  // Foot / Head). Filters key off the group; the tooltip shows the raw value.
  const prettify = s => (s || "").replace(/([a-z])([A-Z])/g, "$1 $2")
  // "open" must be tested BEFORE "pen": "openplay".includes("pen") is true
  // (o-PEN-play), which silently classified every open-play shot as a
  // penalty in the Phase filter.
  const sitGroup = s => { const x = (s || "").toLowerCase(); return x.includes("open") ? "Open Play" : x.includes("pen") ? "Penalty" : x ? "Set Piece" : "" }
  const bodyGroup = b => { const x = (b || "").toLowerCase(); return x.includes("head") ? "Head" : x.includes("foot") ? "Foot" : x ? "Other" : "" }
  // Match half: prefer the live/chain period_id (1/2 = halves, 3/4 = ET, 5 =
  // shootout already excluded); match-shots.parquet has no period_id, so fall
  // back to a stoppage-tolerant minute heuristic (≤48' = 1H, ≤93' = 2H, else ET).
  const shotHalf = d => {
    if (d.period_id != null) return d.period_id >= 3 ? "ET" : d.period_id === 2 ? "2H" : "1H"
    const m = d.minute
    if (m == null) return "1H"
    return m <= 48 ? "1H" : m <= 93 ? "2H" : "ET"
  }
  const hasET = allShots.some(d => shotHalf(d) === "ET")

  // Helper: set common data attributes on shot dots
  function setShotData(el, shot, isHome, m) {
    el.setAttribute("class", "shot-dot")
    el.setAttribute("data-player", shot.player_name || "Unknown")
    el.setAttribute("data-player-id", shot.player_id || "")
    el.setAttribute("data-result", typeLabels[shot.type_id] || "Shot")
    el.setAttribute("data-team", isHome ? shotHome : shotAway)
    el.setAttribute("data-minute", shot.minute != null ? shot.minute + "'" : "")
    el.setAttribute("data-dist", m.dist + "m")
    el.setAttribute("data-angle", m.angle + "\u00B0")
    el.setAttribute("data-xg", shot.xg != null ? shot.xg.toFixed(2) : "\u2014")
    el.setAttribute("data-situation", prettify(shot.situation))
    el.setAttribute("data-sitgroup", sitGroup(shot.situation))
    el.setAttribute("data-bodypart", prettify(shot.body_part))
    el.setAttribute("data-bodygroup", bodyGroup(shot.body_part))
    el.setAttribute("data-half", shotHalf(shot))
    el.setAttribute("data-assist", shot.assist_player || "")
  }

  // Plot shot dots — team color fill, shape by outcome, size by xG.
  // Iterates `allShots` (parquet OR live-chain-derived) so live matches render correctly.
  for (const shot of allShots) {
    const ox = shot.x
    const oy = shot.y
    if (ox == null || oy == null || ox < xMin) continue

    const [sx, sy] = toSVG(ox, oy)
    const m = shotMetrics(ox, oy)
    // Resolve home/away per-shot. Trusts an explicit `is_home` flag from the
    // live-chain build first (including explicit `false`), then falls back to
    // parquet team_id match, then team_name equality. Previously checked
    // `is_home === true` which ignored explicit `false` and could misclassify
    // away shots when a parquet team_id happened to coincide.
    const isHome = shot.is_home != null ? shot.is_home
      : (xgHomeTeamId != null && shot.team_id === xgHomeTeamId) ? true
      : (shot.team_name === shotHome)
    const baseColor = isHome ? homeColor : awayColor
    // Scale dot radius by xG (3-8 range)
    const xgVal = shot.xg != null ? shot.xg : 0.1
    const xgRadius = 3 + Math.min(xgVal, 0.7) * 7

    if (shot.type_id === 16) {
      // Goal: team-colored fill with gold border + glow
      const dot = document.createElementNS(ns, "circle")
      dot.setAttribute("cx", sx); dot.setAttribute("cy", sy)
      dot.setAttribute("r", Math.max(7, xgRadius))
      dot.setAttribute("fill", baseColor)
      dot.setAttribute("stroke", "#fbbf24")
      dot.setAttribute("stroke-width", "2.5")
      dot.setAttribute("opacity", "0.95")
      dot.setAttribute("filter", "url(#goal-glow)")
      setShotData(dot, shot, isHome, m)
      svg.appendChild(dot)
    } else if (shot.type_id === 15) {
      // Saved: team-colored filled circle with light outline so it stands
      // out from the green pitch (was easy to miss on the legend swatch).
      const dot = document.createElementNS(ns, "circle")
      dot.setAttribute("cx", sx); dot.setAttribute("cy", sy)
      dot.setAttribute("r", xgRadius)
      dot.setAttribute("fill", baseColor)
      dot.setAttribute("stroke", "rgba(255,255,255,0.55)")
      dot.setAttribute("stroke-width", "1")
      dot.setAttribute("opacity", "0.85")
      setShotData(dot, shot, isHome, m)
      svg.appendChild(dot)
    } else if (shot.type_id === 14) {
      // Post: team-colored dashed outline. fill="transparent" (not "none")
      // so the empty centre still catches mouseover for the tooltip — under
      // fill="none" only the stroked outline triggered hover.
      const dot = document.createElementNS(ns, "circle")
      dot.setAttribute("cx", sx); dot.setAttribute("cy", sy)
      dot.setAttribute("r", xgRadius)
      dot.setAttribute("fill", "transparent")
      dot.setAttribute("stroke", baseColor)
      dot.setAttribute("stroke-width", "2.5")
      dot.setAttribute("stroke-dasharray", "2,2")
      dot.setAttribute("opacity", "0.95")
      setShotData(dot, shot, isHome, m)
      svg.appendChild(dot)
    } else {
      // Miss (type_id 13): team-colored X marker
      const g = document.createElementNS(ns, "g")
      setShotData(g, shot, isHome, m)
      g.setAttribute("data-team", isHome ? shotHome : shotAway)
      g.setAttribute("data-minute", shot.minute != null ? shot.minute + "'" : "")
      g.setAttribute("data-dist", m.dist + "m")
      g.setAttribute("data-angle", m.angle + "\u00B0")
      g.setAttribute("data-xg", shot.xg != null ? shot.xg.toFixed(2) : "—")
      const sz = 4
      // Invisible underlay circle so the empty centre still catches hover
      // (the bare X strokes leave dead zones on the legend swatch and at
      // small radii); kept transparent so it doesn't change the visual.
      const hover = document.createElementNS(ns, "circle")
      hover.setAttribute("cx", sx); hover.setAttribute("cy", sy)
      hover.setAttribute("r", sz + 2)
      hover.setAttribute("fill", "transparent")
      g.appendChild(hover)
      const strokes = [[-sz, -sz, sz, sz], [sz, -sz, -sz, sz]]
      // White halo first, then the team-colored X over the top — same trick
      // as text-shadow for SVG, gives the marker contrast against pitch green.
      for (const [dx1, dy1, dx2, dy2] of strokes) {
        const halo = document.createElementNS(ns, "line")
        halo.setAttribute("x1", sx + dx1); halo.setAttribute("y1", sy + dy1)
        halo.setAttribute("x2", sx + dx2); halo.setAttribute("y2", sy + dy2)
        halo.setAttribute("stroke", "rgba(255,255,255,0.4)")
        halo.setAttribute("stroke-width", "4")
        halo.setAttribute("stroke-linecap", "round")
        g.appendChild(halo)
      }
      for (const [dx1, dy1, dx2, dy2] of strokes) {
        const line = document.createElementNS(ns, "line")
        line.setAttribute("x1", sx + dx1); line.setAttribute("y1", sy + dy1)
        line.setAttribute("x2", sx + dx2); line.setAttribute("y2", sy + dy2)
        line.setAttribute("stroke", baseColor)
        line.setAttribute("stroke-width", "2.25")
        line.setAttribute("stroke-linecap", "round")
        line.setAttribute("opacity", "0.95")
        g.appendChild(line)
      }
      svg.appendChild(g)
    }
  }

  // Build container — desktop puts the pitch beside per-team stat panels
  // (the old 3+3 centred tiles above a centred pitch read fine on mobile but
  // wasted half the desktop row); the grid collapses to one column on small
  // screens with the panels side by side above the pitch.
  const el = document.createElement("div")
  const layout = document.createElement("div")
  layout.className = "shot-map-layout"
  el.appendChild(layout)

  // Side column — almanac scorecard: ONE vertical unit with a dark scoreline
  // anchor strip (flags + score, echoing the masthead), then per-team halves
  // where xG is the hero numeral (the shot map is about chances) over a
  // dotted-leader ledger — the wall chart's almanac vocabulary (DESIGN.md).
  const statsCol = document.createElement("div")
  statsCol.className = "shot-map-side"
  const card = document.createElement("div")
  card.className = "shot-scorecard"
  statsCol.appendChild(card)
  // Rebuild the scorecard tallies for a given half selection ("1H"/"2H"/"ET"
  // or null = all). Goals shown on the strip stay the FULL-match scoreline so
  // the headline score never changes; only the per-half xG/shots/on-target/
  // goals ledger reflects the filter.
  function renderScorecard(halfSel) {
    const inHalf = d => halfSel == null || shotHalf(d) === halfSel
    const hS = homeShots.filter(inHalf)
    const aS = awayShots.filter(inHalf)
    const mark = (team) => {
      if (paramLeague === "WC" && window.wcMaps) return window.wcMaps.flagImg(team, "ms-team-mark")
      const crest = window.footballMaps.teamCrest && window.footballMaps.teamCrest(team)
      return crest ? `<img class="ms-team-mark" src="${statsEsc(crest)}" alt="" loading="lazy" onerror="this.style.display='none'">` : ""
    }
    const ledgerRow = (label, value) =>
      `<div class="sc-row"><span class="sc-lbl">${statsEsc(label)}</span><span class="sc-leader"></span><span class="sc-num">${statsEsc(String(value))}</span></div>`
    const half = (name, cls, xgV, shots, onT, goals) => `
      <div class="sc-half ${cls}">
        <div class="sc-team">${mark(name)}<span>${statsEsc(name)}</span></div>
        <div class="sc-hero"><span class="sc-hero-num">${statsEsc(xgV)}</span><span class="sc-hero-lbl">expected<br>goals</span></div>
        ${ledgerRow("Shots", shots)}
        ${ledgerRow("On target", onT)}
        ${ledgerRow("Goals", goals)}
      </div>`
    const hXg = hS.reduce((s, d) => s + _shotXg(d), 0)
    const aXg = aS.reduce((s, d) => s + _shotXg(d), 0)
    card.innerHTML = `
      <div class="sc-strip">${mark(shotHome)}<span class="sc-score">${statsEsc(String(homeGoals))}<span class="sc-dash">–</span>${statsEsc(String(awayGoals))}</span>${mark(shotAway)}</div>
      ${half(shotHome, "sc-home", hXg.toFixed(2), hS.length, hS.filter(d => [15, 16].includes(d.type_id)).length, hS.filter(d => d.type_id === 16).length)}
      <div class="sc-rule"></div>
      ${half(shotAway, "sc-away", aXg.toFixed(2), aS.length, aS.filter(d => [15, 16].includes(d.type_id)).length, aS.filter(d => d.type_id === 16).length)}`
  }
  renderScorecard(null)

  // Chart wrap
  const wrap = document.createElement("div")
  wrap.className = "shot-chart-wrap"
  wrap.appendChild(svg)

  // Tooltip
  const tooltip = document.createElement("div")
  tooltip.className = "field-tooltip"
  wrap.appendChild(tooltip)

  const _tip = window.chartHelpers?.buildFieldTooltip
  svg.addEventListener("mousemove", (e) => {
    const dot = e.target.closest("[data-result]")
    if (!dot || !_tip) { tooltip.classList.remove("visible"); return }
    // Rich header (headshot + team badge + name — result), mirroring the AFL
    // shot-map tooltip for cross-sport parity (#228). Headshot is best-effort:
    // football/headshots/{player_id}.webp, hidden via onerror if absent. Crest
    // from footballMaps.teamCrest. Team moves into the header, so it's dropped
    // from the rows below.
    tooltip.textContent = ""
    const header = document.createElement("div")
    header.className = "scatter-tip-header"
    // Headshot rule (site-wide): photo OR initials circle, never an empty
    // gap — the initial sits behind the img and shows through on 404.
    const pid = dot.getAttribute("data-player-id")
    const hsWrap = document.createElement("span")
    hsWrap.className = "mp-hs-wrap mp-hs-tip"
    const hsInit = document.createElement("span")
    hsInit.className = "mp-hs-init"
    hsInit.textContent = (dot.getAttribute("data-player") || "?").trim().charAt(0).toUpperCase()
    hsWrap.appendChild(hsInit)
    if (pid) {
      const hs = document.createElement("img")
      hs.className = "mp-hs-img"
      hs.src = base_url + "football/headshots/" + pid + ".webp"; hs.alt = ""
      hs.onerror = function() { this.style.display = "none" }
      hsWrap.appendChild(hs)
    }
    header.appendChild(hsWrap)
    const info = document.createElement("div")
    const nameEl = document.createElement("div")
    nameEl.className = "scatter-tip-name"
    nameEl.textContent = dot.getAttribute("data-player") + " — " + dot.getAttribute("data-result")
    info.appendChild(nameEl)
    const teamRow = document.createElement("div")
    teamRow.className = "scatter-tip-team"
    const teamName = dot.getAttribute("data-team") || ""
    const crestUrl = window.footballMaps?.teamCrest?.(teamName)
    if (crestUrl) {
      const badge = document.createElement("img")
      badge.className = "scatter-tip-badge"
      badge.src = crestUrl; badge.alt = ""
      badge.onerror = function() { this.style.display = "none" }
      teamRow.appendChild(badge)
      teamRow.appendChild(document.createTextNode(" "))
    } else if (paramLeague === "WC" && window.wcMaps) {
      // International sides have no club crest — use the flag map.
      const span = document.createElement("span")
      span.innerHTML = window.wcMaps.flagImg(teamName, "scatter-tip-badge")
      if (span.firstChild) {
        teamRow.appendChild(span.firstChild)
        teamRow.appendChild(document.createTextNode(" "))
      }
    }
    teamRow.appendChild(document.createTextNode(teamName))
    info.appendChild(teamRow)
    header.appendChild(info)
    tooltip.appendChild(header)
    const tipRows = [
      ["Minute", dot.getAttribute("data-minute")],
      ["xG", dot.getAttribute("data-xg")],
      ["Assist", dot.getAttribute("data-assist")],
      ["Phase", dot.getAttribute("data-situation")],
      ["Type", dot.getAttribute("data-bodypart")],
      ["Distance", dot.getAttribute("data-dist")],
      ["Angle", dot.getAttribute("data-angle")]
    ].filter(([,v]) => v)
    _tip(tooltip, "", tipRows, true)
    const _ftTitle = tooltip.querySelector(".ft-title")
    if (_ftTitle && !_ftTitle.textContent) _ftTitle.remove()
    const rect = wrap.getBoundingClientRect()
    const relY = e.clientY - rect.top
    // Flip below the cursor when there isn't room above. Measure the BUILT
    // tooltip rather than guessing — the rich header (headshot + crest) made
    // it taller than the old fixed 80px threshold, which cropped the name row
    // on shots near the top of the box.
    tooltip.classList.add("visible")
    const tipH = tooltip.offsetHeight || 130
    const flipBelow = relY < tipH + 18
    tooltip.style.left = (e.clientX - rect.left) + "px"
    tooltip.style.top = (relY + (flipBelow ? 16 : -12)) + "px"
    tooltip.style.transform = flipBelow ? "translate(-50%, 0)" : "translate(-50%, calc(-100% - 12px))"
  })
  svg.addEventListener("mouseleave", () => tooltip.classList.remove("visible"))

  // Legend — two independent single-select groups (team + result) that compose
  // via intersection. e.g. team="Manchester United" + result="Saved" highlights
  // Man United's saved shots only. Click the same pill twice to clear that
  // dimension. Both null = everything at full opacity.
  const legendWrap = document.createElement("div")
  legendWrap.className = "shot-chart-legend-wrap"

  // Independent single-select highlight groups that compose via intersection.
  // Each group maps to a dot attribute. team + result were the originals;
  // sit (situation) + body (body part) added for #228. Click a pill twice to
  // clear that group; all null = everything at full opacity.
  const GROUP_ATTR = { team: "data-team", result: "data-result", sit: "data-sitgroup", body: "data-bodygroup", half: "data-half" }
  const sels = { team: null, result: null, sit: null, body: null, half: null }
  const legendItems = []
  let lastHalfSel = null

  function applyHighlight() {
    svg.querySelectorAll("[data-result]").forEach(dot => {
      const matches = Object.keys(sels).every(g => sels[g] == null || dot.getAttribute(GROUP_ATTR[g]) === sels[g])
      dot.style.opacity = matches ? "" : "0.15"
      dot.style.transition = "opacity 0.15s"
    })
    // The half filter also rewrites the scorecard tallies (the other dimensions
    // only dim dots). Re-render only when the half selection actually changed.
    if (sels.half !== lastHalfSel) { lastHalfSel = sels.half; renderScorecard(sels.half) }
    // Sync legend-item styling: active items bold, inactive items in the SAME
    // group dim to 0.4 when that group has a selection, cross-group items stay
    // at full opacity so they look clickable. Filter chips (Phase/Type rows)
    // toggle an .active class instead — they're buttons, not key swatches.
    for (const li of legendItems) {
      const groupSel = sels[li.type]
      const active = groupSel === li.value
      const otherInGroupActive = groupSel != null && !active
      if (li.chip) {
        li.el.classList.toggle("active", active)
        li.el.style.opacity = otherInGroupActive ? "0.55" : "1"
      } else {
        li.el.style.opacity = otherInGroupActive ? "0.4" : "1"
        li.el.style.fontWeight = active ? "700" : ""
      }
    }
  }

  function makeLegendItem(dotStyle, label, type, value) {
    const span = document.createElement("span")
    span.className = "touch-legend-item"
    span.style.cursor = "pointer"
    const dot = document.createElement("span")
    dot.className = "touch-legend-dot"
    Object.assign(dot.style, dotStyle)
    span.appendChild(dot)
    span.appendChild(document.createTextNode(" " + label))
    span.addEventListener("click", () => {
      sels[type] = sels[type] === value ? null : value
      applyHighlight()
    })
    legendItems.push({ el: span, type, value })
    return span
  }

  // Row 1: team pills
  const teamRow = document.createElement("div")
  teamRow.className = "touch-map-legend"
  teamRow.appendChild(makeLegendItem({ background: homeColor }, shotHome, "team", shotHome))
  teamRow.appendChild(makeLegendItem({ background: awayColor }, shotAway, "team", shotAway))
  legendWrap.appendChild(teamRow)

  // Row 2: shot-type pills
  const typeRow = document.createElement("div")
  typeRow.className = "touch-map-legend"
  typeRow.appendChild(makeLegendItem({ background: "#fbbf24" }, "Goal", "result", "Goal"))
  typeRow.appendChild(makeLegendItem({ background: "rgba(255,255,255,0.5)" }, "Saved", "result", "Saved"))
  typeRow.appendChild(makeLegendItem({ background: "transparent", fontSize: "10px", lineHeight: "10px", textContent: "\u2716" }, "Miss", "result", "Miss"))
  typeRow.appendChild(makeLegendItem({ background: "transparent", border: "2px dashed #9ca3af", borderRadius: "50%" }, "Post", "result", "Post"))
  legendWrap.appendChild(typeRow)

  // Rows 3-4: situation (Open Play / Set Piece / Penalty) + body part (Foot /
  // Head) — these don't change the dot's colour or shape, so they present as
  // labelled FILTER CHIPS, not key swatches (rule: the key is for visual
  // encodings; anything else that filters looks like a toggle). Derived from
  // the rendered dots so a row only shows when there's >1 option to choose
  // between (e.g. no Penalty chip if the match had none).
  const presentGroups = attr => [...new Set([...svg.querySelectorAll("[" + attr + "]")].map(d => d.getAttribute(attr)).filter(Boolean))]
  const makeFilterChip = (label, type, value) => {
    const btn = document.createElement("button")
    btn.type = "button"
    btn.className = "shot-filter-chip"
    btn.textContent = label
    btn.addEventListener("click", () => {
      sels[type] = sels[type] === value ? null : value
      applyHighlight()
    })
    legendItems.push({ el: btn, type, value, chip: true })
    return btn
  }
  // Half row — explicit All · 1st · 2nd (· ET only when ET shots exist). Unlike
  // Phase/Type (toggle-to-clear), the half row carries a visible "All" chip and
  // behaves as a single-select: clicking a half sets sels.half; "All" clears it.
  {
    const row = document.createElement("div")
    row.className = "touch-map-legend shot-filter-row"
    const lbl = document.createElement("span")
    lbl.className = "shot-filter-lbl"
    lbl.textContent = "Half"
    row.appendChild(lbl)
    const halfChips = []
    const setHalf = (value) => {
      sels.half = value
      halfChips.forEach(c => c.el.classList.toggle("active", c.value === value))
      applyHighlight()
    }
    const addHalfChip = (label, value) => {
      const btn = document.createElement("button")
      btn.type = "button"
      btn.className = "shot-filter-chip"
      btn.textContent = label
      btn.addEventListener("click", () => setHalf(value))
      halfChips.push({ el: btn, value })
      row.appendChild(btn)
    }
    addHalfChip("All", null)
    addHalfChip("1st Half", "1H")
    addHalfChip("2nd Half", "2H")
    if (hasET) addHalfChip("ET", "ET")
    halfChips[0].el.classList.add("active")  // default = All
    legendWrap.appendChild(row)
  }
  for (const grp of [
    { type: "sit", attr: "data-sitgroup", label: "Phase", order: ["Open Play", "Set Piece", "Penalty"] },
    { type: "body", attr: "data-bodygroup", label: "Type", order: ["Foot", "Head", "Other"] }
  ]) {
    const present = presentGroups(grp.attr)
    const items = grp.order.filter(v => present.includes(v))
    if (items.length > 1) {
      const row = document.createElement("div")
      row.className = "touch-map-legend shot-filter-row"
      const lbl = document.createElement("span")
      lbl.className = "shot-filter-lbl"
      lbl.textContent = grp.label
      row.appendChild(lbl)
      for (const v of items) row.appendChild(makeFilterChip(v, grp.type, v))
      legendWrap.appendChild(row)
    }
  }

  wrap.appendChild(legendWrap)
  layout.appendChild(statsCol)
  layout.appendChild(wrap)

  return el
}
Show code
// ── Source / updated row beneath the shot map ───────────────
{
  if (!paramHome || !paramAway) return html``
  const _md = paramDateKey ? new Date(paramDateKey + "T12:00:00Z") : null
  const matchAsAt = (_md && !isNaN(_md.getTime()))
    ? "Match " + _md.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
    : "Live during play"
  return window.editorial.tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    asAt: matchAsAt,
    hint: "Opta shots · live xG"
  })
}
Show code
_optaId = {
  // 1. Explicit URL param (highest priority)
  if (paramOptaId) return paramOptaId

  // Bail early if no team params (prevents empty-string matching everything)
  if (!paramHome || !paramAway) return ""

  // 2. Use match_id from the predictions row — the authoritative Opta ID shared across
  // every parquet. This is the correct path whenever predictions has the match.
  if (match && match.match_id) return match.match_id

  const norm = window.normalizeTeam
  const nHome = norm(paramHome)
  const nAway = norm(paramAway)

  // 3. Discover from match-stats parquet (has match_id + match_date + team_name).
  // Strict equality after normalization, within the same date.
  if (matchStatsRaw && paramDate) {
    const byMatch = new Map()
    for (const d of matchStatsRaw) {
      if (d.league !== paramLeague) continue
      if (String(d.match_date || "").replace("Z", "").slice(0, 10) !== paramDate) continue
      if (!byMatch.has(d.match_id)) byMatch.set(d.match_id, new Set())
      byMatch.get(d.match_id).add(norm(d.team_name))
    }
    for (const [mid, teams] of byMatch) {
      if (teams.has(nHome) && teams.has(nAway)) return mid
    }
  }

  // 4. Discover from chains parquet (has match_id + home_team + away_team).
  // Strict normalized equality + date filter if chainsRaw exposes match_date.
  if (chainsRaw && paramDate) {
    for (let i = 0; i < chainsRaw.length; i++) {
      const d = chainsRaw[i]
      if (d.match_date) {
        const dKey = String(d.match_date).replace("Z", "").slice(0, 10)
        if (dKey !== paramDate) continue
      }
      if (norm(d.home_team) === nHome && norm(d.away_team) === nAway) return d.match_id
    }
  }

  return ""
}

// Fetch live events from Worker (if Opta match ID available)
_liveEvents = {
  if (!_optaId) return null
  const WORKER = window.WORKER_BASE_URL || "https://inthegame-api.pete-owen1.workers.dev/"
  // Pass league so the worker's EPV feature extractor can encode league_id —
  // panna's EPV model treats league as a feature, so an unknown league means
  // league_id=0 and slightly off-distribution predictions for the row.
  const leagueParam = paramLeague ? `?league=${encodeURIComponent(paramLeague)}` : ""
  try {
    const res = await fetch(`${WORKER}football/live-events/${_optaId}${leagueParam}`, {
      signal: AbortSignal.timeout(12000)
    })
    if (!res.ok) { console.warn("[match] Football live events:", res.status); return null }
    return await res.json()
  } catch (e) { console.warn("[match] Live events fetch failed:", e.message); return null }
}

// Convert live events to chain-parquet-compatible format
_liveChainRows = {
  if (!_liveEvents || !_liveEvents.rows) return []
  const home = _liveEvents.home?.name || paramHome
  const away = _liveEvents.away?.name || paramAway
  return _liveEvents.rows.map(r => ({
    match_id: _liveEvents.matchId,
    match_date: paramDate,
    player_name: r.player_name,
    player_id: r.player_id,
    team_name: r.is_home ? home : away,
    home_team: home,
    away_team: away,
    period_id: r.period_id,
    minute: r.minute,
    second: r.second,
    type_id: r.type_id,
    action: r.action,
    category: r.category,
    outcome: r.outcome,
    x: r.x,
    y: r.y,
    end_x: r.end_x,
    end_y: r.end_y,
    equity: r.equity ?? null,
    // Per-shot xG from the worker = panna's REAL XGBoost model (football/xg-model.json,
    // scored live in worker/src/index.js). The worker also overrides a shot's `equity`
    // with this value, but `equity` is overloaded (EPV state on non-shot rows), so map
    // the unambiguous `xg` field through and let EVERY xG visual read it. Previously this
    // was dropped, forcing the shot map / timeline to fall back to the crude coordinate
    // heuristic (_estimateXg) while other displays showed the model — the cause of the
    // header-vs-shotmap-vs-table xG disagreements. Null on non-shot rows / missing coords.
    xg: typeof r.xg === "number" ? r.xg : null,
    epv_delta: r.epv_delta ?? null,
    // Per-event EPV credit from worker (AFL-pattern parity: cross-team-flipped
    // delta_ep, 50/50 disp/recv split, see worker/src/index.js scoreChainRows
    // equivalent in handleFootballLiveEvents).
    delta_ep: typeof r.delta_ep === "number" ? r.delta_ep : null,
    epv_disp: typeof r.epv_disp === "number" ? r.epv_disp : null,
    epv_recv: typeof r.epv_recv === "number" ? r.epv_recv : null,
    wp: typeof r.wp === "number" ? r.wp : null,
    // Per-event WPA from worker (panna-pipeline parity for finished matches:
    // sign-flipped, outcome-anchored on last event, mean-centred per match,
    // 50/50 actor/receiver split with cross-team flip — see worker).
    wpa: typeof r.wpa === "number" ? r.wpa : null,
    wpa_actor: typeof r.wpa_actor === "number" ? r.wpa_actor : null,
    wpa_receiver: typeof r.wpa_receiver === "number" ? r.wpa_receiver : null,
    is_home: r.is_home === true || r.is_home === 1 ? 1 : 0,
    // Running score entering each row (attachFootballScores, OG-corrected) —
    // drives the score-state per-player WPA below.
    home_score: typeof r.home_score === "number" ? r.home_score : null,
    away_score: typeof r.away_score === "number" ? r.away_score : null,
    // Card color on type-17 rows (worker extracts qualifier 31/32/33) —
    // drives the timeline dismissal markers. Absent on older responses.
    card: r.card ?? null,
    // Shot situation/body part (worker-extracted, match-shots.parquet vocab)
    // — drives the shot map tooltips + filter pills on the live path.
    situation: r.situation ?? null,
    body_part: r.body_part ?? null,
    // Duel-merge flag must survive the mapping — _findAssistIdx skips losers,
    // and without this an opposing duel-loser row between the final pass and
    // the shot reads as a possession change and kills the assist link.
    _duelLoser: r._duelLoser === true,
  }))
}

// Granular GAME position per player (LB / CM / RW …) straight from the
// worker's matchstats-enriched lineups: Opta publishes position ("Defender")
// + positionSide ("Left/Centre") per player, and classifyRole is the JS port
// of panna's canonical 16-role mapper. Empty map when the worker predates
// the matchstats merge or the feed didn't publish — consumers fall back to
// the coarse q44 group. Subs carry position "Substitute" (role null) even
// after coming on — the feed never upgrades them — so only starting XIs
// get granular roles; faithful to the feed, not a gap.
_roleById = {
  const out = new Map()
  const lu = _liveEvents?.lineups
  if (!lu) return out
  if (!window.footballMaps?.classifyRole) {
    // Feed shipped granular positions but the classifier is missing: that's
    // a stale/blocked football-maps.js bundle, not an old worker — without
    // this warn the two render identically (no pills, coarse layout).
    if ([...(lu.home || []), ...(lu.away || [])].some(p => p.pos)) {
      console.warn("[match] footballMaps.classifyRole unavailable but feed has granular positions — stale football-maps.js?")
    }
    return out
  }
  for (const side of ["home", "away"]) {
    for (const p of (lu[side] || [])) {
      const role = window.footballMaps.classifyRole(p.pos, p.pos_side)
      if (role && p.player_id) out.set(p.player_id, role)
    }
  }
  return out
}

// Assist linkage for the Value tab (xA / derived assists) and the shot map.
// Delegates to the SINGLE SOURCE OF TRUTH in chart-helpers.js
// (window.chartHelpers.findAssistIdx) so "who assisted" is computed identically
// here and on the chain visualizer. See that definition for the rule (Pete's
// broad assist #287: last same-team, different-player WON/deliberate action —
// pass / won duel / rebound / touch — within 25s). Returns the row index or -1.
_findAssistIdx = (arr, i) => window.chartHelpers.findAssistIdx(arr, i)

// Filter chain data to this match — reuse match_id from match stats (same ID system)
matchChains = {
  // 1. Authoritative path — match_id from predictions row, strict lookup in chainsRaw.
  // match.match_id is the Opta match_id shared across every parquet, so this is safe
  // whenever the prediction exists and chains has been rebuilt for this match.
  if (chainsRaw && match && match.match_id) {
    const rows = chainsRaw.filter(d => d.match_id === match.match_id)
    if (rows.length > 0) return rows
  }

  // 2. Fall back to matchStats.match_id, which has its own date-safe fuzzy resolver
  // (requires score >= 10 with a +10 date bonus — will only match same-date fixtures).
  if (chainsRaw && matchStats && matchStats.length > 0) {
    const rows = chainsRaw.filter(d => d.match_id === matchStats[0].match_id)
    if (rows.length > 0) return rows
  }

  // 3. Last resort — live events from the Worker. _optaId now prioritizes match.match_id,
  // so this path is authoritative too when predictions has the match. Previously there was
  // a fuzzy first-word-substring fallback here that matched "manchester" against historical
  // Man City chains and served Phil Foden events on Man U vs Leeds pages — removed.
  if (_liveChainRows.length > 0) {
    console.log("[match] Using live Opta events:", _liveChainRows.length, "actions")
    return _liveChainRows
  }

  return []
}

isLiveData = _liveChainRows.length > 0 && matchChains === _liveChainRows

// Identify home/away team names in chain data.
// Uses strict normalized equality against paramHome (no first-word substring match,
// which would match "Manchester" against both Man United AND Man City and silently
// flip the home/away classification for every downstream shot). Falls back to the
// live-chain row's own home_team field, then to paramHome, if no team in the chain
// data matches the URL-provided name. Any fallback is logged so a genuinely
// missing lookup doesn't silently serve plausible-but-wrong team data.
chainHomeTeam = {
  if (matchChains.length === 0) return ""
  const norm = window.normalizeTeam
  const paramHomeNorm = norm(paramHome)
  const teams = [...new Set(matchChains.map(d => d.team_name).filter(Boolean))]
  const exact = teams.find(t => norm(t) === paramHomeNorm)
  if (exact) return exact
  // Fallback: trust the chain row's home_team field if present (live chains from
  // _liveChainRows set this from the Opta matchInfo, which is authoritative)
  const fallback = matchChains[0]?.home_team || paramHome
  console.warn("[match] chainHomeTeam: no normalized match for '" + paramHome + "', falling back to '" + fallback + "' (teams in chain: " + teams.join(", ") + ")")
  return fallback
}
chainAwayTeam = {
  if (matchChains.length === 0) return ""
  const norm = window.normalizeTeam
  const paramAwayNorm = norm(paramAway)
  const teams = [...new Set(matchChains.map(d => d.team_name).filter(Boolean))]
  const exact = teams.find(t => norm(t) === paramAwayNorm)
  if (exact) return exact
  const fallback = matchChains[0]?.away_team || teams.find(t => t !== chainHomeTeam) || paramAway
  console.warn("[match] chainAwayTeam: no normalized match for '" + paramAway + "', falling back to '" + fallback + "'")
  return fallback
}

Territorial Map

Who controlled which zones. The pitch is split into a 12×8 grid — each cell takes on the colour of whichever side had more touches there, with stronger shade for clearer dominance. Toggle a single team to see raw density without the comparison.

Show code
// Custom button toggles matching .stats-cat-btn (same pattern as the AFL match
// page Quarter toggle). Internal value stays "Home"/"Away" so filterRows logic
// is unchanged; the buttons just display the actual team names.
viewof territorialTeam = {
  const outer = document.createElement("div")
  outer.className = "stats-quarter-toggle"
  outer.style.cssText = "display:flex;gap:0.25rem;margin:0 0 0.5rem;font-size:0.85rem;align-items:center;flex-wrap:wrap"
  outer.value = "Both"
  const lbl = document.createElement("span")
  lbl.className = "text-muted"
  lbl.style.cssText = "margin-right:0.4rem"
  lbl.textContent = "Team:"
  outer.appendChild(lbl)
  const opts = [["Both", "Both"], ["Home", chainHomeTeam || "Home"], ["Away", chainAwayTeam || "Away"]]
  for (const [value, label] of opts) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (value === "Both" ? " active" : "")
    btn.textContent = label
    btn.addEventListener("click", () => {
      outer.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      outer.value = value
      outer.dispatchEvent(new Event("input", { bubbles: true }))
    })
    outer.appendChild(btn)
  }
  return outer
}
Show code
viewof territorialPeriod = {
  const outer = document.createElement("div")
  outer.className = "stats-quarter-toggle"
  outer.style.cssText = "display:flex;gap:0.25rem;margin:0 0 0.5rem;font-size:0.85rem;align-items:center;flex-wrap:wrap"
  outer.value = "All"
  const lbl = document.createElement("span")
  lbl.className = "text-muted"
  lbl.style.cssText = "margin-right:0.4rem"
  lbl.textContent = "Period:"
  outer.appendChild(lbl)
  for (const p of ["All", "1H", "2H"]) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (p === "All" ? " active" : "")
    btn.textContent = p
    btn.addEventListener("click", () => {
      outer.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      outer.value = p
      outer.dispatchEvent(new Event("input", { bubbles: true }))
    })
    outer.appendChild(btn)
  }
  return outer
}
Show code
viewof territorialEventType = {
  const outer = document.createElement("div")
  outer.className = "stats-quarter-toggle"
  outer.style.cssText = "display:flex;gap:0.25rem;margin:0 0 0.5rem;font-size:0.85rem;align-items:center;flex-wrap:wrap"
  outer.value = "All"
  const lbl = document.createElement("span")
  lbl.className = "text-muted"
  lbl.style.cssText = "margin-right:0.4rem"
  lbl.textContent = "Events:"
  outer.appendChild(lbl)
  for (const t of ["All", "Passes", "Shots", "Defensive", "Set Pieces"]) {
    const btn = document.createElement("button")
    btn.className = "stats-cat-btn" + (t === "All" ? " active" : "")
    btn.textContent = t
    btn.addEventListener("click", () => {
      outer.querySelectorAll("button").forEach(b => b.classList.remove("active"))
      btn.classList.add("active")
      outer.value = t
      outer.dispatchEvent(new Event("input", { bubbles: true }))
    })
    outer.appendChild(btn)
  }
  return outer
}
Show code
{
  if (!paramHome || !paramAway) return html``
  if (!matchChains || matchChains.length === 0) {
    return html`<p class="text-muted">No chain data available for this match.</p>`
  }
  // chainHomeTeam/chainAwayTeam are the only safe source — they're normalized
  // against the same team_name column filterRows uses.
  const homeTeam = chainHomeTeam
  const awayTeam = chainAwayTeam

  // Vermillion + pure cobalt blue.
  //   Home  #D55E00 — vermillion, pops on green via hue-contrast (red ↔ green opposites)
  //   Away  #2563eb — Tailwind blue-600, MORE blue than the Wong sky blue we
  //         tried first (#56B4E9 had cyan undertones that read green-adjacent
  //         on the dark grass). 53% lightness keeps it clearly distinct from
  //         the pitch's ~30% lightness, and 84% saturation means even at half
  //         opacity it reads as blue rather than washing to muted grey-cyan.
  // Each cell takes ONE colour based on net dominance, so no blend-mode wash.
  const homeColor = "#D55E00"
  const awayColor = "#2563eb"

  const container = document.createElement("div")
  container.style.cssText = "max-width: 720px; margin: 0 auto"
  window.drawTerritorialMap(container, {
    chains: matchChains,
    homeTeam,
    awayTeam,
    homeColor,
    awayColor,
    teamFilter: territorialTeam,
    periodFilter: territorialPeriod,
    eventTypeFilter: territorialEventType
  })

  // Legend. In Both view, a gradient bar communicates the dominance scale
  // directly — the chart's mental model in one glance. In single-team view,
  // a simple swatch suffices since there's no comparison.
  const legend = document.createElement("div")
  legend.style.cssText = "display: flex; flex-direction: column; align-items: center; gap: 0.4rem; margin-top: 0.75rem; font-size: 0.78rem"

  if (territorialTeam === "Both") {
    const row = document.createElement("div")
    row.style.cssText = "display: flex; align-items: center; gap: 0.75rem"

    const homeName = document.createElement("span")
    homeName.style.cssText = `color: ${homeColor}; font-weight: 600; letter-spacing: 0.01em`
    homeName.textContent = homeTeam

    const bar = document.createElement("span")
    bar.style.cssText = `width: 180px; height: 8px; border-radius: 2px; background: linear-gradient(to right, ${homeColor}, rgba(255,255,255,0.18) 50%, ${awayColor}); display: inline-block; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.25)`
    bar.setAttribute("aria-hidden", "true")

    const awayName = document.createElement("span")
    awayName.style.cssText = `color: ${awayColor}; font-weight: 600; letter-spacing: 0.01em`
    awayName.textContent = awayTeam

    row.appendChild(homeName)
    row.appendChild(bar)
    row.appendChild(awayName)
    legend.appendChild(row)

    const caption = document.createElement("div")
    caption.style.cssText = "color: var(--bs-secondary-color, #94a3b8); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.1em"
    caption.textContent = "Dominated  ·  Contested  ·  Dominated"
    legend.appendChild(caption)
  } else {
    const isHome = territorialTeam === "Home"
    const color = isHome ? homeColor : awayColor
    const team = isHome ? homeTeam : awayTeam
    const dot = document.createElement("span")
    dot.style.cssText = `width: 14px; height: 14px; background: ${color}; opacity: 0.85; border-radius: 2px; display: inline-block; vertical-align: middle; margin-right: 0.5rem`
    const label = document.createElement("span")
    label.style.cssText = "color: var(--bs-body-color); font-weight: 600; vertical-align: middle"
    label.textContent = team + " — touch density"
    const row = document.createElement("div")
    row.appendChild(dot)
    row.appendChild(label)
    legend.appendChild(row)
  }
  container.appendChild(legend)

  return container
}

Pass Map

Show code
// Pass map visualization
{
  if (!paramHome || !paramAway) return html``
  if (matchChains.length === 0) return html`<p class="text-muted">No pass data available for this match.</p>`

  const NS = "http://www.w3.org/2000/svg"
  function svgEl(tag, attrs) {
    const el = document.createElementNS(NS, tag)
    if (attrs) Object.keys(attrs).forEach(k => el.setAttribute(k, attrs[k]))
    return el
  }

  const homeColor = "#6ba09a", awayColor = "#c4734a"

  // All passes
  const allPasses = matchChains.filter(d => (d.type_id === 1 || d.type_id === 2) && d.x != null && d.end_x != null)
  const homePasses = allPasses.filter(d => d.team_name === chainHomeTeam)
  const awayPasses = allPasses.filter(d => d.team_name === chainAwayTeam)

  if (allPasses.length === 0) return html`<p class="text-muted">No pass coordinate data available.</p>`

  // Key passes: passes that directly precede a shot in the same chain.
  // Tags the row itself with _isKeyPass — chain-parquet rows lack display_order
  // (only live-worker rows carry it), so the previous chain_number+display_order
  // lookup degenerated to chain_number+undefined and every pass in any chain
  // that ended in a shot got classified as a key pass. Tagging the row works
  // regardless of which order column the source provides.
  const shotTypes = new Set([13, 14, 15, 16])
  for (let i = 0; i < matchChains.length - 1; i++) {
    const a = matchChains[i], b = matchChains[i + 1]
    if (a.chain_number === b.chain_number && (a.type_id === 1 || a.type_id === 2) && a.outcome === 1 && shotTypes.has(b.type_id)) {
      a._isKeyPass = true
    }
  }

  // Pass stats
  const homeComplete = homePasses.filter(d => d.outcome === 1).length
  const awayComplete = awayPasses.filter(d => d.outcome === 1).length
  const homeKeyCount = homePasses.filter(d => d._isKeyPass).length
  const awayKeyCount = awayPasses.filter(d => d._isKeyPass).length

  // ── Container + summary cards ────────────────────────────────
  const el = document.createElement("div")

  const summaryDiv = document.createElement("div")
  summaryDiv.className = "shot-stats"
  const sumCards = [
    { v: homeComplete + " / " + homePasses.length, l: chainHomeTeam + " Passes" },
    { v: homePasses.length > 0 ? Math.round(homeComplete / homePasses.length * 100) + "%" : "—", l: chainHomeTeam + " Accuracy" },
    { v: awayComplete + " / " + awayPasses.length, l: chainAwayTeam + " Passes" },
    { v: awayPasses.length > 0 ? Math.round(awayComplete / awayPasses.length * 100) + "%" : "—", l: chainAwayTeam + " Accuracy" }
  ]
  for (const s of sumCards) {
    const card = document.createElement("div")
    card.className = "shot-stat"
    const valDiv = document.createElement("div")
    valDiv.className = "shot-stat-value"
    valDiv.textContent = s.v
    const lblDiv = document.createElement("div")
    lblDiv.className = "shot-stat-label"
    lblDiv.textContent = s.l
    card.appendChild(valDiv)
    card.appendChild(lblDiv)
    summaryDiv.appendChild(card)
  }
  el.appendChild(summaryDiv)

  // ── Half filter ──────────────────────────────────────────────
  const halfRow = document.createElement("div")
  halfRow.className = "epv-toggle"
  halfRow.style.marginTop = "0"
  const btnAll = document.createElement("button")
  btnAll.textContent = "Full Match"
  btnAll.className = "epv-toggle-btn active"
  const btn1H = document.createElement("button")
  btn1H.textContent = "1st Half"
  btn1H.className = "epv-toggle-btn"
  const btn2H = document.createElement("button")
  btn2H.textContent = "2nd Half"
  btn2H.className = "epv-toggle-btn"
  halfRow.appendChild(btnAll)
  halfRow.appendChild(btn1H)
  halfRow.appendChild(btn2H)
  el.appendChild(halfRow)

  // ── SVG container (side-by-side pitches) ─────────────────────
  const chartWrap = document.createElement("div")
  chartWrap.className = "pass-map-grid"
  el.appendChild(chartWrap)

  // ── Shared pitch drawing ─────────────────────────────────────
  function drawPitch(svg, id) {
    const defs = svgEl("defs")
    defs.appendChild(window.chartHelpers.createGrassPattern(id + "-grass", 110, 6))
    defs.appendChild(window.chartHelpers.createVignette(id + "-vig", { cy: "50%", opacity: 0.3 }))
    // Arrow markers — single semantic palette, not per-team. Complete = blue,
    // incomplete = red, key = yellow. Pete prefers outcome-driven coloring
    // because the team identity is already obvious from the side-by-side layout.
    for (const [mid, col] of [[id + "-arr", "rgba(96,165,250,0.7)"], [id + "-arr-f", "rgba(239,68,68,0.55)"], [id + "-arr-k", "rgba(250,204,21,0.85)"]]) {
      const m = svgEl("marker", { id: mid, viewBox: "0 0 10 10", refX: "10", refY: "5", markerWidth: "3.5", markerHeight: "3.5", orient: "auto-start-reverse" })
      m.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: col }))
      defs.appendChild(m)
    }
    svg.appendChild(defs)
    svg.appendChild(svgEl("rect", { x: "-5", y: "-5", width: "110", height: "78", fill: "url(#" + id + "-grass)" }))
    svg.appendChild(svgEl("rect", { x: "-5", y: "-5", width: "110", height: "78", fill: "url(#" + id + "-vig)" }))
    svg.appendChild(svgEl("rect", { x: "0", y: "0", width: "100", height: "68", fill: "none", stroke: "rgba(255,255,255,0.3)", "stroke-width": "0.4" }))
    svg.appendChild(svgEl("line", { x1: "50", y1: "0", x2: "50", y2: "68", stroke: "rgba(255,255,255,0.2)", "stroke-width": "0.3" }))
    svg.appendChild(svgEl("circle", { cx: "50", cy: "34", r: "8.7", fill: "none", stroke: "rgba(255,255,255,0.15)", "stroke-width": "0.3" }))
    svg.appendChild(svgEl("rect", { x: "0", y: "14.8", width: "15.7", height: "38.4", fill: "none", stroke: "rgba(255,255,255,0.2)", "stroke-width": "0.3" }))
    svg.appendChild(svgEl("rect", { x: "84.3", y: "14.8", width: "15.7", height: "38.4", fill: "none", stroke: "rgba(255,255,255,0.2)", "stroke-width": "0.3" }))
    svg.appendChild(svgEl("rect", { x: "-2", y: "30.5", width: "2", height: "7", fill: "none", stroke: "rgba(255,255,255,0.25)", "stroke-width": "0.3" }))
    svg.appendChild(svgEl("rect", { x: "100", y: "30.5", width: "2", height: "7", fill: "none", stroke: "rgba(255,255,255,0.25)", "stroke-width": "0.3" }))
  }

  // ── Draw pass map on a pitch SVG ─────────────────────────────
  // y-flip: Opta y=0 is attacker's right (same convention the Shot Map uses).
  // Broadcast camera puts attacker's right at the BOTTOM of viewport (camera
  // on south sideline, attacker facing east = right hand toward south = down).
  // Without the flip, attacker's right rendered at the top — Pete flagged
  // this on Forest's 2H pass map (ENG R38).
  function drawPassMap(passes, pitchId) {
    const yT = (y) => (100 - y) * 0.68
    const svg = svgEl("svg", { viewBox: "-5 -5 110 78", class: "touch-map-svg" })
    drawPitch(svg, pitchId)
    // Draw passes in groups: incomplete (under), complete (middle), key (top)
    const incomplete = passes.filter(d => d.outcome !== 1)
    const complete = passes.filter(d => d.outcome === 1 && !d._isKeyPass)
    const key = passes.filter(d => d.outcome === 1 && d._isKeyPass)
    const gInc = svgEl("g", { class: "pass-incomplete" })
    for (const p of incomplete) {
      gInc.appendChild(svgEl("line", {
        x1: p.x, y1: yT(p.y), x2: p.end_x, y2: yT(p.end_y),
        stroke: "rgba(239,68,68,0.4)", "stroke-width": "0.4",
        "marker-end": "url(#" + pitchId + "-arr-f)"
      }))
    }
    svg.appendChild(gInc)
    const gComp = svgEl("g", { class: "pass-complete" })
    for (const p of complete) {
      gComp.appendChild(svgEl("line", {
        x1: p.x, y1: yT(p.y), x2: p.end_x, y2: yT(p.end_y),
        stroke: "rgba(96,165,250,0.55)", "stroke-width": "0.4",
        "marker-end": "url(#" + pitchId + "-arr)"
      }))
    }
    svg.appendChild(gComp)
    const gKey = svgEl("g", { class: "pass-key" })
    for (const p of key) {
      gKey.appendChild(svgEl("line", {
        x1: p.x, y1: yT(p.y), x2: p.end_x, y2: yT(p.end_y),
        stroke: "rgba(250,204,21,0.85)", "stroke-width": "0.7",
        "marker-end": "url(#" + pitchId + "-arr-k)"
      }))
    }
    svg.appendChild(gKey)
    return svg
  }

  // ── Render function ──────────────────────────────────────────
  let currentHalf = 0

  function render() {
    chartWrap.textContent = ""

    // Filter passes by half
    const filterHalf = (arr) => currentHalf === 0 ? arr : arr.filter(d => d.period_id === currentHalf)
    const hPasses = filterHalf(homePasses)
    const aPasses = filterHalf(awayPasses)

    const homeCol = document.createElement("div")
    homeCol.className = "pass-map-col"
    const homeTitle = document.createElement("div")
    homeTitle.className = "squad-team-name"
    const hComp = hPasses.filter(d => d.outcome === 1).length
    const hKey = hPasses.filter(d => d._isKeyPass).length
    homeTitle.textContent = chainHomeTeam + " — " + hComp + "/" + hPasses.length + " (" + (hPasses.length > 0 ? Math.round(hComp / hPasses.length * 100) : 0) + "%)"
    homeCol.appendChild(homeTitle)

    const awayCol = document.createElement("div")
    awayCol.className = "pass-map-col"
    const awayTitle = document.createElement("div")
    awayTitle.className = "squad-team-name"
    const aComp = aPasses.filter(d => d.outcome === 1).length
    const aKey = aPasses.filter(d => d._isKeyPass).length
    awayTitle.textContent = chainAwayTeam + " — " + aComp + "/" + aPasses.length + " (" + (aPasses.length > 0 ? Math.round(aComp / aPasses.length * 100) : 0) + "%)"
    awayCol.appendChild(awayTitle)

    homeCol.appendChild(drawPassMap(hPasses, "pm-h"))
    awayCol.appendChild(drawPassMap(aPasses, "pm-a"))

    chartWrap.appendChild(homeCol)
    chartWrap.appendChild(awayCol)

    // Re-apply pass type filters after DOM rebuild
    for (const [type, visible] of Object.entries(passFilters)) {
      if (!visible) {
        chartWrap.querySelectorAll(".pass-" + type).forEach(g => { g.style.display = "none" })
      }
    }
  }

  const halfBtns = [btnAll, btn1H, btn2H]
  btnAll.addEventListener("click", () => { halfBtns.forEach(b => b.classList.remove("active")); btnAll.classList.add("active"); currentHalf = 0; render() })
  btn1H.addEventListener("click", () => { halfBtns.forEach(b => b.classList.remove("active")); btn1H.classList.add("active"); currentHalf = 1; render() })
  btn2H.addEventListener("click", () => { halfBtns.forEach(b => b.classList.remove("active")); btn2H.classList.add("active"); currentHalf = 2; render() })

  // Clickable legend — toggles pass type visibility on the SVGs
  const passFilters = { complete: true, incomplete: true, key: true }

  const legend = document.createElement("div")
  legend.className = "touch-map-legend"
  legend.style.marginTop = "0.5rem"
  for (const item of [
    { color: "#60a5fa", label: "Complete", filter: "complete" },
    { color: "#ef4444", label: "Incomplete", filter: "incomplete" },
    { color: "#facc15", label: "Key Pass", filter: "key" }
  ]) {
    const span = document.createElement("span")
    span.className = "touch-legend-item pass-legend-toggle"
    span.dataset.filter = item.filter
    const dot = document.createElement("span")
    dot.className = "touch-legend-dot"
    dot.style.background = item.color
    span.appendChild(dot)
    span.appendChild(document.createTextNode(" " + item.label))
    span.addEventListener("click", () => {
      passFilters[item.filter] = !passFilters[item.filter]
      // Update all legend items for this filter
      legend.querySelectorAll(`[data-filter="${item.filter}"]`).forEach(el => {
        el.classList.toggle("pass-legend-off", !passFilters[item.filter])
      })
      // Toggle SVG group visibility
      const cls = "pass-" + item.filter
      chartWrap.querySelectorAll("." + cls).forEach(g => {
        g.style.display = passFilters[item.filter] ? "" : "none"
      })
    })
    legend.appendChild(span)
  }
  el.appendChild(legend)

  // Initial render
  render()
  return el
}
Show code
// Side rail — BTN tiles from the same top-level cells the header card reads
// (liveScoresBest / _liveEvents / match), so the two can't disagree.
{
  const inner = document.createElement("div")
  inner.className = "side-rail-inner"
  const { railBlock, btnTile, tableSource } = window.editorial

  if (paramHome && paramAway) {
    const btn = railBlock("By the numbers")
    const finished = fixture && fixture.status === "FINISHED"
    const isLive = _liveEvents && _liveEvents.status === "Playing"
    if (liveScoresBest.home != null) {
      btn.appendChild(btnTile(`${liveScoresBest.home}–${liveScoresBest.away}`, [
        { text: finished ? "Final score" : isLive ? "Live score" : "Score" },
      ]))
    }
    const xg = (_liveEvents?.xg && typeof _liveEvents.xg.home === "number")
      ? _liveEvents.xg
      : (match && match.xg_home != null ? { home: match.xg_home, away: match.xg_away } : null)
    if (xg) {
      btn.appendChild(btnTile(`${xg.home.toFixed(1)}–${xg.away.toFixed(1)}`, [
        { text: "Expected goals" },
      ]))
    }
    if (match && match.pred_home_goals != null) {
      let verdict = ""
      if (finished && liveScoresBest.home != null) {
        const actual = liveScoresBest.home > liveScoresBest.away ? "H" : liveScoresBest.home < liveScoresBest.away ? "A" : "D"
        verdict = match.predicted_result === actual ? " · called it" : " · wrong call"
      }
      btn.appendChild(btnTile(`${match.pred_home_goals.toFixed(1)}:${match.pred_away_goals.toFixed(1)}`, [
        { text: `Model scoreline, pre-match${verdict}` },
      ]))
    }
    if (_liveEvents?.xg?.shots) {
      btn.appendChild(btnTile(String(_liveEvents.xg.shots), [{ text: "Shots, both teams" }]))
    }
    inner.appendChild(btn)

    // ── The story — ONE notable thing about this match, or nothing at all.
    // Candidate facts each carry a notability score; the best one shows only
    // if it clears the bar, so ordinary games stay quiet ("3 red cards" is a
    // story; "0 red cards" never is). All current facts clear the 25 bar —
    // it exists for future low-grade facts.
    const story = (() => {
      const rows = matchChains
      if (!rows.length) return null
      const hName = paramHome, aName = paramAway
      const homeTeam = rows[0]?.home_team || hName
      const facts = []
      // Dismissals (live rows only — the chains parquet keeps type-17 rows
      // but exports no card-colour qualifier, so `card` is absent there)
      const reds = rows.filter(r => r.card === "red" || r.card === "second_yellow")
      if (reds.length >= 2) facts.push({ score: 80, text: `${reds.length} red cards in one match — ${reds.map(r => r.player_name).filter(Boolean).join(", ")} all saw red.` })
      else if (reds.length === 1) {
        const r = reds[0]
        facts.push({ score: 50, text: `${r.player_name || "A player"} was sent off in the ${r.minute}' — ${r.team_name || "his side"} finished a man down.` })
      }
      // Goal-based facts (open play + ET, OGs flagged separately)
      const goals = rows.filter(r => r.type_id === 16 && (r.period_id == null || r.period_id < 5))
        .sort((x, y) => (x.minute || 0) - (y.minute || 0) || (x.second || 0) - (y.second || 0))
      const og = goals.find(r => r.x != null && r.x < 50)
      if (og) facts.push({ score: 35, text: `${og.player_name || "Someone"} put through his own net in the ${og.minute}'.` })
      // Hat-trick (exclude OGs)
      const tally = new Map()
      for (const g of goals) {
        if (g.x != null && g.x < 50) continue
        if (g.player_name) tally.set(g.player_name, (tally.get(g.player_name) || 0) + 1)
      }
      for (const [name, n] of tally) {
        if (n >= 3) facts.push({ score: 70, text: `${name} scored ${n === 3 ? "a hat-trick" : n + " goals"}.` })
      }
      const fh = liveScoresBest.home, fa = liveScoresBest.away
      if (fh != null && fa != null && goals.length) {
        // Comeback: did the eventual winner trail?
        let h = 0, a = 0, maxDeficit = 0
        const winner = fh > fa ? "home" : fh < fa ? "away" : null
        for (const g of goals) {
          const ogFlip = g.x != null && g.x < 50
          const forHome = ogFlip ? g.team_name !== homeTeam : g.team_name === homeTeam
          if (forHome) h++; else a++
          if (winner === "home") maxDeficit = Math.max(maxDeficit, a - h)
          if (winner === "away") maxDeficit = Math.max(maxDeficit, h - a)
        }
        if (winner && maxDeficit >= 2) facts.push({ score: 85, text: `${winner === "home" ? hName : aName} won it from ${maxDeficit} goals down.` })
        else if (winner && maxDeficit === 1) facts.push({ score: 40, text: `${winner === "home" ? hName : aName} came from behind to win.` })
        // Late decider: last goal at 85'+ broke a tie
        const last = goals[goals.length - 1]
        if (winner && Math.abs(fh - fa) === 1 && (last.minute || 0) >= 85) {
          facts.push({ score: 55, text: `The winner didn't come until the ${last.minute}' — ${last.player_name || "the decisive goal"} settled it.` })
        }
        // Early opener
        if ((goals[0].minute || 0) <= 2) facts.push({ score: 30, text: `${goals[0].player_name || "The opener"} scored inside ${Math.max(1, goals[0].minute)} minute${goals[0].minute === 1 ? "" : "s"}.` })
      }
      // Shootout drama
      if (_liveEvents?.scores?.pen) {
        const p = _liveEvents.scores.pen
        facts.push({ score: 65, text: `Decided from the spot — ${p.home}–${p.away} on penalties after a ${fh ?? "?"}–${fa ?? "?"} draw.` })
      }
      // xG stories (uses the same xg the tiles show)
      if (xg && fh != null && fa != null) {
        const winnerXg = fh > fa ? xg.home : fa > fh ? xg.away : null
        const loserXg = fh > fa ? xg.away : fa > fh ? xg.home : null
        if (winnerXg != null && loserXg != null && loserXg - winnerXg >= 1) {
          facts.push({ score: 60, text: `${fh > fa ? aName : hName} won the xG battle ${loserXg.toFixed(1)}–${winnerXg.toFixed(1)} and still lost — finishing, or a keeper having a day.` })
        }
        const xMax = Math.max(xg.home, xg.away), xMin = Math.min(xg.home, xg.away)
        if (xMax >= 1.2 && xMin >= 0 && xMax / Math.max(xMin, 0.05) >= 5) {
          facts.push({ score: 30, text: `${xg.home > xg.away ? hName : aName} owned the chances — ${xMax.toFixed(1)} xG to ${xMin.toFixed(1)}. One-way traffic.` })
        }
      }
      // Upset vs the pre-match model
      if (match && fh != null && fa != null && fh !== fa) {
        const winProb = fh > fa ? match.prob_H : match.prob_A
        if (winProb != null && winProb <= 0.20) {
          facts.push({ score: 58, text: `The model gave ${fh > fa ? hName : aName} just ${(winProb * 100).toFixed(0)}% — upset delivered.` })
        }
      }
      facts.sort((x, y) => y.score - x.score)
      return facts.length && facts[0].score >= 25 ? facts[0].text : null
    })()
    if (story) {
      const sb = railBlock("The story")
      const d = document.createElement("div")
      d.className = "match-story"
      d.textContent = story
      sb.appendChild(d)
      inner.appendChild(sb)
    }

    const hashParams = `#league=${encodeURIComponent(paramLeague)}&date=${encodeURIComponent(paramDate)}&home=${encodeURIComponent(paramHome)}&away=${encodeURIComponent(paramAway)}${_optaId ? `&optaId=${encodeURIComponent(_optaId)}` : ""}`
    const teamHref = (t) => paramLeague === "WC"
      ? `world-cup-team.html#team=${encodeURIComponent(t)}`
      : `team.html#team=${encodeURIComponent(t)}`
    // No Match Events link here — that page needs a &player= param (it's
    // per-player), reached by clicking a name in the Value tab below.
    const links = railBlock("Read next")
    links.innerHTML = `
      <div><a href="match-chains.html${hashParams}"><strong>Chain Visualizer</strong></a><br><span class="text-muted" style="font-size:0.78rem">How each possession moved the ball</span></div>
      <div style="margin-top:0.7rem"><a href="${teamHref(paramHome)}"><strong>${statsEsc(paramHome)}</strong></a> · <a href="${teamHref(paramAway)}"><strong>${statsEsc(paramAway)}</strong></a><br><span class="text-muted" style="font-size:0.78rem">Team profiles</span></div>
      <div style="margin-top:0.7rem"><a href="matches.html"><strong>All matches</strong></a><br><span class="text-muted" style="font-size:0.78rem">Every league, every matchday</span></div>`
    inner.appendChild(links)
  }

  const _md = paramDateKey ? new Date(paramDateKey + "T12:00:00Z") : null
  const matchAsAt = (_md && !isNaN(_md.getTime()))
    ? "Match " + _md.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
    : "Live during play"
  inner.appendChild(tableSource({
    source: "pannadata", sourceUrl: "https://github.com/peteowen1/pannadata",
    sourceNote: "Opta event feed", license: "CC BY 4.0",
    asAt: matchAsAt,
    hint: "Batch-verified after the pipeline rebuild",
  }))
  return inner
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer