diff --git a/server/src/index.ts b/server/src/index.ts index fc20970..1693866 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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(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 ? { diff --git a/src/components/LineupPitch.tsx b/src/components/LineupPitch.tsx new file mode 100644 index 0000000..a1f2324 --- /dev/null +++ b/src/components/LineupPitch.tsx @@ -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(); + 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 ( + + + + {p.num ?? ''} + + + {shortName(p.name)} + + {p.rating != null && ( + + + + {p.rating.toFixed(1)} + + + )} + {p.goals > 0 && ( + + {/* badge sits on the dot's rim so it never collides with neighbors */} + + + {p.goals} + + + )} + {slot.cameOnAt != null && ( + + + + {slot.cameOnAt}' + + + )} + {slot.unresolvedOff && ( + + + + {slot.player.subOut}' + + + )} + + ); +} + +/** 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 ( + + + + + + + + + + + + ); +} + +function SubbedOffStrip({ off, tone }: { off: PitchPlayer[]; tone: 'home' | 'away' }) { + const t = useT(); + if (!off.length) return null; + return ( +
+
+ {t.match.subbedOff} +
+
    + {off.map((p) => ( +
  • + + {p.name} + {p.subOut != null && {fmt(t.live.minute, { n: p.subOut })}} + {p.rating != null && {p.rating.toFixed(1)}} +
  • + ))} +
+
+ ); +} + +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 ( +
+
{teamLabel(home, lineup.home)}
+ + + {homeXI.slots.map((s) => )} + {awayXI.slots.map((s) => )} + +
{teamLabel(away, lineup.away)}
+ {(homeXI.off.length > 0 || awayXI.off.length > 0) && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx index 57f8b2b..4e9fd46 100644 --- a/src/components/MatchTimeline.tsx +++ b/src/components/MatchTimeline.tsx @@ -160,7 +160,8 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
{/* Desktop / tablet: center spine, home left · away right */}
-
+ {/* inset so the spine ends behind the kick-off / full-time pills */} +
{rows.map((row, i) => { if (row.t === 'divider') { return ( @@ -195,7 +196,7 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }: {/* Phones: single left rail, side shown by the colored card edge */}
-
+
{rows.map((row, i) => { if (row.t === 'divider') { return ( diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index 1ebde41..bd0e6bb 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -11,6 +11,7 @@ import { XgRace } from '@/components/XgRace'; import { ShotMap } from '@/components/ShotMap'; import { MomentumStrip } from '@/components/MomentumStrip'; import { EventIcon, MatchTimeline } from '@/components/MatchTimeline'; +import { LineupPitch } from '@/components/LineupPitch'; import { teamFlag } from '@/lib/teams'; import { fmt, useFormat, useT } from '@/lib/i18n'; import { goalEvents } from '@/lib/matchEvents'; @@ -418,16 +419,28 @@ export function MatchPreviewPage() { {/* ── Lineups ── */} {tab === 'lineups' && (
- - {preview.lineups ? t.match.lineups : t.match.keyPlayers} - - {preview.lineups ? ( - <> - ) : ( - <> - )} - - + {/* FotMob serves predicted XIs before kickoff — only draw the pitch + once the match has started or ESPN confirms the lineups. */} + {rich?.lineup && (hasScore || preview.lineups) ? ( + + {t.match.lineups} + + +

{t.match.xgAttribution}

+
+
+ ) : ( + + {preview.lineups ? t.match.lineups : t.match.keyPlayers} + + {preview.lineups ? ( + <> + ) : ( + <> + )} + + + )}
{(preview.form.home.length > 0 || preview.form.away.length > 0) && ( diff --git a/src/features/scoreboard/ScoreboardPage.tsx b/src/features/scoreboard/ScoreboardPage.tsx index ff75998..51df5fb 100644 --- a/src/features/scoreboard/ScoreboardPage.tsx +++ b/src/features/scoreboard/ScoreboardPage.tsx @@ -16,7 +16,8 @@ function ProbTriple({ label, probs, tone }: { label: string; probs: MatchProbs; const split = `${pct(probs.home)} / ${pct(probs.draw)} / ${pct(probs.away)}`; return (
- {label} + {/* wide enough for "DraftKings" — both rows share the width so the bars align */} + {label}
diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 7827721..5979fb3 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -129,6 +129,7 @@ export const de: Dict = { penalty: "Elfmeter", assist: "Vorlage · {name}", noGoalsYet: "Noch keine Tore.", + subbedOff: "Ausgewechselt", chips: { att: "Zusch.", ref: "SR", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 8d06e58..165d6f5 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -128,6 +128,7 @@ export const en = { penalty: "Penalty", assist: "Assist · {name}", noGoalsYet: "No goals yet.", + subbedOff: "Subbed off", chips: { att: "Att", ref: "Ref", diff --git a/src/lib/types.ts b/src/lib/types.ts index ba4ae33..18cdf08 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -181,11 +181,35 @@ export interface RichShot { 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 { shots: RichShot[]; xg: { home: number; away: number } | null; /** Attack-momentum curve; positive values = home pressure. */ momentum: { minute: number; value: number }[]; + lineup: MatchLineup | null; 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;