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:
2026-06-12 12:02:49 +02:00
parent 4faedd60c4
commit 09c543b009
8 changed files with 328 additions and 13 deletions
+38
View File
@@ -124,11 +124,19 @@ export function buildServer() {
const num = Number((req.params as { num: string }).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 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 {
homeTeamId?: number | null;
shots?: FmShot[];
momentum?: { main?: { data?: { minute: number; value: number }[] } } | 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 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 },
);
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 {
shots,
xg: shots.length ? { home: +xg.home.toFixed(2), away: +xg.away.toFixed(2) } : null,
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,
info: fifaInfo
? {