In The Game
  • Home
  • Blog
  • AFL
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Matches
    • Ladder
    • Definitions
  • Football
    • Overview

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Leagues
    • Matches
    • Definitions
  • About
Skip to content

Cricket Match Detail

Ball-by-ball match visualization with worm chart, manhattan, wagon wheel, and scorecard
Show code
statsEsc = window.statsEsc
statsTable = window.statsTable
fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL
buildFieldTooltip = window.chartHelpers?.buildFieldTooltip || (() => {})
Show code
paramFormat = window._getHashParam("format") || "t20i"
paramMatch = window._getHashParam("match") || null
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
// Build player ID → name lookup
playerName = {
  const m = new Map()
  if (playerNamesRaw) {
    for (const p of playerNamesRaw) m.set(p.player_id, p.player_name)
  }
  return (id) => m.get(id) || ("Player " + id)
}
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
matchId = matchSelector
balls = {
  if (!ballsRaw || !matchId) return []
  return ballsRaw.filter(d => d.match_id === matchId)
}

matchTitle = {
  if (balls.length === 0) return "No match selected"
  return balls[0].title || ("Match " + matchId)
}
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
// Check if win_probability data exists
hasWinProb = {
  if (balls.length === 0) return false
  return balls.some(d => d.win_probability != null && !isNaN(d.win_probability))
}
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"
    }
  })}`
}
 

Pete Owen · Sydney · © 2026 · Source

Privacy | Disclaimer