AI & Computer Vision, lesson 30 of 52

Live Match Analytics Dashboard in JavaScript

intermediate26 min read

Introduction

Detections are just numbers until someone can see what they mean. Today you'll build a scoreboard for your detector: a web dashboard that plays the match video with boxes drawn on top, a heatmap of where players have been, live statistics and an event log, all fed by a JSON file that your Python code writes.

By the end of this lesson you'll be able to:

  • Define a simple JSON format for detection results (frames, players, ball)
  • Fetch JSON in the browser and replay it like a live feed
  • Draw a pitch view, a line chart and a heatmap on a canvas
  • Compute summary numbers (players now, most at once, ball visibility, goal-zone entries)
  • Re-use the edge trigger from lesson 27 to log events once
  • Explain why a dashboard should show a few clear numbers, not everything

Working principle

The contract: a JSON format

A dashboard and a detector are written separately. They agree on one data format. Ours:

{
  "width": 1280, "height": 720, "fps": 30,
  "goal_zone": {"x": 540, "y": 20, "w": 200, "h": 90},
  "frames": [
    {"frame": 0, "t": 0.0,
      "players": [{"id": 3, "x": 412.5, "y": 520.0}],
      "ball": {"x": 633.1, "y": 61.0}}
  ]
}

x and y for a player are their feet (the bottom-centre of the box, as in lesson 27). ball is null if no ball was seen.

What belongs on a dashboard

ShowBecause
A few big numbers (players now, ball visibility, goal entries)A glance tells you the state
A pitch viewShows the data in space
A time chartShows trends and drop-outs
A heatmapShows where activity concentrates
An event logShows what happened, and when

Leave out anything nobody will use. Every extra chart makes the others harder to read.

The heatmap in three lines of thinking

Divide the pitch into a grid (here 64 by 36 cells). Every time a player's feet are in a cell, add 1 to it. After many frames, busy cells have large numbers. Colour each cell from blue (quiet) to red (busy). We use the square root of the count, so quiet areas still show.

Playback: replaying like it's live

We keep a pointer to the current frame. A timer works out how many video frames should have passed since the last screen refresh (seconds × fps × speed) and processes that many. Scrubbing to a new time rebuilds all the statistics from frame 0, so the numbers always match the position.

Events are computed once

Entering the goal zone is detected with the edge trigger and cooldown from lesson 27, here in video time (seconds), not clock time. That way a replay at 8x speed gives the same events as at 1x.

What you'll need

  • Your detector and tracker from lessons 24 to 26, and a match clip
  • VS Code, Chrome or Edge, and python -m http.server

Build it

Step 1: get some data

If you have a tracks.csv from lesson 26, convert it. Set WIDTH and HEIGHT to the size of your video, and edit goal_zone to match.

import csv
import json
import sys
from collections import defaultdict
 
SRC = sys.argv[1] if len(sys.argv) > 1 else "tracks.csv"        # written by track_yolo.py (lesson 26)
OUT = sys.argv[2] if len(sys.argv) > 2 else "detections.json"
WIDTH, HEIGHT, FPS = 1280, 720, 30                              # the size and speed of the video the tracks came from
 
frames = defaultdict(lambda: {"players": [], "ball": None})
with open(SRC) as f:
    for row in csv.DictReader(f):
        n = int(row["frame"])
        x1, y1, x2, y2 = (float(row[k]) for k in ("x1", "y1", "x2", "y2"))
        if row["cls"] == "ball":
            frames[n]["ball"] = {"x": round((x1 + x2) / 2, 1), "y": round((y1 + y2) / 2, 1)}
        else:
            frames[n]["players"].append({"id": int(row["id"]), "x": round((x1 + x2) / 2, 1), "y": round(y2, 1)})   # the feet
 
last = max(frames) if frames else 0
data = {
    "width": WIDTH, "height": HEIGHT, "fps": FPS,
    "goal_zone": {"x": 540, "y": 20, "w": 200, "h": 90},        # edit to match YOUR video (lesson 32 draws zones properly)
    "frames": [{"frame": n, "t": round(n / FPS, 2), **frames.get(n, {"players": [], "ball": None})} for n in range(last + 1)],
}
with open(OUT, "w") as out:
    json.dump(data, out)
print(f"wrote {OUT}: {len(data['frames'])} frames, {sum(1 for f in data['frames'] if f['ball'])} with a ball")

If you don't yet, make demo data: eight players wandering, a ball that is shot into the goal zone three times.

import json
import math
import random
 
random.seed(4)
WIDTH, HEIGHT, FPS, N = 1280, 720, 30, 600          # 20 seconds
GOAL = {"x": 540, "y": 20, "w": 200, "h": 90}
 
players = [{"id": i, "cx": random.randint(200, 1080), "cy": random.randint(250, 650), "ph": random.random() * 6} for i in range(1, 9)]
frames = []
for n in range(N):
    t = n / FPS
    ps = []
    for p in players:
        if random.random() < 0.08:                    # the detector sometimes misses a player
            continue
        ps.append({"id": p["id"],
                   "x": round(p["cx"] + 180 * math.sin(t / 3 + p["ph"]), 1),
                   "y": round(p["cy"] + 90 * math.sin(t / 2 + p["ph"] * 2), 1)})
    # the ball travels across the pitch and is shot into the goal around t = 8 s and t = 15 s
    ball = None
    if n % 200 > 20:
        k = (n % 200 - 20) / 160
        ball = {"x": round(300 + 340 * k + 20 * math.sin(k * 12), 1), "y": round(560 - 500 * k ** 1.5, 1)}
        if random.random() < 0.1:
            ball = None
    frames.append({"frame": n, "t": round(t, 2), "players": ps, "ball": ball})
 
json.dump({"width": WIDTH, "height": HEIGHT, "fps": FPS, "goal_zone": GOAL, "frames": frames}, open("detections.json", "w"))
print("wrote detections.json:", N, "frames")

Step 2: the dashboard

Save as dashboard.html in the same folder as detections.json. Run python -m http.server 8000 and open http://localhost:8000/dashboard.html. Press Play and try 8x.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Match dashboard</title>
  <style>
    :root { --navy: #060633; --blue: #0084ff; --yellow: #ffb300; --card: #f4f6fb; }
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; margin: 0; background: #fff; color: #111; }
    header { background: var(--navy); color: #fff; padding: .8rem 1rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: .5rem; }
    header h1 { margin: 0; font-size: 1.2rem; font-weight: 600; }
    #clock { font-variant-numeric: tabular-nums; font-size: 1.4rem; }
    main { padding: 1rem; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); max-width: 1100px; margin: 0 auto; }
    .card { background: var(--card); border-radius: 10px; padding: .8rem; }
    .card h2 { margin: 0 0 .5rem; font-size: .95rem; font-weight: 600; color: #444; }
    .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: .5rem; grid-column: 1 / -1; }
    .kpi { background: var(--card); border-radius: 10px; padding: .6rem .8rem; }
    .kpi b { display: block; font-size: 1.8rem; font-weight: 600; }
    .kpi span { font-size: .8rem; color: #555; }
    canvas { width: 100%; background: #fff; border-radius: 6px; display: block; }
    #pitch { background: #2e8b3d; }
    .controls { grid-column: 1 / -1; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
    button, select { font-size: 1rem; padding: .4rem .8rem; border-radius: 8px; border: 1px solid #bbb; background: #fff; }
    button.primary { background: var(--yellow); border-color: var(--yellow); }
    input[type=range] { flex: 1; min-width: 160px; }
    #events { max-height: 11rem; overflow: auto; font-size: .9rem; margin: 0; padding-left: 1.1rem; }
    #status { color: #b00020; grid-column: 1 / -1; }
    #status:empty { display: none; }
    @media (max-width: 520px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
  </style>
</head>
<body>
  <header>
    <h1>Match analytics</h1>
    <div id="clock">00:00.0</div>
  </header>
  <main>
    <div id="status"></div>
    <div class="kpis">
      <div class="kpi"><b id="kPlayers">0</b><span>players seen now</span></div>
      <div class="kpi"><b id="kMax">0</b><span>most at once</span></div>
      <div class="kpi"><b id="kBall">0%</b><span>frames with the ball</span></div>
      <div class="kpi"><b id="kGoals">0</b><span>goal-zone entries</span></div>
    </div>
    <div class="controls">
      <button id="play" class="primary">Play</button>
      <select id="speed"><option value="1">1x</option><option value="2">2x</option><option value="4">4x</option><option value="8">8x</option></select>
      <input id="scrub" type="range" min="0" max="0" value="0">
      <button id="resetHeat">Reset heatmap</button>
    </div>
    <div class="card"><h2>Pitch view</h2><canvas id="pitch" width="640" height="360"></canvas></div>
    <div class="card"><h2>Heatmap: where the players spent time</h2><canvas id="heat" width="640" height="360"></canvas></div>
    <div class="card"><h2>Players per frame (last 20 s)</h2><canvas id="chart" width="640" height="260"></canvas></div>
    <div class="card"><h2>Events</h2><ol id="events" reversed></ol></div>
  </main>
 
  <script>
    // ---------- 1. Logic: no screen, easy to test ----------
    function inZone(p, z) { return !!z && p.x >= z.x && p.x <= z.x + z.w && p.y >= z.y && p.y <= z.y + z.h; }
    function makeTrigger(cooldownSec) {                          // the lesson 27 edge trigger, using video time
      let was = false, last = -Infinity;
      return (inside, t) => { const fire = inside && !was && t - last >= cooldownSec; if (fire) last = t; was = inside; return fire; };
    }
    function makeHeat(cols, rows) { return { cols, rows, cells: new Float32Array(cols * rows), max: 0 }; }
    function heatAdd(h, x, y, width, height) {                    // count one visit in the grid cell under (x, y)
      const c = Math.min(h.cols - 1, Math.max(0, Math.floor(x / width * h.cols)));
      const r = Math.min(h.rows - 1, Math.max(0, Math.floor(y / height * h.rows)));
      const v = ++h.cells[r * h.cols + c];
      if (v > h.max) h.max = v;
    }
    function formatClock(t) {
      const m = Math.floor(t / 60), s = t - m * 60;
      return String(m).padStart(2, "0") + ":" + s.toFixed(1).padStart(4, "0");
    }
 
    // ---------- 2. State ----------
    let data = null, frameIndex = 0, playing = false, lastTick = 0, carry = 0;
    let heat, trigger, goalCount = 0, maxPlayers = 0, ballFrames = 0, seenFrames = 0, history = [], ballTrail = [];
    const $ = (id) => document.getElementById(id);
 
    function resetState() {
      heat = makeHeat(64, 36); trigger = makeTrigger(3);
      goalCount = 0; maxPlayers = 0; ballFrames = 0; seenFrames = 0; history = []; ballTrail = [];
      $("events").innerHTML = "";
    }
 
    // ---------- 3. Process one frame ----------
    function processFrame(f) {
      seenFrames++;
      maxPlayers = Math.max(maxPlayers, f.players.length);
      for (const p of f.players) heatAdd(heat, p.x, p.y, data.width, data.height);
      history.push(f.players.length);
      if (f.ball) {
        ballFrames++;
        ballTrail.push(f.ball); if (ballTrail.length > 25) ballTrail.shift();
      }
      if (trigger(!!f.ball && inZone(f.ball, data.goal_zone), f.t)) {
        goalCount++;
        const li = document.createElement("li");
        li.textContent = formatClock(f.t) + "  ball in the goal zone (entry " + goalCount + ")";
        $("events").prepend(li);
      }
    }
 
    // ---------- 4. Drawing ----------
    function drawPitch(f) {
      const c = $("pitch"), g = c.getContext("2d"), sx = c.width / data.width, sy = c.height / data.height;
      g.clearRect(0, 0, c.width, c.height);
      const z = data.goal_zone;
      g.strokeStyle = "rgba(255,255,255,.9)"; g.lineWidth = 2; g.setLineDash([6, 4]);
      g.strokeRect(z.x * sx, z.y * sy, z.w * sx, z.h * sy); g.setLineDash([]);
      for (const p of f.players) {
        g.fillStyle = "#0084ff"; g.beginPath(); g.arc(p.x * sx, p.y * sy, 7, 0, 7); g.fill();
        g.fillStyle = "#fff"; g.font = "11px sans-serif"; g.fillText(p.id, p.x * sx - 3, p.y * sy - 10);
      }
      g.strokeStyle = "#ffb300"; g.lineWidth = 2; g.beginPath();
      ballTrail.forEach((b, i) => (i ? g.lineTo(b.x * sx, b.y * sy) : g.moveTo(b.x * sx, b.y * sy))); g.stroke();
      if (f.ball) { g.fillStyle = "#fff"; g.beginPath(); g.arc(f.ball.x * sx, f.ball.y * sy, 6, 0, 7); g.fill(); g.strokeStyle = "#000"; g.stroke(); }
    }
    function drawHeat() {
      const c = $("heat"), g = c.getContext("2d"), cw = c.width / heat.cols, ch = c.height / heat.rows;
      g.fillStyle = "#1b1b2f"; g.fillRect(0, 0, c.width, c.height);
      for (let r = 0; r < heat.rows; r++) for (let col = 0; col < heat.cols; col++) {
        const v = heat.cells[r * heat.cols + col];
        if (!v) continue;
        const k = Math.sqrt(v / heat.max);                         // sqrt so quiet areas still show up
        g.fillStyle = "hsla(" + (240 - 240 * k) + ",100%,50%," + (0.25 + 0.7 * k) + ")";   // blue (quiet) to red (busy)
        g.fillRect(col * cw, r * ch, cw + 1, ch + 1);
      }
    }
    function drawChart() {
      const c = $("chart"), g = c.getContext("2d"), n = 600;       // last 600 frames = 20 s at 30 FPS
      g.clearRect(0, 0, c.width, c.height);
      const top = Math.max(10, maxPlayers + 1);
      g.strokeStyle = "#ddd"; g.fillStyle = "#666"; g.font = "11px sans-serif"; g.lineWidth = 1;
      for (let v = 0; v <= top; v += 2) { const y = c.height - 20 - (v / top) * (c.height - 30); g.beginPath(); g.moveTo(28, y); g.lineTo(c.width, y); g.stroke(); g.fillText(v, 4, y + 4); }
      const part = history.slice(-n);
      g.strokeStyle = "#0084ff"; g.lineWidth = 2; g.beginPath();
      part.forEach((v, i) => { const x = 28 + (i / n) * (c.width - 28), y = c.height - 20 - (v / top) * (c.height - 30); i ? g.lineTo(x, y) : g.moveTo(x, y); });
      g.stroke();
    }
    function updateKpis(f) {
      $("kPlayers").textContent = f.players.length;
      $("kMax").textContent = maxPlayers;
      $("kBall").textContent = Math.round(100 * ballFrames / Math.max(1, seenFrames)) + "%";
      $("kGoals").textContent = goalCount;
      $("clock").textContent = formatClock(f.t);
      $("scrub").value = frameIndex;
    }
    function render() { const f = data.frames[frameIndex]; drawPitch(f); drawHeat(); drawChart(); updateKpis(f); }
 
    // ---------- 5. Playback ----------
    function step() {
      if (frameIndex >= data.frames.length - 1) { playing = false; $("play").textContent = "Replay"; return; }
      frameIndex++;
      processFrame(data.frames[frameIndex]);
    }
    function tick(now) {
      if (playing) {
        const speed = Number($("speed").value);
        carry += (now - lastTick) / 1000 * data.fps * speed;       // how many video frames should have passed
        while (carry >= 1 && playing) { step(); carry -= 1; }
      }
      lastTick = now;
      if (data) render();
      requestAnimationFrame(tick);
    }
    $("play").onclick = () => {
      if (frameIndex >= data.frames.length - 1) { frameIndex = 0; resetState(); processFrame(data.frames[0]); }
      playing = !playing; $("play").textContent = playing ? "Pause" : "Play";
    };
    $("resetHeat").onclick = () => { heat = makeHeat(64, 36); };
    $("scrub").oninput = (e) => {                                   // jump: rebuild the statistics up to that frame
      const target = Number(e.target.value);
      resetState(); frameIndex = 0; processFrame(data.frames[0]);
      while (frameIndex < target) step();
    };
 
    // ---------- 6. Load the data ----------
    // In lesson 34 we replace this file with a live Flask URL such as http://localhost:5000/api/detections
    const SRC = new URLSearchParams(location.search).get("src") || "detections.json";
    fetch(SRC).then((r) => { if (!r.ok) throw new Error(r.status + " " + r.statusText); return r.json(); }).then((json) => {
      data = json; resetState();
      $("scrub").max = data.frames.length - 1;
      processFrame(data.frames[0]);
      requestAnimationFrame(tick);
    }).catch((err) => {
      $("status").textContent = "Could not load " + SRC + ": " + err.message + ". Serve this folder with python -m http.server and run make_demo_data.py first.";
    });
  </script>
</body>
</html>

Expected with the demo data: eight players, three goal-zone entries at about 5.7 s, 12.3 s and 19.0 s, and a heatmap of looping paths.

Questions: why does the "players per frame" line dip now and then? (Hint: the detector misses a player 8% of the time in the demo.) Drag the slider backwards: what happens to the numbers, and why? What would happen to the event count if the cooldown were 0?

Step 3: choose an extension

ExtensionHint
A second zoneAdd data.zones as a list, with a trigger per zone and a different colour
Possession barCount frames where the ball is nearer to team A's players than B's
Distance runSum how far each player id moved between frames (pixels for now: metres come in lesson 33 and lesson 45)
Chart.jsAdd <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> (pin a version) and replace the hand-drawn line chart. Compare the code length.
Dark modeChange the CSS variables, keep contrast readable
ExportA button to download the events as CSV

If it goes wrong

ErrorCauseFix
"Could not load detections.json"The file is missing or the page was opened as file://Serve the folder and check the file name
Everything is blankJSON has no frames, or the wrong shapeOpen the JSON. Compare with the format above.
Players are drawn off the pitch viewwidth and height do not match the videoSet them to the video size in export_json.py
Goal events fire at 8x speed but not at 1x (or vice versa)Trigger used real timeUse the video time f.t, as the code does
Heatmap is one red dotOne cell dominatesThe square root helps. Use a bigger grid or fewer frames.
Players per frame chart is flatmaxPlayers is smallThe scale uses at least 10. Check the data.
Slider jumps are slowRebuilding thousands of framesFine for classroom clips. For long videos precompute checkpoints.
CORS error when pointing ?src= at another serverThe server does not allow other originsEnable CORS on the server (lesson 34)

Try it

  • Add a second panel with a per-player table (frames seen, distance in pixels)
  • Replace the hand-drawn chart with Chart.js and compare
  • Make the page read ?src= and show a clear message for bad JSON

Check what you learnt

  1. Why use a JSON file between Python and JavaScript?

  2. What does one heatmap cell count?

  3. Why log events with the video's time, not the clock time?

  4. Why show 'frames with the ball'?

  5. You drag the time slider backwards. What must happen?

Conclusion

A dashboard turns detections into something people understand. Python writes a clear JSON format; JavaScript reads it and draws boxes, a heatmap, statistics and an event log, all tied to the video's time so the slider keeps everything in sync. Show your blind spots too, like how often the ball was seen.

What next?

Next: Automation pipelines & triggers. Unit 7 starts: automation, where detections trigger real actions.