function Pitch({ data, allCount, color, setColor, club, setClub, onPick, onPickStack, hover, setHover, introDone }) {
  const ref = React.useRef(null);
  const [size, setSize] = React.useState({w: 600, h: 750});

  // Vertical view: data x ∈ [X_MIN, 120], y ∈ [0, 80].
  // We rotate the rendering: data y → screen x, data x → screen y (flipped so x=120 is at top).
  // X_MIN auto-extends past the halfway line (60) when there are goals from
  // deep — halfway-line strikes, defensive-half volleys, etc. Without this the
  // dot for a 50-yard goal would clip below the rendered pitch and disappear.
  const dotsData = React.useMemo(() => data.filter(d => d.x != null && d.y != null), [data]);
  const X_MAX = 120;
  // Find the deepest goal (smallest x); extend the pitch down so it has
  // ≥ 4 yards of padding below the deepest dot, snapped to the next
  // multiple of 10 below that. Defaults to 60 (attacking half only) when no
  // goals sit deep. Clamped to [0, 60] so the pitch never shrinks above the
  // halfway line or grows past full length.
  const X_MIN = React.useMemo(() => {
    const xs = dotsData.map(d => d.x).filter(x => typeof x === 'number');
    if (xs.length === 0) return 60;
    const minX = Math.min(...xs);
    if (minX >= 60) return 60;
    const snapped = Math.floor((minX - 4) / 10) * 10;
    return Math.max(0, Math.min(60, snapped));
  }, [dotsData]);
  const VIEW_W_M = 80;                 // pitch y axis (lateral) becomes screen x (data coords, yards)
  const VIEW_H_M = X_MAX - X_MIN;      // visible x range (yards)

  // Visual aspect uses real FIFA pitch proportions (105 m × 68 m). The
  // visible region is VIEW_H_M yards along the pitch length; converting
  // back to meters (yard → m via 105/120 = 0.875) keeps the inner pitch
  // rectangle at correct real-world proportions regardless of how far
  // past the halfway line we extend.
  //
  // Critical: we apply the ratio to the *inner* pitch (after PAD), not
  // the outer SVG. Equal padding on all sides would distort the inner
  // aspect if we shaped the outer to match — what the user perceives as
  // "the pitch" is the inner rectangle, so that's the one that needs to
  // match real-world proportions exactly.
  const FIFA_FULL_M = 105;
  const FIFA_WIDTH_M = 68;
  const YD_TO_M = FIFA_FULL_M / 120;             // 0.875
  const PAD = 24;
  const fifaRatio = (VIEW_H_M * YD_TO_M) / FIFA_WIDTH_M;

  React.useEffect(() => {
    const el = ref.current?.parentElement;
    if (!el) return;
    const recompute = (containerW) => {
      const hCap = 660;
      // Start with W = containerW; inner-height follows from inner-width.
      let W = containerW;
      let H = (W - 2*PAD) * fifaRatio + 2*PAD;
      // If height hit the cap, fall back to height-driven sizing.
      if (H > hCap) {
        H = hCap;
        W = (H - 2*PAD) / fifaRatio + 2*PAD;
        if (W > containerW) {
          W = containerW;
          H = (W - 2*PAD) * fifaRatio + 2*PAD;
        }
      }
      setSize({w: W, h: H});
    };
    const ro = new ResizeObserver(es => recompute(es[0].contentRect.width));
    ro.observe(el);
    // Trigger an immediate recompute on fifaRatio change (X_MIN extending
    // past the halfway line) — ResizeObserver fires on element size change,
    // not on our local prop change, so the inner aspect would otherwise
    // stay frozen at last-observed width until the user resizes the window.
    recompute(el.getBoundingClientRect().width);
    return () => ro.disconnect();
  }, [fifaRatio]);

  const W = size.w, H = size.h;
  // Map (data_x, data_y) → (screen_x, screen_y) under the rotation:
  //   screen_x = scale of data_y (0..80)
  //   screen_y = scale of data_x flipped (X_MAX at top, X_MIN at bottom)
  const sx = data_y => PAD + (data_y / VIEW_W_M) * (W - PAD*2);
  const sy = data_x => PAD + ((X_MAX - data_x) / VIEW_H_M) * (H - PAD*2);


  // Group dots by their (x, y) bucket. Penalties all snap to (108, 40), so
  // they form a single cluster instead of every penalty being a separate
  // overlapping dot. Other dense pockets near the goal also collapse naturally.
  const clusters = React.useMemo(() => {
    const map = new Map();
    for (const d of dotsData) {
      const key = `${d.x.toFixed(1)}|${d.y.toFixed(1)}`;
      let bucket = map.get(key);
      if (!bucket) { bucket = { key, x: d.x, y: d.y, goals: [] }; map.set(key, bucket); }
      bucket.goals.push(d);
    }
    return [...map.values()];
  }, [dotsData]);

  // Same-match key (fixture identity). Used to highlight sibling goals when
  // the user hovers any single goal: a brace, hat-trick, or 4+ haul will
  // light up together so you can see "this goal had teammates in the same
  // match" without needing a special mode toggle.
  const fixtureKey = (d) => `${d.date||''}|${d.team||''}|${d.opponent||''}`;
  const hoverFixture = hover && hover.match_key ? fixtureKey(hover) : null;

  // Smart tooltip placement — put the card on the side of the hovered goal
  // that points AWAY from its same-match sibling goals, so the card never
  // covers the other dots of a brace / hat-trick. Falls back to a plain
  // cursor offset, and always clamps to the viewport.
  const TIP_W = 250, TIP_H = 165;
  const tipStyle = (hv, extra = {}) => {
    const cx = hv._x, cy = hv._y;
    const anchor = hv._stackLead || hv;
    const hx = sx(anchor.y), hy = sy(anchor.x);
    const fk = fixtureKey(anchor);
    let dx = 0, dy = 0, n = 0;
    for (const g of dotsData) {
      if (fixtureKey(g) !== fk) continue;
      dx += sx(g.y) - hx; dy += sy(g.x) - hy; n++;
    }
    // n includes the hovered dot(s); when there are siblings the centroid
    // leans toward them, so we place the card on the opposite side.
    const goLeft = n > 1 ? dx > 0 : (cx > window.innerWidth / 2);
    const goUp = n > 1 ? dy > 0 : false;
    let left = goLeft ? cx - TIP_W - 16 : cx + 16;
    let top = goUp ? cy - TIP_H - 12 : cy + 12;
    left = Math.max(8, Math.min(left, window.innerWidth - TIP_W - 8));
    top = Math.max(8, Math.min(top, window.innerHeight - TIP_H - 8));
    return { left, top, ...extra };
  };

  const colorOf = (d) => {
    if (color === 'body') return BODY_COLORS[d.body_part] || COLORS.muted;
    if (color === 'situation') return SITUATION_COLORS[d.situation] || COLORS.muted;
    if (color === 'finish') return FINISH_COLORS[d.finish_style] || COLORS.muted;
    return COLORS.gold;
  };

  // Which field on each goal drives the active colour mode. Used by
  // the stack-colour picker below — when many goals stack on one spot
  // (every penalty at the spot, every right-foot tap-in on a hot zone),
  // we want the stack dot to reflect the MAJORITY-KNOWN value, not
  // whichever goal happens to sit at cluster.goals[0]. If that first
  // goal happens to be unknown, the stack used to render in the muted
  // grey "unknown" colour even when 9 of 10 stacked goals were
  // correctly classified.
  const colorField = color === 'body' ? 'body_part'
                   : color === 'situation' ? 'situation'
                   : color === 'finish' ? 'finish_style'
                   : null;
  const _LOW_INFO_VALUES = new Set(['unknown', 'other', '', null, undefined]);
  const pickStackColorLead = (goals) => {
    if (!goals || goals.length === 0) return null;
    if (!colorField) return goals[0];          // colour mode = "club" → lead doesn't matter
    if (goals.length === 1) return goals[0];   // single goal, nothing to vote
    const counts = new Map();
    let firstKnown = null;
    for (const g of goals) {
      const v = g && g[colorField];
      if (_LOW_INFO_VALUES.has(v)) continue;
      counts.set(v, (counts.get(v) || 0) + 1);
      if (firstKnown == null) firstKnown = g;
    }
    if (counts.size === 0) return goals[0];    // every goal in cluster is low-info — keep current behaviour
    // Pick the most-common known value; tiebreak by first-occurrence so
    // the choice is stable across renders.
    let bestValue = null, bestCount = -1;
    for (const [val, n] of counts.entries()) {
      if (n > bestCount) { bestValue = val; bestCount = n; }
    }
    return goals.find(g => g && g[colorField] === bestValue) || firstKnown || goals[0];
  };

  const clubs = ['All', ...CLUB_ORDER];
  const colorOpts = [
    {key:'body', label:'Body part'},
    {key:'situation', label:'Situation'},
    {key:'finish', label:'Finish style'},
  ];

  const legendItems = React.useMemo(() => {
    if (color === 'body') return [
      ['Right foot', COLORS.rf],
      ['Left foot', COLORS.lf],
      ['Header', COLORS.hd],
      ['Other', COLORS.ot],
    ];
    if (color === 'situation') return Object.entries(SITUATION_COLORS).filter(([k]) => k !== 'unknown').map(([k,v])=>[fmtSituation(k), v]);
    if (color === 'finish') return Object.entries(FINISH_COLORS).filter(([k]) => k !== 'unknown' && k !== 'tap_in').map(([k,v])=>[fmtFinish(k), v]);
    return [];
  }, [color]);

  // Pre-computed pitch-line geometry (cleaner than inlining everywhere).
  // Penalty area: data x ∈ [102, 120], y ∈ [18, 62]
  const boxLeft = sx(18), boxRight = sx(62), boxBottom = sy(102), boxTop = sy(120);
  // 6-yard area: x ∈ [114, 120], y ∈ [30, 50]
  const sixLeft = sx(30), sixRight = sx(50), sixBottom = sy(114), sixTop = sy(120);
  // Penalty arc + centre circle radius — 10 yards in StatsBomb's 120×80
  // coordinate system (equivalent to 9.15 m / 30 ft in real-pitch terms).
  // The previous "9.15" constant was the *metric* radius mistakenly used
  // in a yard-based system, producing arcs ~9 % undersized.
  const arcRadiusY = (10 / VIEW_H_M) * (H - PAD*2);
  const arcRadiusX = (10 / VIEW_W_M) * (W - PAD*2);

  return (
    <div className="panel p-6">
      <div className="flex items-center justify-between gap-4 flex-wrap mb-5">
        <div>
          <div className="text-xs uppercase tracking-widest" style={{color: COLORS.muted2, letterSpacing: '0.14em'}}>The shot map</div>
          <div className="font-serif text-2xl mt-1" style={{fontWeight: 600}}>
            <span className="num-tabular">{dotsData.length.toLocaleString()}</span> goals plotted
            <span style={{color: COLORS.muted, fontStyle: 'italic', fontWeight: 400}}>
              {' '}in{' '}
            </span>
            <span className="num-tabular">{(new Set(dotsData.map(d => `${d.date||''}|${d.team||''}|${d.opponent||''}`))).size.toLocaleString()}</span>
            <span style={{color: COLORS.muted, fontStyle: 'italic', fontWeight: 400}}>
              {' '}matches
            </span>
          </div>
        </div>
        {club && club !== 'All' && (
          <button onClick={()=>setClub('All')} className="text-xs px-2.5 py-1 rounded-full"
            style={{color: COLORS.gold2, border: `1px solid ${COLORS.gold}`, background: 'rgba(205,163,73,0.12)'}}>
            {club} ✕
          </button>
        )}
      </div>

      <div className="flex items-center justify-between gap-3 flex-wrap mb-3">
        <div className="flex items-center gap-2">
          <span className="text-[11px] uppercase tracking-widest" style={{color: COLORS.muted2, letterSpacing:'0.14em'}}>Color by</span>
          <div className="seg">
            {colorOpts.map(o => <button key={o.key} className={color===o.key?'on':''} onClick={()=>setColor(o.key)}>{o.label}</button>)}
          </div>
        </div>
        <div className="flex items-center gap-3 flex-wrap">
          {legendItems.map(([l,c])=>(
            <span key={l} className="text-[11px] flex items-center gap-1.5" style={{color: COLORS.muted}}>
              <span className="legend-dot" style={{background: c}}></span>{l}
            </span>
          ))}
        </div>
      </div>

      <div className="flex gap-4 items-stretch">
        <div className="flex-1 relative flex justify-center" style={{minWidth: 0}}>
          <svg ref={ref} width={W} height={H} style={{display:'block'}}>
            <defs>
              <linearGradient id="grassGrad" x1="0" x2="0" y1="0" y2="1">
                <stop offset="0%" stopColor="#1a5e30"/>
                <stop offset="100%" stopColor="#155026"/>
              </linearGradient>
            </defs>

            {/* turf */}
            <rect x="0" y="0" width={W} height={H} rx="8" fill="url(#grassGrad)"/>

            {/* Mowing stripes — aligned with the lines a real groundsman cuts
                along: goal line, 6-yard box back, penalty spot, 18-yard box
                back, then 6-yard cadence across the whole visible pitch.
                Alternating slightly lighter / darker overlays. Stripes extend
                past the halfway line when the visible region does, so the
                defensive half (when shown) gets the same banding. */}
            {(() => {
              const breaks = [];
              for (let v = 120; v >= X_MIN; v -= 6) breaks.push(v);
              if (breaks[breaks.length - 1] !== X_MIN) breaks.push(X_MIN);
              return breaks.slice(0, -1).map((hi, i) => {
                const lo = breaks[i + 1];
                const yTop = sy(hi);
                const yBot = sy(lo);
                const fill = i % 2 === 0 ? 'rgba(255,255,255,0.025)' : 'rgba(0,0,0,0.04)';
                return (
                  <rect key={i}
                    x={PAD} y={yTop}
                    width={W - PAD*2} height={yBot - yTop}
                    fill={fill}
                  />
                );
              });
            })()}

            {/* Axis-aligned pitch lines — wrapped in a crispEdges group so
                they render pixel-perfect with no anti-aliased fuzz. The arcs
                (centre circle, penalty arc) sit outside this group because
                they need geometricPrecision to look smooth. */}
            <g shapeRendering="crispEdges">
              {/* Outer frame (visible region — X_MIN..120 along the length) */}
              <rect x={PAD} y={PAD} width={W-PAD*2} height={H-PAD*2} className="pitch-line"/>
              {/* Halfway line — pitch x = 60. When the view extends past
                  it, this line sits inside the pitch rather than at its
                  bottom edge. */}
              <line x1={PAD} y1={sy(60)} x2={W-PAD} y2={sy(60)} className="pitch-line"/>
              {/* Attacking penalty area: x ∈ [102, 120], y ∈ [18, 62] */}
              <rect
                x={boxLeft} y={boxTop}
                width={boxRight - boxLeft} height={boxBottom - boxTop}
                className="pitch-line"
              />
              {/* Attacking 6-yard box: x ∈ [114, 120], y ∈ [30, 50] */}
              <rect
                x={sixLeft} y={sixTop}
                width={sixRight - sixLeft} height={sixBottom - sixTop}
                className="pitch-line"
              />
              {/* Defensive-half markings — only rendered when the visible
                  region drops past the corresponding x boundary. Drawn
                  in the same crispEdges group as the attacking-half
                  rectangles so vertical posts and box edges stay
                  pixel-perfect. */}
              {X_MIN <= 18 && (
                <rect
                  x={sx(18)} y={sy(18)}
                  width={sx(62) - sx(18)} height={sy(0) - sy(18)}
                  className="pitch-line"
                />
              )}
              {X_MIN <= 6 && (
                <rect
                  x={sx(30)} y={sy(6)}
                  width={sx(50) - sx(30)} height={sy(0) - sy(6)}
                  className="pitch-line"
                />
              )}
            </g>

            {/* Curved markings — kept outside the crispEdges group so SVG
                renders them with geometricPrecision (smooth) instead of
                pixel-snapped (jagged). */}
            {/* Centre dot */}
            <circle cx={sx(40)} cy={sy(60)} r="2" fill="rgba(255,255,255,0.55)"/>
            {/* Centre-circle arc — attacking half (always visible).
                Endpoints at y = 30 / 50, exactly 10 yards from centre. */}
            <path
              d={`M ${sx(30)} ${sy(60)} A ${arcRadiusX} ${arcRadiusY} 0 0 1 ${sx(50)} ${sy(60)}`}
              className="pitch-line"
            />
            {/* Centre-circle arc — defensive half. Only drawn when the
                view extends past the halfway line; otherwise this arc
                would be off-pitch and clip to the SVG edge. Sweep flag
                0 reverses the curve direction relative to the attacking
                half so the two arcs together complete the circle. */}
            {X_MIN < 60 && (
              <path
                d={`M ${sx(30)} ${sy(60)} A ${arcRadiusX} ${arcRadiusY} 0 0 0 ${sx(50)} ${sy(60)}`}
                className="pitch-line"
              />
            )}
            {/* Attacking penalty spot */}
            <circle cx={sx(40)} cy={sy(108)} r="2" fill="rgba(255,255,255,0.55)"/>
            {/* Attacking penalty arc — the half outside the 18-yard box.
                Penalty spot (108, 40), radius 10, intersects box edge x=102
                at y = 40 ± √(100−36) = 40 ± 8 → endpoints (32, 102) / (48, 102). */}
            <path
              d={`M ${sx(32)} ${sy(102)} A ${arcRadiusX} ${arcRadiusY} 0 0 0 ${sx(48)} ${sy(102)}`}
              className="pitch-line"
            />
            {/* Defensive penalty spot + arc (mirrored). Only drawn when
                the visible region reaches that part of the pitch. */}
            {X_MIN <= 12 && (
              <circle cx={sx(40)} cy={sy(12)} r="2" fill="rgba(255,255,255,0.55)"/>
            )}
            {X_MIN <= 12 && (
              <path
                d={`M ${sx(32)} ${sy(18)} A ${arcRadiusX} ${arcRadiusY} 0 0 1 ${sx(48)} ${sy(18)}`}
                className="pitch-line"
              />
            )}

            {/* Goal — 8 yards wide (y ∈ [36, 44]), drawn above the byline
                with a mesh net inside a gold frame. Depth = 2 yards in pitch
                units so the net scales with the rest of the pitch (the old
                hard-coded 10 px crossbar didn't). shape-rendering="crispEdges"
                ensures every line lands on a whole pixel so vertical posts
                and horizontal stringers never anti-alias into a fuzzy slope. */}
            {(() => {
              const goalDepthYd = 2.0;
              const yardPx = (H - PAD * 2) / VIEW_H_M;  // vertical yard → pixel
              const depthPx = goalDepthYd * yardPx;
              const left = sx(36);
              const right = sx(44);
              const front = sy(120);          // byline / goal line
              const back = front - depthPx;   // back of net (further up on screen)
              const verticalStrands = 8;       // 8 internal vertical lines = 9 cells
              const horizontalStrands = 4;     // 4 internal horizontal lines = 5 rows
              return (
                <g shapeRendering="crispEdges">
                  {/* Net backdrop — a dim panel so the mesh has contrast against the turf. */}
                  <rect
                    x={left} y={back}
                    width={right - left} height={depthPx}
                    fill="rgba(0,0,0,0.30)"
                  />
                  {/* Vertical mesh strands */}
                  {Array.from({length: verticalStrands}, (_, i) => {
                    const x = left + ((i + 1) / (verticalStrands + 1)) * (right - left);
                    return (
                      <line key={`vstrand-${i}`}
                        x1={x} y1={back} x2={x} y2={front}
                        stroke="rgba(255,255,255,0.35)" strokeWidth="0.6"
                      />
                    );
                  })}
                  {/* Horizontal mesh strands */}
                  {Array.from({length: horizontalStrands}, (_, i) => {
                    const y = back + ((i + 1) / (horizontalStrands + 1)) * depthPx;
                    return (
                      <line key={`hstrand-${i}`}
                        x1={left} y1={y} x2={right} y2={y}
                        stroke="rgba(255,255,255,0.35)" strokeWidth="0.6"
                      />
                    );
                  })}
                  {/* Frame: two posts + crossbar, in gold, slightly thicker
                      so it reads as a structural element above the net mesh. */}
                  <line x1={left}  y1={front} x2={left}  y2={back}
                    stroke={COLORS.gold2} strokeWidth="2.5" strokeLinecap="square"/>
                  <line x1={right} y1={front} x2={right} y2={back}
                    stroke={COLORS.gold2} strokeWidth="2.5" strokeLinecap="square"/>
                  <line x1={left}  y1={back}  x2={right} y2={back}
                    stroke={COLORS.gold2} strokeWidth="2.5" strokeLinecap="square"/>
                </g>
              );
            })()}

            {/* Direction indicator at the bottom of the pitch */}
            <text x={W/2} y={H - 10} fill={COLORS.muted2} fontSize="10" fontFamily="JetBrains Mono" textAnchor="middle">
              ↑ ATTACKING DIRECTION ↑
            </text>

            {/* Dots layer — cluster-aware */}
            {(
              <g>
                {clusters.map((cluster) => {
                  const isStack = cluster.goals.length > 1;

                  // Singleton — render with sibling-aware highlighting.
                  if (!isStack) {
                    const d = cluster.goals[0];
                    const dotActive = hover && hover.match_key === d.match_key;
                    const isSibling = !dotActive && hoverFixture && fixtureKey(d) === hoverFixture;
                    const isActive = dotActive || isSibling;
                    const anyHover = hover && hover.match_key;
                    // Hovered dot: large + white stroke. Sibling: medium + gold ring.
                    // Other goals when something's hovered: dimmed.
                    const baseR = dotActive ? 9 : (isSibling ? 7 : 5.5);
                    const opacity = anyHover ? (dotActive ? 1 : (isSibling ? 0.95 : 0.3)) : 0.88;
                    return (
                      <g key={d.match_key}>
                        {d.live && (
                          // Pulsing red ring marks a goal from an in-progress
                          // match (coords still provisional). Self-contained SVG
                          // animation — no CSS dependency.
                          <circle cx={sx(d.y)} cy={sy(d.x)} r={baseR + 2.5}
                            fill="none" stroke="#ff4d5e" strokeWidth="1.6" pointerEvents="none">
                            <animate attributeName="stroke-opacity" values="0.95;0.15;0.95" dur="1.6s" repeatCount="indefinite"/>
                            <animate attributeName="r" values={`${baseR + 2};${baseR + 6};${baseR + 2}`} dur="1.6s" repeatCount="indefinite"/>
                          </circle>
                        )}
                        <circle
                          className="dot"
                          cx={sx(d.y)} cy={sy(d.x)}
                          r={baseR}
                          fill={colorOf(d)}
                          fillOpacity={opacity}
                          stroke={dotActive ? '#fff' : (isSibling ? COLORS.gold2 : 'rgba(0,0,0,0.5)')}
                          strokeWidth={dotActive ? 2 : (isSibling ? 1.6 : 0.9)}
                          onMouseEnter={(e)=>setHover({...d, _x: e.clientX, _y: e.clientY})}
                          onMouseMove={(e)=>setHover(h => h && h.match_key===d.match_key ? {...h, _x: e.clientX, _y: e.clientY} : h)}
                          onMouseLeave={()=>setHover(null)}
                          onClick={(e)=>{ e.stopPropagation(); onPick(d); }}
                          style={{cursor:'pointer'}}
                        />
                      </g>
                    );
                  }

                  // Stack — single dot with a pill-shaped count badge in the
                  // top-right. Pill grows with the digit count so 1, 12, 165
                  // all sit cleanly without truncation.
                  const lead = cluster.goals[0];
                  // The dot's fill colour is picked from the most-common
                  // KNOWN value in the cluster for the active colour mode,
                  // not from `lead` directly. Otherwise a single
                  // body_part="unknown" goal at index 0 forces the whole
                  // stack to render in the muted "unknown" grey even when
                  // most stacked goals are correctly classified.
                  const colorLead = pickStackColorLead(cluster.goals);
                  const dotR = 7.5;
                  const cx = sx(cluster.y);
                  const cy = sy(cluster.x);
                  const isStackHover = hover && hover._stackKey === cluster.key;
                  // If the hovered goal has a sibling in this stack, ring the
                  // whole stack in gold so the user can find the sibling.
                  const siblingsHere = hoverFixture
                    ? cluster.goals.filter(g => fixtureKey(g) === hoverFixture).length
                    : 0;
                  const anyDotHover = hover && hover.match_key;
                  const dimStack = anyDotHover && !isStackHover && siblingsHere === 0;
                  // Any live goal in the stack → the spot flashes (so a live
                  // penalty still pulses even when several penalties share the
                  // exact penalty-spot coordinate).
                  const liveInStack = cluster.goals.some(g => g.live);

                  // Pill geometry — fixed height, width adapts to digit count.
                  const countStr = String(cluster.goals.length);
                  const badgeH = 11;
                  const charW = 4.4;
                  const padX = 4;
                  const badgeW = Math.max(badgeH, countStr.length * charW + padX * 2);
                  const bcx = cx + dotR - 1;
                  const bcy = cy - dotR + 1;

                  return (
                    <g key={cluster.key}
                      onMouseEnter={(e)=>setHover({_stackKey: cluster.key, _stackCount: cluster.goals.length, _stackLead: lead, _stackGoals: cluster.goals, _x: e.clientX, _y: e.clientY})}
                      onMouseMove={(e)=>setHover(h => h && h._stackKey === cluster.key ? {...h, _x: e.clientX, _y: e.clientY} : h)}
                      onMouseLeave={()=>setHover(null)}
                      onClick={(e)=>{ e.stopPropagation(); if (onPickStack) onPickStack(cluster.goals); }}
                      style={{cursor:'pointer'}}>
                      {liveInStack && (
                        <circle cx={cx} cy={cy} r={dotR + 3}
                          fill="none" stroke="#ff4d5e" strokeWidth="1.8" pointerEvents="none">
                          <animate attributeName="stroke-opacity" values="0.95;0.15;0.95" dur="1.6s" repeatCount="indefinite"/>
                          <animate attributeName="r" values={`${dotR + 2};${dotR + 7};${dotR + 2}`} dur="1.6s" repeatCount="indefinite"/>
                        </circle>
                      )}
                      {siblingsHere > 0 && (
                        <circle cx={cx} cy={cy} r={dotR + 4}
                          fill="none" stroke={COLORS.gold2}
                          strokeWidth="1.8" strokeOpacity="0.9"
                          pointerEvents="none"/>
                      )}
                      <circle className="dot" cx={cx} cy={cy} r={dotR}
                        fill={colorOf(colorLead)}
                        fillOpacity={dimStack ? 0.3 : (isStackHover ? 1 : 0.92)}
                        stroke={isStackHover ? '#fff' : 'rgba(0,0,0,0.5)'}
                        strokeWidth={isStackHover ? 2 : 0.9}/>
                      <rect
                        x={bcx - badgeW/2} y={bcy - badgeH/2}
                        width={badgeW} height={badgeH}
                        rx={badgeH/2} ry={badgeH/2}
                        fill={COLORS.gold} stroke={COLORS.bg0} strokeWidth="1"
                        opacity={dimStack ? 0.4 : 1}/>
                      <text x={bcx} y={bcy} dominantBaseline="central"
                        fill={COLORS.bg0} fontSize="8.5" fontWeight="700" fontFamily="JetBrains Mono"
                        textAnchor="middle"
                        style={{pointerEvents:'none', letterSpacing:'-0.01em'}}>
                        {countStr}
                      </text>
                    </g>
                  );
                })}
              </g>
            )}
          </svg>

          {hover && hover._stackKey && (() => {
            // Detect the canonical penalty-spot stack — all penalties project
            // to (108, 40), so a cluster at that point is unambiguously the
            // penalty-spot stack. Surface that label for viewers.
            const isPenStack = hover._stackLead
              && Math.abs((hover._stackLead.x ?? 0) - 108) < 0.5
              && Math.abs((hover._stackLead.y ?? 0) - 40) < 0.5;
            return (
              // Click target: on mobile the underlying dot/cluster
              // is a small SVG hit-area; the tooltip is a much larger
              // tappable surface. Tapping it routes to the same
              // onPickStack handler the cluster's own onClick uses.
              <div className="tt" style={tipStyle(hover, {cursor: 'pointer'})}
                onClick={(e)=>{ e.stopPropagation(); if (onPickStack && hover._stackGoals) onPickStack(hover._stackGoals); }}
                onTouchEnd={(e)=>{ e.stopPropagation(); if (onPickStack && hover._stackGoals) onPickStack(hover._stackGoals); }}>
                <div className="text-[11px] uppercase tracking-widest mb-1" style={{color: COLORS.muted2, letterSpacing:'0.14em'}}>
                  {isPenStack ? 'Penalty spot' : 'Stacked goals'}
                </div>
                <div className="font-serif" style={{fontSize: 28, fontWeight: 600, color: COLORS.gold2, lineHeight: 1, paddingBottom: 2}}>
                  {hover._stackCount}
                </div>
                <div className="text-[11px] mt-1" style={{color: COLORS.muted}}>
                  {isPenStack ? 'goals from the penalty spot' : 'goals at this exact spot'}
                </div>
                <div className="mt-2 pt-2 text-[10px]" style={{color: COLORS.muted2, borderTop: `1px solid ${COLORS.line}`}}>
                  Tap to view the list →
                </div>
              </div>
            );
          })()}

          {hover && !hover._stackKey && hover.match_key && (
            // Click target: on mobile the underlying dot is a small
            // SVG hit-area; the tooltip is a much larger tappable
            // surface. Tapping it routes to the same onPick handler
            // the dot's own onClick uses.
            <div className="tt" style={tipStyle(hover, {cursor: 'pointer'})}
              onClick={(e)=>{ e.stopPropagation(); if (onPick) onPick(hover); }}
              onTouchEnd={(e)=>{ e.stopPropagation(); if (onPick) onPick(hover); }}>
              <div className="flex items-center gap-2 mb-2">
                {hover.goal_number != null && (
                  <span className="font-mono uppercase" style={{color: COLORS.gold2, fontSize: 13, fontWeight: 800, letterSpacing:'0.10em'}}>
                    GOAL #{hover.goal_number}
                  </span>
                )}
                {hover.live && (
                  <span className="font-mono uppercase" style={{color: '#ff8d99', fontSize: 10, fontWeight: 800, letterSpacing:'0.10em'}}>● LIVE</span>
                )}
              </div>
              {/* Matchup with the running score in fixed home–away order — the
                  digit THIS goal updated (the scorer's side) is gold, so you see
                  which goal of the match it was (e.g. Germany 3 vs 0 Curaçao). */}
              {(() => {
                const p = String(hover.score_after_goal || '').split(':');
                const hasScore = p.length === 2;
                const homeName = hover.home_team || hover.team;
                const awayName = hover.away_team || hover.opponent;
                const homeAbbr = hover.home_abbr || hover.team_abbr;
                const awayAbbr = hover.away_abbr || hover.opponent_abbr;
                const homeScored = hover.scorer_side ? hover.scorer_side === 'home' : true;
                const gold = {color: COLORS.gold2, fontWeight: 800};
                const plain = {color: COLORS.ink, fontWeight: 700};
                return (
                  <div className="flex items-center gap-1.5 mb-2" style={{fontSize: 14, whiteSpace: 'nowrap'}}>
                    <FlagImg abbr={homeAbbr} size={18}/>
                    <span style={{color: homeScored ? COLORS.gold2 : COLORS.ink, fontWeight: 600}}>{homeName}</span>
                    {hasScore && <span className="font-mono num-tabular" style={homeScored ? gold : plain}>{p[0]}</span>}
                    <span style={{color: COLORS.muted, fontStyle: 'italic'}}>vs</span>
                    {hasScore && <span className="font-mono num-tabular" style={homeScored ? plain : gold}>{p[1]}</span>}
                    <span style={{color: homeScored ? COLORS.ink : COLORS.gold2, fontWeight: 600}}>{awayName}</span>
                    <FlagImg abbr={awayAbbr} size={18}/>
                  </div>
                );
              })()}
              {/* Scorer + minute — the one key line. */}
              {hover.scorer && (
                <div className="font-serif mb-1" style={{fontSize: 15, fontWeight: 600, lineHeight: 1.15}}>
                  {hover.scorer}
                  {hover.minute != null && <span className="font-mono ml-2" style={{fontSize: 12, color: COLORS.muted}}>{hover.minute}'</span>}
                </div>
              )}
              {hover.xg != null && (
                <div className="font-mono text-xs" style={{color: COLORS.muted}}>
                  xG <span style={{color: COLORS.gold2}}>{Number(hover.xg).toFixed(2)}</span>
                </div>
              )}
              <div className="mt-2 pt-2 text-[10px]" style={{color: COLORS.muted2, borderTop: `1px solid ${COLORS.line}`}}>Tap to open detail →</div>
            </div>
          )}

        </div>
      </div>
    </div>
  );
}

window.Pitch = Pitch;
