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.
|
* 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) && (
|
||||||
|
|||||||
Reference in New Issue
Block a user