Files
cup26/src/lib/types.ts
T
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

171 lines
4.8 KiB
TypeScript
Raw 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.
// Shared domain types — used by the client, the server, and the build scripts.
export type Stage = 'group' | 'r32' | 'r16' | 'qf' | 'sf' | 'third' | 'final';
export type MatchStatus = 'scheduled' | 'live' | 'finished';
/** One side of a fixture: a known team, or an unresolved knockout placeholder. */
export interface TeamSlot {
/** Canonical team name when known, else null (unresolved knockout slot). */
team: string | null;
/** Raw openfootball placeholder: '2A' | 'W74' | 'L101' | '3A/B/C/D/F' | null. */
placeholder: string | null;
/** Display label: the team name, or a pretty placeholder ("Runner-up A"). */
label: string;
}
export interface Fixture {
/** Canonical 1..104 match number (group matches 1-72, knockout 73-104). */
num: number;
stage: Stage;
/** 'A'..'L' for group stage, else null. */
group: string | null;
/** Human round label from the source ("Matchday 1", "Round of 32", …). */
round: string;
/** 1 | 2 | 3 — the team's nth group game, else null. */
groupRound: number | null;
/** Kickoff as an ISO 8601 UTC timestamp. */
kickoff: string;
venue: string;
home: TeamSlot;
away: TeamSlot;
status: MatchStatus;
homeScore: number | null;
awayScore: number | null;
/** Live match minute when status === 'live'. */
minute: number | null;
}
export interface FixturesFile {
season: string;
generatedAt: string;
groups: string[];
venues: string[];
fixtures: Fixture[];
}
export interface StandingRow {
team: string;
played: number;
won: number;
drawn: number;
lost: number;
gf: number;
ga: number;
gd: number;
points: number;
/** 1-based rank within the group after sorting. */
rank: number;
}
/** What the server pushes to clients: fixtures with any live overlay + tables. */
export interface Snapshot {
season: string;
/** When the live layer last refreshed (ISO), or null if seed-only. */
updatedAt: string | null;
/** Which live source produced the current scores. */
source: 'seed' | 'football-data' | 'sofascore';
fixtures: Fixture[];
/** Group letter → ranked standings. */
tables: Record<string, StandingRow[]>;
}
// ---- model / predictions ----
/** Per-team tournament odds from the Monte Carlo simulation. */
export interface TeamOdds {
team: string;
winGroup: number;
qualify: number;
reachR16: number;
reachQF: number;
reachSF: number;
reachFinal: number;
champion: number;
}
export interface MatchProbs {
home: number;
draw: number;
away: number;
}
/** A single per-match prediction (group + resolved knockout games). */
export interface MatchPrediction {
num: number;
home: string;
away: string;
probs: MatchProbs;
lambdaHome: number;
lambdaAway: number;
topScore: { home: number; away: number; p: number };
}
/** A point on the championship-odds-over-time chart. */
export interface OddsHistoryPoint {
t: string;
label: string;
top: { team: string; champion: number }[];
}
export interface ModelSnapshot {
generatedAt: string;
/** Date the underlying ratings reflect (advances as results re-rate teams). */
asOf: string;
iterations: number;
matches: MatchPrediction[];
odds: TeamOdds[];
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
// are heavier to compute, so they ride a separate message sent only when the set
// of finished results changes.
/** Server → client. */
export type ServerMessage =
| { t: 'snapshot'; snapshot: Snapshot }
| { t: 'predictions'; model: ModelSnapshot };