fpl-solver / frontend /src /components /PlayerCardVisual.jsx
AnayShukla's picture
updates
151205f
import React, { useContext, memo } from "react";
import { Plus, RotateCcw } from "lucide-react";
import { getShortName } from "../utils/teams";
import { getPlayerPrice } from "../utils/fplLogic";
import { PlayerContext, FixturesContext } from "../PlayerContext";
// Fixed card dimensions β€” these are design-space pixels (before PitchView scaling).
// Sized to be prominent and legible on desktop, scaling gracefully on mobile via
// the PitchView scale transform. Keeps proportions consistent across devices.
const CARD_W = 108;
const CARD_H = 108; // photo card height only (label strip is separate, in flow below)
// Badge sizing β€” using percentage of card width ensures badges scale proportionally
// with the card rather than being fixed pixels that iOS may try to inflate.
// 15% of 88px = ~13px at design scale β€” large enough to read and tap comfortably.
const BADGE_PCT = 0.25;
export const PlayerCardVisual = ({
player,
isBench,
captainId,
viceId,
handleCapChange,
playerCardGWs,
fixtures,
activeGW,
onPlayerClick,
onUndo,
onSolverUndo,
activeChipType,
}) => {
if (player.isBlank) {
return (
<div className="flex flex-col items-center" style={{ width: CARD_W }}>
<div
onClick={() => onPlayerClick(player)}
style={{ width: CARD_W, height: CARD_H }}
className="relative flex flex-col items-center justify-center cursor-pointer border-2 border-dashed border-slate-500 bg-slate-900/60 rounded-xl hover:bg-slate-800 hover:border-emerald-400 transition-all z-20 shadow-inner group"
>
{player.replacedPlayer && (
<div
className="absolute left-[2px] top-[2px] flex flex-col gap-[1px] z-40 pointer-events-auto"
style={{
WebkitTextSizeAdjust: "none",
textSizeAdjust: "none",
fontSize: 0,
}}
>
<div
role="button"
tabIndex={0}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
style={{
width: CARD_W * BADGE_PCT,
height: CARD_W * BADGE_PCT,
minWidth: 0,
minHeight: 0,
touchAction: "manipulation",
}}
className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transition-colors border border-red-400 shadow-lg cursor-pointer select-none"
title="Undo transfer"
>
<RotateCcw size={7} strokeWidth={2.5} />
</div>
</div>
)}
<Plus className="text-slate-500 group-hover:text-emerald-400 transition-colors mb-1" size={24} />
<span className="text-[10px] font-black text-slate-500 group-hover:text-emerald-400 uppercase tracking-widest">
{player.Pos}
</span>
</div>
{/* Placeholder label area so blank slots have identical height to real cards */}
<div style={{ height: 55 }} />
</div>
);
}
const isCap = player.ID === captainId;
const isVice = player.ID === viceId;
const photoUrl = player.photo
? `https://wsrv.nl/?url=resources.premierleague.com/premierleague25/photos/players/110x140/${player.photo.replace(".jpg", ".png")}&output=webp&w=110&q=75`
: "";
const effectiveFixtures = useContext(FixturesContext);
const TEAM_MAP = {
"Arsenal": 1, "Aston Villa": 2, "Burnley": 3, "Bournemouth": 4, "AFC Bournemouth": 4, "Brentford": 5,
"Brighton": 6, "Brighton and Hove Albion": 6, "Chelsea": 7, "Crystal Palace": 8, "Everton": 9, "Fulham": 10,
"Leeds": 11, "Leeds United": 11, "Liverpool": 12, "Man City": 13, "Manchester City": 13,
"Man Utd": 14, "Manchester United": 14, "Newcastle": 15, "Newcastle United": 15,
"Nott'm Forest": 16, "Nottingham Forest": 16, "Sunderland": 17,
"Spurs": 18, "Tottenham": 18, "Tottenham Hotspur": 18,
"West Ham": 19, "West Ham United": 19, "Wolves": 20, "Wolverhampton Wanderers": 20
};
const getActiveMatches = (teamName, gw) => {
if (!fixtures || !fixtures.length || !gw) return [];
const activeMatches = [];
fixtures.forEach(m => {
if (m.home_team !== teamName && m.away_team !== teamName) return;
const hId = m.home_team_id || TEAM_MAP[m.home_team] || m.home_team;
const aId = m.away_team_id || TEAM_MAP[m.away_team] || m.away_team;
const matchId = `${hId}_vs_${aId}`;
const override = effectiveFixtures?.[matchId];
if (override) {
if (Number(override[gw]) >= 0.01) activeMatches.push({ ...m, prob: Number(override[gw]) });
} else if (String(m.GW) === String(gw)) {
activeMatches.push({ ...m, prob: 1.0 });
}
});
return activeMatches;
};
const currentGwMatches = getActiveMatches(player.Team, activeGW);
const isBlankThisGw = currentGwMatches.length === 0;
const renderFixtures = (teamName, gw) => {
const activeMatches = gw === activeGW ? currentGwMatches : getActiveMatches(teamName, gw);
if (activeMatches.length === 0) {
return <span className="text-[9px] font-bold text-slate-600">BLANK</span>;
}
return activeMatches.map((m, idx) => {
const isHome = m.home_team === teamName;
const oppName = getShortName(isHome ? m.away_team : m.home_team);
const loc = isHome ? "H" : "A";
const isGhost = m.prob < 1;
return (
<React.Fragment key={idx}>
<span
title={isGhost ? `${Math.round(m.prob * 100)}% chance of playing in GW${gw}` : undefined}
className={`inline-flex items-center whitespace-nowrap ${isGhost ? "text-indigo-200 cursor-help" : "text-slate-200"}`}
>
<span className="text-[8px] font-black tracking-tighter leading-none">{oppName}</span>
<span className={`text-[8px] font-bold ml-[1px] leading-none ${isGhost ? "text-indigo-400" : "text-slate-400"}`}>
({loc})
</span>
{isGhost && (
<span
className="text-[6px] font-black text-indigo-100 ml-[2px] bg-indigo-500/50 px-[2px] py-[1px] rounded-[2px] border border-indigo-400/40 tracking-tighter shadow-sm flex items-center justify-center"
style={{ lineHeight: 1 }}
>
{Math.round(m.prob * 100)}%
</span>
)}
</span>
{idx < activeMatches.length - 1 && (
<span className="text-[6px] text-slate-500 mx-[2px] flex items-center leading-none">β€’</span>
)}
</React.Fragment>
);
});
};
// EV text sizes β€” primary GW gets the strongest visual weight, subsequent
// GWs taper down. Sized for comfortable reading at the larger card scale.
const evStyles = [
"text-emerald-400 text-[13px] font-extrabold",
"text-emerald-500 text-[11px] font-bold",
"text-emerald-600 text-[9px] font-semibold",
];
const isTransferIn = Boolean(player.replacedPlayer || onSolverUndo);
return (
/*
OUTER WRAPPER β€” this is what the row gap acts on.
Width is exactly CARD_W so the gap math in PitchView is predictable.
The wrapper is a flex-col: [photo card] then [label strip].
*/
<div
onClick={() => onPlayerClick(player)}
className="relative flex flex-col items-center cursor-grab"
style={{ width: CARD_W }}
>
{/* ── PHOTO CARD ── fixed size, photo is % of this div specifically */}
<div
className={`relative flex-shrink-0 rounded-[10px] transition-transform duration-200 transform-gpu overflow-visible
${isTransferIn
? "bg-gradient-to-t from-cyan-900/60 via-cyan-900/5 to-transparent -translate-y-2 shadow-[0_20px_16px_-8px_rgba(0,0,0,0.8)] z-30"
: "bg-transparent z-10"
}
${isBench ? (activeChipType === "bb" ? "opacity-85" : "opacity-50") : "opacity-100"}
`}
style={{ width: CARD_W, height: CARD_H }}
>
{/*
C / V / Reset stack.
Key fixes for mobile responsiveness:
1. Use <div role="button"> instead of <button> β€” buttons have browser-enforced
minimum touch targets that cause the "huge badges" issue on iOS/mobile.
2. Size badges as percentage of card width so they scale proportionally.
3. Use touch-action: manipulation to prevent double-tap zoom interference.
4. -webkit-text-size-adjust: none (not 100%) fully disables text inflation.
5. appearance: none and explicit min-width/min-height: 0 to override defaults.
*/}
<div
className="absolute left-[2px] top-[2px] flex flex-col gap-[1px] z-40 pointer-events-auto"
style={{
WebkitTextSizeAdjust: "none",
textSizeAdjust: "none",
fontSize: 0, // container has no font size β€” children set their own
}}
>
{!isBench && handleCapChange && (
<>
<div
role="button"
tabIndex={0}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "C"); }}
style={{
width: CARD_W * BADGE_PCT,
height: CARD_W * BADGE_PCT,
minWidth: 0,
minHeight: 0,
touchAction: "manipulation",
}}
className={`flex items-center justify-center rounded-full text-[7px] font-bold leading-none transition-colors shadow-md transform-gpu cursor-pointer select-none
${isCap
? activeChipType === "tc"
? "bg-purple-500 text-white border border-purple-300"
: "bg-yellow-400 text-slate-900 border border-white"
: "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-yellow-400"
}`}
>
{isCap && activeChipType === "tc" ? "TC" : "C"}
</div>
<div
role="button"
tabIndex={0}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "V"); }}
style={{
width: CARD_W * BADGE_PCT,
height: CARD_W * BADGE_PCT,
minWidth: 0,
minHeight: 0,
touchAction: "manipulation",
}}
className={`flex items-center justify-center rounded-full text-[7px] font-bold leading-none transition-colors shadow-md cursor-pointer select-none
${isVice ? "bg-slate-300 text-slate-900 border border-white" : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-white"}`}
>
V
</div>
</>
)}
{onSolverUndo ? (
<div
role="button"
tabIndex={0}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onSolverUndo(player); }}
style={{
width: CARD_W * BADGE_PCT,
height: CARD_W * BADGE_PCT,
minWidth: 0,
minHeight: 0,
touchAction: "manipulation",
}}
className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transform-gpu transition-colors border border-red-400 shadow-md cursor-pointer select-none"
>
<RotateCcw size={7} strokeWidth={2.5} />
</div>
) : player.replacedPlayer ? (
<div
role="button"
tabIndex={0}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
style={{
width: CARD_W * BADGE_PCT,
height: CARD_W * BADGE_PCT,
minWidth: 0,
minHeight: 0,
touchAction: "manipulation",
}}
className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transform-gpu transition-colors border border-red-400 shadow-md cursor-pointer select-none"
>
<RotateCcw size={7} strokeWidth={2.5} />
</div>
) : null}
</div>
{/* EV numbers β€” top-right corner. Primary GW most prominent,
subsequent GWs taper down. Anti-inflation styles applied. */}
<div
className="absolute right-[3px] top-[2px] flex flex-col items-end z-30 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]"
style={{
WebkitTextSizeAdjust: "none",
textSizeAdjust: "none",
}}
>
{playerCardGWs.map((gw, i) => (
<span key={gw} className={`${evStyles[i]} leading-tight tabular-nums`}>
{Number(player[`${gw}_Pts`] || 0).toFixed(2)}
</span>
))}
</div>
{/* Player photo β€” bottom-[-8px] lets the player's body dip into the name strip */}
{photoUrl ? (
<img
src={photoUrl}
alt={player.Name}
loading="lazy"
decoding="async"
draggable="false"
className={`absolute bottom-[-8px] w-full object-contain pointer-events-none z-10 transform-gpu`}
style={{ height: "95%" }}
/>
) : (
<div
className={`absolute bottom-[-8px] w-full object-contain pointer-events-none z-10 transform-gpu`}
style={{ height: "70%" }}
/>
)}
</div>
{/*
LABEL STRIP β€” in normal flow below the photo card.
Width is 110% of CARD_W (β‰ˆ92px @ design) so the strip slightly
overhangs the card for that classic FPL look, while still fitting
comfortably inside the CARD_GAP (24px) so neighbouring labels never
collide on any viewport.
WebkitTextSizeAdjust: "100%" prevents iOS Safari from inflating the
small label text β€” without this the name/stats wrap or overflow on
mobile.
*/}
<div
draggable="false"
className={`flex flex-col items-center z-30 pointer-events-none mt-[1px] flex-shrink-0 ${isBench ? "opacity-80" : "opacity-100"}`}
style={{
width: "100%",
WebkitTextSizeAdjust: "none",
textSizeAdjust: "none",
}}
>
<div className={`w-full text-center py-[3px] truncate px-1 font-bold text-[9px] leading-tight rounded-t shadow-md
${isTransferIn ? "bg-cyan-600 text-white" : "bg-slate-950 text-slate-100 border border-slate-700"}`}>
{player.Name}
</div>
<div className="w-full bg-slate-200 border-x border-slate-700 flex justify-center items-center gap-1 py-[3px] shadow-inner">
<span className={`text-[9px] font-black flex items-baseline gap-[2px] leading-none ${isBlankThisGw ? "text-slate-400" : "text-slate-800"}`}>
{isBlankThisGw ? "-" : (player[`${activeGW}_xMins`] ?? 90)}
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-tight">xMins</span>
</span>
<span className="text-slate-400 font-light text-[9px] leading-none">|</span>
<span className="text-[9px] font-black text-emerald-700 leading-none">
Β£{getPlayerPrice(player).toFixed(1)}
</span>
</div>
<div className="w-full bg-slate-900 border-x border-b border-slate-700 flex items-center rounded-b shadow-md px-0.5 overflow-hidden" style={{ height: 19 }}>
<div className="flex items-center justify-center w-full whitespace-nowrap overflow-hidden">
{renderFixtures(player.Team, activeGW)}
</div>
</div>
</div>
</div>
);
};
export default memo(PlayerCardVisual);