From 1ffa9fba362a4f22423b2e45904da2933b7c1429 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 12:15:53 +0200 Subject: [PATCH] =?UTF-8?q?Lineup=20pitch:=20breathing=20room=20=E2=80=94?= =?UTF-8?q?=20no=20more=20overlapping=20parts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The to-scale pitch was the problem: eleven rows of dots, names and chips can't fit 105x68 proportions. Now: - canvas stretched both ways (84x132) like every lineup UI - rows re-spread evenly per team (keeper pinned at the goal, outfield spans the half) instead of FotMob's bunched raw coordinates - rating chips point toward the pitch center and are sized so two facing chips across the halfway column can never touch; sub markers point outward so nothing clips the touchlines - keeper names sit beside the dot — their row is always alone, and the name was colliding with the back line either side of the goal - goal badge moved onto the dot rim on the marker side Co-Authored-By: Claude Fable 5 --- src/components/LineupPitch.tsx | 80 ++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/src/components/LineupPitch.tsx b/src/components/LineupPitch.tsx index a1f2324..f57e30b 100644 --- a/src/components/LineupPitch.tsx +++ b/src/components/LineupPitch.tsx @@ -12,8 +12,10 @@ import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib * shows the current XI while the page's live refetch keeps the data fresh. */ -const W = 68; -const H = 105; +/** Stretched both ways vs the real 105x68 — eleven rows of dots, names and + * rating chips need the air; every lineup UI distorts the pitch like this. */ +const W = 84; +const H = 132; /** Diacritic/case/punctuation-insensitive name comparison (token sets), so * ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */ @@ -86,15 +88,20 @@ function shortName(name: string): string { return last.length > 12 ? `${last.slice(0, 11)}…` : last; } -function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) { +function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny: number }) { const p = slot.player; const st = slot.starter; - if (st.x == null || st.y == null) return null; + if (st.x == 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 cy = side === 'home' ? ny * (H / 2) : H - ny * (H / 2); + // Rating chip points toward the pitch center, sub markers outward — so + // nothing clips the touchlines or crosses into a neighbor's dot. + const inward = cx <= W / 2 ? 1 : -1; + const chipX = inward === 1 ? cx + 3.5 : cx - 8.3; + const markX = cx - inward * 5.4; + // Keepers sit against their goal line with the next row close above/below — + // their name goes beside the dot instead (the keeper row is always alone). + const gk = ny <= 0.1; const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)'; return ( @@ -102,22 +109,29 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) { {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 */} - - + {/* badge sits on the dot's rim, on the marker side, clear of the chip */} + + {p.goals} @@ -125,10 +139,10 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) { {slot.cameOnAt != null && ( - + {slot.cameOnAt}' @@ -136,10 +150,10 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) { {slot.unresolvedOff && ( - + {slot.player.subOut}' @@ -148,16 +162,32 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) { ); } +/** Map FotMob's y values to evenly-breathing rows: keeper pinned by the + * goal, outfield spread across the half regardless of how tightly the + * source coordinates bunch. */ +function rowSpread(starters: PitchPlayer[]): Map { + const m = new Map(); + const sorted = [...starters].filter((p) => p.y != null).sort((a, b) => a.y! - b.y!); + const gk = sorted[0]; + if (!gk) return m; + m.set(gk, 0.06); + const rest = sorted.slice(1); + const lo = rest[0]?.y ?? 0.3; + const hi = rest[rest.length - 1]?.y ?? 0.9; + for (const p of rest) m.set(p, hi > lo ? 0.22 + ((p.y! - lo) / (hi - lo)) * (0.9 - 0.22) : 0.56); + return m; +} + /** 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 box = { w: W * 0.59, d: 16.5 }; + const six = { w: W * 0.27, d: 5.5 }; const lx = (w: number) => (W - w) / 2; return ( - + @@ -198,13 +228,15 @@ export function LineupPitch({ lineup, home, away, events }: { }) { const t = useT(); - const { homeXI, awayXI } = useMemo(() => { + const { homeXI, awayXI, homeNy, awayNy } = 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')), + homeNy: rowSpread(lineup.home.starters), + awayNy: rowSpread(lineup.away.starters), }; }, [lineup, events]); @@ -221,8 +253,8 @@ export function LineupPitch({ lineup, home, away, events }: { className="block w-full rounded-xl bg-elevated/40" > - {homeXI.slots.map((s) => )} - {awayXI.slots.map((s) => )} + {homeXI.slots.map((s) => )} + {awayXI.slots.map((s) => )}
{teamLabel(away, lineup.away)}
{(homeXI.off.length > 0 || awayXI.off.length > 0) && (