AI & Computer Vision, lesson 30 of 52
Live Match Analytics Dashboard in JavaScript
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
| Show | Because |
|---|---|
| A few big numbers (players now, ball visibility, goal entries) | A glance tells you the state |
| A pitch view | Shows the data in space |
| A time chart | Shows trends and drop-outs |
| A heatmap | Shows where activity concentrates |
| An event log | Shows 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
| Extension | Hint |
|---|---|
| A second zone | Add data.zones as a list, with a trigger per zone and a different colour |
| Possession bar | Count frames where the ball is nearer to team A's players than B's |
| Distance run | Sum how far each player id moved between frames (pixels for now: metres come in lesson 33 and lesson 45) |
| Chart.js | Add <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 mode | Change the CSS variables, keep contrast readable |
| Export | A button to download the events as CSV |
If it goes wrong
| Error | Cause | Fix |
|---|---|---|
| "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 blank | JSON has no frames, or the wrong shape | Open the JSON. Compare with the format above. |
| Players are drawn off the pitch view | width and height do not match the video | Set 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 time | Use the video time f.t, as the code does |
| Heatmap is one red dot | One cell dominates | The square root helps. Use a bigger grid or fewer frames. |
| Players per frame chart is flat | maxPlayers is small | The scale uses at least 10. Check the data. |
| Slider jumps are slow | Rebuilding thousands of frames | Fine for classroom clips. For long videos precompute checkpoints. |
CORS error when pointing ?src= at another server | The server does not allow other origins | Enable 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
Why use a JSON file between Python and JavaScript?
What does one heatmap cell count?
Why log events with the video's time, not the clock time?
Why show 'frames with the ball'?
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.

