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:
2026-06-11 13:34:19 +02:00
parent d604bb14ff
commit dd8156376d
9 changed files with 603 additions and 6 deletions
+41
View File
@@ -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 0120 (attacking toward 120), y 080. */
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 42 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