From dd8156376dd1d050a55bc45a76734d654a23f3d9 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Thu, 11 Jun 2026 13:34:19 +0200 Subject: [PATCH] =?UTF-8?q?Phase=203:=20data-story=20viz=20=E2=80=94=20Sta?= =?UTF-8?q?tsBomb=20shot=20map,=20xG=20race,=20pass=20networks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 5 +- public/data/viz/final-2022.json | 1 + scripts/buildStatsbombViz.ts | 173 +++++++++++++++++++++++++++++ src/components/viz/PassNetwork.tsx | 62 +++++++++++ src/components/viz/Pitch.tsx | 39 +++++++ src/components/viz/ShotMap.tsx | 61 ++++++++++ src/components/viz/XgRace.tsx | 89 +++++++++++++++ src/features/story/StoryPage.tsx | 138 ++++++++++++++++++++++- src/lib/types.ts | 41 +++++++ 9 files changed, 603 insertions(+), 6 deletions(-) create mode 100644 public/data/viz/final-2022.json create mode 100644 scripts/buildStatsbombViz.ts create mode 100644 src/components/viz/PassNetwork.tsx create mode 100644 src/components/viz/Pitch.tsx create mode 100644 src/components/viz/ShotMap.tsx create mode 100644 src/components/viz/XgRace.tsx diff --git a/.gitignore b/.gitignore index bda1df6..bef64fa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,10 @@ server/node_modules .env .env.local # Generated build artifacts (reproduced by `npm run data:build`) -public/data +public/data/fixtures.json +public/data/ratings.json public/pwa-192x192.png public/pwa-512x512.png public/favicon.svg +# Note: public/data/viz/*.json IS committed — it's processed from a fixed +# historical StatsBomb match, so we don't refetch 3.7MB of events at build time. diff --git a/public/data/viz/final-2022.json b/public/data/viz/final-2022.json new file mode 100644 index 0000000..4b4c915 --- /dev/null +++ b/public/data/viz/final-2022.json @@ -0,0 +1 @@ +{"matchId":3869685,"competition":"FIFA World Cup","stage":"Final","date":"2022-12-18","home":"Argentina","away":"France","homeScore":3,"awayScore":3,"result":"Argentina won 4–2 on penalties","pitch":{"length":120,"width":80},"shots":[{"team":"Argentina","player":"Alexis MacAllister","minute":4,"x":92.4,"y":30,"xg":0.025,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Rodrigo De Paul","minute":7,"x":99.2,"y":47.9,"xg":0.063,"outcome":"Blocked","goal":false},{"team":"Argentina","player":"Ángel Di María","minute":16,"x":103.1,"y":34.6,"xg":0.107,"outcome":"Off T","goal":false},{"team":"Argentina","player":"Lionel Messi","minute":22,"x":108,"y":40,"xg":0.784,"outcome":"Goal","goal":true},{"team":"Argentina","player":"Alexis MacAllister","minute":31,"x":94.3,"y":23.5,"xg":0.011,"outcome":"Wayward","goal":false},{"team":"Argentina","player":"Ángel Di María","minute":35,"x":111.8,"y":32.1,"xg":0.303,"outcome":"Goal","goal":true},{"team":"Argentina","player":"Rodrigo De Paul","minute":48,"x":103.3,"y":52.3,"xg":0.04,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Julián Álvarez","minute":58,"x":110.5,"y":24.9,"xg":0.06,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Lionel Messi","minute":59,"x":109.7,"y":46,"xg":0.116,"outcome":"Off T","goal":false},{"team":"France","player":"Randal Kolo","minute":67,"x":113.4,"y":34.6,"xg":0.096,"outcome":"Off T","goal":false},{"team":"France","player":"Kylian Mbappé","minute":70,"x":102.5,"y":27.9,"xg":0.051,"outcome":"Off T","goal":false},{"team":"Argentina","player":"Enzo Fernandez","minute":71,"x":98.9,"y":32.2,"xg":0.042,"outcome":"Saved","goal":false},{"team":"France","player":"Kylian Mbappé","minute":79,"x":108,"y":40,"xg":0.784,"outcome":"Goal","goal":true},{"team":"France","player":"Kylian Mbappé","minute":80,"x":104.8,"y":30.1,"xg":0.102,"outcome":"Goal","goal":true},{"team":"Argentina","player":"Enzo Fernandez","minute":83,"x":92.2,"y":35.9,"xg":0.03,"outcome":"Blocked","goal":false},{"team":"France","player":"Kylian Mbappé","minute":92,"x":95.9,"y":32.2,"xg":0.032,"outcome":"Blocked","goal":false},{"team":"France","player":"Adrien Rabiot","minute":93,"x":108.9,"y":28.5,"xg":0.093,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Lionel Messi","minute":96,"x":96.2,"y":40.9,"xg":0.043,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Alexis MacAllister","minute":92,"x":98.2,"y":23.8,"xg":0.019,"outcome":"Off T","goal":false},{"team":"France","player":"Youssouf Fofana","minute":99,"x":89.8,"y":39.9,"xg":0.023,"outcome":"Blocked","goal":false},{"team":"Argentina","player":"Lautaro Martínez","minute":104,"x":108.8,"y":45.3,"xg":0.233,"outcome":"Blocked","goal":false},{"team":"Argentina","player":"Gonzalo Montiel","minute":104,"x":96.3,"y":49.4,"xg":0.021,"outcome":"Blocked","goal":false},{"team":"Argentina","player":"Lautaro Martínez","minute":105,"x":105.1,"y":33.5,"xg":0.14,"outcome":"Off T","goal":false},{"team":"Argentina","player":"Lionel Messi","minute":106,"x":103.6,"y":55.8,"xg":0.025,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Lautaro Martínez","minute":107,"x":114.4,"y":52,"xg":0.104,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Lionel Messi","minute":107,"x":116.6,"y":43,"xg":0.488,"outcome":"Goal","goal":true},{"team":"France","player":"Kylian Mbappé","minute":115,"x":101.4,"y":26.6,"xg":0.031,"outcome":"Blocked","goal":false},{"team":"France","player":"Kylian Mbappé","minute":117,"x":108,"y":40,"xg":0.784,"outcome":"Goal","goal":true},{"team":"France","player":"Randal Kolo","minute":122,"x":103.7,"y":45.1,"xg":0.278,"outcome":"Saved","goal":false},{"team":"Argentina","player":"Lautaro Martínez","minute":122,"x":110.8,"y":41.6,"xg":0.107,"outcome":"Off T","goal":false}],"goals":[{"team":"Argentina","player":"Lionel Messi","minute":22,"xg":0.784},{"team":"Argentina","player":"Ángel Di María","minute":35,"xg":0.303},{"team":"France","player":"Kylian Mbappé","minute":79,"xg":0.784},{"team":"France","player":"Kylian Mbappé","minute":80,"xg":0.102},{"team":"Argentina","player":"Lionel Messi","minute":107,"xg":0.488},{"team":"France","player":"Kylian Mbappé","minute":117,"xg":0.784}],"xgRace":{"home":[{"minute":0,"cum":0},{"minute":4,"cum":0.025},{"minute":7,"cum":0.088},{"minute":16,"cum":0.195},{"minute":22,"cum":0.979},{"minute":31,"cum":0.99},{"minute":35,"cum":1.293},{"minute":48,"cum":1.333},{"minute":58,"cum":1.393},{"minute":59,"cum":1.509},{"minute":71,"cum":1.551},{"minute":83,"cum":1.581},{"minute":92,"cum":1.6},{"minute":96,"cum":1.643},{"minute":104,"cum":1.876},{"minute":104,"cum":1.897},{"minute":105,"cum":2.037},{"minute":106,"cum":2.062},{"minute":107,"cum":2.166},{"minute":107,"cum":2.654},{"minute":122,"cum":2.761}],"away":[{"minute":0,"cum":0},{"minute":67,"cum":0.096},{"minute":70,"cum":0.147},{"minute":79,"cum":0.931},{"minute":80,"cum":1.033},{"minute":92,"cum":1.065},{"minute":93,"cum":1.158},{"minute":99,"cum":1.181},{"minute":115,"cum":1.212},{"minute":117,"cum":1.996},{"minute":122,"cum":2.274}]},"passNetwork":{"home":{"players":[{"player":"Nahuel Molina","x":58.1375,"y":72.27749999999999,"passes":40},{"player":"Rodrigo De Paul","x":61.53720930232559,"y":59.93720930232558,"passes":43},{"player":"Cristian Romero","x":34.3595238095238,"y":52.030952380952385,"passes":42},{"player":"Nicolás Otamendi","x":38.56304347826087,"y":23.382608695652173,"passes":46},{"player":"Nicolás Tagliafico","x":57.916666666666664,"y":7.897222222222222,"passes":36},{"player":"Emiliano Martínez","x":13.500000000000004,"y":45.136842105263156,"passes":19},{"player":"Alexis MacAllister","x":68.54285714285713,"y":23.157142857142855,"passes":28},{"player":"Lionel Messi","x":77.84242424242423,"y":54.233333333333334,"passes":33},{"player":"Ángel Di María","x":81.76896551724136,"y":11.313793103448276,"passes":29},{"player":"Enzo Fernandez","x":55.37058823529413,"y":36.672549019607835,"passes":51},{"player":"Julián Álvarez","x":80.0764705882353,"y":41.49411764705882,"passes":17}],"edges":[{"a":"Nahuel Molina","b":"Rodrigo De Paul","count":18},{"a":"Cristian Romero","b":"Rodrigo De Paul","count":15},{"a":"Cristian Romero","b":"Nicolás Otamendi","count":21},{"a":"Nicolás Otamendi","b":"Nicolás Tagliafico","count":18},{"a":"Alexis MacAllister","b":"Nicolás Tagliafico","count":12},{"a":"Emiliano Martínez","b":"Nicolás Otamendi","count":9},{"a":"Alexis MacAllister","b":"Lionel Messi","count":7},{"a":"Lionel Messi","b":"Ángel Di María","count":9},{"a":"Lionel Messi","b":"Rodrigo De Paul","count":16},{"a":"Nicolás Otamendi","b":"Rodrigo De Paul","count":9},{"a":"Enzo Fernandez","b":"Nicolás Otamendi","count":17},{"a":"Cristian Romero","b":"Enzo Fernandez","count":13},{"a":"Cristian Romero","b":"Nahuel Molina","count":6},{"a":"Cristian Romero","b":"Lionel Messi","count":3},{"a":"Cristian Romero","b":"Ángel Di María","count":3},{"a":"Nicolás Tagliafico","b":"Ángel Di María","count":12},{"a":"Alexis MacAllister","b":"Ángel Di María","count":11},{"a":"Alexis MacAllister","b":"Julián Álvarez","count":5},{"a":"Enzo Fernandez","b":"Lionel Messi","count":7},{"a":"Alexis MacAllister","b":"Enzo Fernandez","count":9},{"a":"Julián Álvarez","b":"Ángel Di María","count":4},{"a":"Rodrigo De Paul","b":"Ángel Di María","count":3},{"a":"Cristian Romero","b":"Emiliano Martínez","count":7},{"a":"Enzo Fernandez","b":"Nahuel Molina","count":7},{"a":"Enzo Fernandez","b":"Rodrigo De Paul","count":11},{"a":"Julián Álvarez","b":"Lionel Messi","count":6},{"a":"Julián Álvarez","b":"Rodrigo De Paul","count":3},{"a":"Nicolás Otamendi","b":"Ángel Di María","count":7},{"a":"Enzo Fernandez","b":"Nicolás Tagliafico","count":8},{"a":"Lionel Messi","b":"Nahuel Molina","count":9},{"a":"Julián Álvarez","b":"Nahuel Molina","count":3},{"a":"Enzo Fernandez","b":"Julián Álvarez","count":5},{"a":"Emiliano Martínez","b":"Nahuel Molina","count":4},{"a":"Enzo Fernandez","b":"Ángel Di María","count":4}]},"away":{"players":[{"player":"Antoine Griezmann","x":70.58999999999999,"y":52.65,"passes":10},{"player":"Aurélien Tchouaméni","x":53.07142857142856,"y":39.42857142857143,"passes":14},{"player":"Theo Hernández","x":54.33157894736842,"y":9.1,"passes":19},{"player":"Adrien Rabiot","x":57.92,"y":28.87333333333333,"passes":15},{"player":"Raphaël Varane","x":43.61000000000001,"y":52.345000000000006,"passes":20},{"player":"Jules Koundé","x":47.27647058823529,"y":68.81764705882354,"passes":17},{"player":"Hugo Lloris","x":10.95,"y":36.46,"passes":10},{"player":"Olivier Giroud","x":64.32222222222222,"y":46.21111111111111,"passes":9},{"player":"Kylian Mbappé","x":81.43333333333334,"y":13.077777777777776,"passes":9},{"player":"Ousmane Dembélé","x":67.12307692307692,"y":69.4076923076923,"passes":13},{"player":"Dayotchanculle Upamecano","x":41.39375,"y":25.162500000000005,"passes":16}],"edges":[{"a":"Adrien Rabiot","b":"Theo Hernández","count":5},{"a":"Raphaël Varane","b":"Theo Hernández","count":3},{"a":"Jules Koundé","b":"Raphaël Varane","count":9},{"a":"Jules Koundé","b":"Ousmane Dembélé","count":10},{"a":"Hugo Lloris","b":"Olivier Giroud","count":3},{"a":"Antoine Griezmann","b":"Olivier Giroud","count":3},{"a":"Adrien Rabiot","b":"Kylian Mbappé","count":5},{"a":"Antoine Griezmann","b":"Jules Koundé","count":4},{"a":"Adrien Rabiot","b":"Dayotchanculle Upamecano","count":3},{"a":"Dayotchanculle Upamecano","b":"Theo Hernández","count":6},{"a":"Kylian Mbappé","b":"Theo Hernández","count":8},{"a":"Aurélien Tchouaméni","b":"Dayotchanculle Upamecano","count":3},{"a":"Hugo Lloris","b":"Raphaël Varane","count":3},{"a":"Dayotchanculle Upamecano","b":"Raphaël Varane","count":12},{"a":"Adrien Rabiot","b":"Aurélien Tchouaméni","count":6},{"a":"Aurélien Tchouaméni","b":"Raphaël Varane","count":3},{"a":"Olivier Giroud","b":"Ousmane Dembélé","count":3}]}},"totals":{"home":{"xg":2.76,"shots":20,"goals":3,"passes":693,"passAccuracy":81},"away":{"xg":2.27,"shots":10,"goals":3,"passes":570,"passAccuracy":76}}} \ No newline at end of file diff --git a/scripts/buildStatsbombViz.ts b/scripts/buildStatsbombViz.ts new file mode 100644 index 0000000..0260f63 --- /dev/null +++ b/scripts/buildStatsbombViz.ts @@ -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(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); }); diff --git a/src/components/viz/PassNetwork.tsx b/src/components/viz/PassNetwork.tsx new file mode 100644 index 0000000..4d3ab6e --- /dev/null +++ b/src/components/viz/PassNetwork.tsx @@ -0,0 +1,62 @@ +import { Pitch } from './Pitch'; +import { teamFlag } from '@/lib/teams'; +import type { PassNetwork as PassNet } from '@/lib/types'; + +const surname = (name: string) => name.split(' ').slice(-1)[0] ?? name; + +/** A team's passing shape (before its first substitution): nodes ∝ involvement, + * links ∝ completed passes between the pair. The team attacks right. */ +export function PassNetwork({ + net, + team, + color = 'var(--app-accent)', +}: { + net: PassNet; + team: string; + color?: string; +}) { + const pos = new Map(net.players.map((p) => [p.player, p])); + const maxPass = Math.max(...net.players.map((p) => p.passes), 1); + const maxEdge = Math.max(...net.edges.map((e) => e.count), 1); + + return ( +
+
+ {teamFlag(team)} {team} +
+ + {net.edges.map((e, i) => { + const a = pos.get(e.a); + const b = pos.get(e.b); + if (!a || !b) return null; + return ( + + ); + })} + {net.players.map((p) => ( + + + {`${p.player} · ${p.passes} passes`} + + + {surname(p.player)} + + + ))} + +
+ ); +} diff --git a/src/components/viz/Pitch.tsx b/src/components/viz/Pitch.tsx new file mode 100644 index 0000000..4fe091f --- /dev/null +++ b/src/components/viz/Pitch.tsx @@ -0,0 +1,39 @@ +import type { ReactNode, SVGProps } from 'react'; + +/** + * A reusable SVG football pitch in StatsBomb coordinates (120 × 80, attacking + * toward x = 120). Children are drawn in the same coordinate space, so shot maps + * and pass networks just place marks at their raw (x, y). + */ +export function Pitch({ + children, + className, + ...rest +}: { children?: ReactNode; className?: string } & SVGProps) { + const line = { stroke: 'var(--app-line-strong)', strokeWidth: 0.4, fill: 'none', opacity: 0.65 }; + return ( + + + {/* outer + halfway */} + + + + + {/* left box */} + + + + + + + {/* right box */} + + + + + + + {children} + + ); +} diff --git a/src/components/viz/ShotMap.tsx b/src/components/viz/ShotMap.tsx new file mode 100644 index 0000000..704f5de --- /dev/null +++ b/src/components/viz/ShotMap.tsx @@ -0,0 +1,61 @@ +import { Pitch } from './Pitch'; +import { teamFlag } from '@/lib/teams'; +import type { StoryViz } from '@/lib/types'; + +const r = (xg: number) => 1.3 + Math.sqrt(Math.max(0, xg)) * 4.2; + +/** Both teams' shots on one pitch — home attacks right, away mirrored to attack + * left. Dot area ∝ xG; goals are filled and ringed. */ +export function ShotMap({ viz }: { viz: StoryViz }) { + const homeColor = 'var(--app-accent)'; + const awayColor = 'var(--app-info)'; + + return ( +
+ + {viz.shots.map((s, i) => { + const isHome = s.team === viz.home; + const x = isHome ? s.x : 120 - s.x; + const y = isHome ? s.y : 80 - s.y; + const color = isHome ? homeColor : awayColor; + return ( + + {`${s.player} · ${s.minute}' · xG ${s.xg.toFixed(2)}${s.goal ? ' · GOAL' : ` · ${s.outcome}`}`} + + ); + })} + + +
+ + {teamFlag(viz.away)} + {viz.away} + ← attacking + + + + goal + + + shot + + · dot size = xG + + + attacking → + {viz.home} + {teamFlag(viz.home)} + +
+
+ ); +} diff --git a/src/components/viz/XgRace.tsx b/src/components/viz/XgRace.tsx new file mode 100644 index 0000000..6701a83 --- /dev/null +++ b/src/components/viz/XgRace.tsx @@ -0,0 +1,89 @@ +import { scaleLinear } from 'd3-scale'; +import { teamFlag } from '@/lib/teams'; +import type { StoryViz, XgPoint } from '@/lib/types'; + +const W = 640; +const H = 280; +const M = { top: 16, right: 16, bottom: 28, left: 34 }; + +/** stepAfter path: hold the level, jump at each shot. */ +function stepPath(points: XgPoint[], sx: (m: number) => number, sy: (c: number) => number): string { + return points + .map((p, i) => { + const x = sx(p.minute); + const y = sy(p.cum); + if (i === 0) return `M ${x} ${y}`; + const prevY = sy(points[i - 1]!.cum); + return `L ${x} ${prevY} L ${x} ${y}`; + }) + .join(' '); +} + +function cumAt(race: XgPoint[], minute: number): number { + let cum = 0; + for (const p of race) if (p.minute <= minute) cum = p.cum; + return cum; +} + +/** Cumulative expected-goals "race" — the story of momentum, with goals marked. */ +export function XgRace({ viz }: { viz: StoryViz }) { + const last = (r: XgPoint[]) => r[r.length - 1]?.minute ?? 90; + const maxMin = Math.max(last(viz.xgRace.home), last(viz.xgRace.away), 90); + const maxXg = Math.max(viz.totals.home.xg, viz.totals.away.xg) * 1.08; + + const sx = scaleLinear().domain([0, maxMin]).range([M.left, W - M.right]); + const sy = scaleLinear().domain([0, maxXg]).range([H - M.bottom, M.top]); + + const homeColor = 'var(--app-accent)'; + const awayColor = 'var(--app-info)'; + const xTicks = [0, 30, 45, 60, 90, maxMin > 105 ? 120 : maxMin].filter((t, i, a) => a.indexOf(t) === i && t <= maxMin); + const yTicks = sy.ticks(4); + + return ( +
+ + {/* gridlines + y axis */} + {yTicks.map((t) => ( + + + {t.toFixed(1)} + + ))} + {/* x ticks */} + {xTicks.map((t) => ( + {t}' + ))} + {/* full-time line */} + + + {/* race lines */} + + + + {/* goal markers */} + {viz.goals.map((g, i) => { + const isHome = g.team === viz.home; + const race = isHome ? viz.xgRace.home : viz.xgRace.away; + return ( + + + {`${g.player} ${g.minute}' (xG ${g.xg.toFixed(2)})`} + + + ); + })} + +
+ + {teamFlag(viz.home)} {viz.home} + {viz.totals.home.xg.toFixed(2)} + + + {teamFlag(viz.away)} {viz.away} + {viz.totals.away.xg.toFixed(2)} + + cumulative xG · dots = goals +
+
+ ); +} diff --git a/src/features/story/StoryPage.tsx b/src/features/story/StoryPage.tsx index c062a41..e657691 100644 --- a/src/features/story/StoryPage.tsx +++ b/src/features/story/StoryPage.tsx @@ -1,14 +1,142 @@ +import { useEffect, useState } from 'react'; import { Sparkles } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; +import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Placeholder } from '@/components/ui/Placeholder'; +import { ShotMap } from '@/components/viz/ShotMap'; +import { XgRace } from '@/components/viz/XgRace'; +import { PassNetwork } from '@/components/viz/PassNetwork'; +import { teamFlag } from '@/lib/teams'; +import type { StoryViz } from '@/lib/types'; -export function StoryPage() { +function StatRow({ label, home, away, fmt = (n: number) => String(n) }: { + label: string; home: number; away: number; fmt?: (n: number) => string; +}) { + const total = home + away || 1; return (
- - }> - Shot maps, pass networks and xG races arrive in Phase 3. - +
+ {fmt(home)} + {label} + {fmt(away)} +
+
+
+
+
+
+ ); +} + +export function StoryPage() { + const [viz, setViz] = useState(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let alive = true; + fetch('/data/viz/final-2022.json') + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: StoryViz) => alive && setViz(d)) + .catch(() => alive && setFailed(true)); + return () => { alive = false; }; + }, []); + + if (failed) { + return ( +
+ + }> + The story dataset isn't available. Run npm run data:viz to generate it. + +
+ ); + } + + if (!viz) { + return ( +
+ +
+
+ ); + } + + return ( +
+ + + {/* hero scoreline */} + + + + {teamFlag(viz.home)} {viz.home} + + {viz.homeScore} – {viz.awayScore} + + {viz.away} {teamFlag(viz.away)} + + {viz.result} + + + +

+ Argentina and France traded six goals across 120 minutes — Messi twice, Di María, and a + Mbappé hat-trick — before penalties. Here's what the event data says about how it happened. +

+ + {/* totals */} + + By the numbers + + n.toFixed(2)} /> + + + + + + + {/* shot map */} + + Every shot, sized by xG + + +

+ Argentina ({viz.totals.home.xg.toFixed(2)} xG) created the bigger chances; France's + comeback was built on a flurry of high-value shots once Mbappé sparked into life. Each + dot is a shot — bigger means a better chance; filled dots are goals. +

+
+
+ + {/* xG race */} + + The xG race + + +

+ Argentina built a commanding expected-goals lead through 80 minutes — then France's + two goals in 97 seconds dragged the line sharply upward. Steep steps are big chances. +

+
+
+ + {/* pass networks */} + + Passing shape (starting XI) + +
+ + +
+

+ Each node is a player at their average pass position; links are the completed passes + between a pair, up to the first substitution. Bigger nodes saw more of the ball. +

+
+
); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 8b9ab77..100ffd6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -117,6 +117,47 @@ export interface ModelSnapshot { history: OddsHistoryPoint[]; } +// ---- data-story viz (StatsBomb open data, a fixed historical match) ---- + +export interface VizShot { + team: string; + player: string; + minute: number; + /** StatsBomb pitch coords: x 0–120 (attacking toward 120), y 0–80. */ + x: number; + y: number; + xg: number; + outcome: string; + goal: boolean; +} + +export interface XgPoint { minute: number; cum: number; } + +export interface PassNode { player: string; x: number; y: number; passes: number; } +export interface PassEdge { a: string; b: string; count: number; } +export interface PassNetwork { players: PassNode[]; edges: PassEdge[]; } + +export interface TeamTotals { xg: number; shots: number; goals: number; passes: number; passAccuracy: number; } + +export interface StoryViz { + matchId: number; + competition: string; + stage: string; + date: string; + home: string; + away: string; + homeScore: number; + awayScore: number; + /** Final result line, e.g. "Argentina won 4–2 on penalties". */ + result: string; + pitch: { length: number; width: number }; + shots: VizShot[]; + goals: { team: string; player: string; minute: number; xg: number }[]; + xgRace: { home: XgPoint[]; away: XgPoint[] }; + passNetwork: { home: PassNetwork; away: PassNetwork }; + totals: { home: TeamTotals; away: TeamTotals }; +} + // ---- WebSocket protocol ---- // The snapshot is small (~104 fixtures), so the server simply re-pushes the full // snapshot on every change — no diffing, no client-side merge races. Predictions