Spaces:
Running
Running
| // 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> | |
| ); | |
| }; | |