Cricket Match Detail
Ball-by-ball match visualization with worm chart, manhattan, wagon wheel, and scorecard
Show code
Show code
ballsRaw = {
try { return await fetchParquet(base_url + "cricket/balls-" + paramFormat + ".parquet") }
catch (e) { console.warn("[cricket-match] balls load failed:", e); return null }
}
playerNamesRaw = {
try { return await fetchParquet(base_url + "cricket/player-names.parquet") }
catch (e) { console.warn("[cricket-match] player names load failed:", e); return null }
}Show code
Show code
matchList = {
if (!ballsRaw || ballsRaw.length === 0) return []
const seen = new Map()
for (const b of ballsRaw) {
if (!seen.has(b.match_id)) {
// Title comes from first ball: "BowlerName to BatsmanName"
// This represents the opening delivery of the match
seen.set(b.match_id, { match_id: b.match_id, title: b.title || ("Match " + b.match_id) })
}
}
// Sort by match_id descending (higher = more recent for cricinfo IDs)
return [...seen.values()].sort((a, b) => b.match_id - a.match_id)
}
// Determine which match to show
selectedMatchId = {
if (paramMatch) {
// Match IDs may be strings or numbers in the parquet — try both
if (matchList.length > 0) {
const sample = matchList[0].match_id
return typeof sample === "number" ? Number(paramMatch) : String(paramMatch)
}
return paramMatch
}
if (matchList.length > 0) return matchList[0].match_id
return null
}Show code
// Match selector dropdown
viewof matchSelector = {
if (ballsRaw === null) return html`<p class="text-muted">Failed to load match data. Check your connection and refresh.</p>`
if (!matchList || matchList.length === 0) return html`<p class="text-muted">No matches found for this format.</p>`
const container = document.createElement("div")
container.className = "match-selector-wrap"
const label = document.createElement("label")
label.textContent = "Select match: "
label.style.cssText = "font-weight:600;margin-right:0.5rem"
const sel = document.createElement("select")
sel.className = "match-selector-dropdown"
for (const m of matchList) {
const opt = document.createElement("option")
opt.value = String(m.match_id)
opt.textContent = m.title
if (m.match_id === selectedMatchId) opt.selected = true
sel.appendChild(opt)
}
sel.addEventListener("change", () => {
const id = sel.value
window.location.hash = "format=" + paramFormat + "&match=" + id
const sample = matchList[0]?.match_id
container.value = typeof sample === "number" ? Number(id) : String(id)
container.dispatchEvent(new Event("input", { bubbles: true }))
})
container.appendChild(label)
container.appendChild(sel)
container.value = selectedMatchId
return container
}Show code
// Format selector tabs
viewof formatSelector = {
const formats = [
{ key: "t20i", label: "T20I" },
{ key: "odi", label: "ODI" },
{ key: "test", label: "Test" }
]
const container = document.createElement("div")
container.className = "format-tabs"
for (const f of formats) {
const btn = document.createElement("button")
btn.className = "format-tab" + (f.key === paramFormat ? " active" : "")
btn.textContent = f.label
btn.addEventListener("click", () => {
window.location.hash = "format=" + f.key
window.location.reload()
})
container.appendChild(btn)
}
container.value = paramFormat
return container
}Show code
Show code
// Breadcrumb and match header
{
if (balls.length === 0) {
return html`<div class="breadcrumb"><a href="index.html">Cricket</a> > Match</div>
<p class="text-muted">No ball data found for this match. Try selecting a different match above.</p>`
}
return html`<div class="breadcrumb"><a href="index.html">Cricket</a> > Match</div>
<h2 class="match-title-header">${matchTitle}</h2>`
}Show code
// Precompute innings data
inningsData = {
if (balls.length === 0) return []
const innings = []
const inningsNums = [...new Set(balls.map(d => d.innings_number))].sort()
for (const inn of inningsNums) {
const innBalls = balls.filter(d => d.innings_number === inn).sort((a, b) => {
if (a.over_number !== b.over_number) return a.over_number - b.over_number
return a.ball_number - b.ball_number
})
if (innBalls.length === 0) continue
// Per-over aggregation
const overs = new Map()
for (const b of innBalls) {
const ov = b.over_number
if (!overs.has(ov)) overs.set(ov, { runs: 0, wickets: 0, balls: [] })
const o = overs.get(ov)
o.runs += b.total_runs || 0
if (b.is_wicket) o.wickets += (typeof b.is_wicket === "boolean" ? 1 : b.is_wicket)
o.balls.push(b)
}
// Cumulative runs array (per over end)
let cumRuns = 0
const cumByOver = [0] // start at 0
const wicketOvers = [] // overs where wickets fell
const overNums = [...overs.keys()].sort((a, b) => a - b)
for (const ov of overNums) {
const o = overs.get(ov)
cumRuns += o.runs
cumByOver.push(cumRuns)
if (o.wickets > 0) {
wicketOvers.push({ over: ov, cumRuns: cumRuns, wickets: o.wickets })
}
}
// Total runs and wickets from last ball
const lastBall = innBalls[innBalls.length - 1]
const totalRuns = lastBall.total_innings_runs != null ? lastBall.total_innings_runs : cumRuns
const totalWickets = lastBall.total_innings_wickets != null ? lastBall.total_innings_wickets : 0
innings.push({
number: inn,
balls: innBalls,
overs: overs,
overNums: overNums,
cumByOver: cumByOver,
wicketOvers: wicketOvers,
totalRuns: totalRuns,
totalWickets: totalWickets,
maxOver: overNums.length > 0 ? Math.max(...overNums) : 0
})
}
return innings
}Show code
innColors = ["#6ba09a", "#c4734a", "#5a9a7a", "#fbbf24"]
innLabels = {
// Try to extract team names from the match title
const parts = (matchTitle || "").split(/\s+vs?\s+/i)
return inningsData.map((inn, i) => {
if (inn.number <= 2 && parts.length >= 2) {
// Innings 1 = first team batting, Innings 2 = second team batting
// In limited overs: 1st inn = team listed first, 2nd inn = team listed second
// But this is just a guess — we use "Innings N" as fallback label
const teamIdx = (inn.number - 1) % parts.length
const team = parts[teamIdx] ? parts[teamIdx].replace(/,.*$/, "").trim() : null
return team || ("Innings " + inn.number)
}
return "Innings " + inn.number
})
}Show code
// Innings summary cards
{
if (inningsData.length === 0) return html``
const cards = inningsData.map((inn, i) => {
const color = innColors[i % innColors.length]
const label = innLabels[i]
const score = inn.totalRuns + "/" + inn.totalWickets
const overs = inn.maxOver + 1
const card = document.createElement("div")
card.className = "innings-summary-card"
card.style.borderLeft = "3px solid " + color
const lbl = document.createElement("div")
lbl.className = "innings-label"
lbl.textContent = label
lbl.style.color = color
const sc = document.createElement("div")
sc.className = "innings-score"
sc.textContent = score
const ov = document.createElement("div")
ov.className = "innings-overs"
ov.textContent = overs + " overs"
card.appendChild(lbl)
card.appendChild(sc)
card.appendChild(ov)
return card
})
const wrap = document.createElement("div")
wrap.className = "innings-summary-grid"
for (const c of cards) wrap.appendChild(c)
return wrap
}Worm Chart
Show code
{
if (inningsData.length === 0) return html`<p class="text-muted">No innings data available.</p>`
const NS = "http://www.w3.org/2000/svg"
const W = 720, H = 340
const pad = { top: 20, right: 20, bottom: 40, left: 50 }
const plotW = W - pad.left - pad.right
const plotH = H - pad.top - pad.bottom
// Determine max overs and max runs across all innings
const maxOvers = Math.max(...inningsData.map(d => d.cumByOver.length - 1), 1)
const maxRuns = Math.max(...inningsData.map(d => Math.max(...d.cumByOver, 0)), 1)
const xScale = (ov) => pad.left + (ov / maxOvers) * plotW
const yScale = (runs) => pad.top + plotH - (runs / (maxRuns * 1.05)) * plotH
const svg = document.createElementNS(NS, "svg")
svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
svg.setAttribute("class", "worm-chart")
// Background
const bg = document.createElementNS(NS, "rect")
bg.setAttribute("width", W)
bg.setAttribute("height", H)
bg.setAttribute("fill", "var(--bs-body-bg, #1a1a2e)")
bg.setAttribute("rx", "8")
svg.appendChild(bg)
// Grid lines
const runTicks = []
const runStep = maxRuns > 200 ? 50 : maxRuns > 100 ? 25 : 20
for (let r = 0; r <= maxRuns * 1.05; r += runStep) runTicks.push(r)
for (const r of runTicks) {
const y = yScale(r)
const line = document.createElementNS(NS, "line")
line.setAttribute("x1", pad.left)
line.setAttribute("x2", W - pad.right)
line.setAttribute("y1", y)
line.setAttribute("y2", y)
line.setAttribute("stroke", "rgba(255,255,255,0.08)")
line.setAttribute("stroke-width", "1")
svg.appendChild(line)
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", pad.left - 8)
txt.setAttribute("y", y + 4)
txt.setAttribute("text-anchor", "end")
txt.setAttribute("fill", "rgba(255,255,255,0.5)")
txt.setAttribute("font-size", "11")
txt.textContent = r
svg.appendChild(txt)
}
// X-axis labels (overs)
const overStep = maxOvers > 80 ? 20 : maxOvers > 30 ? 10 : 5
for (let ov = 0; ov <= maxOvers; ov += overStep) {
const x = xScale(ov)
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", x)
txt.setAttribute("y", H - pad.bottom + 20)
txt.setAttribute("text-anchor", "middle")
txt.setAttribute("fill", "rgba(255,255,255,0.5)")
txt.setAttribute("font-size", "11")
txt.textContent = ov
svg.appendChild(txt)
}
// X-axis label
const xLabel = document.createElementNS(NS, "text")
xLabel.setAttribute("x", pad.left + plotW / 2)
xLabel.setAttribute("y", H - 4)
xLabel.setAttribute("text-anchor", "middle")
xLabel.setAttribute("fill", "rgba(255,255,255,0.5)")
xLabel.setAttribute("font-size", "12")
xLabel.textContent = "Overs"
svg.appendChild(xLabel)
// Draw worm lines
for (let i = 0; i < inningsData.length; i++) {
const inn = inningsData[i]
const color = innColors[i % innColors.length]
// Build path
let pathD = ""
for (let ov = 0; ov < inn.cumByOver.length; ov++) {
const x = xScale(ov)
const y = yScale(inn.cumByOver[ov])
pathD += (ov === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1)
}
const path = document.createElementNS(NS, "path")
path.setAttribute("d", pathD)
path.setAttribute("fill", "none")
path.setAttribute("stroke", color)
path.setAttribute("stroke-width", "2.5")
path.setAttribute("stroke-linejoin", "round")
svg.appendChild(path)
// Wicket markers
for (const w of inn.wicketOvers) {
const x = xScale(w.over + 1) // wicket at end of that over
const y = yScale(w.cumRuns)
const circ = document.createElementNS(NS, "circle")
circ.setAttribute("cx", x)
circ.setAttribute("cy", y)
circ.setAttribute("r", "4")
circ.setAttribute("fill", color)
circ.setAttribute("stroke", "#fff")
circ.setAttribute("stroke-width", "1.5")
svg.appendChild(circ)
}
}
// Legend
const legendY = pad.top + 8
for (let i = 0; i < inningsData.length; i++) {
const color = innColors[i % innColors.length]
const label = innLabels[i] + " (" + inningsData[i].totalRuns + "/" + inningsData[i].totalWickets + ")"
const lx = pad.left + 10 + i * 200
const rect = document.createElementNS(NS, "rect")
rect.setAttribute("x", lx)
rect.setAttribute("y", legendY - 6)
rect.setAttribute("width", 14)
rect.setAttribute("height", 3)
rect.setAttribute("fill", color)
rect.setAttribute("rx", "1.5")
svg.appendChild(rect)
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", lx + 20)
txt.setAttribute("y", legendY)
txt.setAttribute("fill", "rgba(255,255,255,0.8)")
txt.setAttribute("font-size", "11")
txt.textContent = label
svg.appendChild(txt)
}
// Tooltip
const tip = document.createElement("div")
tip.className = "field-tooltip"
tip.style.display = "none"
const container = document.createElement("div")
container.className = "chart-container"
container.style.position = "relative"
container.appendChild(svg)
container.appendChild(tip)
// Hover interaction — vertical crosshair
const crosshair = document.createElementNS(NS, "line")
crosshair.setAttribute("y1", pad.top)
crosshair.setAttribute("y2", pad.top + plotH)
crosshair.setAttribute("stroke", "rgba(255,255,255,0.3)")
crosshair.setAttribute("stroke-dasharray", "4,4")
crosshair.setAttribute("display", "none")
svg.appendChild(crosshair)
svg.addEventListener("mousemove", (e) => {
const rect = svg.getBoundingClientRect()
const svgX = (e.clientX - rect.left) / rect.width * W
const over = Math.round((svgX - pad.left) / plotW * maxOvers)
if (over < 0 || over > maxOvers) { crosshair.setAttribute("display", "none"); tip.style.display = "none"; return }
const x = xScale(over)
crosshair.setAttribute("x1", x)
crosshair.setAttribute("x2", x)
crosshair.setAttribute("display", "")
const rows = inningsData.map((inn, i) => {
const runs = over < inn.cumByOver.length ? inn.cumByOver[over] : (inn.cumByOver[inn.cumByOver.length - 1] || 0)
return [innLabels[i], String(runs)]
})
buildFieldTooltip(tip, "Over " + over, rows)
tip.style.display = "block"
tip.style.left = (e.clientX - rect.left + 12) + "px"
tip.style.top = (e.clientY - rect.top - 10) + "px"
})
svg.addEventListener("mouseleave", () => {
crosshair.setAttribute("display", "none")
tip.style.display = "none"
})
return container
}Manhattan Chart
Show code
{
if (inningsData.length === 0) return html`<p class="text-muted">No innings data available.</p>`
const NS = "http://www.w3.org/2000/svg"
const W = 720, H = 300
const pad = { top: 20, right: 20, bottom: 40, left: 50 }
const plotW = W - pad.left - pad.right
const plotH = H - pad.top - pad.bottom
const maxOvers = Math.max(...inningsData.map(d => d.maxOver + 1), 1)
const maxOverRuns = Math.max(...inningsData.flatMap(d => [...d.overs.values()].map(o => o.runs)), 1)
const barW = plotW / maxOvers
const numInnings = inningsData.length
const subBarW = barW / numInnings * 0.8
const xScale = (ov) => pad.left + (ov / maxOvers) * plotW
const yScale = (runs) => pad.top + plotH - (runs / (maxOverRuns * 1.15)) * plotH
const svg = document.createElementNS(NS, "svg")
svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
svg.setAttribute("class", "manhattan-chart")
const bg = document.createElementNS(NS, "rect")
bg.setAttribute("width", W)
bg.setAttribute("height", H)
bg.setAttribute("fill", "var(--bs-body-bg, #1a1a2e)")
bg.setAttribute("rx", "8")
svg.appendChild(bg)
// Y-axis grid
const yStep = maxOverRuns > 20 ? 5 : 2
for (let r = 0; r <= maxOverRuns * 1.15; r += yStep) {
const y = yScale(r)
const line = document.createElementNS(NS, "line")
line.setAttribute("x1", pad.left)
line.setAttribute("x2", W - pad.right)
line.setAttribute("y1", y)
line.setAttribute("y2", y)
line.setAttribute("stroke", "rgba(255,255,255,0.08)")
svg.appendChild(line)
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", pad.left - 8)
txt.setAttribute("y", y + 4)
txt.setAttribute("text-anchor", "end")
txt.setAttribute("fill", "rgba(255,255,255,0.5)")
txt.setAttribute("font-size", "11")
txt.textContent = r
svg.appendChild(txt)
}
// X-axis labels
const overStep = maxOvers > 80 ? 20 : maxOvers > 30 ? 10 : maxOvers > 15 ? 5 : 2
for (let ov = 0; ov < maxOvers; ov += overStep) {
const x = xScale(ov) + barW / 2
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", x)
txt.setAttribute("y", H - pad.bottom + 18)
txt.setAttribute("text-anchor", "middle")
txt.setAttribute("fill", "rgba(255,255,255,0.5)")
txt.setAttribute("font-size", "11")
txt.textContent = ov + 1
svg.appendChild(txt)
}
const xLabel = document.createElementNS(NS, "text")
xLabel.setAttribute("x", pad.left + plotW / 2)
xLabel.setAttribute("y", H - 4)
xLabel.setAttribute("text-anchor", "middle")
xLabel.setAttribute("fill", "rgba(255,255,255,0.5)")
xLabel.setAttribute("font-size", "12")
xLabel.textContent = "Overs"
svg.appendChild(xLabel)
// Draw bars
const baseY = pad.top + plotH
for (let i = 0; i < inningsData.length; i++) {
const inn = inningsData[i]
const color = innColors[i % innColors.length]
const offset = (i - numInnings / 2 + 0.5) * subBarW
for (const ov of inn.overNums) {
const o = inn.overs.get(ov)
if (o.runs === 0) continue
const x = xScale(ov) + barW / 2 + offset - subBarW / 2
const h = (o.runs / (maxOverRuns * 1.15)) * plotH
const y = baseY - h
const rect = document.createElementNS(NS, "rect")
rect.setAttribute("x", x)
rect.setAttribute("y", y)
rect.setAttribute("width", subBarW)
rect.setAttribute("height", h)
rect.setAttribute("fill", color)
rect.setAttribute("opacity", "0.8")
rect.setAttribute("rx", "1")
svg.appendChild(rect)
// Wicket dot
if (o.wickets > 0) {
const circ = document.createElementNS(NS, "circle")
circ.setAttribute("cx", x + subBarW / 2)
circ.setAttribute("cy", y - 6)
circ.setAttribute("r", "3")
circ.setAttribute("fill", "#ef4444")
circ.setAttribute("stroke", "#fff")
circ.setAttribute("stroke-width", "1")
svg.appendChild(circ)
}
}
}
// Legend
for (let i = 0; i < inningsData.length; i++) {
const color = innColors[i % innColors.length]
const lx = pad.left + 10 + i * 200
const ly = pad.top + 8
const rect = document.createElementNS(NS, "rect")
rect.setAttribute("x", lx)
rect.setAttribute("y", ly - 6)
rect.setAttribute("width", 12)
rect.setAttribute("height", 12)
rect.setAttribute("fill", color)
rect.setAttribute("opacity", "0.8")
rect.setAttribute("rx", "2")
svg.appendChild(rect)
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", lx + 18)
txt.setAttribute("y", ly + 4)
txt.setAttribute("fill", "rgba(255,255,255,0.8)")
txt.setAttribute("font-size", "11")
txt.textContent = innLabels[i]
svg.appendChild(txt)
}
return svg
}Win Probability
Show code
Show code
{
if (!hasWinProb) return html`<p class="text-muted">Win probability data not available for this match.</p>`
const NS = "http://www.w3.org/2000/svg"
const W = 720, H = 300
const pad = { top: 20, right: 20, bottom: 40, left: 50 }
const plotW = W - pad.left - pad.right
const plotH = H - pad.top - pad.bottom
// Flatten all balls in order with win_probability
const wpBalls = balls
.filter(d => d.win_probability != null && !isNaN(d.win_probability))
.sort((a, b) => {
if (a.innings_number !== b.innings_number) return a.innings_number - b.innings_number
if (a.over_number !== b.over_number) return a.over_number - b.over_number
return a.ball_number - b.ball_number
})
if (wpBalls.length === 0) return html`<p class="text-muted">Win probability data not available for this match.</p>`
const totalBalls = wpBalls.length
const xScale = (i) => pad.left + (i / (totalBalls - 1)) * plotW
const yScale = (wp) => pad.top + plotH - (wp / 100) * plotH
const svg = document.createElementNS(NS, "svg")
svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
svg.setAttribute("class", "winprob-chart")
const bg = document.createElementNS(NS, "rect")
bg.setAttribute("width", W)
bg.setAttribute("height", H)
bg.setAttribute("fill", "var(--bs-body-bg, #1a1a2e)")
bg.setAttribute("rx", "8")
svg.appendChild(bg)
// 50% line
const midLine = document.createElementNS(NS, "line")
midLine.setAttribute("x1", pad.left)
midLine.setAttribute("x2", W - pad.right)
midLine.setAttribute("y1", yScale(50))
midLine.setAttribute("y2", yScale(50))
midLine.setAttribute("stroke", "rgba(255,255,255,0.2)")
midLine.setAttribute("stroke-dasharray", "6,4")
svg.appendChild(midLine)
// Y-axis labels
for (const pct of [0, 25, 50, 75, 100]) {
const y = yScale(pct)
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", pad.left - 8)
txt.setAttribute("y", y + 4)
txt.setAttribute("text-anchor", "end")
txt.setAttribute("fill", "rgba(255,255,255,0.5)")
txt.setAttribute("font-size", "11")
txt.textContent = pct + "%"
svg.appendChild(txt)
if (pct !== 50) {
const line = document.createElementNS(NS, "line")
line.setAttribute("x1", pad.left)
line.setAttribute("x2", W - pad.right)
line.setAttribute("y1", y)
line.setAttribute("y2", y)
line.setAttribute("stroke", "rgba(255,255,255,0.06)")
svg.appendChild(line)
}
}
// Innings boundary markers
let prevInn = wpBalls[0].innings_number
for (let i = 1; i < totalBalls; i++) {
if (wpBalls[i].innings_number !== prevInn) {
const x = xScale(i)
const line = document.createElementNS(NS, "line")
line.setAttribute("x1", x)
line.setAttribute("x2", x)
line.setAttribute("y1", pad.top)
line.setAttribute("y2", pad.top + plotH)
line.setAttribute("stroke", "rgba(255,255,255,0.15)")
line.setAttribute("stroke-dasharray", "4,4")
svg.appendChild(line)
const txt = document.createElementNS(NS, "text")
txt.setAttribute("x", x + 4)
txt.setAttribute("y", pad.top + 14)
txt.setAttribute("fill", "rgba(255,255,255,0.4)")
txt.setAttribute("font-size", "10")
txt.textContent = "Inn " + wpBalls[i].innings_number
svg.appendChild(txt)
prevInn = wpBalls[i].innings_number
}
}
// Area fill under/above 50%
// Build gradient path
let pathD = "M" + xScale(0).toFixed(1) + "," + yScale(wpBalls[0].win_probability).toFixed(1)
for (let i = 1; i < totalBalls; i++) {
pathD += "L" + xScale(i).toFixed(1) + "," + yScale(wpBalls[i].win_probability).toFixed(1)
}
const linePath = document.createElementNS(NS, "path")
linePath.setAttribute("d", pathD)
linePath.setAttribute("fill", "none")
linePath.setAttribute("stroke", "#6ba09a")
linePath.setAttribute("stroke-width", "2")
svg.appendChild(linePath)
// Fill area above 50% line
const areaD = pathD +
"L" + xScale(totalBalls - 1).toFixed(1) + "," + yScale(50).toFixed(1) +
"L" + xScale(0).toFixed(1) + "," + yScale(50).toFixed(1) + "Z"
const area = document.createElementNS(NS, "path")
area.setAttribute("d", areaD)
area.setAttribute("fill", "rgba(96,165,250,0.12)")
svg.insertBefore(area, linePath)
// X-axis label
const xLabel = document.createElementNS(NS, "text")
xLabel.setAttribute("x", pad.left + plotW / 2)
xLabel.setAttribute("y", H - 4)
xLabel.setAttribute("text-anchor", "middle")
xLabel.setAttribute("fill", "rgba(255,255,255,0.5)")
xLabel.setAttribute("font-size", "12")
xLabel.textContent = "Match progression"
svg.appendChild(xLabel)
// Y-axis label
const yLabel = document.createElementNS(NS, "text")
yLabel.setAttribute("x", 14)
yLabel.setAttribute("y", pad.top + plotH / 2)
yLabel.setAttribute("text-anchor", "middle")
yLabel.setAttribute("fill", "rgba(255,255,255,0.5)")
yLabel.setAttribute("font-size", "12")
yLabel.setAttribute("transform", `rotate(-90, 14, ${pad.top + plotH / 2})`)
yLabel.textContent = "Win probability (batting team)"
svg.appendChild(yLabel)
// Tooltip
const tip = document.createElement("div")
tip.className = "field-tooltip"
tip.style.display = "none"
const container = document.createElement("div")
container.className = "chart-container"
container.style.position = "relative"
container.appendChild(svg)
container.appendChild(tip)
const crosshair = document.createElementNS(NS, "line")
crosshair.setAttribute("y1", pad.top)
crosshair.setAttribute("y2", pad.top + plotH)
crosshair.setAttribute("stroke", "rgba(255,255,255,0.3)")
crosshair.setAttribute("stroke-dasharray", "4,4")
crosshair.setAttribute("display", "none")
svg.appendChild(crosshair)
svg.addEventListener("mousemove", (e) => {
const rect = svg.getBoundingClientRect()
const svgX = (e.clientX - rect.left) / rect.width * W
const idx = Math.round((svgX - pad.left) / plotW * (totalBalls - 1))
if (idx < 0 || idx >= totalBalls) { crosshair.setAttribute("display", "none"); tip.style.display = "none"; return }
const b = wpBalls[idx]
const x = xScale(idx)
crosshair.setAttribute("x1", x)
crosshair.setAttribute("x2", x)
crosshair.setAttribute("display", "")
buildFieldTooltip(tip, "Inn " + b.innings_number + " - Over " + (b.over_number + 1) + "." + b.ball_number, [
["Win prob", (b.win_probability).toFixed(1) + "%"],
["Score", (b.total_innings_runs || 0) + "/" + (b.total_innings_wickets || 0)]
])
tip.style.display = "block"
tip.style.left = (e.clientX - rect.left + 12) + "px"
tip.style.top = (e.clientY - rect.top - 10) + "px"
})
svg.addEventListener("mouseleave", () => {
crosshair.setAttribute("display", "none")
tip.style.display = "none"
})
return container
}Wagon Wheel
Show code
viewof wagonInnings = {
if (inningsData.length === 0) return Object.assign(html`<span></span>`, { value: 1 })
const defaultInnings = inningsData[0].number
const container = document.createElement("div")
container.className = "wagon-filter-row"
const label = document.createElement("span")
label.textContent = "Innings: "
label.style.fontWeight = "600"
container.appendChild(label)
for (const inn of inningsData) {
const btn = document.createElement("button")
btn.className = "format-tab" + (inn.number === defaultInnings ? " active" : "")
btn.textContent = innLabels[inn.number - 1] || ("Inn " + inn.number)
btn.addEventListener("click", () => {
container.querySelectorAll(".format-tab").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
container.value = inn.number
container.dispatchEvent(new Event("input", { bubbles: true }))
})
container.appendChild(btn)
}
container.value = defaultInnings
return container
}Show code
wagonBatsmen = {
if (balls.length === 0) return []
const innBalls = balls.filter(d => d.innings_number === wagonInnings)
const batMap = new Map()
for (const b of innBalls) {
const id = b.batsman_player_id
if (!id) continue
if (!batMap.has(id)) batMap.set(id, { id: id, runs: 0, balls: 0 })
const s = batMap.get(id)
s.runs += (b.batsman_runs || 0)
s.balls += 1
}
return [...batMap.values()].sort((a, b) => b.runs - a.runs)
}
viewof wagonBatsman = {
if (wagonBatsmen.length === 0) return Object.assign(html`<span></span>`, { value: null })
const container = document.createElement("div")
container.className = "wagon-filter-row"
const label = document.createElement("label")
label.textContent = "Batsman: "
label.style.cssText = "font-weight:600;margin-right:0.5rem"
const sel = document.createElement("select")
sel.className = "match-selector-dropdown"
const allOpt = document.createElement("option")
allOpt.value = ""
allOpt.textContent = "All batsmen"
sel.appendChild(allOpt)
for (const b of wagonBatsmen) {
const opt = document.createElement("option")
opt.value = String(b.id)
opt.textContent = playerName(b.id) + " (" + b.runs + " runs, " + b.balls + " balls)"
sel.appendChild(opt)
}
sel.addEventListener("change", () => {
container.value = sel.value === "" ? null : Number(sel.value)
container.dispatchEvent(new Event("input", { bubbles: true }))
})
container.appendChild(label)
container.appendChild(sel)
container.value = null
return container
}Show code
{
if (balls.length === 0) return html`<p class="text-muted">No ball data available.</p>`
const NS = "http://www.w3.org/2000/svg"
const W = 500, H = 500
const cx = W / 2, cy = H / 2
const R = 210 // field radius
// Filter balls for this innings + batsman
let shotBalls = balls.filter(d =>
d.innings_number === wagonInnings &&
d.wagon_x != null && d.wagon_y != null &&
(d.wagon_x !== 0 || d.wagon_y !== 0)
)
if (wagonBatsman != null) {
shotBalls = shotBalls.filter(d => d.batsman_player_id === wagonBatsman)
}
if (shotBalls.length === 0) return html`<p class="text-muted">No wagon wheel data available for this selection.</p>`
const svg = document.createElementNS(NS, "svg")
svg.setAttribute("viewBox", `0 0 ${W} ${H}`)
svg.setAttribute("class", "wagon-wheel")
// Field background
const fieldBg = document.createElementNS(NS, "circle")
fieldBg.setAttribute("cx", cx)
fieldBg.setAttribute("cy", cy)
fieldBg.setAttribute("r", R + 20)
fieldBg.setAttribute("fill", "#1a3a1a")
svg.appendChild(fieldBg)
// Grass rings
for (let r = R; r > 0; r -= 40) {
const ring = document.createElementNS(NS, "circle")
ring.setAttribute("cx", cx)
ring.setAttribute("cy", cy)
ring.setAttribute("r", r)
ring.setAttribute("fill", "none")
ring.setAttribute("stroke", "rgba(255,255,255,0.06)")
svg.appendChild(ring)
}
// Pitch rectangle at center
const pitchW = 8, pitchH = 40
const pitch = document.createElementNS(NS, "rect")
pitch.setAttribute("x", cx - pitchW / 2)
pitch.setAttribute("y", cy - pitchH / 2)
pitch.setAttribute("width", pitchW)
pitch.setAttribute("height", pitchH)
pitch.setAttribute("fill", "#c4a35a")
pitch.setAttribute("rx", "2")
svg.appendChild(pitch)
// Determine scale for wagon_x, wagon_y
// Cricinfo wagon_x/y range varies — normalize to fit in our circle
const maxDist = Math.max(
...shotBalls.map(d => Math.sqrt(d.wagon_x * d.wagon_x + d.wagon_y * d.wagon_y)),
1
)
const scale = (R - 10) / maxDist
// Draw shot lines from batsman position (center) to landing point
for (const b of shotBalls) {
const px = cx + b.wagon_x * scale
const py = cy - b.wagon_y * scale // flip Y for SVG
let color, opacity, width
if (b.is_six) {
color = "#fbbf24"; opacity = 0.9; width = 2
} else if (b.is_four) {
color = "#5a9a7a"; opacity = 0.85; width = 1.8
} else if ((b.batsman_runs || 0) >= 1) {
color = "#93c5fd"; opacity = 0.5; width = 1
} else {
color = "#6b7280"; opacity = 0.2; width = 0.5
}
const line = document.createElementNS(NS, "line")
line.setAttribute("x1", cx)
line.setAttribute("y1", cy)
line.setAttribute("x2", px.toFixed(1))
line.setAttribute("y2", py.toFixed(1))
line.setAttribute("stroke", color)
line.setAttribute("stroke-width", width)
line.setAttribute("opacity", opacity)
svg.appendChild(line)
// Small dot at end
const dot = document.createElementNS(NS, "circle")
dot.setAttribute("cx", px.toFixed(1))
dot.setAttribute("cy", py.toFixed(1))
dot.setAttribute("r", b.is_six ? 4 : b.is_four ? 3 : 1.5)
dot.setAttribute("fill", color)
dot.setAttribute("opacity", opacity)
svg.appendChild(dot)
}
// Legend
const legendItems = [
{ color: "#fbbf24", label: "Six" },
{ color: "#5a9a7a", label: "Four" },
{ color: "#93c5fd", label: "1-3 runs" },
{ color: "#6b7280", label: "Dot" }
]
const legendG = document.createElementNS(NS, "g")
legendG.setAttribute("transform", `translate(${W - 100}, 20)`)
for (let i = 0; i < legendItems.length; i++) {
const li = legendItems[i]
const c = document.createElementNS(NS, "circle")
c.setAttribute("cx", 6)
c.setAttribute("cy", i * 18 + 6)
c.setAttribute("r", 5)
c.setAttribute("fill", li.color)
legendG.appendChild(c)
const t = document.createElementNS(NS, "text")
t.setAttribute("x", 16)
t.setAttribute("y", i * 18 + 10)
t.setAttribute("fill", "rgba(255,255,255,0.8)")
t.setAttribute("font-size", "11")
t.textContent = li.label
legendG.appendChild(t)
}
svg.appendChild(legendG)
// Shot count summary
const sixes = shotBalls.filter(d => d.is_six).length
const fours = shotBalls.filter(d => d.is_four).length
const totalRuns = shotBalls.reduce((s, d) => s + (d.batsman_runs || 0), 0)
const summaryEl = document.createElement("div")
summaryEl.className = "wagon-summary"
summaryEl.textContent = shotBalls.length + " shots | " + totalRuns + " runs | " + fours + " fours | " + sixes + " sixes"
const container = document.createElement("div")
container.className = "wagon-container"
container.appendChild(svg)
container.appendChild(summaryEl)
return container
}Partnerships
Show code
{
if (balls.length === 0) return html`<p class="text-muted">No ball data available.</p>`
// Build partnerships for the selected scorecard innings
const innBalls = balls.filter(d => d.innings_number === scorecardInnings)
if (innBalls.length === 0) return html`<p class="text-muted">No innings data.</p>`
// Track partnerships: runs between consecutive wickets
const partnerships = []
let partRuns = 0, partBalls = 0
let bat1 = innBalls[0]?.batsman_player_id
let bat2 = null // non-striker — we approximate from the data
for (const b of innBalls) {
partRuns += (b.batsman_runs || 0) + (b.byes || 0) + (b.legbyes || 0)
if (!b.wides) partBalls++
if (b.is_wicket) {
partnerships.push({
wicket: partnerships.length + 1,
runs: partRuns,
balls: partBalls,
batsman: playerName(b.batsman_player_id || 0),
dismissal: b.dismissal_text || ""
})
partRuns = 0
partBalls = 0
}
}
// Last partnership (not out)
if (partRuns > 0 || partBalls > 0) {
partnerships.push({ wicket: partnerships.length + 1, runs: partRuns, balls: partBalls, batsman: "Not out", dismissal: "" })
}
if (partnerships.length === 0) return html`<p class="text-muted">No partnership data.</p>`
const maxRuns = Math.max(...partnerships.map(p => p.runs), 1)
const container = document.createElement("div")
container.className = "partnership-chart"
for (const p of partnerships) {
const row = document.createElement("div")
row.className = "partnership-row"
const label = document.createElement("div")
label.className = "partnership-label"
label.textContent = p.wicket + (p.wicket === 1 ? "st" : p.wicket === 2 ? "nd" : p.wicket === 3 ? "rd" : "th")
row.appendChild(label)
const barWrap = document.createElement("div")
barWrap.className = "partnership-bar-wrap"
const bar = document.createElement("div")
bar.className = "partnership-bar"
bar.style.width = (p.runs / maxRuns * 100) + "%"
bar.style.background = p.runs > maxRuns * 0.6 ? "rgba(52,211,153,0.6)" : "rgba(96,165,250,0.5)"
const val = document.createElement("span")
val.className = "partnership-value"
val.textContent = p.runs + " (" + p.balls + ")"
bar.appendChild(val)
barWrap.appendChild(bar)
row.appendChild(barWrap)
container.appendChild(row)
}
return container
}Scorecard
Show code
viewof scorecardInnings = {
if (inningsData.length === 0) return Object.assign(html`<span></span>`, { value: 1 })
const defaultInnings = inningsData[0].number
const container = document.createElement("div")
container.className = "wagon-filter-row"
const label = document.createElement("span")
label.textContent = "Innings: "
label.style.fontWeight = "600"
container.appendChild(label)
for (const inn of inningsData) {
const btn = document.createElement("button")
btn.className = "format-tab" + (inn.number === defaultInnings ? " active" : "")
btn.textContent = innLabels[inn.number - 1] || ("Inn " + inn.number)
btn.addEventListener("click", () => {
container.querySelectorAll(".format-tab").forEach(b => b.classList.remove("active"))
btn.classList.add("active")
container.value = inn.number
container.dispatchEvent(new Event("input", { bubbles: true }))
})
container.appendChild(btn)
}
container.value = defaultInnings
return container
}Show code
battingScorecard = {
if (balls.length === 0) return []
const innBalls = balls.filter(d => d.innings_number === scorecardInnings)
// Group by batsman
const batMap = new Map()
for (const b of innBalls) {
const id = b.batsman_player_id
if (!id) continue
if (!batMap.has(id)) batMap.set(id, { batsman_id: id, batsman: playerName(id), runs: 0, balls_faced: 0, fours: 0, sixes: 0, dismissal: "" })
const s = batMap.get(id)
s.runs += (b.batsman_runs || 0)
// Count legitimate balls (exclude wides)
if (!b.wides) s.balls_faced += 1
if (b.is_four) s.fours += 1
if (b.is_six) s.sixes += 1
if (b.is_wicket && b.dismissal_text) s.dismissal = b.dismissal_text
}
return [...batMap.values()]
.map(d => ({
...d,
sr: d.balls_faced > 0 ? ((d.runs / d.balls_faced) * 100).toFixed(1) : "—"
}))
.sort((a, b) => {
// Sort by batting order (first appearance in data)
const aFirst = innBalls.findIndex(x => x.batsman_player_id === a.batsman_id)
const bFirst = innBalls.findIndex(x => x.batsman_player_id === b.batsman_id)
return aFirst - bFirst
})
}
{
if (battingScorecard.length === 0) return html`<p class="text-muted">No batting data available.</p>`
return statsTable(battingScorecard, {
columns: ["batsman", "runs", "balls_faced", "fours", "sixes", "sr", "dismissal"],
header: {
batsman: "Batsman",
runs: "R",
balls_faced: "B",
fours: "4s",
sixes: "6s",
sr: "SR",
dismissal: "Dismissal"
},
sort: null,
rows: 15,
heatmap: {
runs: "high-good",
sr: "high-good"
},
format: {
sr: (v) => typeof v === "number" ? v.toFixed(1) : v
}
})
}Show code
bowlingScorecard = {
if (balls.length === 0) return []
const innBalls = balls.filter(d => d.innings_number === scorecardInnings)
// Group by bowler
const bowlMap = new Map()
for (const b of innBalls) {
const id = b.bowler_player_id
if (!id) continue
if (!bowlMap.has(id)) bowlMap.set(id, { bowler_id: id, bowler: playerName(id), overs_set: new Set(), runs: 0, wickets: 0, wides: 0, noballs: 0, dots: 0, balls: 0 })
const s = bowlMap.get(id)
s.overs_set.add(b.over_number)
s.runs += (b.total_runs || 0)
if (b.is_wicket) s.wickets += 1
if (b.wides) s.wides += (b.wides || 1)
if (b.noballs) s.noballs += (b.noballs || 1)
if (!b.wides && !b.noballs) s.balls += 1
if (b.total_runs === 0 && !b.wides && !b.noballs) s.dots += 1
}
return [...bowlMap.values()]
.map(d => {
const completedOvers = Math.floor(d.balls / 6)
const remainingBalls = d.balls % 6
const oversStr = completedOvers + (remainingBalls > 0 ? "." + remainingBalls : "")
const econ = d.balls > 0 ? ((d.runs / d.balls) * 6).toFixed(2) : "—"
return {
bowler_id: d.bowler_id,
bowler: d.bowler,
overs: oversStr,
runs: d.runs,
wickets: d.wickets,
econ: econ,
dots: d.dots,
extras: d.wides + d.noballs
}
})
.sort((a, b) => {
// Sort by bowling order (first appearance)
const innB = innBalls
const aFirst = innB.findIndex(x => x.bowler_player_id === a.bowler_id)
const bFirst = innB.findIndex(x => x.bowler_player_id === b.bowler_id)
return aFirst - bFirst
})
}
{
if (bowlingScorecard.length === 0) return html`<p class="text-muted">No bowling data available.</p>`
return html`<h3>Bowling</h3>${statsTable(bowlingScorecard, {
columns: ["bowler", "overs", "runs", "wickets", "econ", "dots", "extras"],
header: {
bowler: "Bowler",
overs: "O",
runs: "R",
wickets: "W",
econ: "Econ",
dots: "Dots",
extras: "Extras"
},
sort: null,
rows: 12,
heatmap: {
wickets: "high-good"
}
})}`
}