Lineup pitch with live substitution tracking + polish pass
- LineupPitch: both XIs on a vertical pitch at FotMob's per-player formation coordinates — shirt numbers, live ratings, goal badges, formation + coach labels. Substitutes take over the outgoing player's slot (paired through ESPN's 'X replaces Y' commentary with a unique same-minute fallback — the sources romanize names differently), with a subbed-off strip below; the page's live refetch keeps it current through the match. Falls back to the list view (and hides predicted pre-kickoff XIs) when no confirmed lineup is captured. - /api/match-rich now serves the trimmed FotMob lineup (only complete, fully-positioned XIs) - Fix: vs-Market label box was too narrow — the probability bar covered the end of 'DraftKings' - Timeline spine no longer runs past the kick-off/full-time pills Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -124,11 +124,19 @@ export function buildServer() {
|
|||||||
const num = Number((req.params as { num: string }).num);
|
const num = Number((req.params as { num: string }).num);
|
||||||
if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
|
if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
|
||||||
interface FmShot { teamId?: number; playerName?: string; x?: number; y?: number; min?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string }
|
interface FmShot { teamId?: number; playerName?: string; x?: number; y?: number; min?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string }
|
||||||
|
interface FmLineupPlayer {
|
||||||
|
name?: string;
|
||||||
|
shirtNumber?: string | number;
|
||||||
|
verticalLayout?: { x?: number; y?: number };
|
||||||
|
performance?: { rating?: number; substitutionEvents?: { time?: number; type?: string }[]; events?: { type?: string }[] };
|
||||||
|
}
|
||||||
|
interface FmLineupTeam { formation?: string; coach?: { name?: string }; starters?: FmLineupPlayer[]; subs?: FmLineupPlayer[] }
|
||||||
interface FmDetails {
|
interface FmDetails {
|
||||||
homeTeamId?: number | null;
|
homeTeamId?: number | null;
|
||||||
shots?: FmShot[];
|
shots?: FmShot[];
|
||||||
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
|
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
|
||||||
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
|
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
|
||||||
|
lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null;
|
||||||
}
|
}
|
||||||
const fm = getMatchProvider<FmDetails>(num, 'fotmob');
|
const fm = getMatchProvider<FmDetails>(num, 'fotmob');
|
||||||
const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info');
|
const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info');
|
||||||
@@ -155,10 +163,40 @@ export function buildServer() {
|
|||||||
{ home: 0, away: 0 },
|
{ home: 0, away: 0 },
|
||||||
);
|
);
|
||||||
const potmRaw = fm?.data.matchFacts?.playerOfTheMatch;
|
const potmRaw = fm?.data.matchFacts?.playerOfTheMatch;
|
||||||
|
|
||||||
|
const subTime = (p: FmLineupPlayer, type: string): number | null => {
|
||||||
|
const e = p.performance?.substitutionEvents?.find((s) => s.type === type);
|
||||||
|
return typeof e?.time === 'number' ? e.time : null;
|
||||||
|
};
|
||||||
|
const trimPlayer = (p: FmLineupPlayer) => ({
|
||||||
|
name: p.name ?? '',
|
||||||
|
num: p.shirtNumber != null ? String(p.shirtNumber) : null,
|
||||||
|
x: typeof p.verticalLayout?.x === 'number' ? p.verticalLayout.x : null,
|
||||||
|
y: typeof p.verticalLayout?.y === 'number' ? p.verticalLayout.y : null,
|
||||||
|
rating: typeof p.performance?.rating === 'number' ? p.performance.rating : null,
|
||||||
|
goals: p.performance?.events?.filter((e) => e?.type === 'goal').length ?? 0,
|
||||||
|
subOut: subTime(p, 'subOut'),
|
||||||
|
subIn: subTime(p, 'subIn'),
|
||||||
|
});
|
||||||
|
// Only a complete, fully-positioned XI is worth drawing as a pitch.
|
||||||
|
const trimTeam = (tm?: FmLineupTeam) => {
|
||||||
|
const starters = (tm?.starters ?? []).map(trimPlayer);
|
||||||
|
if (starters.length !== 11 || starters.some((p) => p.x == null || p.y == null || !p.name)) return null;
|
||||||
|
return {
|
||||||
|
formation: tm?.formation ?? null,
|
||||||
|
coach: tm?.coach?.name ?? null,
|
||||||
|
starters,
|
||||||
|
subs: (tm?.subs ?? []).map(trimPlayer),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const lineupHome = trimTeam(fm?.data.lineup?.homeTeam);
|
||||||
|
const lineupAway = trimTeam(fm?.data.lineup?.awayTeam);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shots,
|
shots,
|
||||||
xg: shots.length ? { home: +xg.home.toFixed(2), away: +xg.away.toFixed(2) } : null,
|
xg: shots.length ? { home: +xg.home.toFixed(2), away: +xg.away.toFixed(2) } : null,
|
||||||
momentum: fm?.data.momentum?.main?.data ?? [],
|
momentum: fm?.data.momentum?.main?.data ?? [],
|
||||||
|
lineup: lineupHome && lineupAway ? { home: lineupHome, away: lineupAway } : null,
|
||||||
potm: potmRaw?.name?.fullName ? { name: potmRaw.name.fullName, rating: potmRaw.rating?.num ?? null } : null,
|
potm: potmRaw?.name?.fullName ? { name: potmRaw.name.fullName, rating: potmRaw.rating?.num ?? null } : null,
|
||||||
info: fifaInfo
|
info: fifaInfo
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { fmt, useT } from '@/lib/i18n';
|
||||||
|
import { parseEvents } from '@/lib/matchEvents';
|
||||||
|
import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both starting XIs on one vertical pitch — home defends the top goal, away
|
||||||
|
* the bottom. Players sit at FotMob's normalized formation coordinates and
|
||||||
|
* carry shirt number, live rating and goal markers. When a player is
|
||||||
|
* substituted, the replacement takes over the slot (▲ minute) and the
|
||||||
|
* outgoing player moves to the "subbed off" strip below, so the pitch always
|
||||||
|
* shows the current XI while the page's live refetch keeps the data fresh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const W = 68;
|
||||||
|
const H = 105;
|
||||||
|
|
||||||
|
/** Diacritic/case/punctuation-insensitive name comparison (token sets), so
|
||||||
|
* ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */
|
||||||
|
const norm = (s: string) =>
|
||||||
|
s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-'.]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
function nameMatch(a: string, b: string): boolean {
|
||||||
|
const na = norm(a);
|
||||||
|
const nb = norm(b);
|
||||||
|
if (!na || !nb) return false;
|
||||||
|
if (na === nb) return true;
|
||||||
|
const ta = na.split(' ');
|
||||||
|
const tb = nb.split(' ');
|
||||||
|
const sa = new Set(ta);
|
||||||
|
const sb = new Set(tb);
|
||||||
|
return ta.every((x) => sb.has(x)) || tb.every((x) => sa.has(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
/** Who currently occupies the position. */
|
||||||
|
player: PitchPlayer;
|
||||||
|
/** The starter the slot belongs to (=== player until a swap). */
|
||||||
|
starter: PitchPlayer;
|
||||||
|
cameOnAt: number | null;
|
||||||
|
/** The occupant went off but the replacement couldn't be paired — keep the
|
||||||
|
* dot with a ▼ marker instead of guessing. */
|
||||||
|
unresolvedOff: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubPair { in: string; out: string }
|
||||||
|
|
||||||
|
/** Resolve the current XI. Two pairing phases per round — ESPN's explicit
|
||||||
|
* "X replaces Y" text first (FotMob and ESPN order/romanize names
|
||||||
|
* differently, so this can miss), then unique same-minute matching among
|
||||||
|
* the subs not yet accounted for. Rounds repeat so chained substitutions
|
||||||
|
* and triple-sub minutes resolve as earlier pairings free up candidates. */
|
||||||
|
function currentXI(team: LineupTeam, pairs: SubPair[]): { slots: Slot[]; off: PitchPlayer[] } {
|
||||||
|
const off: PitchPlayer[] = [];
|
||||||
|
const used = new Set<PitchPlayer>();
|
||||||
|
const slots: Slot[] = team.starters.map((starter) => ({ player: starter, starter, cameOnAt: null, unresolvedOff: false }));
|
||||||
|
const swap = (slot: Slot, repl: PitchPlayer) => {
|
||||||
|
used.add(repl);
|
||||||
|
off.push(slot.player);
|
||||||
|
slot.player = repl;
|
||||||
|
slot.cameOnAt = repl.subIn;
|
||||||
|
};
|
||||||
|
const pending = () => slots.filter((s) => s.player.subOut != null && !s.unresolvedOff);
|
||||||
|
|
||||||
|
for (let round = 0; round < 8; round++) {
|
||||||
|
let progress = false;
|
||||||
|
for (const slot of pending()) {
|
||||||
|
const byText = pairs.find((p) => nameMatch(p.out, slot.player.name));
|
||||||
|
const repl = byText ? team.subs.find((c) => !used.has(c) && nameMatch(c.name, byText.in)) : undefined;
|
||||||
|
if (repl) { swap(slot, repl); progress = true; }
|
||||||
|
}
|
||||||
|
for (const slot of pending()) {
|
||||||
|
const minute = slot.player.subOut;
|
||||||
|
const candidates = team.subs.filter((c) => !used.has(c) && c.subIn === minute);
|
||||||
|
const outs = pending().filter((s) => s.player.subOut === minute);
|
||||||
|
if (candidates.length === 1 && outs.length === 1 && candidates[0]) { swap(slot, candidates[0]); progress = true; }
|
||||||
|
}
|
||||||
|
if (!progress) break;
|
||||||
|
}
|
||||||
|
for (const slot of pending()) slot.unresolvedOff = true;
|
||||||
|
return { slots, off };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last name token, capped — fits under a dot on a 68-unit-wide pitch. */
|
||||||
|
function shortName(name: string): string {
|
||||||
|
const last = name.trim().split(' ').pop() ?? name;
|
||||||
|
return last.length > 12 ? `${last.slice(0, 11)}…` : last;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) {
|
||||||
|
const p = slot.player;
|
||||||
|
const st = slot.starter;
|
||||||
|
if (st.x == null || st.y == null) return null;
|
||||||
|
const cx = side === 'home' ? st.x * W : (1 - st.x) * W;
|
||||||
|
const cy = side === 'home' ? Math.min(Math.max(st.y * (H / 2), 5), H / 2 - 8) : Math.max(Math.min(H - st.y * (H / 2), H - 5.3), H / 2 + 5);
|
||||||
|
// The away keeper hugs the bottom goal — put the name in the empty space
|
||||||
|
// above the dot instead of past the goal line / under the row above.
|
||||||
|
const nameY = side === 'away' && st.y <= 0.14 ? cy - 4.8 : cy + 6.6;
|
||||||
|
const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)';
|
||||||
|
return (
|
||||||
|
<g opacity={slot.unresolvedOff ? 0.55 : 1}>
|
||||||
|
<circle cx={cx} cy={cy} r={3.3} fill="var(--app-panel-2)" stroke={stroke} strokeWidth={0.6} />
|
||||||
|
<text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum">
|
||||||
|
{p.num ?? ''}
|
||||||
|
</text>
|
||||||
|
<text x={cx} y={nameY} textAnchor="middle" fontSize={2.4} fontWeight={600} fill="var(--app-ink-soft)">
|
||||||
|
{shortName(p.name)}
|
||||||
|
</text>
|
||||||
|
{p.rating != null && (
|
||||||
|
<g>
|
||||||
|
<rect x={cx + 3.9} y={cy - 1.55} width={6} height={3.1} rx={1} fill="var(--app-elevated)" stroke="var(--app-line)" strokeWidth={0.2} />
|
||||||
|
<text x={cx + 6.9} y={cy + 0.8} textAnchor="middle" fontSize={2.2} fontWeight={700} fill="var(--app-ink-soft)" className="tnum">
|
||||||
|
{p.rating.toFixed(1)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{p.goals > 0 && (
|
||||||
|
<g>
|
||||||
|
{/* badge sits on the dot's rim so it never collides with neighbors */}
|
||||||
|
<circle cx={cx - 2.7} cy={cy - 2.7} r={1.4} fill={stroke} stroke="var(--app-panel-2)" strokeWidth={0.25} />
|
||||||
|
<text x={cx - 2.7} y={cy - 2.05} textAnchor="middle" fontSize={1.8} fontWeight={800} fill="var(--app-panel)">
|
||||||
|
{p.goals}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{slot.cameOnAt != null && (
|
||||||
|
<g>
|
||||||
|
<polygon
|
||||||
|
points={`${cx - 6.2},${cy + 0.3} ${cx - 4.2},${cy + 0.3} ${cx - 5.2},${cy - 1.5}`}
|
||||||
|
fill="var(--app-success)"
|
||||||
|
/>
|
||||||
|
<text x={cx - 5.2} y={cy + 3} textAnchor="middle" fontSize={1.9} fill="var(--app-success)" className="tnum">
|
||||||
|
{slot.cameOnAt}'
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
{slot.unresolvedOff && (
|
||||||
|
<g>
|
||||||
|
<polygon
|
||||||
|
points={`${cx - 6.2},${cy - 1.5} ${cx - 4.2},${cy - 1.5} ${cx - 5.2},${cy + 0.3}`}
|
||||||
|
fill="var(--app-loss)"
|
||||||
|
/>
|
||||||
|
<text x={cx - 5.2} y={cy + 3} textAnchor="middle" fontSize={1.9} fill="var(--app-loss)" className="tnum">
|
||||||
|
{slot.player.subOut}'
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pitch furniture: outline, halfway line, center circle, boxes, spots. */
|
||||||
|
function PitchLines() {
|
||||||
|
const box = { w: 40.32, d: 16.5 };
|
||||||
|
const six = { w: 18.32, d: 5.5 };
|
||||||
|
const lx = (w: number) => (W - w) / 2;
|
||||||
|
return (
|
||||||
|
<g stroke="var(--app-line)" strokeWidth={0.35} fill="none">
|
||||||
|
<rect x={0.4} y={0.4} width={W - 0.8} height={H - 0.8} rx={1} />
|
||||||
|
<line x1={0.4} y1={H / 2} x2={W - 0.4} y2={H / 2} />
|
||||||
|
<circle cx={W / 2} cy={H / 2} r={9.15} />
|
||||||
|
<rect x={lx(box.w)} y={0.4} width={box.w} height={box.d} />
|
||||||
|
<rect x={lx(six.w)} y={0.4} width={six.w} height={six.d} />
|
||||||
|
<rect x={lx(box.w)} y={H - 0.4 - box.d} width={box.w} height={box.d} />
|
||||||
|
<rect x={lx(six.w)} y={H - 0.4 - six.d} width={six.w} height={six.d} />
|
||||||
|
<circle cx={W / 2} cy={11} r={0.4} fill="var(--app-line)" />
|
||||||
|
<circle cx={W / 2} cy={H - 11} r={0.4} fill="var(--app-line)" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubbedOffStrip({ off, tone }: { off: PitchPlayer[]; tone: 'home' | 'away' }) {
|
||||||
|
const t = useT();
|
||||||
|
if (!off.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={`mb-1 text-[10px] font-bold uppercase tracking-wide ${tone === 'home' ? 'text-accent' : 'text-info'}`}>
|
||||||
|
{t.match.subbedOff}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 text-xs text-muted">
|
||||||
|
{off.map((p) => (
|
||||||
|
<li key={p.name} className="flex items-center gap-1.5">
|
||||||
|
<span aria-hidden className="text-loss">▼</span>
|
||||||
|
<span className="truncate">{p.name}</span>
|
||||||
|
{p.subOut != null && <span className="tnum shrink-0 text-faint">{fmt(t.live.minute, { n: p.subOut })}</span>}
|
||||||
|
{p.rating != null && <span className="tnum ml-auto shrink-0 text-faint">{p.rating.toFixed(1)}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineupPitch({ lineup, home, away, events }: {
|
||||||
|
lineup: MatchLineup;
|
||||||
|
home: string;
|
||||||
|
away: string;
|
||||||
|
events: MatchLiveEvent[];
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const { homeXI, awayXI } = useMemo(() => {
|
||||||
|
const subs = parseEvents(events).filter((e) => e.kind === 'sub' && e.subOut);
|
||||||
|
const pairsFor = (side: 'home' | 'away'): SubPair[] =>
|
||||||
|
subs.filter((e) => e.side === side).map((e) => ({ in: e.title, out: e.subOut! }));
|
||||||
|
return {
|
||||||
|
homeXI: currentXI(lineup.home, pairsFor('home')),
|
||||||
|
awayXI: currentXI(lineup.away, pairsFor('away')),
|
||||||
|
};
|
||||||
|
}, [lineup, events]);
|
||||||
|
|
||||||
|
const teamLabel = (name: string, tm: LineupTeam) =>
|
||||||
|
[name, tm.formation, tm.coach].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-105">
|
||||||
|
<div className="mb-1.5 truncate text-center text-xs font-bold text-accent">{teamLabel(home, lineup.home)}</div>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${t.match.lineups}: ${home} – ${away}`}
|
||||||
|
className="block w-full rounded-xl bg-elevated/40"
|
||||||
|
>
|
||||||
|
<PitchLines />
|
||||||
|
{homeXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="home" />)}
|
||||||
|
{awayXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="away" />)}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-1.5 truncate text-center text-xs font-bold text-info">{teamLabel(away, lineup.away)}</div>
|
||||||
|
{(homeXI.off.length > 0 || awayXI.off.length > 0) && (
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-4">
|
||||||
|
<SubbedOffStrip off={homeXI.off} tone="home" />
|
||||||
|
<SubbedOffStrip off={awayXI.off} tone="away" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -160,7 +160,8 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
|
|||||||
<div>
|
<div>
|
||||||
{/* Desktop / tablet: center spine, home left · away right */}
|
{/* Desktop / tablet: center spine, home left · away right */}
|
||||||
<div className="relative hidden px-2 py-4 sm:block">
|
<div className="relative hidden px-2 py-4 sm:block">
|
||||||
<div aria-hidden className="absolute bottom-0 left-1/2 top-0 w-0.5 -translate-x-1/2" style={{ background: SPINE_GRADIENT }} />
|
{/* inset so the spine ends behind the kick-off / full-time pills */}
|
||||||
|
<div aria-hidden className="absolute bottom-8 left-1/2 top-8 w-0.5 -translate-x-1/2" style={{ background: SPINE_GRADIENT }} />
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
if (row.t === 'divider') {
|
if (row.t === 'divider') {
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +196,7 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
|
|||||||
|
|
||||||
{/* Phones: single left rail, side shown by the colored card edge */}
|
{/* Phones: single left rail, side shown by the colored card edge */}
|
||||||
<div className="relative py-3 pl-2 pr-3.5 sm:hidden">
|
<div className="relative py-3 pl-2 pr-3.5 sm:hidden">
|
||||||
<div aria-hidden className="absolute bottom-0 left-[35px] top-0 w-0.5" style={{ background: SPINE_GRADIENT }} />
|
<div aria-hidden className="absolute bottom-7 left-[35px] top-7 w-0.5" style={{ background: SPINE_GRADIENT }} />
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
if (row.t === 'divider') {
|
if (row.t === 'divider') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { XgRace } from '@/components/XgRace';
|
|||||||
import { ShotMap } from '@/components/ShotMap';
|
import { ShotMap } from '@/components/ShotMap';
|
||||||
import { MomentumStrip } from '@/components/MomentumStrip';
|
import { MomentumStrip } from '@/components/MomentumStrip';
|
||||||
import { EventIcon, MatchTimeline } from '@/components/MatchTimeline';
|
import { EventIcon, MatchTimeline } from '@/components/MatchTimeline';
|
||||||
|
import { LineupPitch } from '@/components/LineupPitch';
|
||||||
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 { goalEvents } from '@/lib/matchEvents';
|
import { goalEvents } from '@/lib/matchEvents';
|
||||||
@@ -418,16 +419,28 @@ export function MatchPreviewPage() {
|
|||||||
{/* ── Lineups ── */}
|
{/* ── Lineups ── */}
|
||||||
{tab === 'lineups' && (
|
{tab === 'lineups' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
{/* FotMob serves predicted XIs before kickoff — only draw the pitch
|
||||||
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
|
once the match has started or ESPN confirms the lineups. */}
|
||||||
<CardBody className="grid grid-cols-2 gap-6">
|
{rich?.lineup && (hasScore || preview.lineups) ? (
|
||||||
{preview.lineups ? (
|
<Card>
|
||||||
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
|
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{t.match.lineups}</span></CardHeader>
|
||||||
) : (
|
<CardBody>
|
||||||
<><ScorerList team={homeName} players={preview.keyPlayers.home} /><ScorerList team={awayName} players={preview.keyPlayers.away} /></>
|
<LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} />
|
||||||
)}
|
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
|
||||||
|
<CardBody className="grid grid-cols-2 gap-6">
|
||||||
|
{preview.lineups ? (
|
||||||
|
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
|
||||||
|
) : (
|
||||||
|
<><ScorerList team={homeName} players={preview.keyPlayers.home} /><ScorerList team={awayName} players={preview.keyPlayers.away} /></>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||||
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
|
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ function ProbTriple({ label, probs, tone }: { label: string; probs: MatchProbs;
|
|||||||
const split = `${pct(probs.home)} / ${pct(probs.draw)} / ${pct(probs.away)}`;
|
const split = `${pct(probs.home)} / ${pct(probs.draw)} / ${pct(probs.away)}`;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2" title={`${label}: ${split}`} aria-label={`${label}: ${split}`}>
|
<div className="flex items-center gap-2" title={`${label}: ${split}`} aria-label={`${label}: ${split}`}>
|
||||||
<span className={cn('w-12 shrink-0 text-[11px] font-bold uppercase tracking-wide', tone === 'model' ? 'text-accent' : 'text-gold')}>{label}</span>
|
{/* wide enough for "DraftKings" — both rows share the width so the bars align */}
|
||||||
|
<span className={cn('w-20 shrink-0 text-[11px] font-bold uppercase tracking-wide', tone === 'model' ? 'text-accent' : 'text-gold')}>{label}</span>
|
||||||
<div className="flex h-2 flex-1 overflow-hidden rounded-full bg-elevated">
|
<div className="flex h-2 flex-1 overflow-hidden rounded-full bg-elevated">
|
||||||
<div className={color} style={{ width: pct(probs.home) }} />
|
<div className={color} style={{ width: pct(probs.home) }} />
|
||||||
<div className="bg-line-strong" style={{ width: pct(probs.draw) }} />
|
<div className="bg-line-strong" style={{ width: pct(probs.draw) }} />
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const de: Dict = {
|
|||||||
penalty: "Elfmeter",
|
penalty: "Elfmeter",
|
||||||
assist: "Vorlage · {name}",
|
assist: "Vorlage · {name}",
|
||||||
noGoalsYet: "Noch keine Tore.",
|
noGoalsYet: "Noch keine Tore.",
|
||||||
|
subbedOff: "Ausgewechselt",
|
||||||
chips: {
|
chips: {
|
||||||
att: "Zusch.",
|
att: "Zusch.",
|
||||||
ref: "SR",
|
ref: "SR",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const en = {
|
|||||||
penalty: "Penalty",
|
penalty: "Penalty",
|
||||||
assist: "Assist · {name}",
|
assist: "Assist · {name}",
|
||||||
noGoalsYet: "No goals yet.",
|
noGoalsYet: "No goals yet.",
|
||||||
|
subbedOff: "Subbed off",
|
||||||
chips: {
|
chips: {
|
||||||
att: "Att",
|
att: "Att",
|
||||||
ref: "Ref",
|
ref: "Ref",
|
||||||
|
|||||||
@@ -181,11 +181,35 @@ export interface RichShot {
|
|||||||
situation: string;
|
situation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One player in a FotMob lineup. x/y are normalized vertical-pitch
|
||||||
|
* coordinates (0..1 across · 0..1 own-goal-line → halfway); bench players
|
||||||
|
* have no coordinates. subIn/subOut are substitution minutes. */
|
||||||
|
export interface PitchPlayer {
|
||||||
|
name: string;
|
||||||
|
num: string | null;
|
||||||
|
x: number | null;
|
||||||
|
y: number | null;
|
||||||
|
rating: number | null;
|
||||||
|
goals: number;
|
||||||
|
subOut: number | null;
|
||||||
|
subIn: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineupTeam {
|
||||||
|
formation: string | null;
|
||||||
|
coach: string | null;
|
||||||
|
starters: PitchPlayer[];
|
||||||
|
subs: PitchPlayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchLineup { home: LineupTeam; away: LineupTeam }
|
||||||
|
|
||||||
export interface MatchRich {
|
export interface MatchRich {
|
||||||
shots: RichShot[];
|
shots: RichShot[];
|
||||||
xg: { home: number; away: number } | null;
|
xg: { home: number; away: number } | null;
|
||||||
/** Attack-momentum curve; positive values = home pressure. */
|
/** Attack-momentum curve; positive values = home pressure. */
|
||||||
momentum: { minute: number; value: number }[];
|
momentum: { minute: number; value: number }[];
|
||||||
|
lineup: MatchLineup | null;
|
||||||
potm: { name: string; rating: string | null } | null;
|
potm: { name: string; rating: string | null } | null;
|
||||||
info: { attendance: number | null; stadium: string; referee: 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;
|
weather: { tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number; tz: string } | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user