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:
+4
-1
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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); });
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<span aria-hidden>{teamFlag(team)}</span> {team}
|
||||
</div>
|
||||
<Pitch className="w-full">
|
||||
{net.edges.map((e, i) => {
|
||||
const a = pos.get(e.a);
|
||||
const b = pos.get(e.b);
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={a.x} y1={a.y} x2={b.x} y2={b.y}
|
||||
stroke={color}
|
||||
strokeOpacity={0.15 + 0.5 * (e.count / maxEdge)}
|
||||
strokeWidth={0.2 + 1.4 * (e.count / maxEdge)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{net.players.map((p) => (
|
||||
<g key={p.player}>
|
||||
<circle
|
||||
cx={p.x} cy={p.y} r={1.6 + 3.4 * (p.passes / maxPass)}
|
||||
fill={color} fillOpacity={0.85} stroke="var(--app-surface)" strokeWidth={0.4}
|
||||
>
|
||||
<title>{`${p.player} · ${p.passes} passes`}</title>
|
||||
</circle>
|
||||
<text
|
||||
x={p.x} y={p.y - 3.6} textAnchor="middle"
|
||||
fontSize="2.6" fill="var(--app-ink)" stroke="var(--app-surface)" strokeWidth={0.5}
|
||||
paintOrder="stroke" style={{ fontWeight: 600 }}
|
||||
>
|
||||
{surname(p.player)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</Pitch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SVGSVGElement>) {
|
||||
const line = { stroke: 'var(--app-line-strong)', strokeWidth: 0.4, fill: 'none', opacity: 0.65 };
|
||||
return (
|
||||
<svg viewBox="-2 -2 124 84" className={className} {...rest}>
|
||||
<rect x="0" y="0" width="120" height="80" rx="0.5" fill="var(--app-pitch)" opacity={0.1} />
|
||||
{/* outer + halfway */}
|
||||
<rect x="0" y="0" width="120" height="80" {...line} />
|
||||
<line x1="60" y1="0" x2="60" y2="80" {...line} />
|
||||
<circle cx="60" cy="40" r="9.15" {...line} />
|
||||
<circle cx="60" cy="40" r="0.6" fill="var(--app-line-strong)" opacity={0.65} />
|
||||
{/* left box */}
|
||||
<rect x="0" y="18" width="18" height="44" {...line} />
|
||||
<rect x="0" y="30" width="6" height="20" {...line} />
|
||||
<circle cx="12" cy="40" r="0.6" fill="var(--app-line-strong)" opacity={0.65} />
|
||||
<line x1="0" y1="36" x2="-1.2" y2="36" {...line} />
|
||||
<line x1="0" y1="44" x2="-1.2" y2="44" {...line} />
|
||||
<line x1="-1.2" y1="36" x2="-1.2" y2="44" {...line} />
|
||||
{/* right box */}
|
||||
<rect x="102" y="18" width="18" height="44" {...line} />
|
||||
<rect x="114" y="30" width="6" height="20" {...line} />
|
||||
<circle cx="108" cy="40" r="0.6" fill="var(--app-line-strong)" opacity={0.65} />
|
||||
<line x1="120" y1="36" x2="121.2" y2="36" {...line} />
|
||||
<line x1="120" y1="44" x2="121.2" y2="44" {...line} />
|
||||
<line x1="121.2" y1="36" x2="121.2" y2="44" {...line} />
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Pitch className="w-full">
|
||||
{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 (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={r(s.xg)}
|
||||
fill={color}
|
||||
fillOpacity={s.goal ? 0.95 : 0.18}
|
||||
stroke={color}
|
||||
strokeWidth={s.goal ? 0.8 : 0.5}
|
||||
>
|
||||
<title>{`${s.player} · ${s.minute}' · xG ${s.xg.toFixed(2)}${s.goal ? ' · GOAL' : ` · ${s.outcome}`}`}</title>
|
||||
</circle>
|
||||
);
|
||||
})}
|
||||
</Pitch>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span aria-hidden>{teamFlag(viz.away)}</span>
|
||||
<span className="font-medium text-info">{viz.away}</span>
|
||||
<span className="text-faint">← attacking</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-3 text-xs text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded-full bg-accent" /> goal
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-3 w-3 rounded-full border border-accent bg-accent/20" /> shot
|
||||
</span>
|
||||
<span>· dot size = xG</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-faint">attacking →</span>
|
||||
<span className="font-medium text-accent">{viz.home}</span>
|
||||
<span aria-hidden>{teamFlag(viz.home)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full">
|
||||
{/* gridlines + y axis */}
|
||||
{yTicks.map((t) => (
|
||||
<g key={t}>
|
||||
<line x1={M.left} x2={W - M.right} y1={sy(t)} y2={sy(t)} stroke="var(--app-line)" strokeDasharray="3 3" strokeWidth={0.5} />
|
||||
<text x={M.left - 6} y={sy(t) + 3} textAnchor="end" fontSize="10" fill="var(--app-faint)">{t.toFixed(1)}</text>
|
||||
</g>
|
||||
))}
|
||||
{/* x ticks */}
|
||||
{xTicks.map((t) => (
|
||||
<text key={t} x={sx(t)} y={H - 10} textAnchor="middle" fontSize="10" fill="var(--app-faint)">{t}'</text>
|
||||
))}
|
||||
{/* full-time line */}
|
||||
<line x1={sx(90)} x2={sx(90)} y1={M.top} y2={H - M.bottom} stroke="var(--app-line-strong)" strokeWidth={0.5} opacity={0.5} />
|
||||
|
||||
{/* race lines */}
|
||||
<path d={stepPath(viz.xgRace.away, sx, sy)} fill="none" stroke={awayColor} strokeWidth={2} />
|
||||
<path d={stepPath(viz.xgRace.home, sx, sy)} fill="none" stroke={homeColor} strokeWidth={2} />
|
||||
|
||||
{/* goal markers */}
|
||||
{viz.goals.map((g, i) => {
|
||||
const isHome = g.team === viz.home;
|
||||
const race = isHome ? viz.xgRace.home : viz.xgRace.away;
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle cx={sx(g.minute)} cy={sy(cumAt(race, g.minute))} r={3} fill={isHome ? homeColor : awayColor} stroke="var(--app-surface)" strokeWidth={1}>
|
||||
<title>{`${g.player} ${g.minute}' (xG ${g.xg.toFixed(2)})`}</title>
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="mt-1 flex items-center justify-center gap-5 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-4 rounded bg-accent" /> {teamFlag(viz.home)} {viz.home}
|
||||
<span className="tnum font-bold text-ink">{viz.totals.home.xg.toFixed(2)}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-4 rounded bg-info" /> {teamFlag(viz.away)} {viz.away}
|
||||
<span className="tnum font-bold text-ink">{viz.totals.away.xg.toFixed(2)}</span>
|
||||
</span>
|
||||
<span className="text-xs text-faint">cumulative xG · dots = goals</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<PageHeader title="Story" subtitle="A data-driven look back, powered by StatsBomb open data." />
|
||||
<Placeholder icon={<Sparkles size={28} />}>
|
||||
Shot maps, pass networks and xG races arrive in Phase 3.
|
||||
</Placeholder>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="tnum font-bold text-accent">{fmt(home)}</span>
|
||||
<span className="text-xs uppercase tracking-wide text-faint">{label}</span>
|
||||
<span className="tnum font-bold text-info">{fmt(away)}</span>
|
||||
</div>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-elevated">
|
||||
<div className="bg-accent" style={{ width: `${(home / total) * 100}%` }} />
|
||||
<div className="bg-info" style={{ width: `${(away / total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoryPage() {
|
||||
const [viz, setViz] = useState<StoryViz | null>(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 (
|
||||
<div>
|
||||
<PageHeader title="Story" subtitle="A data-driven look back." />
|
||||
<Placeholder icon={<Sparkles size={28} />}>
|
||||
The story dataset isn't available. Run <code className="text-accent">npm run data:viz</code> to generate it.
|
||||
</Placeholder>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!viz) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Story" subtitle="Loading the data story…" />
|
||||
<div className="h-96 animate-pulse rounded-xl border border-line bg-panel" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="The greatest final ever, in data"
|
||||
subtitle={`${viz.competition} · ${viz.stage} · ${viz.date}`}
|
||||
/>
|
||||
|
||||
{/* hero scoreline */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-center">
|
||||
<span className="flex items-center gap-2 text-xl font-bold text-ink">
|
||||
<span aria-hidden className="text-2xl">{teamFlag(viz.home)}</span> {viz.home}
|
||||
</span>
|
||||
<span className="tnum text-3xl font-extrabold text-ink">{viz.homeScore} – {viz.awayScore}</span>
|
||||
<span className="flex items-center gap-2 text-xl font-bold text-ink">
|
||||
{viz.away} <span aria-hidden className="text-2xl">{teamFlag(viz.away)}</span>
|
||||
</span>
|
||||
<span className="w-full text-sm font-medium text-gold">{viz.result}</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<p className="mx-auto max-w-2xl text-center text-sm text-muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* totals */}
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">By the numbers</span></CardHeader>
|
||||
<CardBody className="grid gap-4 sm:grid-cols-2">
|
||||
<StatRow label="Expected goals" home={viz.totals.home.xg} away={viz.totals.away.xg} fmt={(n) => n.toFixed(2)} />
|
||||
<StatRow label="Shots" home={viz.totals.home.shots} away={viz.totals.away.shots} />
|
||||
<StatRow label="Passes" home={viz.totals.home.passes} away={viz.totals.away.passes} />
|
||||
<StatRow label="Pass accuracy %" home={viz.totals.home.passAccuracy} away={viz.totals.away.passAccuracy} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* shot map */}
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">Every shot, sized by xG</span></CardHeader>
|
||||
<CardBody>
|
||||
<ShotMap viz={viz} />
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
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.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* xG race */}
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">The xG race</span></CardHeader>
|
||||
<CardBody>
|
||||
<XgRace viz={viz} />
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
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.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* pass networks */}
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">Passing shape (starting XI)</span></CardHeader>
|
||||
<CardBody>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<PassNetwork net={viz.passNetwork.home} team={viz.home} color="var(--app-accent)" />
|
||||
<PassNetwork net={viz.passNetwork.away} team={viz.away} color="var(--app-info)" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted">
|
||||
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.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user