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 Events

Skip to content

Football · Match Centre · Event Log

Every action, valued

The full Opta event log for one player or one match — every pass, duel, carry and shot, scored for how much it moved expected possession value (EPV) and win probability (WPA).

Player event breakdown from Opta event data. Success = completed action. Fail = incomplete action. When EPV data is available, equity per action is shown.

Show code
statsEsc = window.statsEsc
statsTable = window.statsTable

fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL
Show code
paramLeague = window._getHashParam("league") || ""
paramDate = window._getHashParam("date") || ""
// Some upstream callers emit full ISO timestamps; downstream comparisons
// expect YYYY-MM-DD, so derive once.
paramDateKey = (paramDate || "").slice(0, 10)
paramPlayer = {
  const raw = window._getHashParam("player")
  return raw ? raw.replace(/\+/g, " ") : ""
}
Show code
leagueNames = window.footballMaps.leagueNames

optaEventTypes = ({
  1: "Pass", 2: "Offside Pass", 3: "Take On", 4: "Foul", 5: "Ball Out",
  6: "Corner Awarded", 7: "Tackle", 8: "Interception", 9: "Turnover",
  10: "Save", 11: "Claim", 12: "Clearance", 13: "Miss", 14: "Post",
  15: "Attempt Saved", 16: "Goal", 17: "Card", 41: "Punch", 42: "Good Skill",
  44: "Aerial", 45: "Challenge", 49: "Ball Recovery", 50: "Blocked Pass",
  51: "Delay of Play", 52: "Keeper Pick-up", 53: "Chance Missed",
  54: "Ball Touch", 55: "Temp Goal", 56: "Resume Play",
  57: "Contentious Decision", 61: "Ball Touch", 67: "Offside",
  68: "Offside Provoked", 70: "Shield Ball", 74: "Injury Clearance",
  77: "Keeper Sweeper", 80: "Chance Missed", 83: "1-on-1", 84: "Unknown"
})
Show code
paramOptaId = window._getHashParam("optaId") || ""

chainsRaw = {
  const rows = paramLeague ? await fetchParquet(base_url + `football/chains-${paramLeague}.parquet`) : null
  // Forward-compat: alias a future pannadata `epv_credit` column onto `equity`
  // (the chains per-action EPV CREDIT) so getEpv keeps working through a rename.
  if (rows) for (const r of rows) if (r.equity == null && r.epv_credit != null) r.equity = r.epv_credit
  return rows
}

// Live events fallback from Worker
_liveEvents = {
  if (!paramOptaId) return null
  const WORKER = window.WORKER_BASE_URL || "https://inthegame-api.pete-owen1.workers.dev/"
  try {
    const res = await fetch(`${WORKER}football/live-events/${paramOptaId}`, { signal: AbortSignal.timeout(12000) })
    if (!res.ok) { console.warn("[match-events] Live events:", res.status); return null }
    return await res.json()
  } catch (e) { console.warn("[match-events] Live events fetch failed:", e.message); return null }
}

_liveChainRows = {
  if (!_liveEvents || !_liveEvents.rows) return []
  const home = _liveEvents.home?.name || ""
  const away = _liveEvents.away?.name || ""
  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-event EPV + WPA credit (worker-scored) for the action-by-action view.
    epv_delta: typeof r.epv_disp === "number" || typeof r.epv_recv === "number"
      ? (r.epv_disp || 0) + (r.epv_recv || 0)
      : (typeof r.epv_delta === "number" ? r.epv_delta : null),
    wpa: typeof r.wpa_actor === "number" || typeof r.wpa_receiver === "number"
      ? (r.wpa_actor || 0) + (r.wpa_receiver || 0)
      : (typeof r.wpa === "number" ? r.wpa : null),
  }))
}
Show code
playerEvents = {
  // When an Opta match id is present, the worker supplies per-event WPA (the
  // chains parquet only carries EPV/equity), so prefer it for the action-by-
  // action view — the EPV and WPA columns then come from one self-consistent
  // source. Falls back to the chains parquet (EPV only) when there's no optaId.
  if (paramOptaId && _liveChainRows.length > 0) {
    console.log("[match-events] Using worker live events (per-event WPA):", _liveChainRows.length)
    return _liveChainRows
  }
  // Try parquet data next
  if (chainsRaw && paramPlayer && paramDateKey) {
    const playerLower = paramPlayer.toLowerCase()
    const playerRows = chainsRaw.filter(d =>
      d.player_name && d.player_name.toLowerCase() === playerLower
    )
    if (playerRows.length > 0) {
      const matchIds = [...new Set(playerRows.map(d => d.match_id))]
      if (matchIds.length === 1) return chainsRaw.filter(d => d.match_id === matchIds[0])
      for (const mid of matchIds) {
        const sample = chainsRaw.find(d => d.match_id === mid)
        if (sample) {
          const dateStr = String(sample.match_date || sample.date || "").replace("Z", "").slice(0, 10)
          if (dateStr === paramDateKey) return chainsRaw.filter(d => d.match_id === mid)
        }
      }
      return chainsRaw.filter(d => d.match_id === matchIds[0])
    }
  }

  // Fallback: live events from Worker
  if (_liveChainRows.length > 0) {
    console.log("[match-events] Using live Opta events:", _liveChainRows.length)
    return _liveChainRows
  }

  return []
}

// Filter to just this player
playerOnly = {
  if (playerEvents.length === 0) return []
  const playerLower = paramPlayer.toLowerCase()
  return playerEvents
    .filter(d => d.player_name && d.player_name.toLowerCase() === playerLower)
    .sort((a, b) => (a.period_id || 0) - (b.period_id || 0) || (a.minute || 0) - (b.minute || 0) || (a.second || 0) - (b.second || 0))
}

// Get match context
matchContext = {
  if (playerEvents.length === 0) return null
  const first = playerEvents[0]
  return {
    home_team: first.home_team || "",
    away_team: first.away_team || "",
    match_id: first.match_id
  }
}

// Which team is this player on?
playerTeam = {
  if (playerOnly.length === 0) return ""
  return playerOnly[0].team_name || ""
}

// Opta player_id for the headshot lookup (football/headshots/{id}.webp). Both the
// worker live-events rows and the chains parquet carry player_id, so this resolves
// the same key the match shot-map tooltip uses — the photo renders when it exists,
// and the initials circle stays as the fallback when it doesn't.
playerId = {
  if (playerOnly.length === 0) return ""
  const r = playerOnly.find(d => d.player_id != null && d.player_id !== "")
  return r ? String(r.player_id) : ""
}
Show code
// Cell 6: Header (breadcrumb + player card + match context)
{
  if (!paramPlayer || !paramLeague) {
    return html`<div class="breadcrumb"><a href="index.html">Football</a> > <a href="matches.html">Matches</a></div>
    <p class="text-muted">No player selected. Go to a <a href="matches.html">matches page</a> and click a player name.</p>`
  }

  const leagueLabel = leagueNames[paramLeague] || paramLeague
  const playerLink = `player.html#name=${encodeURIComponent(paramPlayer)}`

  // Match page link
  let matchLink = ""
  if (matchContext) {
    matchLink = `match/#league=${encodeURIComponent(paramLeague)}&date=${encodeURIComponent(paramDate)}&home=${encodeURIComponent(matchContext.home_team)}&away=${encodeURIComponent(matchContext.away_team)}`
  }

  const matchLabel = matchContext
    ? `${matchContext.home_team} vs ${matchContext.away_team}`
    : ""

  const dateLabel = paramDate || ""
  const initials = paramPlayer.split(" ").map(w => w[0]).join("").substring(0, 2).toUpperCase()

  const el = document.createElement("div")

  // Breadcrumb
  const bc = document.createElement("div")
  bc.className = "breadcrumb"
  const bcFootball = document.createElement("a")
  bcFootball.href = "index.html"
  bcFootball.textContent = "Football"
  bc.appendChild(bcFootball)
  bc.appendChild(document.createTextNode(" > "))
  const bcPred = document.createElement("a")
  bcPred.href = "matches.html"
  bcPred.textContent = "Matches"
  bc.appendChild(bcPred)
  bc.appendChild(document.createTextNode(" > "))
  if (matchLink) {
    const bcMatch = document.createElement("a")
    bcMatch.href = matchLink
    bcMatch.textContent = matchLabel
    bc.appendChild(bcMatch)
    bc.appendChild(document.createTextNode(" > "))
  }
  const bcPlayer = document.createElement("a")
  bcPlayer.href = playerLink
  bcPlayer.textContent = paramPlayer
  bc.appendChild(bcPlayer)
  bc.appendChild(document.createTextNode(" > Match Events"))
  el.appendChild(bc)

  // Player header
  const header = document.createElement("div")
  header.className = "player-header"

  // Headshot rule (site-wide): photo OR initials circle, never an empty gap.
  // The initials sit behind the <img>; the img fills the circle and is hidden
  // on 404, revealing the initials. player_id keys football/headshots/{id}.webp
  // — the same lookup the match shot-map tooltip uses (#228 mismatch was a
  // different parquet's id; the chains/live-events player_id resolves correctly).
  const avatar = document.createElement("div")
  avatar.className = "player-avatar player-avatar--hs"
  avatar.style.background = "#5dadec18"
  avatar.style.color = "#5dadec"
  avatar.style.border = "2px solid #5dadec"
  const avInit = document.createElement("span")
  avInit.className = "player-avatar-init"
  avInit.textContent = initials
  avatar.appendChild(avInit)
  if (playerId) {
    const img = document.createElement("img")
    img.className = "player-avatar-img"
    img.src = base_url + "football/headshots/" + encodeURIComponent(playerId) + ".webp"
    img.alt = paramPlayer
    img.loading = "lazy"
    img.onerror = function () { this.style.display = "none" }
    avatar.appendChild(img)
  }

  const info = document.createElement("div")
  info.className = "player-info"

  const nameEl = document.createElement("div")
  nameEl.className = "player-name"
  nameEl.textContent = paramPlayer

  const meta = document.createElement("div")
  meta.className = "player-meta"
  meta.textContent = playerTeam ? playerTeam + " · " + leagueLabel : leagueLabel

  info.appendChild(nameEl)
  info.appendChild(meta)

  if (matchLabel) {
    const ctx = document.createElement("div")
    ctx.className = "events-match-context"
    const parts = [matchLabel, dateLabel].filter(Boolean)
    ctx.textContent = parts.join(" · ")
    info.appendChild(ctx)
  }

  header.appendChild(avatar)
  header.appendChild(info)
  el.appendChild(header)

  return el
}
Show code
// Cell 7: Event summary grid
{
  if (!paramPlayer || !paramLeague) return html``

  if (chainsRaw === null) {
    return html`<h2>Event Summary</h2><p class="text-muted">Chain data could not be loaded for league "${statsEsc(paramLeague)}".</p>`
  }

  if (playerOnly.length === 0) {
    return html`<h2>Event Summary</h2><p class="text-muted">No events found for ${statsEsc(paramPlayer)} on ${statsEsc(paramDate)}.</p>`
  }

  // Count events by category
  const passAttempted = playerOnly.filter(d => d.type_id === 1 || d.type_id === 2).length
  const passCompleted = playerOnly.filter(d => (d.type_id === 1 || d.type_id === 2) && d.outcome === 1).length

  const shotTypes = [13, 14, 15, 16]
  const shotsTotal = playerOnly.filter(d => shotTypes.includes(d.type_id)).length
  const shotsOnTarget = playerOnly.filter(d => d.type_id === 15 || d.type_id === 16).length
  const goals = playerOnly.filter(d => d.type_id === 16).length

  const tackleTotal = playerOnly.filter(d => d.type_id === 7).length
  const tackleWon = playerOnly.filter(d => d.type_id === 7 && d.outcome === 1).length

  const aerialTotal = playerOnly.filter(d => d.type_id === 44).length
  const aerialWon = playerOnly.filter(d => d.type_id === 44 && d.outcome === 1).length

  const interceptions = playerOnly.filter(d => d.type_id === 8).length
  const clearances = playerOnly.filter(d => d.type_id === 12).length
  const fouls = playerOnly.filter(d => d.type_id === 4).length
  const ballRecoveries = playerOnly.filter(d => d.type_id === 49).length
  const takeOns = playerOnly.filter(d => d.type_id === 3).length
  const takeOnsWon = playerOnly.filter(d => d.type_id === 3 && d.outcome === 1).length

  const el = document.createElement("div")
  const h2 = document.createElement("h2")
  h2.textContent = "Event Summary"
  h2.style.marginTop = "0"
  el.appendChild(h2)

  const grid = document.createElement("div")
  grid.className = "events-summary-cards"

  const items = [
    { label: "Passes", value: `${passCompleted} / ${passAttempted}`, sub: passAttempted > 0 ? Math.round(passCompleted / passAttempted * 100) + "% accuracy" : "" },
    { label: "Shots", value: `${shotsOnTarget} / ${shotsTotal}`, sub: goals > 0 ? goals + " goal" + (goals > 1 ? "s" : "") : "on target" },
    { label: "Tackles", value: `${tackleWon} / ${tackleTotal}`, sub: tackleTotal > 0 ? Math.round(tackleWon / tackleTotal * 100) + "% won" : "" },
    { label: "Aerials", value: `${aerialWon} / ${aerialTotal}`, sub: aerialTotal > 0 ? Math.round(aerialWon / aerialTotal * 100) + "% won" : "" },
    { label: "Take Ons", value: `${takeOnsWon} / ${takeOns}`, sub: takeOns > 0 ? Math.round(takeOnsWon / takeOns * 100) + "% success" : "" },
    { label: "Interceptions", value: String(interceptions), sub: "" },
    { label: "Clearances", value: String(clearances), sub: "" },
    { label: "Ball Recoveries", value: String(ballRecoveries), sub: "" },
    { label: "Fouls", value: String(fouls), sub: "" }
  ]

  for (const item of items) {
    const card = document.createElement("div")
    card.className = "event-summary-card"

    const lbl = document.createElement("div")
    lbl.className = "event-card-label"
    lbl.textContent = item.label
    card.appendChild(lbl)

    const val = document.createElement("div")
    val.className = "event-card-value"
    val.textContent = item.value
    card.appendChild(val)

    if (item.sub) {
      const sub = document.createElement("div")
      sub.className = "event-card-sub"
      sub.textContent = item.sub
      card.appendChild(sub)
    }

    grid.appendChild(card)
  }

  el.appendChild(grid)
  return el
}
Show code
// Cell 7b: EPV Summary Grid (category x phase) — only shown when EPV data is available.
// INVARIANT: the grand Total here MUST equal the Event Log's final running "EPV Σ"
// (Cell 8). They are the same quantity (sum of per-action EPV credit over every one
// of this player's events), so showing two different numbers destroys trust. To hold
// the invariant this cell (a) buckets EVERY event — uncategorised types fall into an
// "Other" row instead of being silently dropped (the old bug: type 50 Blocked Pass /
// 61 Ball Touch carried EPV but mapped to "Other" and were discarded), and (b) keeps a
// column for EVERY period present (incl. ET/pens), not just H1/H2.
{
  if (!paramPlayer || !paramLeague) return html``
  if (playerOnly.length === 0) return html``

  // Check if EPV data exists (equity or epv_delta column)
  const hasEpv = playerOnly.some(d => d.equity != null || d.epv_delta != null)
  if (!hasEpv) return html``

  // Prefer epv_delta (per-action EPV CREDIT) over equity. On worker live-events
  // rows `equity` is the EPV STATE (0-1) and epv_delta is the credit — summing
  // state would be meaningless, so use the credit. On chains-parquet rows there
  // is no epv_delta and `equity` IS the per-action credit, so it falls through.
  // MUST be byte-for-byte identical to Cell 8's getEpv so the totals reconcile.
  const getEpv = (d) => d.epv_delta ?? d.equity ?? 0

  // Football categories — mapped from SPADL action types. Anything not listed
  // here lands in "Other" (carries, ball touches, blocked passes, ball recoveries
  // not flagged defensive, …) — and "Other" IS displayed, so no EPV is dropped.
  const categoryMap = {
    1: "Passing", 2: "Passing", 50: "Passing", // Pass, Offside Pass, Blocked Pass
    3: "Dribbling", 42: "Dribbling", 54: "Dribbling", 61: "Dribbling", // Take On, Good Skill, Ball Touch
    7: "Defending", 8: "Defending", 12: "Defending", 44: "Defending", 45: "Defending", 49: "Defending", // Tackle, Intercept, Clearance, Aerial, Challenge, Recovery
    13: "Shooting", 14: "Shooting", 15: "Shooting", 16: "Shooting", 53: "Shooting", 80: "Shooting", // Miss, Post, Attempt Saved, Goal, Chance Missed
    4: "Negatives", 9: "Negatives", 5: "Negatives", 51: "Negatives" // Foul, Turnover, Ball Out, Delay
  }
  function getCategory(typeId) { return categoryMap[typeId] || "Other" }

  // Category row order — "Other" last so the named buckets read first.
  const catOrder = ["Passing", "Shooting", "Dribbling", "Defending", "Negatives", "Other"]

  // Phase columns: keep one per period actually present (1H/2H/ET1/ET2/Pens),
  // plus a fallback "—" column for events missing period_id, so every event's
  // EPV has a home and the column totals reconcile with the grand total.
  const phaseLabel = (p) => ({ 1: "1H", 2: "2H", 3: "ET1", 4: "ET2", 5: "Pens" })[p] || (p != null ? "P" + p : "—")
  const phaseKey = (ev) => ev.period_id == null ? "_" : ev.period_id
  const presentPhases = [...new Set(playerOnly.map(phaseKey))]
    .sort((a, b) => (a === "_" ? 1e9 : a) - (b === "_" ? 1e9 : b))

  // Aggregate EPV credit by category x phase over EVERY event.
  const grid = {}
  for (const cat of catOrder) { grid[cat] = {}; for (const ph of presentPhases) grid[cat][ph] = 0 }
  for (const ev of playerOnly) {
    const cat = getCategory(ev.type_id)
    grid[cat][phaseKey(ev)] += getEpv(ev)
  }

  // Only show categories that carry value.
  const categories = catOrder.filter(cat => presentPhases.some(ph => Math.abs(grid[cat][ph]) > 0.001))
  if (categories.length === 0) return html``

  const rowTotals = {}
  for (const cat of categories) rowTotals[cat] = presentPhases.reduce((s, ph) => s + grid[cat][ph], 0)
  const colTotals = {}
  for (const ph of presentPhases) colTotals[ph] = categories.reduce((s, cat) => s + grid[cat][ph], 0)
  const grandTotal = categories.reduce((s, cat) => s + rowTotals[cat], 0)

  const allVals = []
  for (const cat of categories) { for (const ph of presentPhases) allVals.push(grid[cat][ph]); allVals.push(rowTotals[cat]) }
  for (const ph of presentPhases) allVals.push(colTotals[ph])
  allVals.push(grandTotal)
  const maxAbs = Math.max(...allVals.map(Math.abs), 0.01)

  function cellColor(val) {
    const norm = val / maxAbs
    if (norm > 0) return `rgba(52, 211, 153, ${Math.min(Math.abs(norm) * 0.35, 0.35)})`
    if (norm < 0) return `rgba(248, 113, 113, ${Math.min(Math.abs(norm) * 0.35, 0.35)})`
    return "transparent"
  }
  function fmtVal(val) { return (val >= 0 ? "+" : "") + val.toFixed(2) }

  const el = document.createElement("div")
  const h2 = document.createElement("h2")
  h2.textContent = "EPV Summary"
  h2.style.marginTop = "0"
  el.appendChild(h2)

  const note = document.createElement("p")
  note.className = "text-muted"
  note.style.cssText = "font-size:0.82rem;margin:0.1rem 0 0.6rem"
  note.textContent = "Per-action EPV credit grouped by action type and match phase. " +
    "The Total reconciles with the running “EPV Σ” in the Event Log below."
  el.appendChild(note)

  const table = document.createElement("table")
  table.className = "events-summary-grid"

  const thead = document.createElement("thead")
  const headRow = document.createElement("tr")
  headRow.appendChild(document.createElement("th"))
  for (const ph of presentPhases) { const th = document.createElement("th"); th.textContent = phaseLabel(ph === "_" ? null : ph); headRow.appendChild(th) }
  const totalTh = document.createElement("th"); totalTh.textContent = "Total"; headRow.appendChild(totalTh)
  thead.appendChild(headRow)
  table.appendChild(thead)

  const tbody = document.createElement("tbody")
  for (const cat of categories) {
    const tr = document.createElement("tr")
    const th = document.createElement("th"); th.textContent = cat; tr.appendChild(th)
    for (const ph of presentPhases) {
      const td = document.createElement("td"); td.textContent = fmtVal(grid[cat][ph]); td.style.background = cellColor(grid[cat][ph]); tr.appendChild(td)
    }
    const tdT = document.createElement("td"); tdT.textContent = fmtVal(rowTotals[cat]); tdT.style.background = cellColor(rowTotals[cat]); tdT.className = "total-col"; tr.appendChild(tdT)
    tbody.appendChild(tr)
  }

  const totalRow = document.createElement("tr"); totalRow.className = "total-row"
  const totalTh2 = document.createElement("th"); totalTh2.textContent = "Total"; totalRow.appendChild(totalTh2)
  for (const ph of presentPhases) { const td = document.createElement("td"); td.textContent = fmtVal(colTotals[ph]); td.style.background = cellColor(colTotals[ph]); totalRow.appendChild(td) }
  const tdG = document.createElement("td"); tdG.textContent = fmtVal(grandTotal); tdG.style.background = cellColor(grandTotal); tdG.className = "total-col"; totalRow.appendChild(tdG)
  tbody.appendChild(totalRow)

  table.appendChild(tbody)
  el.appendChild(table)
  return el
}
Show code
// Cell 8: Event log table
{
  if (!paramPlayer || !paramLeague) return html``
  // Don't gate on the chains parquet — playerOnly may come from the worker
  // live-events (the only source for leagues without a chains-<league>.parquet,
  // e.g. UCL/UEL/World Cup). The empty-playerOnly guard below covers loading.
  if (playerOnly.length === 0) return html``

  const hasEpv = playerOnly.some(d => d.equity != null || d.epv_delta != null)
  // Prefer epv_delta (per-action EPV CREDIT) over equity. On worker live-events
  // rows `equity` is the EPV STATE (0-1) and epv_delta is the credit — summing
  // state would be meaningless, so use the credit. On chains-parquet rows there
  // is no epv_delta and `equity` IS the per-action credit, so it falls through.
  const getEpv = (d) => d.epv_delta ?? d.equity ?? 0
  // Per-event WPA is only present on worker-sourced rows (the chains parquet
  // carries EPV but not WPA). When available it lets the user see exactly which
  // actions moved win probability — the action-by-action explanation of the
  // player's match WPA total.
  const hasWpa = playerOnly.some(d => typeof d.wpa === "number")

  // Readable match-phase label from Opta period_id.
  const phaseLabel = (p) => ({ 1: "1H", 2: "2H", 3: "ET1", 4: "ET2", 5: "Pens" })[p] || (p != null ? p : "")

  // Compute cumulative EPV / WPA if available
  let cum = 0, cumW = 0
  const sorted = [...playerOnly]
  const tableData = sorted.map(d => {
    const epv = hasEpv ? getEpv(d) : null
    if (epv != null) cum += epv
    const wpa = (hasWpa && typeof d.wpa === "number") ? d.wpa : null
    if (wpa != null) cumW += wpa
    return {
      minute: d.minute != null ? d.minute + ":" + String(d.second || 0).padStart(2, "0") : "",
      // period_id may be null (worker emits null for missing fields via intOrNull);
      // render as blank rather than "0" so a real period 0 isn't ambiguous with missing.
      period: phaseLabel(d.period_id),
      event_type: optaEventTypes[d.type_id] || ("Event " + d.type_id),
      outcome: d.outcome === 1 ? "Success" : "Fail",
      outcome_val: d.outcome === 1 ? 1 : 0,
      x: d.x != null ? d.x.toFixed(1) : "",
      y: d.y != null ? d.y.toFixed(1) : "",
      epv: epv != null ? epv : null,
      cum_epv: hasEpv ? cum : null,
      wpa: wpa,
      cum_wpa: hasWpa ? cumW : null
    }
  })

  const el = document.createElement("div")
  const h2 = document.createElement("h2")
  h2.textContent = "Event Log"
  el.appendChild(h2)
  if (hasWpa) {
    const note = document.createElement("p")
    note.className = "text-muted"
    note.style.cssText = "font-size:0.82rem;margin:0.1rem 0 0.6rem"
    note.textContent = "EPV is in expected-goals units; WPA is the win-probability shift per action. " +
      "These per-event values come from the live model — use the running totals to see which actions moved the needle. " +
      "The match Value tab reports the batch-pipeline total, which runs lower, so treat the figure here as the per-action shape rather than the headline number."
    el.appendChild(note)
  }

  const baseCols = ["minute", "period", "event_type", "outcome"]
  const valCols = []
  if (hasEpv) valCols.push("epv", "cum_epv")
  if (hasWpa) valCols.push("wpa", "cum_wpa")
  const columns = [...baseCols, ...valCols, "x", "y"]

  const header = {
    minute: "Time", period: "Phase", event_type: "Event", outcome: "Outcome",
    epv: "EPV", cum_epv: "EPV Σ", wpa: "WPA", cum_wpa: "WPA Σ", x: "x", y: "y"
  }

  const render = {
    outcome: (v, row) => {
      const cls = row.outcome_val === 1 ? "outcome-success" : "outcome-fail"
      return `<span class="${cls}">${statsEsc(v)}</span>`
    }
  }

  const epvCell = (v) => {
    if (v == null) return ""
    const cls = v >= 0 ? "epv-pos" : "epv-neg"
    return `<span class="${cls}">${(v >= 0 ? "+" : "") + v.toFixed(2)}</span>`
  }
  const wpaCell = (v) => {
    if (v == null) return ""
    const cls = v >= 0 ? "epv-pos" : "epv-neg"
    return `<span class="${cls}">${(v >= 0 ? "+" : "") + (v * 100).toFixed(1) + "%"}</span>`
  }
  if (hasEpv) { render.epv = epvCell; render.cum_epv = epvCell }
  if (hasWpa) { render.wpa = wpaCell; render.cum_wpa = wpaCell }

  const heatmap = { outcome_val: "high-good" }
  if (hasEpv) { heatmap.epv = "diverging"; heatmap.cum_epv = "diverging" }
  if (hasWpa) { heatmap.wpa = "diverging"; heatmap.cum_wpa = "diverging" }

  el.appendChild(statsTable(tableData, { columns, header, render, heatmap, sort: null, rows: 200 }))

  // ── Source attribution row ──────────────────────────────────
  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"
  el.appendChild(window.editorial.tableSource({
    source: "pannadata",
    sourceUrl: "https://github.com/peteowen1/pannadata",
    asAt: matchAsAt,
    hint: "Per-action EPV/WPA from the live model"
  }))
  return el
}
Show code
panelLabel = function (text) {
  const lbl = document.createElement("div")
  lbl.className = "pmp-label"
  lbl.textContent = text
  return lbl
}

// A labelled row of single-select filter chips (shot-map chip styling).
// `sels[dim]` holds the active value; "All" => null. Re-runs `applyFilter`.
makeFilterGroup = function (panelLabel, label, dim, chips, sels, applyFilter) {
  const group = document.createElement("div")
  group.className = "pmp-group"
  group.appendChild(panelLabel(label))
  const row = document.createElement("div")
  row.className = "pmp-chips"
  const btns = []
  chips.forEach(([text, val]) => {
    const btn = document.createElement("button")
    btn.type = "button"
    btn.className = "shot-filter-chip"
    btn.textContent = text
    btn.addEventListener("click", () => {
      sels[dim] = val
      btns.forEach(b => b.el.classList.toggle("active", b.val === val))
      applyFilter()
    })
    btns.push({ el: btn, val })
    row.appendChild(btn)
  })
  btns[0].el.classList.add("active")  // default = All
  group.appendChild(row)
  return group
}

// Place the field tooltip relative to its position:relative stage and CLAMP it
// inside the stage's bounds, so showing/moving it can never push past the edge,
// add a scrollbar, and reflow the page (the hover-wobble fix). Defaults to
// above the cursor; flips below near the top edge.
positionFieldTooltip = function (tooltip, stage, e) {
  const rect = stage.getBoundingClientRect()
  const relX = e.clientX - rect.left
  const relY = e.clientY - rect.top
  tooltip.classList.add("visible")
  const tipW = tooltip.offsetWidth || 160
  const tipH = tooltip.offsetHeight || 120
  const pad = 6
  const flipBelow = relY < tipH + 18
  // Clamp X so the centred tooltip stays within [pad, width-pad].
  const half = tipW / 2
  const clampedX = Math.max(half + pad, Math.min(relX, rect.width - half - pad))
  tooltip.style.left = clampedX + "px"
  tooltip.style.top = (relY + (flipBelow ? 16 : -12)) + "px"
  tooltip.style.transform = flipBelow ? "translate(-50%, 0)" : "translate(-50%, calc(-100% - 12px))"
}
Show code
// Cell 8b: Pass map (full pitch with pass arrows)
{
  if (!paramPlayer || !paramLeague) return html``
  if (playerOnly.length === 0) return html``

  const passes = playerOnly.filter(d => (d.type_id === 1 || d.type_id === 2) && d.x != null && d.end_x != null)
  if (passes.length === 0) 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
  }

  const el = document.createElement("div")
  const h2 = document.createElement("h2")
  h2.textContent = "Pass Map"
  el.appendChild(h2)

  // Layout: large pitch on the left, a tidy control panel (legend + filter
  // chips) on the right — mirrors the shot map's pitch+side-rail split in
  // match.qmd. The pitch lives in its own position:relative wrapper with a
  // fixed aspect-ratio so showing/moving the absolutely-positioned tooltip
  // never reflows the page (fixes the hover wobble).
  const layout = document.createElement("div")
  layout.className = "pitch-map-layout"

  const wrap = document.createElement("div")
  wrap.className = "pitch-map-stage"

  const svg = svgEl("svg", { viewBox: "-5 -5 110 78", class: "touch-map-svg", preserveAspectRatio: "xMidYMid meet" })

  // Defs
  const defs = svgEl("defs")
  defs.appendChild(window.chartHelpers.createGrassPattern("pm-grass", 110, 6))
  defs.appendChild(window.chartHelpers.createVignette("pm-vignette", { cy: "50%", opacity: 0.3 }))

  // Arrow markers
  const mkr = svgEl("marker", { id: "pm-arrow", viewBox: "0 0 10 10", refX: "10", refY: "5", markerWidth: "4", markerHeight: "4", orient: "auto-start-reverse" })
  mkr.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "rgba(96,165,250,0.6)" }))
  defs.appendChild(mkr)
  const mkrF = svgEl("marker", { id: "pm-arrow-fail", viewBox: "0 0 10 10", refX: "10", refY: "5", markerWidth: "4", markerHeight: "4", orient: "auto-start-reverse" })
  mkrF.appendChild(svgEl("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "rgba(239,68,68,0.5)" }))
  defs.appendChild(mkrF)
  svg.appendChild(defs)

  // Pitch
  svg.appendChild(svgEl("rect", { x: "-5", y: "-5", width: "110", height: "78", fill: "url(#pm-grass)" }))
  svg.appendChild(svgEl("rect", { x: "-5", y: "-5", width: "110", height: "78", fill: "url(#pm-vignette)" }))
  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" }))
  // Penalty boxes
  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" }))
  // Goal mouths
  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" }))

  // Shared helpers — match phase label + tabular EPV/WPA for the tooltip rows.
  const phaseLabel = (p) => ({ 1: "1H", 2: "2H", 3: "ET1", 4: "ET2", 5: "Pens" })[p] || (p != null ? "P" + p : "")
  const halfBucket = (p) => (p === 1 ? "1H" : p === 2 ? "2H" : (p != null ? "ET" : ""))
  const getEpv = (d) => d.epv_delta ?? d.equity ?? null
  const fmtSigned = (v, dp = 2) => (v >= 0 ? "+" : "") + v.toFixed(dp)

  // Draw each pass as a <g> (line + origin dot) carrying data-* attributes so a
  // single delegated handler can build the tooltip and the filter chips can
  // dim/show by outcome and half — same interaction model as the shot map.
  for (const p of passes) {
    const ok = p.outcome === 1
    const g = svgEl("g", {
      "data-pass": "1",
      "data-outcome": ok ? "ok" : "fail",
      "data-half": halfBucket(p.period_id)
    })
    g.appendChild(svgEl("line", {
      x1: p.x, y1: p.y * 0.68,
      x2: p.end_x, y2: p.end_y * 0.68,
      stroke: ok ? "rgba(96,165,250,0.45)" : "rgba(239,68,68,0.35)",
      "stroke-width": ok ? "0.5" : "0.35",
      "marker-end": ok ? "url(#pm-arrow)" : "url(#pm-arrow-fail)"
    }))
    // A wider transparent hit-line makes the thin arrow easy to hover.
    g.appendChild(svgEl("line", {
      x1: p.x, y1: p.y * 0.68, x2: p.end_x, y2: p.end_y * 0.68,
      stroke: "transparent", "stroke-width": "2.5"
    }))
    g.appendChild(svgEl("circle", {
      cx: p.x, cy: p.y * 0.68, r: "0.8",
      fill: ok ? "rgba(96,165,250,0.7)" : "rgba(239,68,68,0.6)"
    }))
    const epv = getEpv(p)
    g.setAttribute("data-minute", p.minute != null ? p.minute + ":" + String(p.second || 0).padStart(2, "0") : "")
    g.setAttribute("data-phase", phaseLabel(p.period_id))
    g.setAttribute("data-result", ok ? "Completed" : "Incomplete")
    if (epv != null) g.setAttribute("data-epv", fmtSigned(epv))
    if (typeof p.wpa === "number") g.setAttribute("data-wpa", fmtSigned(p.wpa * 100, 1) + "%")
    g.setAttribute("data-from", p.x.toFixed(0) + ", " + p.y.toFixed(0))
    g.setAttribute("data-to", p.end_x.toFixed(0) + ", " + p.end_y.toFixed(0))
    svg.appendChild(g)
  }

  wrap.appendChild(svg)

  // Tooltip — reuse the shot-map field tooltip (buildFieldTooltip + .field-tooltip).
  // Anchored to the position:relative .pitch-map-stage; clamped within the stage
  // so it can never overflow and trigger a page reflow (the old wobble).
  const tooltip = document.createElement("div")
  tooltip.className = "field-tooltip"
  wrap.appendChild(tooltip)
  const _tip = window.chartHelpers?.buildFieldTooltip
  svg.addEventListener("mousemove", (e) => {
    const g = e.target.closest("[data-pass]")
    if (!g || !_tip) { tooltip.classList.remove("visible"); return }
    const rows = [
      ["Minute", g.getAttribute("data-minute")],
      ["Phase", g.getAttribute("data-phase")],
      ["Outcome", g.getAttribute("data-result")],
      ["EPV", g.getAttribute("data-epv")],
      ["WPA", g.getAttribute("data-wpa")],
      ["From", g.getAttribute("data-from")],
      ["To", g.getAttribute("data-to")]
    ].filter(([, v]) => v)
    _tip(tooltip, "Pass", rows)
    positionFieldTooltip(tooltip, wrap, e)
  })
  svg.addEventListener("mouseleave", () => tooltip.classList.remove("visible"))

  // Control panel — ONE aligned column beside the pitch: a legend block then
  // the filter-chip groups (Outcome / Half), all sharing the shot-map chip
  // styling. Replaces the loose legend+chips that floated under the pitch.
  const panel = document.createElement("div")
  panel.className = "pitch-map-panel"

  const legend = document.createElement("div")
  legend.className = "pmp-group"
  legend.appendChild(panelLabel("Key"))
  const legendItems = document.createElement("div")
  legendItems.className = "pmp-legend"
  for (const item of [{ color: "#3b82f6", label: "Completed" }, { color: "#ef4444", label: "Incomplete" }]) {
    const row = document.createElement("span")
    row.className = "pmp-legend-item"
    const dot = document.createElement("span")
    dot.className = "pmp-legend-dot"
    dot.style.background = item.color
    row.appendChild(dot)
    row.appendChild(document.createTextNode(item.label))
    legendItems.appendChild(row)
  }
  legend.appendChild(legendItems)
  panel.appendChild(legend)

  // Filter chips — single-select Outcome + Half, composing via intersection
  // (mirrors the shot-map filter rows). Clicking a chip sets that dimension;
  // "All" clears it. Non-matching passes dim rather than disappear.
  const sels = { outcome: null, half: null }
  function applyFilter() {
    svg.querySelectorAll("[data-pass]").forEach(g => {
      const okOut = sels.outcome == null || g.getAttribute("data-outcome") === sels.outcome
      const okHalf = sels.half == null || g.getAttribute("data-half") === sels.half
      g.style.opacity = (okOut && okHalf) ? "" : "0.08"
      g.style.transition = "opacity 0.15s"
    })
  }
  const halves = [...new Set(passes.map(p => halfBucket(p.period_id)).filter(Boolean))]
  panel.appendChild(makeFilterGroup(panelLabel, "Outcome", "outcome", [["All", null], ["Completed", "ok"], ["Incomplete", "fail"]], sels, applyFilter))
  if (halves.length > 1) {
    const halfChips = [["All", null]]
    if (halves.includes("1H")) halfChips.push(["1st Half", "1H"])
    if (halves.includes("2H")) halfChips.push(["2nd Half", "2H"])
    if (halves.includes("ET")) halfChips.push(["ET", "ET"])
    panel.appendChild(makeFilterGroup(panelLabel, "Half", "half", halfChips, sels, applyFilter))
  }

  layout.appendChild(wrap)
  layout.appendChild(panel)
  el.appendChild(layout)
  return el
}
Show code
// Cell 9: Touch heatmap (full pitch)
{
  if (!paramPlayer || !paramLeague) return html``
  if (playerOnly.length === 0) return html``

  // Filter to events with valid coordinates
  const touches = playerOnly.filter(d => d.x != null && d.y != null)
  if (touches.length === 0) return html`<h2>Touch Map</h2><p class="text-muted">No location data available.</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 el = document.createElement("div")
  const h2 = document.createElement("h2")
  h2.textContent = "Touch Map"
  el.appendChild(h2)

  // Large pitch + control-panel split (same layout as the Pass Map).
  const layout = document.createElement("div")
  layout.className = "pitch-map-layout"

  const wrap = document.createElement("div")
  wrap.className = "pitch-map-stage"

  // Full pitch SVG — Opta 0-100 x, 0-68 y coordinate system (matching match-chains)
  const svg = svgEl("svg", { viewBox: "-5 -5 110 78", class: "touch-map-svg", preserveAspectRatio: "xMidYMid meet" })

  // Defs
  const defs = svgEl("defs")
  defs.appendChild(window.chartHelpers.createGrassPattern("me-grass", 110, 6))
  defs.appendChild(window.chartHelpers.createVignette("me-vignette", { cy: "50%", opacity: 0.3 }))
  svg.appendChild(defs)

  // Pitch background
  svg.appendChild(svgEl("rect", { x: "-5", y: "-5", width: "110", height: "78", fill: "url(#me-grass)" }))
  svg.appendChild(svgEl("rect", { x: "-5", y: "-5", width: "110", height: "78", fill: "url(#me-vignette)" }))

  // Outer boundary
  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" }))

  // Halfway line
  svg.appendChild(svgEl("line", { x1: "50", y1: "0", x2: "50", y2: "68", stroke: "rgba(255,255,255,0.2)", "stroke-width": "0.3" }))

  // Centre circle
  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("circle", { cx: "50", cy: "34", r: "0.6", fill: "rgba(255,255,255,0.15)" }))

  // Left penalty box
  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: "0", y: "25.3", width: "5.2", height: "17.4", fill: "none", stroke: "rgba(255,255,255,0.2)", "stroke-width": "0.3" }))
  svg.appendChild(svgEl("circle", { cx: "10.5", cy: "34", r: "0.4", fill: "rgba(255,255,255,0.2)" }))
  svg.appendChild(svgEl("path", { d: "M 15.7,27.5 A 8.7,8.7 0 0,1 15.7,40.5", fill: "none", stroke: "rgba(255,255,255,0.12)", "stroke-width": "0.3" }))

  // Right penalty box
  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: "94.8", y: "25.3", width: "5.2", height: "17.4", fill: "none", stroke: "rgba(255,255,255,0.2)", "stroke-width": "0.3" }))
  svg.appendChild(svgEl("circle", { cx: "89.5", cy: "34", r: "0.4", fill: "rgba(255,255,255,0.2)" }))
  svg.appendChild(svgEl("path", { d: "M 84.3,27.5 A 8.7,8.7 0 0,0 84.3,40.5", fill: "none", stroke: "rgba(255,255,255,0.12)", "stroke-width": "0.3" }))

  // Corner arcs
  svg.appendChild(svgEl("path", { d: "M 0.95,0 A 0.95,0.95 0 0,1 0,0.95", fill: "none", stroke: "rgba(255,255,255,0.15)", "stroke-width": "0.3" }))
  svg.appendChild(svgEl("path", { d: "M 99.05,0 A 0.95,0.95 0 0,0 100,0.95", fill: "none", stroke: "rgba(255,255,255,0.15)", "stroke-width": "0.3" }))
  svg.appendChild(svgEl("path", { d: "M 0,67.05 A 0.95,0.95 0 0,0 0.95,68", fill: "none", stroke: "rgba(255,255,255,0.15)", "stroke-width": "0.3" }))
  svg.appendChild(svgEl("path", { d: "M 100,67.05 A 0.95,0.95 0 0,1 99.05,68", fill: "none", stroke: "rgba(255,255,255,0.15)", "stroke-width": "0.3" }))

  // Goal mouths
  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" }))

  // Compute density for opacity — grid-based density
  const gridSize = 5
  const density = new Map()
  for (const t of touches) {
    const gx = Math.floor(t.x / gridSize)
    const gy = Math.floor(t.y / gridSize)
    const key = gx + "," + gy
    density.set(key, (density.get(key) || 0) + 1)
  }
  const maxDensity = Math.max(...density.values(), 1)

  // Shared helpers for tooltip rows + filter buckets.
  const phaseLabel = (p) => ({ 1: "1H", 2: "2H", 3: "ET1", 4: "ET2", 5: "Pens" })[p] || (p != null ? "P" + p : "")
  const halfBucket = (p) => (p === 1 ? "1H" : p === 2 ? "2H" : (p != null ? "ET" : ""))
  const getEpv = (d) => d.epv_delta ?? d.equity ?? null
  const fmtSigned = (v, dp = 2) => (v >= 0 ? "+" : "") + v.toFixed(dp)

  // Draw touch dots — each carries data-* so a delegated handler can build the
  // tooltip and the filter chips can dim/show by touch type and half.
  const shotTypes = new Set([13, 14, 15, 16])
  for (const t of touches) {
    const gx = Math.floor(t.x / gridSize)
    const gy = Math.floor(t.y / gridSize)
    const key = gx + "," + gy
    const d = density.get(key) || 1
    const alpha = 0.3 + 0.7 * (d / maxDensity)

    const isGoal = t.type_id === 16
    const isShot = shotTypes.has(t.type_id)

    let color
    if (isGoal) color = `rgba(250, 204, 21, ${alpha})`
    else if (isShot) color = `rgba(248, 113, 113, ${alpha})`
    else color = `rgba(96, 165, 250, ${alpha})`

    const r = isShot ? 1.4 : 0.9
    const epv = getEpv(t)
    const kind = isGoal ? "goal" : (isShot ? "shot" : "touch")
    const dot = svgEl("circle", {
      cx: String(t.x),
      cy: String(t.y * 0.68),
      r: String(r),
      fill: color,
      stroke: isGoal ? "rgba(250, 204, 21, 0.8)" : "none",
      "stroke-width": isGoal ? "0.4" : "0",
      "data-touch": "1",
      "data-kind": kind,
      "data-half": halfBucket(t.period_id),
      "data-minute": t.minute != null ? t.minute + ":" + String(t.second || 0).padStart(2, "0") : "",
      "data-phase": phaseLabel(t.period_id),
      "data-event": optaEventTypes[t.type_id] || ("Event " + t.type_id),
      "data-outcome": t.outcome === 1 ? "Success" : "Fail",
      "data-xy": t.x.toFixed(0) + ", " + t.y.toFixed(0)
    })
    if (epv != null) dot.setAttribute("data-epv", fmtSigned(epv))
    if (typeof t.wpa === "number") dot.setAttribute("data-wpa", fmtSigned(t.wpa * 100, 1) + "%")
    svg.appendChild(dot)
  }

  wrap.appendChild(svg)

  // Tooltip — reuse the shot-map field tooltip. Anchored + clamped to the stage.
  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-touch]")
    if (!dot || !_tip) { tooltip.classList.remove("visible"); return }
    const rows = [
      ["Minute", dot.getAttribute("data-minute")],
      ["Phase", dot.getAttribute("data-phase")],
      ["Outcome", dot.getAttribute("data-outcome")],
      ["EPV", dot.getAttribute("data-epv")],
      ["WPA", dot.getAttribute("data-wpa")],
      ["x, y", dot.getAttribute("data-xy")]
    ].filter(([, v]) => v)
    _tip(tooltip, dot.getAttribute("data-event") || "Touch", rows)
    positionFieldTooltip(tooltip, wrap, e)
  })
  svg.addEventListener("mouseleave", () => tooltip.classList.remove("visible"))

  // Control panel — legend + filter groups, one aligned column beside the pitch.
  const panel = document.createElement("div")
  panel.className = "pitch-map-panel"

  const legend = document.createElement("div")
  legend.className = "pmp-group"
  legend.appendChild(panelLabel("Key"))
  const legendItems = document.createElement("div")
  legendItems.className = "pmp-legend"
  for (const item of [
    { color: "#3b82f6", label: "Touch" },
    { color: "#ef4444", label: "Shot" },
    { color: "#facc15", label: "Goal" }
  ]) {
    const row = document.createElement("span")
    row.className = "pmp-legend-item"
    const dot = document.createElement("span")
    dot.className = "pmp-legend-dot"
    dot.style.background = item.color
    row.appendChild(dot)
    row.appendChild(document.createTextNode(item.label))
    legendItems.appendChild(row)
  }
  legend.appendChild(legendItems)
  panel.appendChild(legend)

  // Filter chips — single-select Type + Half, composing via intersection.
  const sels = { kind: null, half: null }
  function applyFilter() {
    svg.querySelectorAll("[data-touch]").forEach(dot => {
      const okKind = sels.kind == null || dot.getAttribute("data-kind") === sels.kind
      const okHalf = sels.half == null || dot.getAttribute("data-half") === sels.half
      dot.style.opacity = (okKind && okHalf) ? "" : "0.08"
      dot.style.transition = "opacity 0.15s"
    })
  }
  // Only offer Type chips for kinds that exist in this player's touches.
  const kinds = new Set(touches.map(t => t.type_id === 16 ? "goal" : (shotTypes.has(t.type_id) ? "shot" : "touch")))
  const typeChips = [["All", null]]
  if (kinds.has("touch")) typeChips.push(["Touch", "touch"])
  if (kinds.has("shot")) typeChips.push(["Shot", "shot"])
  if (kinds.has("goal")) typeChips.push(["Goal", "goal"])
  if (typeChips.length > 2) panel.appendChild(makeFilterGroup(panelLabel, "Type", "kind", typeChips, sels, applyFilter))

  const halves = [...new Set(touches.map(t => halfBucket(t.period_id)).filter(Boolean))]
  if (halves.length > 1) {
    const halfChips = [["All", null]]
    if (halves.includes("1H")) halfChips.push(["1st Half", "1H"])
    if (halves.includes("2H")) halfChips.push(["2nd Half", "2H"])
    if (halves.includes("ET")) halfChips.push(["ET", "ET"])
    panel.appendChild(makeFilterGroup(panelLabel, "Half", "half", halfChips, sels, applyFilter))
  }

  layout.appendChild(wrap)
  layout.appendChild(panel)
  el.appendChild(layout)
  return el
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer