Files
cup26/scripts/buildStatsbombViz.ts
NilsBriggen dd8156376d Phase 3: data-story viz — StatsBomb shot map, xG race, pass networks
- buildStatsbombViz.ts: processes the 2022 WC Final (Argentina 3-3 France) from
  StatsBomb open data → shots+xG, cumulative xG race, starting-XI pass networks,
  totals, shootout result. Output committed (11KB) to avoid build-time fetch
- Custom SVG viz (no chart lib): reusable Pitch (StatsBomb 120x80 coords),
  ShotMap (xG-sized dots, teams attacking opposite goals), XgRace (stepped
  cumulative xG with goal markers, d3-scale), PassNetwork (nodes ∝ involvement,
  links ∝ pass volume)
- StoryPage: narrative with hero scoreline, by-the-numbers bars, and the three
  visualizations with explanatory copy
- Verified in browser: all three render correctly, no console errors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:34:19 +02:00

174 lines
7.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// StatsBomb open data → public/data/viz/final-2022.json
// Processes one fixed historical match (the 2022 World Cup final, Argentina 33
// France) into shot, xG-race and pass-network datasets for the Story page.
// The output JSON is committed, so this script is only re-run to regenerate it.
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import type {
PassNetwork, StoryViz, TeamTotals, VizShot, XgPoint,
} from '../src/lib/types';
const MATCH_ID = 3869685; // WC2022 Final
const COMP = 43;
const SEASON = 106;
const RAW = 'https://raw.githubusercontent.com/statsbomb/open-data/master/data';
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'data', 'viz');
interface SbEvent {
type: { name: string };
period: number;
minute: number;
second: number;
team?: { name: string };
player?: { name: string };
location?: [number, number];
shot?: { statsbomb_xg: number; outcome: { name: string } };
pass?: { recipient?: { name: string }; outcome?: { name: string } };
}
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
if (!res.ok) throw new Error(`${url}${res.status}`);
return (await res.json()) as T;
}
/** nickname, else the last two name tokens. */
function shorten(full: string, nickname: string | null): string {
if (nickname) return nickname;
const parts = full.split(' ');
return parts.length <= 2 ? full : parts.slice(-1)[0]!;
}
async function main(): Promise<void> {
const matches = await getJson<Array<{
match_id: number; match_date: string; home_score: number; away_score: number;
competition: { competition_name: string }; competition_stage: { name: string };
home_team: { home_team_name: string }; away_team: { away_team_name: string };
}>>(`${RAW}/matches/${COMP}/${SEASON}.json`);
const meta = matches.find((m) => m.match_id === MATCH_ID)!;
const home = meta.home_team.home_team_name;
const away = meta.away_team.away_team_name;
const lineups = await getJson<Array<{ team_name: string; lineup: Array<{ player_name: string; player_nickname: string | null }> }>>(
`${RAW}/lineups/${MATCH_ID}.json`,
);
const nick = new Map<string, string>();
for (const t of lineups) for (const p of t.lineup) nick.set(p.player_name, shorten(p.player_name, p.player_nickname));
const name = (full: string) => nick.get(full) ?? shorten(full, null);
const events = await getJson<SbEvent[]>(`${RAW}/events/${MATCH_ID}.json`);
// ---- shots + goals (regulation + extra time, periods 14) ----
const shots: VizShot[] = [];
for (const e of events) {
if (e.type.name !== 'Shot' || e.period > 4 || !e.shot || !e.location || !e.team || !e.player) continue;
const goal = e.shot.outcome.name === 'Goal';
shots.push({
team: e.team.name,
player: name(e.player.name),
minute: e.minute,
x: e.location[0],
y: e.location[1],
xg: Math.round(e.shot.statsbomb_xg * 1000) / 1000,
outcome: e.shot.outcome.name,
goal,
});
}
const goals = shots.filter((s) => s.goal).map((s) => ({ team: s.team, player: s.player, minute: s.minute, xg: s.xg }));
// ---- cumulative xG race ----
const race = (team: string): XgPoint[] => {
const pts: XgPoint[] = [{ minute: 0, cum: 0 }];
let cum = 0;
for (const s of shots.filter((x) => x.team === team).sort((a, b) => a.minute - b.minute)) {
cum += s.xg;
pts.push({ minute: s.minute, cum: Math.round(cum * 1000) / 1000 });
}
return pts;
};
// ---- pass networks (completed passes before each team's first substitution) ----
const firstSub = new Map<string, number>();
for (const e of events) {
if (e.type.name === 'Substitution' && e.team && !firstSub.has(e.team.name)) firstSub.set(e.team.name, e.minute);
}
const network = (team: string): PassNetwork => {
const cutoff = firstSub.get(team) ?? 200;
const pos = new Map<string, { x: number; y: number; n: number }>();
const edges = new Map<string, number>();
let lastPlayer = '';
for (const e of events) {
if (e.type.name !== 'Pass' || !e.team || e.team.name !== team || e.minute >= cutoff) continue;
if (!e.location || !e.player) continue;
const p = name(e.player.name);
const cur = pos.get(p) ?? { x: 0, y: 0, n: 0 };
cur.x += e.location[0]; cur.y += e.location[1]; cur.n += 1;
pos.set(p, cur);
lastPlayer = p;
if (e.pass && !e.pass.outcome && e.pass.recipient) {
const r = name(e.pass.recipient.name);
const key = [p, r].sort().join('|');
edges.set(key, (edges.get(key) ?? 0) + 1);
}
}
void lastPlayer;
const players = [...pos.entries()]
.map(([player, v]) => ({ player, x: v.x / v.n, y: v.y / v.n, passes: v.n }))
.filter((p) => p.passes >= 5);
const live = new Set(players.map((p) => p.player));
const edgeList = [...edges.entries()]
.map(([k, count]) => { const [a, b] = k.split('|') as [string, string]; return { a, b, count }; })
.filter((e) => e.count >= 3 && live.has(e.a) && live.has(e.b));
return { players, edges: edgeList };
};
// ---- totals ----
const totals = (team: string): TeamTotals => {
const ts = shots.filter((s) => s.team === team);
const passes = events.filter((e) => e.type.name === 'Pass' && e.team?.name === team);
const completed = passes.filter((e) => e.pass && !e.pass.outcome);
return {
xg: Math.round(ts.reduce((s, x) => s + x.xg, 0) * 100) / 100,
shots: ts.length,
goals: ts.filter((s) => s.goal).length,
passes: passes.length,
passAccuracy: Math.round((completed.length / Math.max(1, passes.length)) * 100),
};
};
// ---- penalty shootout result (period 5) ----
const pens = (team: string) =>
events.filter((e) => e.type.name === 'Shot' && e.period === 5 && e.team?.name === team && e.shot?.outcome.name === 'Goal').length;
const ph = pens(home), pa = pens(away);
const result = ph || pa
? `${ph > pa ? home : away} won ${Math.max(ph, pa)}${Math.min(ph, pa)} on penalties`
: meta.home_score > meta.away_score ? `${home} won` : meta.home_score < meta.away_score ? `${away} won` : 'Draw';
const viz: StoryViz = {
matchId: MATCH_ID,
competition: meta.competition.competition_name,
stage: meta.competition_stage.name,
date: meta.match_date,
home,
away,
homeScore: meta.home_score,
awayScore: meta.away_score,
result,
pitch: { length: 120, width: 80 },
shots,
goals,
xgRace: { home: race(home), away: race(away) },
passNetwork: { home: network(home), away: network(away) },
totals: { home: totals(home), away: totals(away) },
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'final-2022.json'), JSON.stringify(viz));
console.log(`viz → public/data/viz/final-2022.json`);
console.log(` ${home} ${meta.home_score}${meta.away_score} ${away} · ${result}`);
console.log(` shots ${shots.length} (xG ${viz.totals.home.xg} vs ${viz.totals.away.xg}) · network nodes ${viz.passNetwork.home.players.length}/${viz.passNetwork.away.players.length}`);
}
main().catch((e) => { console.error(e); process.exit(1); });