/* Causal-flow animation — clean, story-driven, six stages + logo morph.

   STAGE FLOW:
     S1   0  →  s1   ONE REVIEW — sentence fades in, centered.
     S2   s1 → s2   DECOMPOSE — words highlight in the sentence; then each
                    highlighted phrase travels to its destination bubble:
                    "rude" → bottom-left navy cause bubble,
                    "long wait" → top-left navy cause bubble,
                    "wont come back" → right cyan effect bubble.
                    Each bubble grows from zero as its traveller lands.
                    Bubbles are OUTLINED (paper fill, brand-color stroke,
                    brand-color text) so the labels stay readable.
                    No tree yet — just LEFT (causes) / RIGHT (effect).
     S3   s2 → s3   MULTIPLICITY FLASH — small dots fly into the bubbles
                    briefly, then fade. Conveys "now do this for millions".
     S4   s3 → s4   TIMELINE — bubbles shrink onto a horizontal axis with
                    quarters Q1 2020 → Q2 2021. Effects row above, causes
                    below.
     S5   s4 → s5   METHODOLOGY — time-lag arrows curve from cause-quarters
                    to later effect-quarters. A small DAG card flashes to
                    hint at confounder isolation.
     S6   s5 → s6   TREE + LOGO MORPH — bubbles converge to a top-down
                    tree (effect on top, two causes below). Background
                    context bubbles sit on the SAME Y as the main bubbles,
                    spread horizontally. Y-shape lines connect the causes
                    to the effect; once the highlighted ATE callout lands,
                    a third bottom line connects the two causes — the
                    triangle is now the GenData logo. Bubbles transition
                    from outlined to filled, scale down to logo size, and
                    "GenData" wordmark fades in next to it.

   Brand palette: cyan #22D3EE (effect, top), navy #1D4ED8 (causes, bottom).
   Reduced motion: lands near the end of Stage 6 (logo visible).
   Stages prop: [s1, s2, s3, s4, s5, s6, total].
*/
const { useEffect: hUseEffect, useRef: hUseRef, useState: hUseState } = React;

const REVIEW_TEXT = "Tried to book service. Advisor cut me off, never called back. Won't come again.";

// Brand-aligned colours — exactly matching the GenData logo in icons.jsx.
const NAVY      = '#1D4ED8';   // logo bottom-left + bottom-right circles
const NAVY_DEEP = '#1538A3';
const NAVY_LINE = '#1E3A8A';   // logo connecting lines (slightly deeper)
const CYAN      = '#22D3EE';   // logo top circle
const CYAN_DEEP = '#0E96AE';

// Highlight ranges in REVIEW_TEXT.
//   rude    → "cut me off"        (Dismissive Staff — cause)
//   wait    → "never called back" (Non-Responsiveness — cause)
//   effectA → "service"           (effect ingredient #1 — the domain)
//   effectB → "Won't come again"  (effect ingredient #2 — the action)
//   effectA + effectB together = Service Churn.
const HL = {
  rude:    { start: 31, end: 41, color: NAVY },
  wait:    { start: 43, end: 60, color: NAVY },
  effectA: { start: 14, end: 21, color: CYAN },
  effectB: { start: 62, end: 78, color: CYAN },
};

// Stage-3 micro snippets — short phrases extracted from "many other reviews".
// Each one flies in from a canvas edge to its assigned bubble, conveying
// the SAME extraction process (review → cause/effect) repeated millions of
// times. `target` is one of: 'effect', 'causeL', 'causeR', or `bgEffect[i]`,
// `bgCause[i]` referencing the bg-bubble arrays.
const MICRO_S3 = [
  // causeL = Non-Responsiveness (Communication & Staff)
  { text: 'no callback',     target: 'causeL'     },
  { text: 'three voicemails',target: 'causeL'     },
  { text: 'ignored email',   target: 'causeL'     },
  { text: 'never replied',   target: 'causeL'     },
  // causeR = Dismissive Staff (Communication & Staff)
  { text: 'got cut off',     target: 'causeR'     },
  { text: 'talked down',     target: 'causeR'     },
  { text: 'wouldn’t listen', target: 'causeR'     },
  // bg causes — adjacent operational themes (6 entries total)
  { text: 'parts delay',     target: 'bgCause0'   },  // Repair Quality
  { text: 'no loaner',       target: 'bgCause1'   },  // Loaner
  { text: 'tech skill',      target: 'bgCause2'   },  // Tech Skill
  { text: 'three weeks',     target: 'bgCause3'   },  // Long Wait
  { text: 'hidden fees',     target: 'bgCause4'   },  // Hidden Fees
  { text: 'portal crashed',  target: 'bgCause5'   },  // Online Booking
  // effect = Service Churn (customer-voice: they don't come back)
  { text: 'won’t come back', target: 'effect'     },
  { text: 'never again',     target: 'effect'     },
  { text: 'last visit',      target: 'effect'     },
  { text: 'going elsewhere', target: 'effect'     },
  // bg effects (6 entries total)
  { text: 'lost trust',      target: 'bgEffect0'  },  // Brand Trust
  { text: 'one and done',    target: 'bgEffect1'  },  // Repeat Purchase
  { text: 'over with them',  target: 'bgEffect2'  },  // Lost Loyalty
  { text: 'won’t recommend', target: 'bgEffect3'  },  // Lost Recos
  { text: '1 star',          target: 'bgEffect4'  },  // Bad Reviews
  { text: 'cancelling plan', target: 'bgEffect5'  },  // Cancel Plan
];

function easeStd(t) {
  if (t <= 0) return 0;
  if (t >= 1) return 1;
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
const range01 = (v, a, b) => (b <= a ? 0 : Math.max(0, Math.min(1, (v - a) / (b - a))));
const lerp    = (a, b, t) => a + (b - a) * t;

function HeroAnimation({ totalDuration = 45, stages, paused = false, className = '' }) {
  const ends = stages && stages.length >= 7 ? stages : [4, 11, 17, 26, 34, 45, 45];
  const [s1, s2, s3, s4, s5, s6] = ends;

  const [t, setT] = hUseState(0);
  const rafRef   = hUseRef(0);
  const startRef = hUseRef(typeof performance !== 'undefined' ? performance.now() : 0);
  const reduced  = typeof window !== 'undefined'
    && window.matchMedia
    && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  hUseEffect(() => {
    if (paused || reduced) return;
    let mounted = true;
    startRef.current = performance.now();
    const tick = (now) => {
      if (!mounted) return;
      let elapsed = (now - startRef.current) / 1000;
      if (elapsed >= totalDuration) {
        startRef.current = now;
        elapsed = 0;
      }
      setT(elapsed);
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => { mounted = false; cancelAnimationFrame(rafRef.current); };
  }, [paused, reduced, totalDuration]);

  const tt = reduced ? lerp(s5, s6, 0.55) : t;

  // ── Layout ──────────────────────────────────────────────────────────
  const W = 1200, H = 600;

  // Stage-2 left/right positions — sentence sits high (y≈100), bubbles
  // get the lower half so they don't crowd the typed sentence.
  const s2EffectPos = { x: W * 0.72, y: 395, r: 60 };
  const s2CauseTop  = { x: W * 0.28, y: 320, r: 46 };  // Long Wait
  const s2CauseBot  = { x: W * 0.28, y: 470, r: 46 };  // Rude

  // Stage-6 tree positions (top-down, like the logo)
  const treeEffect = { x: W/2,        y: 165, r: 64 };
  const treeCauseL = { x: W/2 - 235,  y: 425, r: 50 };  // Long Wait
  const treeCauseR = { x: W/2 + 235,  y: 425, r: 50 };  // Rude

  // Stage-4 timeline positions — taller rows (±100 from axis vs ±60),
  // axis closer to vertical centre, fewer quarters so each tick has room.
  const tlAxisY      = H/2 + 30;
  const tlEffectRowY = tlAxisY - 100;
  const tlCauseRowY  = tlAxisY + 100;
  const xStart = 200;
  const xEnd   = W - 200;
  const xRange = xEnd - xStart;
  const QUARTERS = ['Q1 2020', 'Q2 2020', 'Q3 2020', 'Q4 2020'];
  const quarterX = (i) => xStart + (i + 0.5) / QUARTERS.length * xRange;

  // Logo morph targets — matches icons.jsx Logo component exactly.
  // Original viewBox 0 0 44 44. Vertices at (22,9), (9,34), (35,34), r=6,
  // strokeWidth 2.2. Center of triangle ≈ (22, 22).
  // Scale chosen so the morphed logo sits prominently at canvas center.
  const LOGO_SIZE  = 110;             // overall logo footprint in px
  const LOGO_SCALE = LOGO_SIZE / 44;
  // Center the WHOLE composition (wordmark + gap + triangle) around W/2.
  // Estimated wordmark width at fontSize 92, weight 300, letter-spacing
  // -0.025: ~285px for "GenData". Gap 26, triangle width 65.
  const _wordmarkW = 285;
  const _gap = 26;
  const _triW = (35 - 9) * LOGO_SCALE;        // 65 at scale 2.5
  const _totalW = _wordmarkW + _gap + _triW;
  const _logoLeftEdge = W/2 - _totalW / 2;
  const _triLeftEdge  = _logoLeftEdge + _wordmarkW + _gap;
  const logoCenter = { x: _triLeftEdge + _triW / 2, y: H * 0.50 };
  const logoEffect = { x: logoCenter.x + (22 - 22) * LOGO_SCALE, y: logoCenter.y + (9  - 22) * LOGO_SCALE, r: 6 * LOGO_SCALE };
  const logoCauseL = { x: logoCenter.x + (9  - 22) * LOGO_SCALE, y: logoCenter.y + (34 - 22) * LOGO_SCALE, r: 6 * LOGO_SCALE };
  const logoCauseR = { x: logoCenter.x + (35 - 22) * LOGO_SCALE, y: logoCenter.y + (34 - 22) * LOGO_SCALE, r: 6 * LOGO_SCALE };
  const logoLineW  = 1.6 * LOGO_SCALE; // thinner, more elegant

  // ── Stage progress ──────────────────────────────────────────────────
  const reviewIn   = easeStd(range01(tt, 0,        s1 - 0.3));

  // Stage 2 sub-phases — four sequential travelers, tighter timing so all
  // four fit in the same s2 window. effectA + effectB both fly to the
  // effect bubble (the domain word "service" + the customer's churn
  // statement "Won't come again" — together they spell out Service Churn).
  const s2Span     = s2 - s1;
  const highlightP = easeStd(range01(tt, s1 - 0.1, s1 + 0.6));
  const trav = (idx) => {
    const startOffset = 0.8 + idx * 1.4;
    const endOffset   = startOffset + 1.20;
    return easeStd(range01(tt, s1 + startOffset, s1 + endOffset));
  };
  const travRude    = trav(0);  // cut me off       (0.8 → 2.0)
  const travWait    = trav(1);  // never called back (2.2 → 3.4)
  const travEffectA = trav(2);  // service          (3.6 → 4.8)
  const travEffectB = trav(3);  // Won't come again (5.0 → 6.2)
  const travEff     = Math.max(travEffectA, travEffectB);

  const flashP     = easeStd(range01(tt, s2 + 0.3, s2 + (s3 - s2) * 0.45));
  const flashOut   = easeStd(range01(tt, s3 - 0.6, s3 - 0.05));
  const flashOpacity = flashP * (1 - flashOut);
  const tlInP      = easeStd(range01(tt, s3,       s3 + (s4 - s3) * 0.55));
  const tlOutP     = easeStd(range01(tt, s5 + (s6 - s5) * 0.05, s5 + (s6 - s5) * 0.30));
  const tlOpacity  = tlInP * (1 - tlOutP);
  const lagP       = easeStd(range01(tt, s4,       s4 + (s5 - s4) * 0.55));
  const dagFlashP  = easeStd(range01(tt, s4 + (s5 - s4) * 0.55, s4 + (s5 - s4) * 0.7))
                   * (1 - easeStd(range01(tt, s4 + (s5 - s4) * 0.85, s5 - 0.05)));

  // Stage 6 sub-phases — extended hold so the highlighted-path callout
  // stays visible long enough to read before the logo morph kicks in.
  const s6Span     = s6 - s5;
  const treeBackP  = easeStd(range01(tt, s5 + s6Span * 0.05, s5 + s6Span * 0.25));
  const contextP   = easeStd(range01(tt, s5 + s6Span * 0.20, s5 + s6Span * 0.40));
  const highlightStageP  = easeStd(range01(tt, s5 + s6Span * 0.28, s5 + s6Span * 0.45));
  const logoMorphP = easeStd(range01(tt, s5 + s6Span * 0.78, s5 + s6Span * 0.95));

  // ── Sentence visibility ─────────────────────────────────────────────
  // Sentence sits near the top (y=100) so the bubbles below have ample
  // breathing room. Fades out as Stage 3 begins.
  const sentenceY = 100;
  const sentenceFade  = easeStd(range01(tt, s2 + (s3 - s2) * 0.05, s2 + (s3 - s2) * 0.45));
  const sentenceOpacity = (1 - sentenceFade);

  // ── Sentence char-position approximation for travelers ───────────────
  // Sentence is 84 chars, fontSize 24, avg char width ~10.5px (Geist 500).
  const SENT_CHAR_W = 10.5;
  const sentenceVisualWidth = REVIEW_TEXT.length * SENT_CHAR_W;
  const sentenceLeftEdge = W/2 - sentenceVisualWidth / 2;
  const phraseCenterX = (start, end) => sentenceLeftEdge + ((start + end) / 2) * SENT_CHAR_W;

  const phrasePos = {
    rude:    { x: phraseCenterX(HL.rude.start,    HL.rude.end),    y: sentenceY },
    wait:    { x: phraseCenterX(HL.wait.start,    HL.wait.end),    y: sentenceY },
    effectA: { x: phraseCenterX(HL.effectA.start, HL.effectA.end), y: sentenceY },
    effectB: { x: phraseCenterX(HL.effectB.start, HL.effectB.end), y: sentenceY },
  };

  // ── Bubble positions per phase ──────────────────────────────────────
  // Three bubbles morph through stages: arrival (S2 traveler) → flash hold
  // → timeline → tree-rebuild → logo-morph.
  function bubblePosition(s2Pos, treePos, logoPos, isEffect, role, travProgress) {
    const tlIndex = 2; // Q3 2020 — example quarter
    const tlX = quarterX(tlIndex);
    const tlYpos = isEffect ? tlEffectRowY : tlCauseRowY;
    // Same size as bg bubbles → main bubbles BLEND with peers on the
    // timeline (no "look at me, I'm the example" permanent highlight).
    const tlR = 12;

    if (tt < s2) {
      // S2: bubble grows from r=0 to s2Pos.r as traveler arrives
      return {
        x: s2Pos.x, y: s2Pos.y,
        r: s2Pos.r * travProgress,
        phase: 'split',
      };
    }
    if (tt < s3) {
      return { x: s2Pos.x, y: s2Pos.y, r: s2Pos.r, phase: 's2-hold' };
    }

    const migrateP = easeStd(range01(tt, s3 + 0.2, s3 + (s4 - s3) * 0.7));
    if (tt < s4) {
      return {
        x: lerp(s2Pos.x, tlX, migrateP),
        y: lerp(s2Pos.y, tlYpos, migrateP),
        r: lerp(s2Pos.r, tlR, migrateP),
        phase: 'to-timeline',
      };
    }
    if (tt < s5) {
      return { x: tlX, y: tlYpos, r: tlR, phase: 'timeline' };
    }

    // S6
    const treeReturnX = lerp(tlX, treePos.x, treeBackP);
    const treeReturnY = lerp(tlYpos, treePos.y, treeBackP);
    const treeReturnR = lerp(tlR, treePos.r, treeBackP);

    if (logoMorphP < 0.02) {
      return { x: treeReturnX, y: treeReturnY, r: treeReturnR, phase: 'tree' };
    }

    return {
      x: lerp(treePos.x, logoPos.x, logoMorphP),
      y: lerp(treePos.y, logoPos.y, logoMorphP),
      r: lerp(treePos.r, logoPos.r, logoMorphP),
      phase: 'logo',
    };
  }

  const effectBubble = bubblePosition(s2EffectPos, treeEffect, logoEffect, true,  'effect', travEff);
  const causeLBubble = bubblePosition(s2CauseTop,  treeCauseL, logoCauseL, false, 'causeL', travWait);  // Long Wait
  const causeRBubble = bubblePosition(s2CauseBot,  treeCauseR, logoCauseR, false, 'causeR', travRude);  // Rude

  // ── Sentence rendering helpers ──────────────────────────────────────
  // Highlights ordered by start position: effectA (14) → rude (31)
  // → wait (43) → effectB (62). Build segments by walking the text.
  const T = REVIEW_TEXT;
  const segments = [
    { txt: T.slice(0, HL.effectA.start) },
    { txt: T.slice(HL.effectA.start, HL.effectA.end), color: HL.effectA.color, key: 'effectA' },
    { txt: T.slice(HL.effectA.end, HL.rude.start) },
    { txt: T.slice(HL.rude.start, HL.rude.end), color: HL.rude.color, key: 'rude' },
    { txt: T.slice(HL.rude.end, HL.wait.start) },
    { txt: T.slice(HL.wait.start, HL.wait.end), color: HL.wait.color, key: 'wait' },
    { txt: T.slice(HL.wait.end, HL.effectB.start) },
    { txt: T.slice(HL.effectB.start, HL.effectB.end), color: HL.effectB.color, key: 'effectB' },
    { txt: T.slice(HL.effectB.end) },
  ];

  // Background bubbles — represent the broader landscape of additional
  // causes/effects beyond the example. They first APPEAR around the three
  // main bubbles during Stage 3 ("now do it a million times"), conveying
  // that there are many more drivers out there. Then in Stage 4 they fly
  // to timeline quarter positions. Then in Stage 6 they fly to tree
  // positions. Same bubbles travel through all three phases.
  // Stage-3 cloud + downstream phases (timeline + tree).
  // `phaseA` = absolute (x,y) for the multiplicity cluster — spread wide
  // across the canvas so it instantly reads as "this is just one slice
  // of a much bigger landscape of causes/effects".
  // `phaseAOnly: true` = only visible in Stage 3 (fades before timeline)
  // — used to crank up the cloud richness without crowding later phases.
  // Cause cloud — strictly LEFT half (x: 60..440), 3 columns × 2 rows.
  // Wide gap (~300px) to the centre so the causes/effects split is unmistakable.
  const bgCauses = [
    { tlIdx: 0, treeX: 100,     label: 'Repair Quality', phaseA: { x:  85, y: 130 } },
    { tlIdx: 1, treeX: 250,     label: 'Loaner',         phaseA: { x: 220, y:  95 } },
    { tlIdx: 3, treeX: W - 100, label: 'Tech Skill',     phaseA: { x: 440, y: 130 } },
    { phaseAOnly: true,         label: 'Long Wait',      phaseA: { x:  85, y: 565 } },
    { phaseAOnly: true,         label: 'Hidden Fees',    phaseA: { x: 240, y: 580 } },
    { phaseAOnly: true,         label: 'Online Booking', phaseA: { x: 440, y: 555 } },
  ];

  // Effect cloud — strictly RIGHT half (x: 760..1140), 3 columns × 2 rows.
  const bgEffects = [
    { tlIdx: 0, treeX: 145,     label: 'Brand Trust',    phaseA: { x:  760, y: 130 } },
    { tlIdx: 1, treeX: 320,     label: 'Repeat Purchase',phaseA: { x:  920, y:  95 } },
    { tlIdx: 3, treeX: W - 145, label: 'Lost Loyalty',   phaseA: { x: 1110, y: 130 } },
    { phaseAOnly: true,         label: 'Bad Reviews',    phaseA: { x:  760, y: 565 } },
    { phaseAOnly: true,         label: 'Lost Recos',     phaseA: { x:  940, y: 580 } },
    { phaseAOnly: true,         label: 'Cancel Plan',    phaseA: { x: 1110, y: 555 } },
  ];

  // Tree connecting lines (S6)
  const treeLineFadeIn = easeStd(range01(tt, s5 + s6Span * 0.20, s5 + s6Span * 0.45));
  // Bottom (third) line — shows up only when the logo morph begins, completing the triangle
  const bottomLineP    = easeStd(range01(tt, s5 + s6Span * 0.55, s5 + s6Span * 0.80));

  // ── Render ──────────────────────────────────────────────────────────
  return (
    <div className={`absolute inset-0 ${className}`} aria-hidden="true">
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet"
           className="w-full h-full">
        <defs>
          <filter id="bubble-shadow" x="-30%" y="-30%" width="160%" height="160%">
            <feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/>
            <feOffset dx="0" dy="2" result="offsetblur"/>
            <feFlood floodColor="#000" floodOpacity="0.10"/>
            <feComposite in2="offsetblur" operator="in"/>
            <feMerge>
              <feMergeNode/>
              <feMergeNode in="SourceGraphic"/>
            </feMerge>
          </filter>
          <marker id="arrow-cyan" viewBox="0 0 10 10" refX="9.5" refY="5"
            markerWidth="3.6" markerHeight="3.6" orient="auto">
            <path d="M 0 0 L 10 5 L 0 10 z" fill={CYAN}/>
          </marker>
        </defs>

        {/* Tech-stack hint, top-right */}
        <text x={W - 24} y={26} textAnchor="end"
          fontSize="9" fontFamily="JetBrains Mono, ui-monospace"
          opacity="0.4"
          style={{ fill: 'var(--mute)', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
          DAG-NLP · DOWHY · CI₉₅
        </text>

        {/* ── Review sentence ── */}
        {tt > -0.1 && (
          <foreignObject
            x={0}
            y={sentenceY - 30}
            width={W}
            height={60}
            style={{ overflow: 'visible', opacity: reviewIn * sentenceOpacity }}
          >
            <div xmlns="http://www.w3.org/1999/xhtml"
              style={{
                width: '100%', height: '100%',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                fontFamily: 'Plus Jakarta Sans, system-ui',
                fontSize: '24px',
                fontWeight: 500,
                color: 'var(--ink)',
                letterSpacing: '-0.01em',
                whiteSpace: 'pre',
                lineHeight: 1,
              }}>
              {segments.map((seg, i) => {
                if (seg.color) {
                  // box-shadow inset gives the highlight visual breathing
                  // *without* changing text-flow width — that fixes the
                  // ugly whitespace gap before commas / periods that
                  // padding+inline-block was creating.
                  const bg = seg.color === CYAN
                    ? `rgba(34,211,238,${0.22 * highlightP})`
                    : `rgba(29,78,216,${0.20 * highlightP})`;
                  return (
                    <span key={i} style={{
                      background: bg,
                      borderRadius: 3,
                      boxShadow: `0 0 0 2px ${bg}`,
                      whiteSpace: 'pre',
                      transition: 'background 200ms linear, box-shadow 200ms linear',
                    }}>{seg.txt}</span>
                  );
                }
                return <span key={i} style={{ whiteSpace: 'pre' }}>{seg.txt}</span>;
              })}
            </div>
          </foreignObject>
        )}

        {/* ── Stage 2: subtle CAUSES / EFFECT row labels ── */}
        {(() => {
          const labelP = easeStd(range01(tt, s1 + 0.5, s2 - 0.5))
                       * (1 - easeStd(range01(tt, s2 + 0.4, s3 - 0.5)));
          if (labelP < 0.02) return null;
          // Same typographic treatment as the Stage-4 timeline row
          // labels (Plus Jakarta 600 / 22px) so the two stages feel like
          // one consistent system.
          return (
            <g opacity={labelP * 0.95}>
              <text x={W * 0.28} y={232} textAnchor="middle"
                fontSize="22" fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
                fill={NAVY}
                style={{ letterSpacing: '-0.014em' }}>
                Causes
              </text>
              <text x={W * 0.72} y={232} textAnchor="middle"
                fontSize="22" fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
                fill={CYAN}
                style={{ letterSpacing: '-0.014em' }}>
                Effect
              </text>
            </g>
          );
        })()}

        {/* ── STAGE 2 TRAVELERS — phrase pills flying from sentence to bubbles.
             Filled brand-color pill with white text for max readability mid-flight.
             ── */}
        {(() => {
          if (tt < s1 - 0.1 || tt > s2 + 0.2) return null;
          const items = [];
          const travelers = [
            { phrase: 'cut me off',        p: travRude,    src: phrasePos.rude,    dst: s2CauseBot,  color: NAVY, width: 124 },
            { phrase: 'never called back', p: travWait,    src: phrasePos.wait,    dst: s2CauseTop,  color: NAVY, width: 172 },
            { phrase: 'service',           p: travEffectA, src: phrasePos.effectA, dst: s2EffectPos, color: CYAN, width:  98 },
            { phrase: 'won’t come again', p: travEffectB, src: phrasePos.effectB, dst: s2EffectPos, color: CYAN, width: 168 },
          ];
          for (const trv of travelers) {
            if (trv.p <= 0 || trv.p >= 1) continue;
            const ease = easeStd(trv.p);
            const x = lerp(trv.src.x, trv.dst.x, ease);
            const y = lerp(trv.src.y, trv.dst.y, ease);
            // Fade in over first 12%, hold, fade out over last 18% (so it lands
            // softly into its bubble rather than vanishing abruptly).
            const fadeIn  = Math.min(1, trv.p / 0.12);
            const fadeOut = Math.min(1, (1 - trv.p) / 0.18);
            const fade = fadeIn * fadeOut;
            const pillH = 30;
            const pillW = trv.width;
            items.push(
              <g key={trv.phrase} opacity={fade}>
                <rect x={x - pillW/2} y={y - pillH/2} width={pillW} height={pillH} rx="15"
                  fill={trv.color}
                  filter="url(#bubble-shadow)"/>
                <text x={x} y={y + 5} textAnchor="middle"
                  fontSize="14" fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
                  fill="white"
                  style={{ letterSpacing: '-0.005em' }}>
                  {trv.phrase}
                </text>
              </g>
            );
          }
          return <g>{items}</g>;
        })()}

        {/* ── STAGE 3 SNIPPET TRAVELERS — short phrases fly from canvas
             edges into the various bubbles (main + bg). Conveys the SAME
             extraction process repeated millions of times, not just for
             the one example review.                                    ── */}
        {(tt > s2 + 0.3 && tt < s3 + 0.3) && (() => {
          const targetForKey = (key) => {
            if (key === 'effect') return s2EffectPos;
            if (key === 'causeL') return s2CauseTop;
            if (key === 'causeR') return s2CauseBot;
            if (key.startsWith('bgEffect')) {
              const idx = parseInt(key.slice(8), 10);
              const b = bgEffects[idx];
              return { x: b.phaseA.x, y: b.phaseA.y };
            }
            if (key.startsWith('bgCause')) {
              const idx = parseInt(key.slice(7), 10);
              const b = bgCauses[idx];
              return { x: b.phaseA.x, y: b.phaseA.y };
            }
            return { x: W/2, y: H/2 };
          };
          const microStart = s2 + 0.5;
          const microEnd   = s3 - 0.4;
          const microSpan  = Math.max(0.5, microEnd - microStart);
          const items = [];
          MICRO_S3.forEach((p, i) => {
            const spawnT   = microStart + (i / MICRO_S3.length) * microSpan;
            const lifetime = 0.85;
            const localT   = tt - spawnT;
            if (localT < 0 || localT > lifetime) return;
            const localP = easeStd(localT / lifetime);
            const target = targetForKey(p.target);
            // Topics rain DOWN from above — they were extracted from the
            // millions of reviews represented by the sentence at the top.
            // Source is just above the canvas with a small horizontal
            // jitter near each target's x (so they "find" their bubble).
            const jitter = ((i * 37) % 180) - 90;     // ±90 px horizontal
            const stagger = ((i * 23) % 60);          // 0..60 vertical
            const sx = target.x + jitter;
            const sy = -30 - stagger;                 // above canvas top
            const x = lerp(sx, target.x, localP);
            const y = lerp(sy, target.y, localP);
            const isEffect = p.target === 'effect' || p.target.startsWith('bgEffect');
            const fill = isEffect ? CYAN_DEEP : NAVY_DEEP;
            const fadeIn  = Math.min(1, localP / 0.12);
            const fadeOut = Math.min(1, (1 - localP) / 0.25);
            const alpha = fadeIn * fadeOut * 0.85;
            if (alpha < 0.04) return;
            items.push(
              <text key={'ms' + i}
                x={x} y={y} textAnchor="middle"
                fontSize="11" fontFamily="JetBrains Mono, ui-monospace" fontWeight="500"
                fill={fill} opacity={alpha}
                style={{ letterSpacing: '0.04em' }}>
                {p.text}
              </text>
            );
          });
          return <g>{items}</g>;
        })()}

        {/* ── MULTIPLICITY COUNTER (S3) — exponential ramp to 4.5M reviews.
             The bg-bubbles cluster around the 3 main bubbles during Stage 3
             (rendered in the bg-bubbles block above). Together with the
             snippet travelers, this conveys the millionfold extraction. ── */}
        {(tt > s2 + 0.4 && tt < s3 + 0.2) && (() => {
          const counterP = easeStd(range01(tt, s2 + 0.6, s3 - 0.5));
          const counterFadeOut = easeStd(range01(tt, s3 - 0.4, s3 + 0.1));
          const counterAlpha = counterP * (1 - counterFadeOut);
          if (counterAlpha < 0.02) return null;
          const target = 4500000;
          const value = Math.floor(Math.pow(target, counterP));
          const counterText = value >= 1000000
            ? (value / 1000000).toFixed(1) + 'M'
            : value >= 1000
              ? Math.round(value / 1000) + 'K'
              : value.toString();
          return (
            <g opacity={counterAlpha}>
              <text x={W/2} y={H - 40} textAnchor="middle"
                fontSize="22" fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
                fill="var(--ink)" style={{ letterSpacing: '-0.012em' }}>
                × {counterText} reviews
              </text>
            </g>
          );
        })()}

        {/* ── TIMELINE (S4-S5) ── */}
        {tlOpacity > 0.02 && (
          <g opacity={tlOpacity}>
            <line x1={xStart} y1={tlAxisY} x2={xEnd} y2={tlAxisY}
              stroke="rgb(var(--c-line))" strokeWidth="1"/>
            {QUARTERS.map((q, i) => {
              const px = quarterX(i);
              const isExample = i === 2;
              return (
                <g key={i}>
                  <line x1={px} y1={tlAxisY - 4} x2={px} y2={tlAxisY + 4}
                    stroke="rgb(var(--c-line))" strokeWidth="1"/>
                  <text x={px} y={tlAxisY} dy="24" textAnchor="middle"
                    fontSize="13" fontFamily="JetBrains Mono, ui-monospace"
                    fill="var(--mute)"
                    style={{ letterSpacing: '0.14em' }}>
                    {q}
                  </text>
                  {/* Per-quarter background bubbles are now rendered by the
                       dedicated bg-bubbles block below so they can morph
                       smoothly into tree positions during Stage 6.        */}
                </g>
              );
            })}
            <text x={xStart - 24} y={tlEffectRowY} dy="8" textAnchor="end"
              fontSize="22" fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
              fill={CYAN}
              style={{ letterSpacing: '-0.014em' }}>
              Effects
            </text>
            <text x={xStart - 24} y={tlCauseRowY} dy="8" textAnchor="end"
              fontSize="22" fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
              fill={NAVY}
              style={{ letterSpacing: '-0.014em' }}>
              Causes
            </text>
          </g>
        )}

        {/* ── Time-lag arrows (S5) ── */}
        {tlOpacity > 0.4 && lagP > 0 && (() => {
          const fade = 1 - tlOutP;
          if (fade < 0.05) return null;
          // Q1 cause → Q3 effect, Q2 cause → Q4 effect — both +2Q lag.
          const pairs = [
            { causeQ: 0, effectQ: 2, delay: 0.0 },
            { causeQ: 1, effectQ: 3, delay: 0.3 },
          ];
          return (
            <g opacity={fade}>
              {pairs.map((p, i) => {
                const localP = easeStd(Math.max(0, Math.min(1, (lagP - p.delay) / Math.max(0.01, 1 - p.delay))));
                if (localP <= 0) return null;
                const cx = quarterX(p.causeQ);
                const ex = quarterX(p.effectQ);
                // Straight diagonal from cause-quarter to a later
                // effect-quarter — minimalist, no swoop. The diagonal
                // itself encodes the lag (left-low to right-high).
                const dPath = `M ${cx} ${tlCauseRowY - 12} L ${ex} ${tlEffectRowY + 12}`;
                return (
                  <path key={i}
                    d={dPath}
                    fill="none" stroke={CYAN} strokeWidth="1.6"
                    strokeLinecap="round"
                    pathLength="1" strokeDasharray="1"
                    strokeDashoffset={1 - localP}
                    opacity="0.85"
                    markerEnd={localP > 0.92 ? 'url(#arrow-cyan)' : undefined}/>
                );
              })}
            </g>
          );
        })()}

        {/* ── DAG flash (S5) ── */}
        {dagFlashP > 0.05 && (() => {
          const cx = W - 215, cy = 130;
          return (
            <g opacity={dagFlashP * 0.92}>
              <rect x={cx - 110} y={cy - 60} width="220" height="130" rx="12"
                fill="rgb(var(--c-paper))" stroke="rgb(var(--c-line))" strokeWidth="1"
                filter="url(#bubble-shadow)"/>
              <text x={cx} y={cy - 42} textAnchor="middle"
                fontSize="9" fontFamily="JetBrains Mono, ui-monospace"
                fill="var(--mute)"
                style={{ letterSpacing: '0.18em', textTransform: 'uppercase' }}>
                Isolate confounders
              </text>
              {[
                { x: cx,      y: cy - 20, label: 'C₁' },
                { x: cx - 40, y: cy + 28, label: 'C₂' },
                { x: cx + 40, y: cy + 28, label: 'C₃' },
              ].map((n, i) => (
                <g key={i}>
                  <circle cx={n.x} cy={n.y} r="6.5" fill="none" stroke="var(--mute)" strokeWidth="1" opacity="0.55"/>
                  <text x={n.x} y={n.y + 3} textAnchor="middle"
                    fontSize="8" fontFamily="JetBrains Mono, ui-monospace" fill="var(--mute)" opacity="0.6">
                    {n.label}
                  </text>
                </g>
              ))}
              <line x1={cx - 72} y1={cy + 5} x2={cx + 70} y2={cy + 5}
                stroke={CYAN} strokeWidth="1.6" strokeLinecap="round"
                markerEnd="url(#arrow-cyan)"/>
              <circle cx={cx - 80} cy={cy + 5} r="9"
                fill="rgb(var(--c-paper))" stroke={NAVY} strokeWidth="2"/>
              <text x={cx - 80} y={cy + 8} textAnchor="middle"
                fontSize="9" fontFamily="JetBrains Mono, ui-monospace" fill={NAVY_DEEP} fontWeight="600">X</text>
              <circle cx={cx + 80} cy={cy + 5} r="9"
                fill="rgb(var(--c-paper))" stroke={CYAN} strokeWidth="2"/>
              <text x={cx + 80} y={cy + 8} textAnchor="middle"
                fontSize="9" fontFamily="JetBrains Mono, ui-monospace" fill={CYAN_DEEP} fontWeight="600">Y</text>
            </g>
          );
        })()}

        {/* ── BACKGROUND BUBBLES — three phases, all softer/smaller than
             the main 3 (so they sit clearly behind the focal trio):
             A) Stage 3: appear in clusters around the 3 main bubbles
                ("now do it a million times" — many more drivers exist).
             B) Stage 4-5: fly to quarter positions on the timeline.
             C) Stage 6: fly to tree positions (same Y as main, spread
                horizontally). Labels reveal once they've settled. ── */}
        {(() => {
          // Visibility windows
          const phaseAFadeIn  = easeStd(range01(tt, s2 + (s3 - s2) * 0.10, s2 + (s3 - s2) * 0.45));
          const phaseAtoB     = easeStd(range01(tt, s3 + (s4 - s3) * 0.05, s3 + (s4 - s3) * 0.55));
          const phaseBtoC     = treeBackP;
          const fadeOutLogo   = easeStd(range01(tt, s5 + s6Span * 0.65, s5 + s6Span * 0.85));
          if (tt < s2 + 0.2 || phaseAFadeIn < 0.02) return null;

          const items = [];
          const renderBg = (b, i, isEffect) => {
            // Phase A: ABSOLUTE position (b.phaseA.x/y) so bubbles fan
            // out across the canvas — the "this is one slice of a much
            // bigger universe" Stage 3 statement.
            const initX = b.phaseA.x;
            const initY = b.phaseA.y;
            // phaseAOnly entries vanish at the end of Stage 3 — keeps the
            // timeline / tree phases uncluttered while letting Stage 3
            // breathe with extra named bubbles.
            const isCloudOnly = !!b.phaseAOnly;
            // Timeline + tree positions only meaningful for entries that
            // continue past Phase A.
            const tlX  = isCloudOnly ? initX : quarterX(b.tlIdx);
            const tlYpos = isEffect ? tlEffectRowY : tlCauseRowY;
            const treeXp = isCloudOnly ? initX : b.treeX;
            const treeYp = isEffect ? treeEffect.y : treeCauseL.y;

            // Compute current x/y/r based on tt. Phase A radius bumped
            // way up (12 → 26) so the cloud reads as real, named topics
            // rather than decorative dots.
            let x, y, r;
            if (tt < s3) {
              // Phase A: spread across canvas at absolute position
              x = initX; y = initY;
              r = 26 * phaseAFadeIn;
            } else if (tt < s4) {
              // A → B: phaseAOnly fades out; others migrate to timeline
              x = lerp(initX, tlX, phaseAtoB);
              y = lerp(initY, tlYpos, phaseAtoB);
              r = isCloudOnly ? 26 * (1 - phaseAtoB) : lerp(26, 12, phaseAtoB);
            } else if (tt < s5) {
              if (isCloudOnly) return null;
              x = tlX; y = tlYpos; r = 12;
            } else {
              if (isCloudOnly) return null;
              x = lerp(tlX, treeXp, phaseBtoC);
              y = lerp(tlYpos, treeYp, phaseBtoC);
              r = lerp(12, 18, phaseBtoC);
            }

            const stroke = isEffect ? CYAN : NAVY;
            const overallAlpha = phaseAFadeIn * (1 - fadeOutLogo);
            // In the timeline phase EVERY bubble (main + bg) is shown
            // equally — the user wants "all bubbles arranged" rather than
            // "two protagonists + a chorus". Outside the timeline, bg
            // stays softer so the focal trio leads.
            const inTimeline = tt > s3 + 0.5 && tt < s5 - 0.3;
            const bubbleAlpha = (inTimeline ? 0.92 : 0.55) * overallAlpha;
            // Labels visible during Phase A (the cloud) and after the
            // tree has settled in Stage 6. Hidden during Phase A → B
            // migration to keep the timeline transition uncluttered.
            let labelAlpha = 0;
            if (b.label) {
              if (tt < s3) {
                labelAlpha = phaseAFadeIn * overallAlpha * 0.85;
              } else if (!isCloudOnly && phaseBtoC > 0.6) {
                labelAlpha = ((phaseBtoC - 0.6) / 0.4) * overallAlpha * 0.7;
              }
            }
            if (bubbleAlpha < 0.02) return null;
            return (
              <g key={(isEffect ? 'be' : 'bc') + i}>
                <circle cx={x} cy={y} r={r}
                  fill="rgb(var(--c-paper))"
                  stroke={stroke}
                  strokeWidth={inTimeline ? 1.6 : 1.2}
                  opacity={bubbleAlpha}/>
                {labelAlpha > 0.05 && (
                  <text x={x} y={y + r + 18} textAnchor="middle"
                    fontSize="11.5" fontFamily="JetBrains Mono, ui-monospace"
                    fill="var(--mute)" opacity={labelAlpha}
                    style={{ letterSpacing: '0.10em' }}>
                    {b.label}
                  </text>
                )}
              </g>
            );
          };
          bgEffects.forEach((b, i) => items.push(renderBg(b, i, true)));
          bgCauses.forEach((b, i) => items.push(renderBg(b, i, false)));
          return <g>{items}</g>;
        })()}

        {/* ── TREE LINES (S6) ──
            During tree phase: 2 lines (causes → effect) — Y-shape.
            During logo morph: 3rd line (causeL ↔ causeR) fades in to
            complete the triangle, matching the GenData logo exactly.   */}
        {treeLineFadeIn > 0.02 && (() => {
          const baseAlpha = treeLineFadeIn;
          if (baseAlpha < 0.02) return null;
          // Stroke width interpolates from line-thin during tree to logo-line-width during morph
          const sw = lerp(1.6, logoLineW, logoMorphP);
          const isHighlightL = highlightStageP > 0;
          // All line endpoints sit at the bubbles' CENTERS (not edges) so
          // the circles cover them in the background — matches the brand
          // logo where lines visually emerge from inside each dot.
          return (
            <g>
              {/* CauseL → Effect (highlighted line during S6 highlight phase) */}
              <line
                x1={causeLBubble.x} y1={causeLBubble.y}
                x2={effectBubble.x} y2={effectBubble.y}
                stroke={logoMorphP > 0.4 ? NAVY_LINE : (isHighlightL && logoMorphP < 0.3 ? CYAN : NAVY_LINE)}
                strokeWidth={lerp(isHighlightL && logoMorphP < 0.3 ? 2.4 : 1.6, logoLineW, logoMorphP)}
                strokeLinecap="round"
                opacity={baseAlpha * (isHighlightL && logoMorphP < 0.3 ? lerp(0.5, 0.95, highlightStageP) : 0.7)}/>
              {/* CauseR → Effect */}
              <line
                x1={causeRBubble.x} y1={causeRBubble.y}
                x2={effectBubble.x} y2={effectBubble.y}
                stroke={NAVY_LINE}
                strokeWidth={sw}
                strokeLinecap="round"
                opacity={baseAlpha * 0.7}/>
              {/* Bottom line CauseL ↔ CauseR — completes the triangle for logo */}
              {bottomLineP > 0.02 && (
                <line
                  x1={causeLBubble.x} y1={causeLBubble.y}
                  x2={causeRBubble.x} y2={causeRBubble.y}
                  stroke={NAVY_LINE}
                  strokeWidth={sw}
                  strokeLinecap="round"
                  opacity={bottomLineP * 0.7}/>
              )}
            </g>
          );
        })()}

        {/* ── ATE callout — minimalist, single message: just the impact in
             plain language. Sits to the LEFT of the highlighted path so it
             doesn't overlap the line. Holds long enough to read.       ── */}
        {highlightStageP > 0.2 && logoMorphP < 0.5 && (() => {
          const alpha = highlightStageP * (1 - logoMorphP * 2);
          if (alpha < 0.05) return null;
          const mx = (causeLBubble.x + effectBubble.x) / 2 - 130;
          const my = (causeLBubble.y + effectBubble.y) / 2;
          const cardW = 230;
          const cardH = 76;
          return (
            <g opacity={alpha}>
              <rect x={mx - cardW/2} y={my - cardH/2} width={cardW} height={cardH} rx="10"
                fill="rgb(var(--c-paper))" stroke={CYAN} strokeWidth="1"
                filter="url(#bubble-shadow)"/>
              <text x={mx} y={my - 4} textAnchor="middle"
                fontSize="28" fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
                fill={CYAN_DEEP}
                style={{ letterSpacing: '-0.022em' }}>
                ATE = +0.42
              </text>
              <text x={mx} y={my + 18} textAnchor="middle"
                fontSize="10.5" fontFamily="JetBrains Mono, ui-monospace"
                fill="var(--mute)"
                style={{ letterSpacing: '0.16em', textTransform: 'uppercase' }}>
                Non-Responsiveness → churn
              </text>
            </g>
          );
        })()}

        {/* ── The three main bubbles ──
            Default = OUTLINED (paper fill + brand stroke + brand text).
            Logo morph = FILLED solid color (no stroke, no text — matches
            the actual GenData logo). Smooth alpha-crossfade between
            outlined and filled versions. ── */}
        {tt > s1 - 0.2 && (() => {
          const showLabels = (tt < s3 + 0.2 || (tt > s5 + s6Span * 0.30 && logoMorphP < 0.3));
          const items = [
            { b: effectBubble, color: CYAN, deep: CYAN_DEEP, logoColor: CYAN,      label: 'Service Churn',     sub: 'Effect', fontSize: 16, role: 'effect',  isHighlight: false },
            { b: causeLBubble, color: NAVY, deep: NAVY_DEEP, logoColor: NAVY,      label: 'Non-Responsiveness', sub: 'Cause', fontSize: 13, role: 'causeL',  isHighlight: highlightStageP > 0 },
            { b: causeRBubble, color: NAVY, deep: NAVY_DEEP, logoColor: NAVY_LINE, label: 'Rude Staff',         sub: 'Cause', fontSize: 14, role: 'causeR',  isHighlight: false },
          ];
          return (
            <g>
              {items.map((it, i) => {
                if (it.b.r < 0.5) return null;
                const isHighlightActive = it.isHighlight && highlightStageP > 0 && logoMorphP < 0.3;
                // In timeline phase, drop main stroke to match bg (1.6) so
                // every bubble looks the same — no permanent highlight on
                // the two example bubbles. Outside timeline keep the bolder
                // 2.4 so the focal trio reads as protagonists.
                const inTimeline = tt > s3 + 0.5 && tt < s5 - 0.3;
                const strokeW = isHighlightActive
                  ? lerp(2.4, 3.4, highlightStageP)
                  : (inTimeline ? 1.6 : 2.4);
                return (
                  <g key={i}>
                    {/* Highlight ring around the dominant cause */}
                    {isHighlightActive && (
                      <circle cx={it.b.x} cy={it.b.y} r={it.b.r + 9}
                        fill="none" stroke={CYAN} strokeWidth="1.4"
                        opacity={highlightStageP * 0.5 * (1 - logoMorphP * 3)}/>
                    )}
                    {/* Outlined version (default) — bubble itself is the
                         visual anchor; labels live OUTSIDE it (above OR
                         below, depending on which half of the canvas the
                         bubble sits in) so they always fit and never
                         crowd the rest of the diagram. In tree mode the
                         effect bubble at top → label ABOVE; cause bubbles
                         at bottom → label BELOW. In Stage 2 all three
                         bubbles sit in the lower half → labels below. */}
                    {logoMorphP < 1 && (() => {
                      const labelAbove = it.b.y < H/2;
                      const labelMainY = labelAbove
                        ? it.b.y - it.b.r - 28
                        : it.b.y + it.b.r + 22;
                      const labelSubY = labelAbove
                        ? it.b.y - it.b.r - 12
                        : it.b.y + it.b.r + 40;
                      return (
                        <g opacity={1 - logoMorphP}>
                          <circle cx={it.b.x} cy={it.b.y} r={it.b.r}
                            fill="rgb(var(--c-paper))"
                            stroke={isHighlightActive ? CYAN : it.color}
                            strokeWidth={strokeW}
                            filter={it.b.r > 8 ? 'url(#bubble-shadow)' : undefined}/>
                          {showLabels && it.b.r > 18 && (
                            <>
                              <text x={it.b.x} y={labelMainY} textAnchor="middle"
                                fontSize={it.fontSize + 3} fontFamily="Plus Jakarta Sans, system-ui" fontWeight="600"
                                fill={it.deep}
                                style={{ letterSpacing: '-0.012em' }}>
                                {it.label}
                              </text>
                              <text x={it.b.x} y={labelSubY} textAnchor="middle"
                                fontSize="9" fontFamily="JetBrains Mono, ui-monospace"
                                fill={it.deep} opacity="0.55"
                                style={{ letterSpacing: '0.20em', textTransform: 'uppercase' }}>
                                {it.sub}
                              </text>
                            </>
                          )}
                        </g>
                      );
                    })()}
                    {/* Filled logo version (during morph) — uses logoColor
                         (right cause becomes deep navy to match the brand mark) */}
                    {logoMorphP > 0 && (
                      <circle cx={it.b.x} cy={it.b.y} r={it.b.r}
                        fill={it.logoColor || it.color}
                        opacity={logoMorphP}/>
                    )}
                  </g>
                );
              })}
            </g>
          );
        })()}

        {/* ── GenData wordmark — centered composition (wordmark left,
             triangle right). Thinner font (Outfit weight 200) for elegance.  ── */}
        {logoMorphP > 0.5 && (() => {
          const alpha = easeStd(range01(tt, s5 + s6Span * 0.83, s5 + s6Span * 0.97));
          if (alpha < 0.05) return null;
          const wordmarkFontSize = LOGO_SIZE * 0.84;  // ≈92
          // Right edge of wordmark sits gap-distance left of the triangle's left vertex
          const wordmarkRightX = logoCauseL.x - logoCauseL.r - 26;
          const wordmarkY = logoCenter.y + wordmarkFontSize * 0.34;
          return (
            <g opacity={alpha}>
              <text x={wordmarkRightX} y={wordmarkY} textAnchor="end"
                fontSize={wordmarkFontSize}
                fontFamily="Outfit, Plus Jakarta Sans, sans-serif" fontWeight="200"
                fill="var(--ink)"
                style={{ letterSpacing: '-0.025em' }}>
                GenData
              </text>
            </g>
          );
        })()}
      </svg>
    </div>
  );
}

window.HeroAnimation = HeroAnimation;
