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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// StatsBomb open data → public/data/viz/final-2022.json
|
||||
// Processes one fixed historical match (the 2022 World Cup final, Argentina 3–3
|
||||
// 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 1–4) ----
|
||||
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); });
|
||||
Reference in New Issue
Block a user