From b823bef2d0134f45055618436f3a97f28f72d97f Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 10:10:35 +0200 Subject: [PATCH] Match center v2: xG race, shot map, attack momentum, weather, officials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The captured FotMob/FIFA/Open-Meteo data becomes visible. Match pages now carry: a cumulative xG race with goal dots and team totals, a full- pitch shot map (each side attacking its own end, dots sized by xG, goals filled), the 90-minute attack-momentum chart, player-of-the-match with rating, the official info line (stadium · attendance · referee), and a kickoff-hour weather line for upcoming matches with an altitude chip above 1,000m. All hand-rolled SVG on the existing token conventions — design-ready for the upcoming visual overhaul. EN/DE included, with an honest 'xG and ratings are FotMob estimates' note. Co-Authored-By: Claude Fable 5 --- src/components/MomentumStrip.tsx | 56 +++++++++++++ src/components/ShotMap.tsx | 68 +++++++++++++++ src/components/XgRace.tsx | 107 ++++++++++++++++++++++++ src/features/match/MatchPreviewPage.tsx | 86 ++++++++++++++++++- src/lib/i18n/de.ts | 8 ++ src/lib/i18n/en.ts | 8 ++ src/lib/types.ts | 27 ++++++ 7 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 src/components/MomentumStrip.tsx create mode 100644 src/components/ShotMap.tsx create mode 100644 src/components/XgRace.tsx 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 ( +
+ + + {data.bars.map((b, i) => ( + + ))} + + {data.ticks.map(({ m, px }) => ( + + + {m}' + + ))} + +
+ {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 ( +
+ + {/* pitch: outline, halfway line, both penalty boxes */} + + + + + {dots.map((d, i) => ( + + {d.label} + + ))} + +
+ ← {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 ( +
+ + + {data.yTicks.map((yt) => ( + + + {yt.label} + + ))} + + + {data.goals.map((g, i) => ( + + {g.label} + + ))} + {data.ticks.map(({ m, px }) => ( + + + {m}' + + ))} + +
+ {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;