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:
@@ -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<MatchPreview | null>(null);
|
||||
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
|
||||
const [oddsHistory, setOddsHistory] = useState<OddsPoint[]>([]);
|
||||
const [rich, setRich] = useState<MatchRich | null>(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 (
|
||||
<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>
|
||||
@@ -175,6 +199,17 @@ export function MatchPreviewPage() {
|
||||
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
|
||||
</Link>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -207,6 +242,44 @@ export function MatchPreviewPage() {
|
||||
</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 */}
|
||||
{preview.liveStats && (
|
||||
<Card>
|
||||
@@ -215,6 +288,15 @@ export function MatchPreviewPage() {
|
||||
{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])} />
|
||||
))}
|
||||
{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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user