Football Match Events
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
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
}