Lineup pitch: breathing room — no more overlapping parts

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:15:53 +02:00
parent 09c543b009
commit 1ffa9fba36
+56 -24
View File
@@ -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. * shows the current XI while the page's live refetch keeps the data fresh.
*/ */
const W = 68; /** Stretched both ways vs the real 105x68 — eleven rows of dots, names and
const H = 105; * 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 /** Diacritic/case/punctuation-insensitive name comparison (token sets), so
* ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */ * 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; 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 p = slot.player;
const st = slot.starter; 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 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); const cy = side === 'home' ? ny * (H / 2) : H - ny * (H / 2);
// The away keeper hugs the bottom goal — put the name in the empty space // Rating chip points toward the pitch center, sub markers outward — so
// above the dot instead of past the goal line / under the row above. // nothing clips the touchlines or crosses into a neighbor's dot.
const nameY = side === 'away' && st.y <= 0.14 ? cy - 4.8 : cy + 6.6; 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)'; const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)';
return ( return (
<g opacity={slot.unresolvedOff ? 0.55 : 1}> <g opacity={slot.unresolvedOff ? 0.55 : 1}>
@@ -102,22 +109,29 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) {
<text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum"> <text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum">
{p.num ?? ''} {p.num ?? ''}
</text> </text>
<text x={cx} y={nameY} textAnchor="middle" fontSize={2.4} fontWeight={600} fill="var(--app-ink-soft)"> <text
x={gk ? cx - 4.4 : cx}
y={gk ? cy + 0.8 : cy + 6.9}
textAnchor={gk ? 'end' : 'middle'}
fontSize={2.3}
fontWeight={600}
fill="var(--app-ink-soft)"
>
{shortName(p.name)} {shortName(p.name)}
</text> </text>
{p.rating != null && ( {p.rating != null && (
<g> <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} /> <rect x={chipX} y={cy - 1.55} width={4.8} 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"> <text x={chipX + 2.4} y={cy + 0.82} textAnchor="middle" fontSize={2.05} fontWeight={700} fill="var(--app-ink-soft)" className="tnum">
{p.rating.toFixed(1)} {p.rating.toFixed(1)}
</text> </text>
</g> </g>
)} )}
{p.goals > 0 && ( {p.goals > 0 && (
<g> <g>
{/* 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 */}
<circle cx={cx - 2.7} cy={cy - 2.7} r={1.4} fill={stroke} stroke="var(--app-panel-2)" strokeWidth={0.25} /> <circle cx={cx - inward * 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)"> <text x={cx - inward * 2.7} y={cy - 2.05} textAnchor="middle" fontSize={1.8} fontWeight={800} fill="var(--app-panel)">
{p.goals} {p.goals}
</text> </text>
</g> </g>
@@ -125,10 +139,10 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) {
{slot.cameOnAt != null && ( {slot.cameOnAt != null && (
<g> <g>
<polygon <polygon
points={`${cx - 6.2},${cy + 0.3} ${cx - 4.2},${cy + 0.3} ${cx - 5.2},${cy - 1.5}`} points={`${markX - 1},${cy + 0.4} ${markX + 1},${cy + 0.4} ${markX},${cy - 1.4}`}
fill="var(--app-success)" fill="var(--app-success)"
/> />
<text x={cx - 5.2} y={cy + 3} textAnchor="middle" fontSize={1.9} fill="var(--app-success)" className="tnum"> <text x={markX} y={cy + 3.2} textAnchor="middle" fontSize={1.9} fill="var(--app-success)" className="tnum">
{slot.cameOnAt}' {slot.cameOnAt}'
</text> </text>
</g> </g>
@@ -136,10 +150,10 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) {
{slot.unresolvedOff && ( {slot.unresolvedOff && (
<g> <g>
<polygon <polygon
points={`${cx - 6.2},${cy - 1.5} ${cx - 4.2},${cy - 1.5} ${cx - 5.2},${cy + 0.3}`} points={`${markX - 1},${cy - 1.4} ${markX + 1},${cy - 1.4} ${markX},${cy + 0.4}`}
fill="var(--app-loss)" fill="var(--app-loss)"
/> />
<text x={cx - 5.2} y={cy + 3} textAnchor="middle" fontSize={1.9} fill="var(--app-loss)" className="tnum"> <text x={markX} y={cy + 3.2} textAnchor="middle" fontSize={1.9} fill="var(--app-loss)" className="tnum">
{slot.player.subOut}' {slot.player.subOut}'
</text> </text>
</g> </g>
@@ -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<PitchPlayer, number> {
const m = new Map<PitchPlayer, number>();
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. */ /** Pitch furniture: outline, halfway line, center circle, boxes, spots. */
function PitchLines() { function PitchLines() {
const box = { w: 40.32, d: 16.5 }; const box = { w: W * 0.59, d: 16.5 };
const six = { w: 18.32, d: 5.5 }; const six = { w: W * 0.27, d: 5.5 };
const lx = (w: number) => (W - w) / 2; const lx = (w: number) => (W - w) / 2;
return ( return (
<g stroke="var(--app-line)" strokeWidth={0.35} fill="none"> <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} /> <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} /> <line x1={0.4} y1={H / 2} x2={W - 0.4} y2={H / 2} />
<circle cx={W / 2} cy={H / 2} r={9.15} /> <circle cx={W / 2} cy={H / 2} r={W * 0.135} />
<rect x={lx(box.w)} y={0.4} width={box.w} height={box.d} /> <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(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(box.w)} y={H - 0.4 - box.d} width={box.w} height={box.d} />
@@ -198,13 +228,15 @@ export function LineupPitch({ lineup, home, away, events }: {
}) { }) {
const t = useT(); 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 subs = parseEvents(events).filter((e) => e.kind === 'sub' && e.subOut);
const pairsFor = (side: 'home' | 'away'): SubPair[] => const pairsFor = (side: 'home' | 'away'): SubPair[] =>
subs.filter((e) => e.side === side).map((e) => ({ in: e.title, out: e.subOut! })); subs.filter((e) => e.side === side).map((e) => ({ in: e.title, out: e.subOut! }));
return { return {
homeXI: currentXI(lineup.home, pairsFor('home')), homeXI: currentXI(lineup.home, pairsFor('home')),
awayXI: currentXI(lineup.away, pairsFor('away')), awayXI: currentXI(lineup.away, pairsFor('away')),
homeNy: rowSpread(lineup.home.starters),
awayNy: rowSpread(lineup.away.starters),
}; };
}, [lineup, events]); }, [lineup, events]);
@@ -221,8 +253,8 @@ export function LineupPitch({ lineup, home, away, events }: {
className="block w-full rounded-xl bg-elevated/40" className="block w-full rounded-xl bg-elevated/40"
> >
<PitchLines /> <PitchLines />
{homeXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="home" />)} {homeXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="home" ny={homeNy.get(s.starter) ?? 0.5} />)}
{awayXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="away" />)} {awayXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="away" ny={awayNy.get(s.starter) ?? 0.5} />)}
</svg> </svg>
<div className="mt-1.5 truncate text-center text-xs font-bold text-info">{teamLabel(away, lineup.away)}</div> <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) && ( {(homeXI.off.length > 0 || awayXI.off.length > 0) && (