Match center v2: xG race, shot map, attack momentum, weather, officials
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`${t.match.momentumTitle}: ${home} / ${away}`}>
|
||||||
|
<rect x={PAD.l} y={PAD.t} width={W - PAD.l - PAD.r} height={H - PAD.t - PAD.b} className="fill-[var(--app-elevated)]" rx={4} />
|
||||||
|
{data.bars.map((b, i) => (
|
||||||
|
<rect key={i} x={b.x} y={b.y} width={data.barW} height={b.h} className={b.up ? 'fill-[var(--app-accent)]' : 'fill-[var(--app-info)]'} opacity={0.85} />
|
||||||
|
))}
|
||||||
|
<line x1={PAD.l} x2={W - PAD.r} y1={data.mid} y2={data.mid} className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
{data.ticks.map(({ m, px }) => (
|
||||||
|
<g key={m}>
|
||||||
|
<line x1={px} x2={px} y1={H - PAD.b} y2={H - PAD.b + 4} className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
<text x={px} y={H - 6} textAnchor="middle" className="fill-[var(--app-faint)] text-[10px]">{m}'</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-1.5 flex items-center justify-between text-[11px]">
|
||||||
|
<span className="font-semibold text-accent">{home} ↑</span>
|
||||||
|
<span className="font-semibold text-info">{away} ↓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`${t.match.shotMap}: ${home} / ${away}`}>
|
||||||
|
{/* pitch: outline, halfway line, both penalty boxes */}
|
||||||
|
<rect x={PAD} y={PAD} width={PITCH_L * S} height={PITCH_W * S} className="fill-[var(--app-elevated)] stroke-[var(--app-line-strong)]" strokeWidth={1.5} />
|
||||||
|
<line x1={PAD + (PITCH_L / 2) * S} x2={PAD + (PITCH_L / 2) * S} y1={PAD} y2={PAD + PITCH_W * S} className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
<rect x={PAD} y={PAD + boxY * S} width={BOX_DEPTH * S} height={BOX_WIDTH * S} fill="none" className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
<rect x={PAD + (PITCH_L - BOX_DEPTH) * S} y={PAD + boxY * S} width={BOX_DEPTH * S} height={BOX_WIDTH * S} fill="none" className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
{dots.map((d, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={d.cx}
|
||||||
|
cy={d.cy}
|
||||||
|
r={d.r}
|
||||||
|
className={d.isHome ? 'fill-[var(--app-accent)] stroke-[var(--app-accent)]' : 'fill-[var(--app-info)] stroke-[var(--app-info)]'}
|
||||||
|
fillOpacity={d.goal ? 0.95 : 0.15}
|
||||||
|
strokeWidth={d.goal ? 0 : 1.5}
|
||||||
|
>
|
||||||
|
<title>{d.label}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-1.5 flex items-center justify-between text-[11px]">
|
||||||
|
<span className="font-semibold text-info">← {away}</span>
|
||||||
|
<span className="font-semibold text-accent">{home} →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`${t.match.xgRace}: ${home} / ${away}`}>
|
||||||
|
<rect x={PAD.l} y={PAD.t} width={W - PAD.l - PAD.r} height={H - PAD.t - PAD.b} className="fill-[var(--app-elevated)]" rx={4} />
|
||||||
|
{data.yTicks.map((yt) => (
|
||||||
|
<g key={yt.label}>
|
||||||
|
<line x1={PAD.l} x2={W - PAD.r} y1={yt.py} y2={yt.py} className="stroke-[var(--app-line)]" strokeWidth={1} />
|
||||||
|
<text x={PAD.l - 4} y={yt.py + 3} textAnchor="end" className="fill-[var(--app-faint)] text-[10px]">{yt.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
<path d={data.awayPath} fill="none" className="stroke-[var(--app-info)]" strokeWidth={2.5} />
|
||||||
|
<path d={data.homePath} fill="none" className="stroke-[var(--app-accent)]" strokeWidth={2.5} />
|
||||||
|
{data.goals.map((g, i) => (
|
||||||
|
<circle key={i} cx={g.px} cy={g.py} r={4} className={`${g.isHome ? 'fill-[var(--app-accent)]' : 'fill-[var(--app-info)]'} stroke-[var(--app-panel)]`} strokeWidth={1.5}>
|
||||||
|
<title>{g.label}</title>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
{data.ticks.map(({ m, px }) => (
|
||||||
|
<g key={m}>
|
||||||
|
<line x1={px} x2={px} y1={H - PAD.b} y2={H - PAD.b + 4} className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
<text x={px} y={H - 6} textAnchor="middle" className="fill-[var(--app-faint)] text-[10px]">{m}'</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-1.5 flex items-center justify-between text-[11px]">
|
||||||
|
<span className="font-semibold text-accent">{home}</span>
|
||||||
|
<span className="font-semibold text-info">{away}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
|
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 { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||||
import { WinProbBar } from '@/components/WinProbBar';
|
import { WinProbBar } from '@/components/WinProbBar';
|
||||||
import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline';
|
import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline';
|
||||||
import { OddsMovement } from '@/components/OddsMovement';
|
import { OddsMovement } from '@/components/OddsMovement';
|
||||||
import { FormChips } from '@/components/FormChips';
|
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 { teamFlag } from '@/lib/teams';
|
||||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||||
import { inPlayProbs } from '@/lib/model/inplay';
|
import { inPlayProbs } from '@/lib/model/inplay';
|
||||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
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);
|
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() {
|
export function MatchPreviewPage() {
|
||||||
const { num: numParam } = useParams({ strict: false }) as { num: string };
|
const { num: numParam } = useParams({ strict: false }) as { num: string };
|
||||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
const { t, locale, kickoffDay, kickoffTime } = useFormat();
|
||||||
const [preview, setPreview] = useState<MatchPreview | null>(null);
|
const [preview, setPreview] = useState<MatchPreview | null>(null);
|
||||||
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
|
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
|
||||||
const [oddsHistory, setOddsHistory] = useState<OddsPoint[]>([]);
|
const [oddsHistory, setOddsHistory] = useState<OddsPoint[]>([]);
|
||||||
|
const [rich, setRich] = useState<MatchRich | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
|
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
|
||||||
@@ -97,6 +102,7 @@ export function MatchPreviewPage() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setTimeline([]);
|
setTimeline([]);
|
||||||
|
setRich(null);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
const load = () =>
|
const load = () =>
|
||||||
fetch(`/api/preview/${numParam}`)
|
fetch(`/api/preview/${numParam}`)
|
||||||
@@ -108,8 +114,14 @@ export function MatchPreviewPage() {
|
|||||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||||
.then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points))
|
.then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points))
|
||||||
.catch(() => {});
|
.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 load();
|
||||||
void loadTimeline();
|
void loadTimeline();
|
||||||
|
void loadRich();
|
||||||
// line history changes on a 3h sweep — once per visit is plenty
|
// line history changes on a 3h sweep — once per visit is plenty
|
||||||
fetch(`/api/odds/${numParam}`)
|
fetch(`/api/odds/${numParam}`)
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
.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') {
|
if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') {
|
||||||
void load();
|
void load();
|
||||||
void loadTimeline();
|
void loadTimeline();
|
||||||
|
void loadRich();
|
||||||
}
|
}
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
return () => { alive = false; clearInterval(id); };
|
return () => { alive = false; clearInterval(id); };
|
||||||
@@ -150,6 +163,17 @@ export function MatchPreviewPage() {
|
|||||||
const koT = new Date(f.kickoff).getTime();
|
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;
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"><ArrowLeft size={15} /> {t.common.back}</Link>
|
<Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"><ArrowLeft size={15} /> {t.common.back}</Link>
|
||||||
@@ -175,6 +199,17 @@ export function MatchPreviewPage() {
|
|||||||
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
|
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{(weatherText || infoText) && (
|
||||||
|
<div className="mt-3 space-y-1 border-t border-line pt-2 text-center text-xs text-muted">
|
||||||
|
{weatherText && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<span>{weatherText}</span>
|
||||||
|
{altitudeM != null && <Badge tone="muted">{fmt(t.match.altitude, { m: altitudeM })}</Badge>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{infoText && <div>{infoText}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -207,6 +242,44 @@ export function MatchPreviewPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* LIVE/FT: cumulative xG race + shot map from the captured shot list */}
|
||||||
|
{rich && rich.shots.length >= 3 && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex items-center justify-between">
|
||||||
|
<span className="font-display font-bold text-ink">{t.match.xgRace}</span>
|
||||||
|
{rich.xg && (
|
||||||
|
<span className="tnum text-sm font-bold">
|
||||||
|
<span className="text-accent">{rich.xg.home.toFixed(2)}</span>
|
||||||
|
<span className="font-normal text-faint"> – </span>
|
||||||
|
<span className="text-info">{rich.xg.away.toFixed(2)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<XgRace shots={rich.shots} home={homeName} away={awayName} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader><span className="font-display font-bold text-ink">{t.match.shotMap}</span></CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ShotMap shots={rich.shots} home={homeName} away={awayName} />
|
||||||
|
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* LIVE/FT: attack momentum */}
|
||||||
|
{rich && rich.momentum.length >= 10 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<MomentumStrip points={rich.momentum} home={homeName} away={awayName} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* LIVE/FT: stats */}
|
{/* LIVE/FT: stats */}
|
||||||
{preview.liveStats && (
|
{preview.liveStats && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -215,6 +288,15 @@ export function MatchPreviewPage() {
|
|||||||
{statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
|
{statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
|
||||||
<LiveStatRow key={k} label={label} home={num(preview.liveStats!.home[k])} away={num(preview.liveStats!.away[k])} />
|
<LiveStatRow key={k} label={label} home={num(preview.liveStats!.home[k])} away={num(preview.liveStats!.away[k])} />
|
||||||
))}
|
))}
|
||||||
|
{rich?.potm && (
|
||||||
|
<div className="flex items-center justify-between border-t border-line pt-3">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-faint">{t.match.potm}</span>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||||
|
{rich.potm.name}
|
||||||
|
{rich.potm.rating != null && <Badge tone="gold">{rich.potm.rating}</Badge>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ export const de: Dict = {
|
|||||||
},
|
},
|
||||||
lineups: "Aufstellungen",
|
lineups: "Aufstellungen",
|
||||||
keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)",
|
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: {
|
compare: {
|
||||||
title: "Vergleich",
|
title: "Vergleich",
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ export const en = {
|
|||||||
},
|
},
|
||||||
lineups: "Lineups",
|
lineups: "Lineups",
|
||||||
keyPlayers: "Key players (all-time top scorers)",
|
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: {
|
compare: {
|
||||||
title: "Compare",
|
title: "Compare",
|
||||||
|
|||||||
@@ -164,6 +164,33 @@ export interface MatchPreview {
|
|||||||
updatedAt: number | null;
|
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 {
|
export interface TeamProfile {
|
||||||
team: string;
|
team: string;
|
||||||
elo: number;
|
elo: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user