// 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(url: string): Promise { 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 { const matches = await getJson>(`${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 }>>( `${RAW}/lineups/${MATCH_ID}.json`, ); const nick = new Map(); 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(`${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(); 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(); const edges = new Map(); 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); });