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:
@@ -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 (
|
||||
<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">
|
||||
{p.num ?? ''}
|
||||
</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)}
|
||||
</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">
|
||||
<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={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)}
|
||||
</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)">
|
||||
{/* badge sits on the dot's rim, on the marker side, clear of the chip */}
|
||||
<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 - inward * 2.7} y={cy - 2.05} textAnchor="middle" fontSize={1.8} fontWeight={800} fill="var(--app-panel)">
|
||||
{p.goals}
|
||||
</text>
|
||||
</g>
|
||||
@@ -125,10 +139,10 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) {
|
||||
{slot.cameOnAt != null && (
|
||||
<g>
|
||||
<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)"
|
||||
/>
|
||||
<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}'
|
||||
</text>
|
||||
</g>
|
||||
@@ -136,10 +150,10 @@ function PlayerDot({ slot, side }: { slot: Slot; side: 'home' | 'away' }) {
|
||||
{slot.unresolvedOff && (
|
||||
<g>
|
||||
<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)"
|
||||
/>
|
||||
<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}'
|
||||
</text>
|
||||
</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. */
|
||||
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 (
|
||||
<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} />
|
||||
<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(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} />
|
||||
@@ -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"
|
||||
>
|
||||
<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" />)}
|
||||
{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" ny={awayNy.get(s.starter) ?? 0.5} />)}
|
||||
</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) && (
|
||||
|
||||
Reference in New Issue
Block a user