fpl-solver / frontend /src /components /PitchView.jsx
AnayShukla's picture
updates
a7edf74
// src/components/PitchView.jsx
import React, { useRef, useState, useEffect, useCallback } from "react";
import { DraggablePlayer } from "./DraggablePlayer";
/*
DESIGN DIMENSIONS β€” single source of truth for the pitch.
All cards are laid out at these fixed pixel dimensions and the whole pitch
is uniformly transform-scaled to fit the actual container. This keeps
proportions identical across every screen size (no element gets relatively
bigger or smaller than another when the viewport changes).
Card: 88Γ—106px (larger for better legibility)
Label strip: ~52px
Card slot total β‰ˆ 158px per row
DESIGN_WIDTH = 670: fits widest row (5 mids/defs) = 5Γ—88 + 4Γ—24 = 536 ≀ 670 βœ“
4 starter rows + gaps β‰ˆ 780px total height
Bench β‰ˆ 230px
MAX_SCALE > 1 lets the pitch scale modestly on wide screens without badges
and labels becoming disproportionately large.
*/
const DESIGN_WIDTH = 670;
const STARTERS_H = 780;
const BENCH_H = 230;
// Gap between player card wrappers in px (in design space)
const CARD_GAP = 24;
// Allow the pitch to scale up to 1.1Γ— on wide screens so cards don't look
// undersized on desktop. Below DESIGN_WIDTH it scales down naturally to fit.
const MAX_SCALE = 1.0;
export const PitchView = ({
teamData,
activeDragPlayer,
isValidSwap,
captainId,
viceId,
handleCapChange,
playerCardGWs,
fixtures,
activeGW,
setSelectedPlayer,
handleUndoTransfer,
highlightTransferIds,
solverTransferPairs,
resetHighlightedTransfer,
chipsByGw,
}) => {
const containerRef = useRef(null);
const [scale, setScale] = useState(1);
const [isFullscreen, setIsFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}, []);
const handlePlayerClick = useCallback((player) => {
setSelectedPlayer(player);
}, [setSelectedPlayer]);
const updateScale = useCallback(() => {
if (!containerRef.current) return;
const next = Math.min(MAX_SCALE, containerRef.current.offsetWidth / DESIGN_WIDTH);
setScale(prev => Math.abs(prev - next) > 0.005 ? next : prev);
}, []);
useEffect(() => {
updateScale();
const ro = new ResizeObserver(updateScale);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [updateScale]);
useEffect(() => {
const handler = () => {
const fs = !!document.fullscreenElement;
setIsFullscreen(fs);
if (!containerRef.current) return;
const w = fs ? window.innerWidth : containerRef.current.offsetWidth;
const next = Math.min(MAX_SCALE, w / DESIGN_WIDTH);
setScale(prev => Math.abs(prev - next) > 0.005 ? next : prev);
};
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
const isBBChip = chipsByGw[activeGW] === "bb";
// The outer "height-reserving" divs must use the SCALED height so the
// document flow collapses correctly β€” otherwise content below (bench, etc.)
// overlaps the starters area.
const scaledStartersH = STARTERS_H * scale;
const scaledBenchH = BENCH_H * scale;
// Inner transform div: fixed at design size, scaled from top-center
const innerStyle = (designH, extraStyle = {}) => ({
width: DESIGN_WIDTH,
height: designH,
transform: `scale(${scale})`,
transformOrigin: "top center",
position: "absolute",
left: "50%",
marginLeft: -(DESIGN_WIDTH / 2),
top: 0,
willChange: "transform",
...extraStyle,
});
return (
<div
ref={containerRef}
className="fullscreen-pitch w-full bg-[#0a3a2a] rounded-2xl border-4 border-[#072a1e] relative flex flex-col shadow-[0_0_50px_rgba(0,0,0,0.5)] isolate"
>
{/* Fullscreen toggle β€” mobile only */}
<button
onClick={toggleFullscreen}
className="small-touch-target absolute top-2 right-2 z-50 md:hidden p-1.5 bg-black/40 hover:bg-black/60 rounded-lg border border-white/20 text-white/70 hover:text-white transition-colors"
>
{isFullscreen ? (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>
<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/>
<path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/>
</svg>
)}
</button>
{/* ── PITCH LINES β€” fill the full container, never scaled ── */}
<div className="absolute inset-0 pointer-events-none opacity-30">
<div className="absolute top-0 w-full h-1/2 border-b-2 border-white/40" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 border-2 border-white/40 rounded-full" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-2 h-2 bg-white/40 rounded-full" />
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[50%] h-[15%] border-2 border-white/40 border-t-0" />
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[20%] h-[5%] border-2 border-white/40 border-t-0" />
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[50%] h-[15%] border-2 border-white/40 border-b-0" />
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[20%] h-[5%] border-2 border-white/40 border-b-0" />
</div>
{/* ── STARTERS ── */}
{/* Outer reserves scaled height in document flow */}
<div style={{ height: scaledStartersH }} className="relative w-full">
{/* Inner is at design dimensions, scaled from top-center */}
<div
style={innerStyle(STARTERS_H)}
className="flex flex-col justify-evenly z-10"
// py-6 = 24px top + 24px bottom padding (48px total)
// justify-evenly distributes remaining 692px across 4 rows of 152px + 5 gaps
>
<div style={{ height: 24 }} /> {/* top padding */}
{["G", "D", "M", "F"].map((pos) => {
const rowPlayers = teamData.slice(0, 11).filter((p) => p.Pos === pos);
if (rowPlayers.length === 0) return null;
return (
<div
key={pos}
className="flex justify-center items-start w-full flex-1"
style={{ gap: CARD_GAP }}
>
{rowPlayers.map((p) => (
<DraggablePlayer
key={p.ID}
player={p}
isBench={false}
isActiveDrag={activeDragPlayer !== null}
isValidTarget={isValidSwap(activeDragPlayer, p)}
captainId={captainId}
viceId={viceId}
handleCapChange={handleCapChange}
playerCardGWs={playerCardGWs}
fixtures={fixtures}
activeGW={activeGW}
onPlayerClick={handlePlayerClick}
onUndo={handleUndoTransfer}
isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
activeChipType={chipsByGw[activeGW]}
/>
))}
</div>
);
})}
<div style={{ height: 24 }} /> {/* bottom padding */}
</div>
</div>
{/* ── BENCH ── */}
<div
className={`w-full border-t-2 z-10 transition-colors duration-300 relative ${
isBBChip
? "bg-emerald-950/60 border-emerald-500/50 shadow-[0_0_24px_rgba(16,185,129,0.2)]"
: "bg-slate-950/90 border-slate-800"
}`}
style={{ height: scaledBenchH }}
>
<div
style={innerStyle(BENCH_H, {
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
gap: CARD_GAP,
paddingTop: 16,
paddingBottom: 32,
paddingLeft: 24,
paddingRight: 24,
boxSizing: "border-box",
})}
>
{teamData.slice(11, 15).map((p, benchIndex) => (
<DraggablePlayer
key={p.ID}
player={p}
isBench={true}
benchIndex={benchIndex}
isActiveDrag={activeDragPlayer !== null}
isValidTarget={isValidSwap(activeDragPlayer, p)}
captainId={captainId}
viceId={viceId}
handleCapChange={handleCapChange}
playerCardGWs={playerCardGWs}
fixtures={fixtures}
activeGW={activeGW}
onPlayerClick={handlePlayerClick}
onUndo={handleUndoTransfer}
isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
activeChipType={chipsByGw[activeGW]}
/>
))}
</div>
</div>
</div>
);
};