In-play win-probability timeline: the momentum curve on match pages

While a match runs, every poll records the in-play model probability
per game state (insert-if-new on minute+score) into inplay_history.
The match page draws it as a stacked area chart — home fills from the
bottom, away from the top, the gap is the draw — with minute ticks and
a 50% guide. Shows live (under the win-probability bar) and after full
time as its own card. EN/DE strings included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:50:56 +02:00
parent acd8c3e75d
commit cbc5ff0468
6 changed files with 163 additions and 2 deletions
+66
View File
@@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { useT } from '@/lib/i18n';
export interface TimelinePoint {
minute: number;
p_home: number;
p_draw: number;
p_away: number;
}
/** Stacked 100% area chart of the in-play win probability: home fills from the
* bottom (accent), away from the top (info), draw is the gap between. */
export function WinProbTimeline({ points, home, away }: { points: TimelinePoint[]; home: string; away: string }) {
const t = useT();
const W = 600;
const H = 180;
const PAD = { l: 6, r: 6, t: 6, b: 20 };
const { homePath, awayPath, ticks, maxMin } = useMemo(() => {
const pts = [...points].sort((a, b) => a.minute - b.minute);
const maxMin = Math.max(90, pts[pts.length - 1]?.minute ?? 90);
const x = (min: number) => PAD.l + (min / maxMin) * (W - PAD.l - PAD.r);
const y = (p: number) => PAD.t + (1 - p) * (H - PAD.t - PAD.b);
// Home area: from the bottom edge up to p_home.
const homeTop = pts.map((p) => `${x(p.minute).toFixed(1)},${y(p.p_home).toFixed(1)}`);
const homePath = pts.length
? `M ${PAD.l},${y(0)} L ${homeTop.join(' L ')} L ${x(pts[pts.length - 1]!.minute).toFixed(1)},${y(0)} Z`
: '';
// Away area: from the top edge down to 1 p_away.
const awayBottom = pts.map((p) => `${x(p.minute).toFixed(1)},${y(1 - p.p_away).toFixed(1)}`);
const awayPath = pts.length
? `M ${PAD.l},${y(1)} L ${awayBottom.join(' L ')} L ${x(pts[pts.length - 1]!.minute).toFixed(1)},${y(1)} Z`
: '';
const ticks = [0, 15, 30, 45, 60, 75, 90].filter((m) => m <= maxMin).map((m) => ({ m, px: x(m) }));
return { homePath, awayPath, ticks, maxMin };
}, [points]);
if (points.length < 2) return null;
return (
<div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`${home} / ${t.common.draw} / ${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} />
<path d={awayPath} className="fill-[var(--app-info)] opacity-70" />
<path d={homePath} className="fill-[var(--app-accent)] opacity-80" />
{/* 50% guide */}
<line x1={PAD.l} x2={W - PAD.r} y1={PAD.t + (H - PAD.t - PAD.b) / 2} y2={PAD.t + (H - PAD.t - PAD.b) / 2} className="stroke-[var(--app-line-strong)]" strokeDasharray="4 4" strokeWidth={1} />
{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>
))}
{maxMin > 90 && (
<line x1={PAD.l + (90 / maxMin) * (W - PAD.l - PAD.r)} x2={PAD.l + (90 / maxMin) * (W - PAD.l - PAD.r)} y1={PAD.t} y2={H - PAD.b} className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
)}
</svg>
<div className="mt-1.5 flex items-center justify-between text-[11px]">
<span className="font-semibold text-accent">{home}</span>
<span className="text-faint">{t.common.draw}</span>
<span className="font-semibold text-info">{away}</span>
</div>
</div>
);
}
+30 -1
View File
@@ -3,6 +3,7 @@ import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { WinProbBar } from '@/components/WinProbBar';
import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline';
import { FormChips } from '@/components/FormChips';
import { teamFlag } from '@/lib/teams';
import { fmt, useFormat, useT } from '@/lib/i18n';
@@ -82,6 +83,7 @@ export function MatchPreviewPage() {
const { num: numParam } = useParams({ strict: false }) as { num: string };
const { t, kickoffDay, kickoffTime } = useFormat();
const [preview, setPreview] = useState<MatchPreview | null>(null);
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
const [failed, setFailed] = useState(false);
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
@@ -92,14 +94,26 @@ export function MatchPreviewPage() {
useEffect(() => {
let alive = true;
setPreview(null);
setTimeline([]);
setFailed(false);
const load = () =>
fetch(`/api/preview/${numParam}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: MatchPreview) => alive && setPreview(d))
.catch(() => alive && setFailed(true));
const loadTimeline = () =>
fetch(`/api/inplay/${numParam}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points))
.catch(() => {});
void load();
const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 10_000);
void loadTimeline();
const id = setInterval(() => {
if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') {
void load();
void loadTimeline();
}
}, 10_000);
return () => { alive = false; clearInterval(id); };
}, [numParam]);
@@ -164,11 +178,26 @@ export function MatchPreviewPage() {
</CardHeader>
<CardBody>
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
{timeline.length >= 2 && (
<div className="mt-4">
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
</div>
)}
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
</CardBody>
</Card>
)}
{/* FT: how the win probability swung over the match */}
{finished && timeline.length >= 2 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentum}</span></CardHeader>
<CardBody>
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
</CardBody>
</Card>
)}
{/* LIVE/FT: stats */}
{preview.liveStats && (
<Card>
+1
View File
@@ -85,6 +85,7 @@ export const de: Dict = {
saves: "Paraden",
},
timeline: "Spielverlauf",
momentum: "So schwankte die Siegwahrscheinlichkeit",
preMatchModel: "Modell vor Anstoß",
modelExpectation: "Modell-Prognose",
expectedGoalsLine: "Erwartete Tore (xG): {home} {away} · Modell-Wahrscheinlichkeiten, keine Wettempfehlung",
+1
View File
@@ -84,6 +84,7 @@ export const en = {
saves: "Saves",
},
timeline: "Timeline",
momentum: "How the win probability swung",
preMatchModel: "Pre-match model",
modelExpectation: "Model expectation",
expectedGoalsLine: "Expected goals: {home} {away} · model probabilities, not betting advice",