// VisitorPulse — right-edge collapsible vector-space panel
//
// Three layers:
//   1. Collapsed rail (charcoal pill, right edge, ~64px wide) — mini viz + definition %
//   2. Expanded panel (slides in, ~380px wide) — User Context + Adaptive Profile + 2D Vector Space
//
// Progressive definition (blurry → sharp) across welcome → home → search → pdp → merch.
// Hearts pull the visitor dot toward the matching cluster + boost that cluster's definition.

// ---------- Cluster taxonomy ----------
// Each cluster sits along a fixed angle around the nucleus. Its DISTANCE from
// the nucleus is computed per-render from persona affinity + step + hearts —
// so well-matched clusters migrate INWARD as the journey progresses, while
// off-persona clusters stay parked at the rim.
const CLUSTERS = [
  {
    id: 'tucci',
    label: 'Tucci · Marino Blue',
    short: 'Tucci',
    color: '#3F628A',
    angle: -30, // upper-right
    products: ['stanley-tucci-11pc-marino-blue', 'tucci-pizza-oven-marino-blue', 'tucci-steak-knife-set-6', 'reserve-pro-stainless-12pc-twilight'],
  },
  {
    id: 'ceramic',
    label: 'PFAS-free Ceramic Sets',
    short: 'Ceramic',
    color: '#7AAB7C',
    angle: -150, // upper-left
    products: ['valencia-pro-19pc-sage', 'valencia-pro-19pc-gray', 'reserve-pro-10pc-cream', 'spectra-thermobond-10pc'],
  },
  {
    id: 'electrics',
    label: 'Frost & Electrics',
    short: 'Electrics',
    color: '#D89251',
    angle: -90, // top
    products: ['frost-pro-ice-cream', 'frost-classic-ice-cream', 'elite-air-fryer-toaster', 'elite-slow-cooker-cloud-cream'],
  },
  {
    id: 'bakeware',
    label: 'Bakeware & Ovenware',
    short: 'Bakeware',
    color: '#A7A0EB',
    angle: 130, // lower-left
    products: ['reserve-bakeware-7pc-twilight', 'reserve-bakeware-8pc-cream', 'premiere-ovenware-6pc-taupe'],
  },
  {
    id: 'value-tools',
    label: 'Value Frypans & Tools',
    short: 'Value · Tools',
    color: '#B89968',
    angle: 50, // lower-right
    products: ['bobby-flay-carbon-steel-12', 'gp5-frypan-set-cloud-cream', 'valencia-pro-frypan-set-gray', 'premiere-silicone-utensil-bisque', 'platinum-silicone-utensil-navy'],
  },
];

const REST_R = 42;
const NEAR_R = 15;

// Per-persona affinity to each cluster (0–1). Drives how strongly that cluster
// gets pulled toward the nucleus across the journey. "Tied to user session":
// the persona chosen on the homepage drives this for every later step.
const PERSONA_AFFINITY = {
  'tucci-italian': {
    ceramic: 1.00, bakeware: 0.78, 'value-tools': 0.15, electrics: 0.22, tucci: 0.05,
  },
  'first-apt-cook': {
    'value-tools': 1.00, ceramic: 0.35, bakeware: 0.20, electrics: 0.20, tucci: 0.05,
  },
  'wellness-replacer': {
    ceramic: 1.00, bakeware: 0.55, electrics: 0.15, 'value-tools': 0.20, tucci: 0.05,
  },
};

// Map any product id → cluster id (for heart-driven drift)
const PRODUCT_TO_CLUSTER = (() => {
  const map = {};
  for (const c of CLUSTERS) for (const pid of c.products) map[pid] = c.id;
  return map;
})();

// ---------- Per-persona narrative (scripted but reacts to hearts) ----------
const PERSONA_NARRATIVE = {
  'tucci-italian': {
    primaryCluster: 'ceramic',
    bars: ['Ceramic affinity', 'PFAS-free intent', 'Cookware sets', 'Bakeware', 'Electrics', 'Premium tier'],
    barKey: ['ceramic', 'wellness', 'cookware-set', 'bakeware-set', 'electric', 'premium'],
    perStep: {
      welcome: { bars: [0.38, 0.42, 0.32, 0.18, 0.18, 0.20], events: [
        { kind: 'PAGE VIEW', label: 'Best Sellers PLP', val: '48 items' },
      ]},
      home:    { bars: [0.58, 0.62, 0.55, 0.32, 0.28, 0.30], events: [
        { kind: 'DWELL', label: 'Valencia Pro 19pc Sage', val: '11s' },
        { kind: 'HOVER', label: 'Reserve Pro Cream', val: 'hover 2×' },
      ]},
      search:  { bars: [0.72, 0.74, 0.66, 0.38, 0.30, 0.36], events: [
        { kind: 'SEARCH', label: '"ceramik nonstick"', val: 'mal-rank' },
      ]},
      pdp:     { bars: [0.84, 0.82, 0.78, 0.48, 0.34, 0.42], events: [
        { kind: 'DWELL', label: 'Valencia Pro 19pc PDP', val: '18s' },
        { kind: 'ADD INTENT', label: 'Sage · 19pc', val: 'cart 1×' },
      ]},
      merch:   { bars: [0.92, 0.88, 0.84, 0.58, 0.36, 0.46], events: [
        { kind: 'BUNDLE', label: '+ Reserve Bakeware Cream', val: 'open' },
      ]},
    },
  },
  'first-apt-cook': {
    primaryCluster: 'value-tools',
    bars: ['Value affinity', 'Frypans', 'Cream / Neutral', 'Best-sellers', 'Utensil bundles', 'Premium tier'],
    barKey: ['value', 'frypan', 'cream', 'best-seller', 'utensil', 'premium'],
    perStep: {
      welcome: { bars: [0.38, 0.30, 0.30, 0.30, 0.22, 0.10], events: [
        { kind: 'PAGE VIEW', label: 'Best Sellers PLP', val: '38 items' },
      ]},
      home:    { bars: [0.58, 0.55, 0.45, 0.45, 0.34, 0.08], events: [
        { kind: 'DWELL', label: 'Bobby Flay Carbon Steel', val: '6s' },
        { kind: 'HOVER', label: 'GP5 Frypan Set', val: 'hover 3×' },
      ]},
      search:  { bars: [0.70, 0.66, 0.50, 0.55, 0.38, 0.06], events: [
        { kind: 'SEARCH', label: '"ceramik nonstick"', val: 'mal-rank' },
      ]},
      pdp:     { bars: [0.78, 0.72, 0.58, 0.62, 0.50, 0.06], events: [
        { kind: 'DWELL', label: 'Valencia Pro frypans', val: '14s' },
      ]},
      merch:   { bars: [0.86, 0.78, 0.62, 0.70, 0.58, 0.06], events: [
        { kind: 'CART', label: 'Frypan + Silicone tools', val: '$179' },
      ]},
    },
  },
  'wellness-replacer': {
    primaryCluster: 'ceramic',
    bars: ['PFAS-free intent', 'Ceramic affinity', 'Sage / Taupe', 'Family-size sets', 'Bakeware', 'Carbon Steel'],
    barKey: ['wellness', 'ceramic', 'sage', 'set', 'bakeware', 'carbon-steel'],
    perStep: {
      welcome: { bars: [0.55, 0.40, 0.32, 0.30, 0.20, 0.05], events: [
        { kind: 'SEARCH', label: '"pfas free cookware"', val: 'organic' },
      ]},
      home:    { bars: [0.72, 0.62, 0.50, 0.48, 0.30, 0.05], events: [
        { kind: 'DWELL', label: 'Valencia Pro Sage', val: '11s' },
        { kind: 'HOVER', label: 'Reserve Pro Cream', val: 'hover 2×' },
      ]},
      search:  { bars: [0.80, 0.74, 0.60, 0.58, 0.36, 0.04], events: [
        { kind: 'SEARCH', label: '"pan that won\u2019t ruin scrambled eggs"', val: 'mal-rank' },
      ]},
      pdp:     { bars: [0.86, 0.82, 0.66, 0.68, 0.42, 0.04], events: [
        { kind: 'DWELL', label: 'Valencia Pro 19-Piece PDP', val: '18s' },
      ]},
      merch:   { bars: [0.92, 0.88, 0.70, 0.76, 0.54, 0.04], events: [
        { kind: 'BUNDLE', label: '+ Reserve Bakeware Cream', val: 'open' },
      ]},
    },
  },
};

const PERSONA_CONTEXT = {
  'tucci-italian': {
    visitorId: '0x8273·c0okware',
    returning: '1st visit · no account',
    device: 'iOS 17 · 390w · mobile',
    referrer: 'google.com · "pfas free cookware"',
    location: 'Brooklyn, NY',
    time: 'Sat 11:22a · morning',
  },
  'first-apt-cook': {
    visitorId: '0xF1·c0okware',
    returning: '1st visit · no account',
    device: 'iOS 17 · 390w · mobile',
    referrer: 'tiktok.com · paid',
    location: 'Austin, TX',
    time: 'Sat 7:08p · evening',
  },
  'wellness-replacer': {
    visitorId: '0xC3·c0okware',
    returning: '2nd visit · email subscriber',
    device: 'macOS · 1280w · desktop',
    referrer: 'instagram.com · organic',
    location: 'Portland, OR',
    time: 'Sat 9:14a · morning',
  },
};

const PERSONA_CONTEXT_PLACEHOLDER = null; // (kept for legibility)

// ---------- Component ----------
const STEP_ORDER = ['welcome', 'home', 'search', 'pdp', 'merch'];
const STEP_LABELS = { welcome: 'Cold start', home: 'PLP', search: 'Search', pdp: 'PDP', merch: 'Merch' };
// Per-step image floors so images show progressively, never empty after Welcome.
const STEP_IMG_FLOOR    = { welcome: 0.00, home: 0.20, search: 0.55, pdp: 0.80, merch: 0.92 };
const STEP_BLUR_CEILING = { welcome: 18,   home: 5,    search: 2.5,  pdp: 1.0,  merch: 0.5 };
const STEP_BASE_DEF     = { welcome: 0.12, home: 0.42, search: 0.70, pdp: 0.92, merch: 0.99 };

function VisitorPulse({ stepId, persona, favorites, expanded, setExpanded }) {
  // Pre-warm browser cache for every cluster product image so SVG/<img> renders
  // them reliably the first time a cluster comes into view.
  React.useEffect(() => {
    if (!window.PRODUCTS) return;
    const seen = new Set();
    CLUSTERS.flatMap(c => c.products).forEach(pid => {
      if (seen.has(pid)) return;
      seen.add(pid);
      const p = window.PRODUCTS.find(x => x.id === pid);
      if (!p?.img) return;
      const img = new Image();
      img.src = p.img;
    });
  }, []);

  // Slider lets the user scrub the SCRIPTED vector progression from cold-start
  // up to wherever they are now. Defaults to the latest step; resets when the
  // outer demo step advances.
  const currentStepOrd = Math.max(0, STEP_ORDER.indexOf(stepId));
  const [sliderOrd, setSliderOrd] = React.useState(currentStepOrd);
  React.useEffect(() => { setSliderOrd(currentStepOrd); }, [currentStepOrd]);
  const effectiveStepId = STEP_ORDER[Math.max(0, Math.min(STEP_ORDER.length - 1, sliderOrd))];
  const isScrubbing = sliderOrd !== currentStepOrd;

  const narrative = PERSONA_NARRATIVE[persona.id] || PERSONA_NARRATIVE['tucci-italian'];
  const context = PERSONA_CONTEXT[persona.id] || PERSONA_CONTEXT['tucci-italian'];
  const stepData = narrative.perStep[effectiveStepId] || narrative.perStep.welcome;
  const affinityMap = PERSONA_AFFINITY[persona.id] || PERSONA_AFFINITY['tucci-italian'];

  // Hearted product clusters
  const heartClusterCounts = React.useMemo(() => {
    const counts = {};
    for (const fid of (favorites || [])) {
      const cid = PRODUCT_TO_CLUSTER[fid];
      if (cid) counts[cid] = (counts[cid] || 0) + 1;
    }
    return counts;
  }, [favorites]);
  const heartTotal = Object.values(heartClusterCounts).reduce((s, v) => s + v, 0);

  // Step-keyed definition table (curve so the world feels resolved by PDP)
  const baseDef = STEP_BASE_DEF[effectiveStepId] ?? 0.12;
  // Hearts only count when we're scrubbed forward to / past the step they happened on.
  // Demo simplification: hearts always count (collected during the run).
  const heartBoost = Math.min(0.32, heartTotal * 0.10);
  const definition = Math.min(0.99, baseDef + heartBoost);

  // Compute each cluster's PULL (0–1): 0 = at rest, 1 = right at the nucleus.
  // Pull = persona affinity * step progression + heart-driven bonus.
  // Visitor stays at the true center — it is the nucleus, the world orbits it.
  const computedClusters = React.useMemo(() => {
    return CLUSTERS.map(c => {
      const aff = affinityMap[c.id] ?? 0.1;
      const hearts = heartClusterCounts[c.id] || 0;
      // Exponential affinity curve: matched clusters race in; mismatched stay parked.
      let pull = Math.pow(aff, 1.4) * (0.40 + definition * 1.00) + hearts * 0.40;
      pull = Math.max(0, Math.min(0.97, pull));
      const ang = c.angle * Math.PI / 180;
      const dist = REST_R - (REST_R - NEAR_R) * pull;
      return {
        ...c,
        pos: { x: 50 + Math.cos(ang) * dist, y: 50 + Math.sin(ang) * dist },
        pull,
        affinity: aff,
        hearts,
        normDist: dist / REST_R, // 1 = far, ~0.45 = close
      };
    });
  }, [persona.id, definition, heartClusterCounts]);

  // Visitor is locked to center (50, 50) — the user IS the nucleus.
  const visitorPos = { x: 50, y: 50 };

  // Closest cluster (for status line)
  const nearest = React.useMemo(() => {
    let best = computedClusters[0];
    let bestD = Infinity;
    for (const c of computedClusters) {
      const dx = c.pos.x - visitorPos.x, dy = c.pos.y - visitorPos.y;
      const d = Math.sqrt(dx * dx + dy * dy);
      if (d < bestD) { bestD = d; best = c; }
    }
    return { cluster: best, distance: bestD };
  }, [computedClusters]);
  const nearestNorm = (nearest.distance / REST_R).toFixed(2);

  // Events: cumulative across steps (uses scrubbed step so the log replays).
  const eventLog = React.useMemo(() => {
    const upto = STEP_ORDER.indexOf(effectiveStepId);
    const arr = [];
    for (let i = 0; i <= upto; i++) {
      const step = STEP_ORDER[i];
      const s = narrative.perStep[step];
      if (s) for (const e of s.events) arr.push({ ...e, step });
    }
    return arr.slice(-6);
  }, [effectiveStepId, persona.id]);

  return (
    <>
      {/* Hidden DOM preloaders — force-fetch every cluster product image as a
          plain <img> the moment VisitorPulse mounts. Cluster thumbs inside the
          SVG foreignObject can then pull from the cache instantly. */}
      <div
        aria-hidden="true"
        style={{
          position: 'absolute',
          width: 1,
          height: 1,
          overflow: 'hidden',
          opacity: 0,
          pointerEvents: 'none',
          left: -9999,
          top: -9999,
        }}
      >
        {CLUSTERS.flatMap(c => c.products).map(pid => {
          const p = (window.PRODUCTS || []).find(x => x.id === pid);
          if (!p?.img) return null;
          return <img key={pid} src={p.img} alt="" loading="eager" />;
        })}
      </div>
      {/* Collapsed rail */}
      {!expanded && (
        <button
          className="vp-rail"
          onClick={() => setExpanded(true)}
          title="Visitor Pulse · click to expand"
        >
          <div className="vp-rail__mini">
            <VectorSpaceSVG
              clusters={computedClusters}
              visitorPos={visitorPos}
              definition={definition}
              stepId={effectiveStepId}
              compact
            />
          </div>
          <div className="vp-rail__label">VECTOR<span>·</span>384d</div>
          <div className="vp-rail__def">
            <div className="vp-rail__def-bar">
              <div className="vp-rail__def-fill" style={{ height: `${Math.round(definition * 100)}%` }} />
            </div>
            <div className="vp-rail__def-num">{Math.round(definition * 100)}%</div>
          </div>
          <div className="vp-rail__chev">‹</div>
        </button>
      )}

      {/* Expanded panel */}
      {expanded && (
        <div className="vp-panel" role="dialog" aria-label="Visitor Pulse">
          <div className="vp-panel__head">
            <div className="vp-panel__head-l">
              <span className="vp-panel__eye">VISITOR PULSE</span>
              <span className="vp-panel__id">{context.visitorId} · {Math.round(definition * 100)}% defined</span>
            </div>
            <button className="vp-panel__close" onClick={() => setExpanded(false)} aria-label="Collapse">›</button>
          </div>

          <div className="vp-panel__body">
            {/* LEFT col: User Context */}
            <div className="vp-panel__col vp-panel__col--left">
              <div className="vp-card vp-card--dark vp-card--context">
                <div className="vp-card__eye">USER CONTEXT</div>
                <div className="vp-rows">
                  <div className="vp-row"><span className="vp-row__k">RETURNING</span><span className="vp-row__v">{context.returning}</span></div>
                  <div className="vp-row"><span className="vp-row__k">DEVICE</span><span className="vp-row__v">{context.device}</span></div>
                  <div className="vp-row"><span className="vp-row__k">REFERRER</span><span className="vp-row__v">{context.referrer}</span></div>
                  <div className="vp-row"><span className="vp-row__k">LOCATION</span><span className="vp-row__v">{context.location}</span></div>
                  <div className="vp-row"><span className="vp-row__k">TIME</span><span className="vp-row__v">{context.time}</span></div>
                </div>
              </div>
              <div className="vp-card vp-card--tracker">
                <div className="vp-card__eye vp-card__eye--tracker">
                  IN-SESSION BEHAVIOURS <span className="vp-card__eye-meta">· <span className="vp-dot" />{eventLog.length} events</span>
                </div>
                <div className="vp-events">
                  {eventLog.map((e, i) => (
                    <div key={i} className="vp-event">
                      <span className="vp-event__kind">{e.kind}</span>
                      <span className="vp-event__label">{e.label}</span>
                      <span className="vp-event__val">{e.val}</span>
                    </div>
                  ))}
                </div>
              </div>
            </div>

            {/* CENTER col: Vector Space (hero) */}
            <div className="vp-panel__col vp-panel__col--center">
              <div className="vp-card vp-card--cream">
                <div className="vp-card__eye vp-card__eye--cream">
                  VECTOR SPACE · 2D PROJECTION
                  <span style={{ marginLeft: 10, color: '#9C9277', fontWeight: 500, letterSpacing: '0.10em' }}>
                    384 DIMS · UPDATED PER EVENT
                  </span>
                </div>
                <div className="vp-space">
                  {currentStepOrd > 0 && (
                    <div className="vp-scrubber">
                      <div className="vp-scrubber__head">
                        <span className="vp-scrubber__eye">REPLAY PROGRESSION</span>
                        <span className="vp-scrubber__pos">
                          {STEP_LABELS[effectiveStepId]}
                          {isScrubbing && <span className="vp-scrubber__scrub">· scrubbing</span>}
                        </span>
                      </div>
                      <input
                        type="range"
                        className="vp-scrubber__range"
                        min={0}
                        max={currentStepOrd}
                        step={1}
                        value={sliderOrd}
                        onChange={(e) => setSliderOrd(Number(e.target.value))}
                      />
                      <div className="vp-scrubber__ticks">
                        {STEP_ORDER.slice(0, currentStepOrd + 1).map((s, i) => (
                          <button
                            key={s}
                            type="button"
                            className={`vp-scrubber__tick ${i === sliderOrd ? 'is-active' : ''}`}
                            onClick={() => setSliderOrd(i)}
                          >{STEP_LABELS[s]}</button>
                        ))}
                      </div>
                    </div>
                  )}
                  <VectorSpaceSVG
                    clusters={computedClusters}
                    visitorPos={visitorPos}
                    definition={definition}
                    stepId={effectiveStepId}
                    hearted={favorites || []}
                    visitorTag={context.visitorId}
                  />
                </div>
              </div>
            </div>

            {/* RIGHT col: Adaptive Profile */}
            <div className="vp-panel__col vp-panel__col--right">
              <div className="vp-card vp-card--dark">
                <div className="vp-card__eye vp-card__eye--top">ADAPTIVE PROFILE</div>
                <div className="vp-bars">
                  {narrative.bars.map((label, i) => {
                    const val = stepData.bars[i] || 0;
                    const tone = ['lime', 'violet', 'rose', 'lime', 'violet', 'amber'][i % 6];
                    return (
                      <div key={i} className="vp-bar">
                        <div className="vp-bar__head">
                          <span className="vp-bar__label">{label}</span>
                          <span className={`vp-bar__val vp-bar__val--${tone}`}>{val.toFixed(2)}</span>
                        </div>
                        <div className="vp-bar__track">
                          <div className={`vp-bar__fill vp-bar__fill--${tone}`} style={{ width: `${val * 100}%` }} />
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
          </div>
        </div>
      )}
    </>
  );
}

// ---------- SVG renderer ----------
function VectorSpaceSVG({ clusters, visitorPos, definition, compact = false, hearted = [], visitorTag = '5f8a·2e91', stepId = 'home' }) {
  const products = window.PRODUCTS || [];
  const byId = (id) => products.find(p => p.id === id);

  // Per-step visibility envelope (so the progression reads cleanly across the journey).
  const stepFloor = STEP_IMG_FLOOR[stepId] ?? 0.16;
  const stepBlurMax = STEP_BLUR_CEILING[stepId] ?? 6;

  // Distance lines opacity rises with definition
  const lineOpacity = Math.max(0, (definition - 0.25) * 1.4);
  // Visitor dot blur fades out
  const visitorBlur = Math.max(0, (1 - definition) * 4);

  return (
    <svg viewBox="-8 -8 116 116" className={`vp-svg ${compact ? 'vp-svg--compact' : ''}`} preserveAspectRatio="xMidYMid meet">
      {/* Concentric guide rings centered on visitor */}
      <circle cx={visitorPos.x} cy={visitorPos.y} r="36" className="vp-ring" />
      <circle cx={visitorPos.x} cy={visitorPos.y} r="24" className="vp-ring" />
      <circle cx={visitorPos.x} cy={visitorPos.y} r="14" className="vp-ring" />

      {/* Distance lines visitor → cluster (stronger for hotter clusters) */}
      {clusters.map((c) => (
        <line
          key={`line-${c.id}`}
          x1={visitorPos.x} y1={visitorPos.y}
          x2={c.pos.x} y2={c.pos.y}
          className="vp-line"
          stroke={c.color}
          strokeOpacity={Math.max(0.10, (c.pull ?? 0) * 0.75 + lineOpacity * 0.2)}
          strokeDasharray="1.4 1.6"
          style={{ transition: 'stroke-opacity 600ms ease' }}
        />
      ))}

      {/* Clusters */}
      {clusters.map((c) => {
        // `pull` (0–1) is computed upstream from persona affinity + step + hearts.
        // 0 = at rest position (far rim, blurry), 1 = pulled all the way in (sharp, big).
        const pull = c.pull ?? 0;
        const haloR = compact ? 8.5 : 8 + pull * 5 + definition * 1.5;
        // Per-step envelope: at Welcome, images are hidden; by PDP/Merch every
        // cluster shows its products (just dimmer/blurrier for off-persona).
        const naturalOpacity = 0.40 + pull * 0.55;
        const imgOpacity = stepId === 'welcome' ? 0 : Math.max(stepFloor, naturalOpacity);
        const blurAmt = Math.min(stepBlurMax, (1 - pull) * 7);
        const labelOpacity = 0.35 + pull * 0.65;
        // Hearted ids in this cluster (shown sharp regardless of pull)
        const heartedHere = c.products.filter(pid => hearted.includes(pid));
        const otherHere = c.products.filter(pid => !hearted.includes(pid));
        const pickIds = [...heartedHere, ...otherHere].slice(0, compact ? 0 : 4);
        const productThumbs = pickIds.map(byId).filter(Boolean);
        const distVisitor = Math.sqrt(Math.pow(c.pos.x - visitorPos.x, 2) + Math.pow(c.pos.y - visitorPos.y, 2));
        const isTop = c.pos.y < 50;
        const labelY = isTop ? -haloR - 4.5 : haloR + 4.6;
        const dY = isTop ? -haloR - 1.6 : haloR + 7.8;
        return (
          <g
            key={c.id}
            className="vp-cluster"
            transform={`translate(${c.pos.x} ${c.pos.y})`}
            style={{ transition: 'transform 700ms cubic-bezier(.22,.61,.36,1)' }}
          >
            {/* Soft outer halo — grows with pull */}
            <ellipse rx={haloR * 1.18} ry={haloR * 0.92} fill={c.color} fillOpacity={0.08 + pull * 0.16} />
            <ellipse rx={haloR * 0.80} ry={haloR * 0.66} fill={c.color} fillOpacity={0.18 + pull * 0.22} />
            {/* Product thumbs in a 2x2 grid */}
            {productThumbs.map((p, i) => {
              const cols = 2;
              const col = i % cols;
              const row = Math.floor(i / cols);
              const gap = compact ? 0 : haloR * 0.42;
              const tx = (col - 0.5) * gap;
              const ty = (row - 0.5) * gap;
              const tr = compact ? 2.4 : 3.4 + pull * 1.0;
              const isHearted = hearted.includes(p.id);
              // Per-product blur variance so even matched clusters have a mix.
              const variance = 0.5 + ((i * 37) % 100) / 100 * 0.5; // 0.5–1.0
              const localBlur = isHearted ? 0 : Math.min(6, blurAmt * variance);
              return (
                <g key={p.id} transform={`translate(${tx} ${ty})`}>
                  {/* Cluster-tinted backdrop so faded photos still read as their cluster */}
                  <circle r={tr + 0.5} fill={c.color} fillOpacity={0.22 + (1 - pull) * 0.30} />
                  {/* HTML <img> inside foreignObject — same load mechanism as the BS PLP grid. */}
                  <foreignObject x={-tr} y={-tr} width={tr * 2} height={tr * 2} style={{ overflow: 'visible' }}>
                    <img
                      xmlns="http://www.w3.org/1999/xhtml"
                      src={p.img}
                      alt=""
                      loading="eager"
                      decoding="sync"
                      style={{
                        width: '100%',
                        height: '100%',
                        objectFit: 'cover',
                        borderRadius: '50%',
                        display: 'block',
                        filter: `blur(${localBlur}px)`,
                        opacity: isHearted ? 1 : imgOpacity,
                        transition: 'filter 600ms ease, opacity 600ms ease',
                      }}
                    />
                  </foreignObject>
                  <circle
                    r={tr + 0.3}
                    fill="none"
                    stroke={isHearted ? '#E55248' : c.color}
                    strokeWidth={isHearted ? '0.7' : '0.4'}
                    strokeOpacity={isHearted ? 1 : (0.5 + pull * 0.5)}
                  />
                  {isHearted && (
                    <text
                      x={tr * 0.95}
                      y={-tr * 0.65}
                      fontSize="3"
                      textAnchor="middle"
                      fill="#E55248"
                    >♥</text>
                  )}
                </g>
              );
            })}
            {!compact && (
              <text
                y={labelY}
                textAnchor="middle"
                className="vp-cluster__label"
                fill={c.color}
                fontSize="4.2"
                fontWeight="700"
                style={{ opacity: labelOpacity }}
              >
                {c.short}
              </text>
            )}
            {!compact && pull > 0.2 && (
              <text
                y={dY}
                textAnchor="middle"
                className="vp-cluster__d"
                fill={c.color}
                fontSize="2.6"
                style={{ opacity: 0.45 + pull * 0.45 }}
              >
                d={(distVisitor / REST_R).toFixed(2)} · n={c.products.length}
              </text>
            )}
          </g>
        );
      })}

      {/* Visitor dot — glossy 3D nucleus */}
      <defs>
        <radialGradient id="vp-visitor-glow" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stopColor="#ADEF9B" stopOpacity="0.55" />
          <stop offset="60%" stopColor="#3DBE85" stopOpacity="0.22" />
          <stop offset="100%" stopColor="#038362" stopOpacity="0" />
        </radialGradient>
        <radialGradient id="vp-visitor-body" cx="35%" cy="30%" r="75%">
          <stop offset="0%" stopColor="#9AE9C8" />
          <stop offset="35%" stopColor="#3DBE85" />
          <stop offset="80%" stopColor="#0C5A45" />
          <stop offset="100%" stopColor="#062F26" />
        </radialGradient>
        <radialGradient id="vp-visitor-spec" cx="30%" cy="22%" r="22%">
          <stop offset="0%" stopColor="#FFFFFF" stopOpacity="0.95" />
          <stop offset="100%" stopColor="#FFFFFF" stopOpacity="0" />
        </radialGradient>
      </defs>
      <g
        transform={`translate(${visitorPos.x} ${visitorPos.y})`}
        style={{ transition: 'transform 700ms cubic-bezier(.22,.61,.36,1)' }}
      >
        {/* Soft cast shadow */}
        <ellipse cx="0" cy={compact ? 3.6 : 5.6} rx={compact ? 3.6 : 5.6} ry={compact ? 0.8 : 1.2}
          fill="#06171F" fillOpacity={0.18} style={{ filter: 'blur(1px)' }} />
        {/* Outer ambient glow */}
        <circle r={compact ? 5 : 10} fill="url(#vp-visitor-glow)" />
        {/* Sphere body */}
        <circle r={compact ? 3 : 4.6} fill="url(#vp-visitor-body)" style={{ filter: `blur(${visitorBlur * 0.6}px)` }} />
        {/* Rim highlight */}
        <circle r={compact ? 3 : 4.6} fill="none" stroke="#ADEF9B" strokeWidth="0.35" strokeOpacity="0.45" />
        {/* Specular highlight */}
        <ellipse cx={compact ? -0.9 : -1.4} cy={compact ? -1.0 : -1.6} rx={compact ? 1.2 : 1.8} ry={compact ? 0.7 : 1.1}
          fill="url(#vp-visitor-spec)" />
        {!compact && (
          <>
            <text y={9.6} textAnchor="middle" fontSize="3.6" fontWeight="700" fill="#06171F"
              style={{ paintOrder: 'stroke', stroke: '#FBF6E9', strokeWidth: 0.8 }}>
              visitor
            </text>
            <text y={13} textAnchor="middle" fontSize="2.4" fontWeight="500" fill="#6E6657"
              style={{ paintOrder: 'stroke', stroke: '#FBF6E9', strokeWidth: 0.5, letterSpacing: '0.06em' }}>
              {visitorTag}
            </text>
          </>
        )}
      </g>
    </svg>
  );
}

Object.assign(window, { VisitorPulse });
