diff --git a/src/components/MomentumStrip.tsx b/src/components/MomentumStrip.tsx
new file mode 100644
index 0000000..4f5bcaf
--- /dev/null
+++ b/src/components/MomentumStrip.tsx
@@ -0,0 +1,56 @@
+import { useMemo } from 'react';
+import { useT } from '@/lib/i18n';
+import type { MatchRich } from '@/lib/types';
+
+/** Attack-momentum strip: one bar per sampled minute, up (accent) when the
+ * home side is pressing, down (info) for the away side, baseline at center. */
+export function MomentumStrip({ points, home, away }: { points: MatchRich['momentum']; home: string; away: string }) {
+ const t = useT();
+ const W = 600;
+ const H = 150;
+ const PAD = { l: 6, r: 6, t: 6, b: 20 };
+
+ const data = useMemo(() => {
+ const pts = [...points].sort((a, b) => a.minute - b.minute);
+ if (!pts.length) return null;
+ const maxMin = Math.max(90, pts[pts.length - 1]!.minute);
+ const maxAbs = Math.max(...pts.map((p) => Math.abs(p.value)), 1);
+ const innerW = W - PAD.l - PAD.r;
+ const halfH = (H - PAD.t - PAD.b) / 2;
+ const mid = PAD.t + halfH;
+ const x = (min: number) => PAD.l + (min / maxMin) * innerW;
+ const barW = Math.max(1.5, (innerW / pts.length) * 0.65);
+ const bars = pts
+ .filter((p) => p.value !== 0)
+ .map((p) => {
+ const h = (Math.abs(p.value) / maxAbs) * halfH;
+ return { x: x(p.minute) - barW / 2, y: p.value > 0 ? mid - h : mid, h, up: p.value > 0 };
+ });
+ const ticks = [45, 90].filter((m) => m <= maxMin).map((m) => ({ m, px: x(m) }));
+ return { bars, barW, mid, ticks };
+ }, [points]);
+
+ if (!data) return null;
+
+ return (
+
+
+
+ {home} ↑
+ {away} ↓
+
+
+ );
+}
diff --git a/src/components/ShotMap.tsx b/src/components/ShotMap.tsx
new file mode 100644
index 0000000..a0d7344
--- /dev/null
+++ b/src/components/ShotMap.tsx
@@ -0,0 +1,68 @@
+import { useMemo } from 'react';
+import { useT } from '@/lib/i18n';
+import type { RichShot } from '@/lib/types';
+
+const PITCH_L = 105;
+const PITCH_W = 68;
+const BOX_DEPTH = 16.5;
+const BOX_WIDTH = 40.32;
+
+/** Both teams' shots on one full pitch. The captured coords have every shot
+ * attacking toward x=105, so home shots plot as-is (attacking right) and away
+ * shots are mirrored (x→105−x, y→68−y) to attack left. Dot radius scales with
+ * xG; goals are filled, other attempts outlined with a faint fill. */
+export function ShotMap({ shots, home, away }: { shots: RichShot[]; home: string; away: string }) {
+ const t = useT();
+ const W = 600;
+ const PAD = 8;
+ const S = (W - 2 * PAD) / PITCH_L;
+ const H = PITCH_W * S + 2 * PAD;
+
+ const dots = useMemo(() => {
+ const px = (x: number) => PAD + x * S;
+ const py = (y: number) => PAD + y * S;
+ return shots
+ .filter((s): s is RichShot & { isHome: boolean; x: number; y: number } => s.isHome != null && s.x != null && s.y != null)
+ .map((s) => ({
+ cx: px(s.isHome ? s.x : PITCH_L - s.x),
+ cy: py(s.isHome ? s.y : PITCH_W - s.y),
+ r: 3 + 6 * Math.sqrt(Math.min(Math.max(s.xg ?? 0, 0), 1)),
+ goal: s.type === 'Goal',
+ isHome: s.isHome,
+ label: `${s.player} ${s.min ?? '–'}' · xG ${s.xg != null ? s.xg.toFixed(2) : '–'}`,
+ }))
+ // goals last so they sit on top of the open attempts
+ .sort((a, b) => Number(a.goal) - Number(b.goal));
+ }, [shots]);
+
+ const boxY = (PITCH_W - BOX_WIDTH) / 2;
+
+ return (
+
+
+
+ ← {away}
+ {home} →
+
+
+ );
+}
diff --git a/src/components/XgRace.tsx b/src/components/XgRace.tsx
new file mode 100644
index 0000000..fbd6e9c
--- /dev/null
+++ b/src/components/XgRace.tsx
@@ -0,0 +1,107 @@
+import { useMemo } from 'react';
+import { useT } from '@/lib/i18n';
+import type { RichShot } from '@/lib/types';
+
+interface Step {
+ min: number;
+ cum: number;
+ goal: boolean;
+ player: string;
+ xg: number;
+}
+
+/** Cumulative xG step lines built from the captured shot list — home in accent,
+ * away in info. The line holds its level and jumps at each shot; goals are
+ * marked as dots on the line. */
+export function XgRace({ shots, home, away }: { shots: RichShot[]; home: string; away: string }) {
+ const t = useT();
+ const W = 600;
+ const H = 200;
+ const PAD = { l: 34, r: 8, t: 8, b: 20 };
+
+ const data = useMemo(() => {
+ const usable = shots
+ .filter((s): s is RichShot & { isHome: boolean; min: number; xg: number } => s.isHome != null && s.min != null && s.xg != null)
+ .sort((a, b) => a.min - b.min);
+ if (!usable.length) return null;
+
+ const accumulate = (side: boolean): Step[] => {
+ let cum = 0;
+ return usable
+ .filter((s) => s.isHome === side)
+ .map((s) => {
+ cum += s.xg;
+ return { min: s.min, cum, goal: s.type === 'Goal', player: s.player, xg: s.xg };
+ });
+ };
+ const homeSteps = accumulate(true);
+ const awaySteps = accumulate(false);
+ const homeTotal = homeSteps[homeSteps.length - 1]?.cum ?? 0;
+ const awayTotal = awaySteps[awaySteps.length - 1]?.cum ?? 0;
+
+ const maxMin = Math.max(90, usable[usable.length - 1]!.min);
+ const maxXg = Math.max(homeTotal, awayTotal, 0.4) * 1.1;
+ const x = (min: number) => PAD.l + (min / maxMin) * (W - PAD.l - PAD.r);
+ const y = (c: number) => PAD.t + (1 - c / maxXg) * (H - PAD.t - PAD.b);
+
+ // stepAfter: hold the level, jump at each shot, then run out to maxMin.
+ const path = (steps: Step[]) => {
+ let d = `M ${x(0).toFixed(1)},${y(0).toFixed(1)}`;
+ let prev = 0;
+ for (const s of steps) {
+ d += ` L ${x(s.min).toFixed(1)},${y(prev).toFixed(1)} L ${x(s.min).toFixed(1)},${y(s.cum).toFixed(1)}`;
+ prev = s.cum;
+ }
+ d += ` L ${x(maxMin).toFixed(1)},${y(prev).toFixed(1)}`;
+ return d;
+ };
+ const goalDots = (steps: Step[], isHome: boolean) =>
+ steps.filter((s) => s.goal).map((s) => ({ px: x(s.min), py: y(s.cum), isHome, label: `${s.player} ${s.min}' · xG ${s.xg.toFixed(2)}` }));
+
+ const ticks = [0, 15, 30, 45, 60, 75, 90].filter((m) => m <= maxMin).map((m) => ({ m, px: x(m) }));
+ const yStep = maxXg > 3 ? 1 : maxXg > 1.2 ? 0.5 : 0.25;
+ const yTicks: { py: number; label: string }[] = [];
+ for (let v = yStep; v < maxXg; v += yStep) yTicks.push({ py: y(v), label: String(+v.toFixed(2)) });
+
+ return {
+ homePath: path(homeSteps),
+ awayPath: path(awaySteps),
+ goals: [...goalDots(homeSteps, true), ...goalDots(awaySteps, false)],
+ ticks,
+ yTicks,
+ };
+ }, [shots]);
+
+ if (!data) return null;
+
+ return (
+
+
+
+ {home}
+ {away}
+
+
+ );
+}
diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx
index 7bf57fb..b853ebf 100644
--- a/src/features/match/MatchPreviewPage.tsx
+++ b/src/features/match/MatchPreviewPage.tsx
@@ -1,16 +1,20 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
+import { Badge } from '@/components/ui/Badge';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { WinProbBar } from '@/components/WinProbBar';
import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline';
import { OddsMovement } from '@/components/OddsMovement';
import { FormChips } from '@/components/FormChips';
+import { XgRace } from '@/components/XgRace';
+import { ShotMap } from '@/components/ShotMap';
+import { MomentumStrip } from '@/components/MomentumStrip';
import { teamFlag } from '@/lib/teams';
import { fmt, useFormat, useT } from '@/lib/i18n';
import { inPlayProbs } from '@/lib/model/inplay';
import { useTournamentStore } from '@/stores/tournamentStore';
-import type { Fixture, KeyPlayer, MatchPreview, OddsPoint } from '@/lib/types';
+import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
@@ -82,10 +86,11 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
export function MatchPreviewPage() {
const { num: numParam } = useParams({ strict: false }) as { num: string };
- const { t, kickoffDay, kickoffTime } = useFormat();
+ const { t, locale, kickoffDay, kickoffTime } = useFormat();
const [preview, setPreview] = useState(null);
const [timeline, setTimeline] = useState([]);
const [oddsHistory, setOddsHistory] = useState([]);
+ const [rich, setRich] = useState(null);
const [failed, setFailed] = useState(false);
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
@@ -97,6 +102,7 @@ export function MatchPreviewPage() {
let alive = true;
setPreview(null);
setTimeline([]);
+ setRich(null);
setFailed(false);
const load = () =>
fetch(`/api/preview/${numParam}`)
@@ -108,8 +114,14 @@ export function MatchPreviewPage() {
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points))
.catch(() => {});
+ const loadRich = () =>
+ fetch(`/api/match-rich/${numParam}`)
+ .then((r) => (r.ok ? r.json() : Promise.reject()))
+ .then((d: MatchRich) => alive && setRich(d))
+ .catch(() => {});
void load();
void loadTimeline();
+ void loadRich();
// line history changes on a 3h sweep — once per visit is plenty
fetch(`/api/odds/${numParam}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
@@ -119,6 +131,7 @@ export function MatchPreviewPage() {
if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') {
void load();
void loadTimeline();
+ void loadRich();
}
}, 10_000);
return () => { alive = false; clearInterval(id); };
@@ -150,6 +163,17 @@ export function MatchPreviewPage() {
const koT = new Date(f.kickoff).getTime();
const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2;
+ // Hero extras from the rich capture: kickoff-hour weather (pre-match only)
+ // and the official stadium/attendance/referee line.
+ const wx = f.status === 'scheduled' ? rich?.weather : null;
+ const weatherText = wx && wx.tempC != null && wx.precipProbPct != null && wx.windKmh != null
+ ? fmt(t.match.weatherLine, { temp: Math.round(wx.tempC), precip: Math.round(wx.precipProbPct), wind: Math.round(wx.windKmh) })
+ : null;
+ const altitudeM = wx && wx.elevationM >= 1000 ? Math.round(wx.elevationM) : null;
+ const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee
+ ? fmt(t.match.attendanceLine, { stadium: rich.info.stadium, n: rich.info.attendance.toLocaleString(locale), ref: rich.info.referee })
+ : null;
+
return (
{t.common.back}
@@ -175,6 +199,17 @@ export function MatchPreviewPage() {
{awayName}
+ {(weatherText || infoText) && (
+
+ {weatherText && (
+
+ {weatherText}
+ {altitudeM != null && {fmt(t.match.altitude, { m: altitudeM })}}
+
+ )}
+ {infoText &&
{infoText}
}
+
+ )}
@@ -207,6 +242,44 @@ export function MatchPreviewPage() {
)}
+ {/* LIVE/FT: cumulative xG race + shot map from the captured shot list */}
+ {rich && rich.shots.length >= 3 && (
+ <>
+
+
+ {t.match.xgRace}
+ {rich.xg && (
+
+ {rich.xg.home.toFixed(2)}
+ –
+ {rich.xg.away.toFixed(2)}
+
+ )}
+
+
+
+
+
+
+ {t.match.shotMap}
+
+
+ {t.match.xgAttribution}
+
+
+ >
+ )}
+
+ {/* LIVE/FT: attack momentum */}
+ {rich && rich.momentum.length >= 10 && (
+
+ {t.match.momentumTitle}
+
+
+
+
+ )}
+
{/* LIVE/FT: stats */}
{preview.liveStats && (
@@ -215,6 +288,15 @@ export function MatchPreviewPage() {
{statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
))}
+ {rich?.potm && (
+
+ {t.match.potm}
+
+ {rich.potm.name}
+ {rich.potm.rating != null && {rich.potm.rating}}
+
+
+ )}
)}
diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts
index 4b5f09e..aa03679 100644
--- a/src/lib/i18n/de.ts
+++ b/src/lib/i18n/de.ts
@@ -106,6 +106,14 @@ export const de: Dict = {
},
lineups: "Aufstellungen",
keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)",
+ xgRace: "xG-Rennen",
+ shotMap: "Schusskarte",
+ momentumTitle: "Angriffsdruck",
+ weatherLine: "{temp} °C · {precip} % Regen · {wind} km/h Wind",
+ altitude: "{m} m Höhe",
+ attendanceLine: "{stadium} · {n} Zuschauer · Schiedsrichter {ref}",
+ potm: "Spieler des Spiels",
+ xgAttribution: "xG und Bewertungen sind FotMob-Schätzungen.",
},
compare: {
title: "Vergleich",
diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts
index 18e0fab..c5682b7 100644
--- a/src/lib/i18n/en.ts
+++ b/src/lib/i18n/en.ts
@@ -105,6 +105,14 @@ export const en = {
},
lineups: "Lineups",
keyPlayers: "Key players (all-time top scorers)",
+ xgRace: "xG race",
+ shotMap: "Shot map",
+ momentumTitle: "Attack momentum",
+ weatherLine: "{temp}°C · {precip}% rain · {wind} km/h wind",
+ altitude: "{m} m altitude",
+ attendanceLine: "{stadium} · {n} spectators · referee {ref}",
+ potm: "Player of the match",
+ xgAttribution: "xG and ratings are FotMob estimates.",
},
compare: {
title: "Compare",
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 1368ea0..ba4ae33 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -164,6 +164,33 @@ export interface MatchPreview {
updatedAt: number | null;
}
+// ---- rich match data (/api/match-rich — FotMob/FIFA/Open-Meteo capture) ----
+
+/** One captured shot. FotMob pitch coords ≈105×68 m, attacking toward x=105. */
+export interface RichShot {
+ /** true = home, false = away, null when the side couldn't be resolved. */
+ isHome: boolean | null;
+ player: string;
+ x: number | null;
+ y: number | null;
+ min: number | null;
+ xg: number | null;
+ xgot: number | null;
+ /** FotMob event type: 'Goal' means scored; 'AttemptSaved' | 'Miss' | 'Post' otherwise. */
+ type: string;
+ situation: string;
+}
+
+export interface MatchRich {
+ shots: RichShot[];
+ xg: { home: number; away: number } | null;
+ /** Attack-momentum curve; positive values = home pressure. */
+ momentum: { minute: number; value: number }[];
+ potm: { name: string; rating: string | null } | null;
+ info: { attendance: number | null; stadium: string; referee: string | null } | null;
+ weather: { tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number; tz: string } | null;
+}
+
export interface TeamProfile {
team: string;
elo: number;