v0 Anay commited on
Commit
c5bbd02
·
1 Parent(s): 5853416

feat: implement counter-scale for badge size adjustment

Browse files

Decouple badge size from card width and pitch scale for consistent visual size.

Co-authored-by: Anay <129646663+anay1104@users.noreply.github.com>

frontend/src/components/DraggablePlayer.jsx CHANGED
@@ -20,6 +20,7 @@ export const DraggablePlayer = ({
20
  isHighlighted,
21
  onSolverUndo,
22
  activeChipType,
 
23
  }) => {
24
  const disableBenchGkDrag = Boolean(
25
  isBench && benchIndex === 0 && player.Pos === "G" && !player.isBlank,
@@ -72,7 +73,8 @@ export const DraggablePlayer = ({
72
  onUndo={onUndo}
73
  onSolverUndo={onSolverUndo}
74
  activeChipType={activeChipType}
 
75
  />
76
  </div>
77
  );
78
- };
 
20
  isHighlighted,
21
  onSolverUndo,
22
  activeChipType,
23
+ pitchScale,
24
  }) => {
25
  const disableBenchGkDrag = Boolean(
26
  isBench && benchIndex === 0 && player.Pos === "G" && !player.isBlank,
 
73
  onUndo={onUndo}
74
  onSolverUndo={onSolverUndo}
75
  activeChipType={activeChipType}
76
+ pitchScale={pitchScale}
77
  />
78
  </div>
79
  );
80
+ };
frontend/src/components/PitchView.jsx CHANGED
@@ -140,6 +140,7 @@ export const PitchView = ({
140
  isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
141
  onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
142
  activeChipType={chipsByGw[activeGW]}
 
143
  />
144
  ))}
145
  </div>
@@ -190,6 +191,7 @@ export const PitchView = ({
190
  isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
191
  onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
192
  activeChipType={chipsByGw[activeGW]}
 
193
  />
194
  ))}
195
  </div>
 
140
  isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
141
  onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
142
  activeChipType={chipsByGw[activeGW]}
143
+ pitchScale={scale}
144
  />
145
  ))}
146
  </div>
 
191
  isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
192
  onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
193
  activeChipType={chipsByGw[activeGW]}
194
+ pitchScale={scale}
195
  />
196
  ))}
197
  </div>
frontend/src/components/PlayerCardVisual.jsx CHANGED
@@ -4,16 +4,16 @@ import { getShortName } from "../utils/teams";
4
  import { getPlayerPrice } from "../utils/fplLogic";
5
  import { PlayerContext } from "../PlayerContext";
6
 
7
- // Fixed card dimensions — these are design-space pixels (before PitchView scaling).
8
- // Modest increase from original (72×88) to improve clarity on desktop while the
9
- // PitchView scale transform ensures proportions stay consistent across all devices.
10
  const CARD_W = 76;
11
- const CARD_H = 92; // photo card height only (label strip is separate, in flow below)
12
 
13
- // Badge sizing — using percentage of card width ensures badges scale proportionally
14
- // with the card rather than being fixed pixels that iOS may try to inflate.
15
- // 12% of 76px = ~9px at design scale, but will shrink/grow with the card.
16
- const BADGE_PCT = 0.12;
 
 
17
 
18
  export const PlayerCardVisual = ({
19
  player,
@@ -28,7 +28,17 @@ export const PlayerCardVisual = ({
28
  onUndo,
29
  onSolverUndo,
30
  activeChipType,
 
31
  }) => {
 
 
 
 
 
 
 
 
 
32
  if (player.isBlank) {
33
  return (
34
  <div className="flex flex-col items-center" style={{ width: CARD_W }}>
@@ -39,11 +49,12 @@ export const PlayerCardVisual = ({
39
  >
40
  {player.replacedPlayer && (
41
  <div
42
- className="absolute left-[2px] top-[2px] flex flex-col gap-[1px] z-40 pointer-events-auto"
43
  style={{
44
- WebkitTextSizeAdjust: "none",
45
- textSizeAdjust: "none",
46
- fontSize: 0,
 
47
  }}
48
  >
49
  <div
@@ -52,16 +63,14 @@ export const PlayerCardVisual = ({
52
  onPointerDown={(e) => e.stopPropagation()}
53
  onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
54
  style={{
55
- width: CARD_W * BADGE_PCT,
56
- height: CARD_W * BADGE_PCT,
57
- minWidth: 0,
58
- minHeight: 0,
59
  touchAction: "manipulation",
60
  }}
61
  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"
62
  title="Undo transfer"
63
  >
64
- <RotateCcw size={5} strokeWidth={3} />
65
  </div>
66
  </div>
67
  )}
@@ -152,12 +161,13 @@ export const PlayerCardVisual = ({
152
  });
153
  };
154
 
155
- // EV text sizes — primary GW gets the strongest visual weight, subsequent
156
- // GWs taper down. The WebkitTextSizeAdjust fix prevents iOS inflation.
 
157
  const evStyles = [
158
- "text-emerald-400 text-[10px] font-extrabold",
159
- "text-emerald-500 text-[8px] font-bold",
160
- "text-emerald-600 text-[6px] font-semibold",
161
  ];
162
 
163
  const isTransferIn = Boolean(player.replacedPlayer || onSolverUndo);
@@ -173,7 +183,7 @@ export const PlayerCardVisual = ({
173
  className="relative flex flex-col items-center cursor-grab"
174
  style={{ width: CARD_W }}
175
  >
176
- {/* ── PHOTO CARD ── fixed size, photo is % of this div specifically */}
177
  <div
178
  className={`relative flex-shrink-0 rounded-[10px] transition-transform duration-200 transform-gpu overflow-visible
179
  ${isTransferIn
@@ -184,21 +194,27 @@ export const PlayerCardVisual = ({
184
  style={{ width: CARD_W, height: CARD_H }}
185
  >
186
  {/*
187
- C / V / Reset stack.
188
- Key fixes for mobile responsiveness:
189
- 1. Use <div role="button"> instead of <button> — buttons have browser-enforced
190
- minimum touch targets that cause the "huge badges" issue on iOS/mobile.
191
- 2. Size badges as percentage of card width so they scale proportionally.
192
- 3. Use touch-action: manipulation to prevent double-tap zoom interference.
193
- 4. -webkit-text-size-adjust: none (not 100%) fully disables text inflation.
194
- 5. appearance: none and explicit min-width/min-height: 0 to override defaults.
 
 
195
  */}
196
  <div
197
- className="absolute left-[2px] top-[2px] flex flex-col gap-[1px] z-40 pointer-events-auto"
198
  style={{
 
 
 
 
 
199
  WebkitTextSizeAdjust: "none",
200
  textSizeAdjust: "none",
201
- fontSize: 0, // container has no font size — children set their own
202
  }}
203
  >
204
  {!isBench && handleCapChange && (
@@ -209,13 +225,12 @@ export const PlayerCardVisual = ({
209
  onPointerDown={(e) => e.stopPropagation()}
210
  onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "C"); }}
211
  style={{
212
- width: CARD_W * BADGE_PCT,
213
- height: CARD_W * BADGE_PCT,
214
- minWidth: 0,
215
- minHeight: 0,
216
  touchAction: "manipulation",
 
217
  }}
218
- className={`flex items-center justify-center rounded-full text-[5px] font-bold leading-none transition-colors shadow-md transform-gpu cursor-pointer select-none
219
  ${isCap
220
  ? activeChipType === "tc"
221
  ? "bg-purple-500 text-white border border-purple-300"
@@ -231,13 +246,12 @@ export const PlayerCardVisual = ({
231
  onPointerDown={(e) => e.stopPropagation()}
232
  onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "V"); }}
233
  style={{
234
- width: CARD_W * BADGE_PCT,
235
- height: CARD_W * BADGE_PCT,
236
- minWidth: 0,
237
- minHeight: 0,
238
  touchAction: "manipulation",
 
239
  }}
240
- className={`flex items-center justify-center rounded-full text-[5px] font-bold leading-none transition-colors shadow-md cursor-pointer select-none
241
  ${isVice ? "bg-slate-300 text-slate-900 border border-white" : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-white"}`}
242
  >
243
  V
@@ -251,15 +265,13 @@ export const PlayerCardVisual = ({
251
  onPointerDown={(e) => e.stopPropagation()}
252
  onClick={(e) => { e.stopPropagation(); onSolverUndo(player); }}
253
  style={{
254
- width: CARD_W * BADGE_PCT,
255
- height: CARD_W * BADGE_PCT,
256
- minWidth: 0,
257
- minHeight: 0,
258
  touchAction: "manipulation",
259
  }}
260
- 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"
261
  >
262
- <RotateCcw size={5} strokeWidth={3} />
263
  </div>
264
  ) : player.replacedPlayer ? (
265
  <div
@@ -268,24 +280,27 @@ export const PlayerCardVisual = ({
268
  onPointerDown={(e) => e.stopPropagation()}
269
  onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
270
  style={{
271
- width: CARD_W * BADGE_PCT,
272
- height: CARD_W * BADGE_PCT,
273
- minWidth: 0,
274
- minHeight: 0,
275
  touchAction: "manipulation",
276
  }}
277
- 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"
278
  >
279
- <RotateCcw size={5} strokeWidth={3} />
280
  </div>
281
  ) : null}
282
  </div>
283
 
284
- {/* EV numbers — top-right corner. Primary GW most prominent,
285
- subsequent GWs taper down. Anti-inflation styles applied. */}
 
286
  <div
287
- 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)]"
288
  style={{
 
 
 
 
289
  WebkitTextSizeAdjust: "none",
290
  textSizeAdjust: "none",
291
  }}
 
4
  import { getPlayerPrice } from "../utils/fplLogic";
5
  import { PlayerContext } from "../PlayerContext";
6
 
7
+ // Fixed card dimensions — design-space pixels (before PitchView scale transform).
 
 
8
  const CARD_W = 76;
9
+ const CARD_H = 92;
10
 
11
+ // The visual size (in real screen pixels) that we ALWAYS want the badges to
12
+ // appear at, regardless of the pitch scale. By counter-scaling the badge
13
+ // container with scale(1/pitchScale) we cancel out the parent transform, so
14
+ // badges are always BADGE_VISUAL_PX × BADGE_VISUAL_PX on screen.
15
+ // 16px is large enough to tap comfortably on mobile and read clearly on desktop.
16
+ const BADGE_VISUAL_PX = 16;
17
 
18
  export const PlayerCardVisual = ({
19
  player,
 
28
  onUndo,
29
  onSolverUndo,
30
  activeChipType,
31
+ pitchScale = 1,
32
  }) => {
33
+ // Counter-scale: cancel out the parent pitch transform so badges always
34
+ // render at BADGE_VISUAL_PX on screen regardless of zoom level.
35
+ // e.g. if pitch is scaled 0.6×, badges scale up by 1/0.6 = 1.667×
36
+ // so they still appear as 16px visually. transform-origin: top left
37
+ // anchors to the card's top-left corner so positioning stays correct.
38
+ const badgeScale = 1 / (pitchScale || 1);
39
+ // How much space the counter-scaled stack occupies in design-space.
40
+ // We offset the stack by this to avoid it overflowing the card at low scales.
41
+ const badgeDesignPx = BADGE_VISUAL_PX * badgeScale;
42
  if (player.isBlank) {
43
  return (
44
  <div className="flex flex-col items-center" style={{ width: CARD_W }}>
 
49
  >
50
  {player.replacedPlayer && (
51
  <div
52
+ className="absolute z-40 pointer-events-auto"
53
  style={{
54
+ left: 2,
55
+ top: 2,
56
+ transformOrigin: "top left",
57
+ transform: `scale(${badgeScale})`,
58
  }}
59
  >
60
  <div
 
63
  onPointerDown={(e) => e.stopPropagation()}
64
  onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
65
  style={{
66
+ width: BADGE_VISUAL_PX,
67
+ height: BADGE_VISUAL_PX,
 
 
68
  touchAction: "manipulation",
69
  }}
70
  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"
71
  title="Undo transfer"
72
  >
73
+ <RotateCcw size={9} strokeWidth={2.5} />
74
  </div>
75
  </div>
76
  )}
 
161
  });
162
  };
163
 
164
+ // EV text sizes — rendered at true visual size (counter-scale applied to
165
+ // parent div), so these map 1:1 to screen pixels. Primary GW is most
166
+ // prominent; subsequent GWs taper for hierarchy.
167
  const evStyles = [
168
+ "text-emerald-400 text-[11px] font-extrabold",
169
+ "text-emerald-500 text-[9px] font-bold",
170
+ "text-emerald-600 text-[7px] font-semibold",
171
  ];
172
 
173
  const isTransferIn = Boolean(player.replacedPlayer || onSolverUndo);
 
183
  className="relative flex flex-col items-center cursor-grab"
184
  style={{ width: CARD_W }}
185
  >
186
+ {/* ── PHOTO CARD ��─ fixed size, photo is % of this div specifically */}
187
  <div
188
  className={`relative flex-shrink-0 rounded-[10px] transition-transform duration-200 transform-gpu overflow-visible
189
  ${isTransferIn
 
194
  style={{ width: CARD_W, height: CARD_H }}
195
  >
196
  {/*
197
+ C / V / Reset badge stack.
198
+
199
+ The core technique: counter-scale with scale(1/pitchScale) anchored
200
+ at top-left. This cancels the parent pitch transform so the badges
201
+ are always BADGE_VISUAL_PX (16px) on screen — large enough to read
202
+ and tap on mobile, compact enough not to obstruct on desktop.
203
+
204
+ Using <div role="button"> instead of <button> bypasses the 44px
205
+ minimum touch-target that iOS Safari enforces on native buttons,
206
+ which was the original cause of the oversized badges.
207
  */}
208
  <div
209
+ className="absolute z-40 pointer-events-auto flex flex-col"
210
  style={{
211
+ left: 2,
212
+ top: 2,
213
+ gap: Math.max(1, 2 * badgeScale),
214
+ transformOrigin: "top left",
215
+ transform: `scale(${badgeScale})`,
216
  WebkitTextSizeAdjust: "none",
217
  textSizeAdjust: "none",
 
218
  }}
219
  >
220
  {!isBench && handleCapChange && (
 
225
  onPointerDown={(e) => e.stopPropagation()}
226
  onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "C"); }}
227
  style={{
228
+ width: BADGE_VISUAL_PX,
229
+ height: BADGE_VISUAL_PX,
 
 
230
  touchAction: "manipulation",
231
+ fontSize: 8,
232
  }}
233
+ className={`flex items-center justify-center rounded-full font-bold leading-none transition-colors shadow-md cursor-pointer select-none
234
  ${isCap
235
  ? activeChipType === "tc"
236
  ? "bg-purple-500 text-white border border-purple-300"
 
246
  onPointerDown={(e) => e.stopPropagation()}
247
  onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "V"); }}
248
  style={{
249
+ width: BADGE_VISUAL_PX,
250
+ height: BADGE_VISUAL_PX,
 
 
251
  touchAction: "manipulation",
252
+ fontSize: 8,
253
  }}
254
+ className={`flex items-center justify-center rounded-full font-bold leading-none transition-colors shadow-md cursor-pointer select-none
255
  ${isVice ? "bg-slate-300 text-slate-900 border border-white" : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-white"}`}
256
  >
257
  V
 
265
  onPointerDown={(e) => e.stopPropagation()}
266
  onClick={(e) => { e.stopPropagation(); onSolverUndo(player); }}
267
  style={{
268
+ width: BADGE_VISUAL_PX,
269
+ height: BADGE_VISUAL_PX,
 
 
270
  touchAction: "manipulation",
271
  }}
272
+ className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transition-colors border border-red-400 shadow-md cursor-pointer select-none"
273
  >
274
+ <RotateCcw size={9} strokeWidth={2.5} />
275
  </div>
276
  ) : player.replacedPlayer ? (
277
  <div
 
280
  onPointerDown={(e) => e.stopPropagation()}
281
  onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
282
  style={{
283
+ width: BADGE_VISUAL_PX,
284
+ height: BADGE_VISUAL_PX,
 
 
285
  touchAction: "manipulation",
286
  }}
287
+ className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transition-colors border border-red-400 shadow-md cursor-pointer select-none"
288
  >
289
+ <RotateCcw size={9} strokeWidth={2.5} />
290
  </div>
291
  ) : null}
292
  </div>
293
 
294
+ {/* EV numbers — top-right, counter-scaled to remain readable at
295
+ consistent size across all pitch scales. transform-origin: top right
296
+ anchors the scale to the top-right corner. */}
297
  <div
298
+ className="absolute z-30 pointer-events-none flex flex-col items-end drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]"
299
  style={{
300
+ right: 2,
301
+ top: 2,
302
+ transformOrigin: "top right",
303
+ transform: `scale(${badgeScale})`,
304
  WebkitTextSizeAdjust: "none",
305
  textSizeAdjust: "none",
306
  }}