// Life Memory Tree — visualizations.
//
// Two SVG visualizations the Dashboard hinges on:
//   - LifeTree  : procedural tree, branches = scenes, leaves = events,
//                 color = recency (matches VISUALIZATION.md getLeafColor).
//   - LifeGraph : force-directed network, nodes = events, edges = relations,
//                 edge color = relation type (causes / follows / similar / part_of).
//
// Both are pure SVG (no force engine) — node + branch coords are precomputed
// to read as natural while staying static enough to screenshot. Both expose
// a `highlight` prop so the host can spotlight a single event during a
// transition (e.g. tab morph, hover-in-graph storyboards).

const { useMemo: lmem } = React;

// ─── LifeTree ─────────────────────────────────────────────────────────────
// A surreal, dreamlike tree — recursive organic branches, layered leaf clusters,
// drifting motes of light. The whole canopy sways and breathes; individual leaves
// flutter on their stems; ambient light pulses behind the crown.
//
// All animation is CSS keyframes inside the SVG — no JS tick loop — so it stays
// performant in a Design Canvas with many artboards on screen.

// Three leaf silhouettes, each drawn with the BASE at (0,0) and the tip extending
// upward into negative Y. That lets us rotate them around their stem.
const LEAF_PATHS = [
  // 0 · pointed teardrop
  'M0 0 C -7 -3 -9 -12 -3 -19 C 0 -22 0 -22 3 -19 C 9 -12 7 -3 0 0 Z',
  // 1 · rounder almond
  'M0 0 C -9 -2 -11 -10 -6 -16 C -2 -20 2 -20 6 -16 C 11 -10 9 -2 0 0 Z',
  // 2 · slender lance
  'M0 0 C -4 -6 -3 -16 -1 -22 C 0 -25 0 -25 1 -22 C 3 -16 4 -6 0 0 Z',
];

// Tiny deterministic PRNG so the layout is stable per render but varied per seed.
function lmtPrng(seed) {
  let s = (seed | 0) || 1;
  return () => {
    s = (s * 1664525 + 1013904223) | 0;
    return ((s >>> 0) % 100000) / 100000;
  };
}

function LifeTree({ width = 540, height = 540, highlight = null, scenesData, eventsData, dense = false, showLabels = true, season = 'spring' }) {
  const scenes = scenesData || LMTData.scenes;
  const events = eventsData || LMTData.events;
  const cx = width / 2;
  const groundY = height - (dense ? 22 : 36);
  const trunkTopY = height * 0.14;
  const trunkLen = groundY - trunkTopY;

  // Build the whole arboreal structure deterministically. Memoize so animations
  // don't tear when the parent re-renders.
  const tree = lmem(() => {
    // Trunk — gentle S-curve, slightly off-center to feel alive
    const tx = cx;
    const trunkPath = [
      `M ${tx - 7} ${groundY}`,
      `C ${tx - 11} ${groundY - trunkLen * 0.18}, ${tx + 9} ${groundY - trunkLen * 0.36}, ${tx + 2} ${groundY - trunkLen * 0.55}`,
      `S ${tx - 4} ${groundY - trunkLen * 0.82}, ${tx + 1} ${trunkTopY}`,
    ].join(' ');
    // A thinner mirrored highlight stroke for tonal depth
    const trunkInner = [
      `M ${tx - 2} ${groundY - 4}`,
      `C ${tx - 6} ${groundY - trunkLen * 0.2}, ${tx + 6} ${groundY - trunkLen * 0.4}, ${tx + 1} ${groundY - trunkLen * 0.6}`,
      `S ${tx - 3} ${groundY - trunkLen * 0.85}, ${tx} ${trunkTopY + 6}`,
    ].join(' ');

    // 6 main branches — alternating sides, fanning outward + upward.
    const mainBranches = scenes.slice(0, 6).map((s, i) => {
      const r = lmtPrng(s.id.charCodeAt(2) * 71 + i * 13);
      const side = i % 2 === 0 ? -1 : 1;
      const t = 0.18 + i * 0.13;                            // height along trunk
      const baseY = groundY - trunkLen * t;
      const baseX = tx + (i % 2 === 0 ? -3 : 3) + (r() - 0.5) * 6;
      // Angle from vertical, opening more for higher branches
      const ang = side * (0.55 + r() * 0.35 + i * 0.04);
      const len = (dense ? 78 : 96) + i * 6 + r() * 22;
      // Tip in screen coords (Y grows down)
      const tipX = baseX + Math.sin(ang) * len;
      const tipY = baseY - Math.cos(ang) * len * 0.82;
      // Two control points for a graceful arc (lifts then settles outward)
      const c1x = baseX + Math.sin(ang * 0.35) * len * 0.32;
      const c1y = baseY - len * 0.34;
      const c2x = baseX + Math.sin(ang) * len * 0.78;
      const c2y = baseY - Math.cos(ang) * len * 0.62;
      const path = `M ${baseX} ${baseY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${tipX} ${tipY}`;
      return { scene: s, baseX, baseY, tipX, tipY, c1x, c1y, c2x, c2y, ang, len, side, i, path };
    });

    // 2–3 sub-branches per main branch, branching outward from the middle.
    const subBranches = [];
    mainBranches.forEach((b) => {
      const r = lmtPrng(b.scene.id.charCodeAt(3) * 47 + b.i * 29);
      const nSubs = 2 + (b.i % 2);
      for (let k = 0; k < nSubs; k++) {
        // Cubic Bezier position lookup (approx via parametric formula)
        const u = 0.42 + k * 0.22;
        const ax = bez(u, b.baseX, b.c1x, b.c2x, b.tipX);
        const ay = bez(u, b.baseY, b.c1y, b.c2y, b.tipY);
        const sSide = k % 2 === 0 ? 1 : -1;
        const subAng = b.ang + sSide * (0.45 + r() * 0.45);
        const sLen = b.len * (0.32 + r() * 0.22);
        const stipX = ax + Math.sin(subAng) * sLen;
        const stipY = ay - Math.cos(subAng) * sLen * 0.82;
        const cmx = ax + Math.sin(subAng * 0.4) * sLen * 0.4;
        const cmy = ay - sLen * 0.3;
        subBranches.push({
          parent: b, ax, ay, stipX, stipY, ang: subAng, len: sLen, k,
          path: `M ${ax} ${ay} Q ${cmx} ${cmy}, ${stipX} ${stipY}`,
        });
      }
    });

    // Distribute every event as a leaf on its scene's branch (or fallback to spread).
    const sceneOrder = mainBranches.map(b => b.scene.title);
    const eventsByBranch = mainBranches.map(b => events.filter(e => e.scene === b.scene.title));
    // Spread orphan events evenly across branches
    const orphans = events.filter(e => !sceneOrder.includes(e.scene));
    orphans.forEach((e, idx) => eventsByBranch[idx % mainBranches.length].push(e));

    const leaves = [];
    mainBranches.forEach((b, bi) => {
      const myEvents = eventsByBranch[bi];
      const mySubs = subBranches.filter(s => s.parent === b);
      myEvents.forEach((e, ei) => {
        const r = lmtPrng(e.id.charCodeAt(1) * 131 + ei * 17 + bi * 7);
        // Pick a host: a sub-branch tip (most leaves) or the main branch tip
        let host;
        if (mySubs.length && r() < 0.85) host = { type: 'sub', ref: mySubs[ei % mySubs.length] };
        else host = { type: 'main', ref: b };

        // Position along the host, clustered near the tip
        const t = 0.55 + r() * 0.55;
        let hx, hy, hAng;
        if (host.type === 'sub') {
          hx = host.ref.ax + (host.ref.stipX - host.ref.ax) * t;
          hy = host.ref.ay + (host.ref.stipY - host.ref.ay) * t;
          hAng = host.ref.ang;
        } else {
          hx = bez(0.7 + r() * 0.3, b.baseX, b.c1x, b.c2x, b.tipX);
          hy = bez(0.7 + r() * 0.3, b.baseY, b.c1y, b.c2y, b.tipY);
          hAng = b.ang;
        }
        // Jitter so leaves cluster naturally
        const jx = (r() - 0.5) * 22;
        const jy = (r() - 0.5) * 18;
        const size = (dense ? 0.85 : 1) * (1 + r() * 0.55);
        // Leaf points away from branch axis with some random twist
        const rotate = (hAng * 180 / Math.PI) + (r() - 0.5) * 110;

        leaves.push({
          event: e,
          x: hx + jx, y: hy + jy,
          rotate,
          size,
          shape: Math.floor(r() * LEAF_PATHS.length),
          delay: -(r() * 6),
          dur: 4.5 + r() * 3.5,
          flutterAmp: 2.4 + r() * 2.2,
          scene: b.scene,
        });
      });
      // A few extra "ambient" leaves to fill out the silhouette
      const extra = dense ? 3 : 6;
      for (let k = 0; k < extra; k++) {
        const r = lmtPrng(b.scene.id.charCodeAt(2) + k * 41 + bi * 5);
        const sub = subBranches.filter(s => s.parent === b);
        const ref = sub.length ? sub[k % sub.length] : null;
        if (!ref) continue;
        const t = 0.5 + r() * 0.55;
        const hx = ref.ax + (ref.stipX - ref.ax) * t + (r() - 0.5) * 26;
        const hy = ref.ay + (ref.stipY - ref.ay) * t + (r() - 0.5) * 22;
        leaves.push({
          event: null,
          x: hx, y: hy,
          rotate: (ref.ang * 180 / Math.PI) + (r() - 0.5) * 130,
          size: (dense ? 0.7 : 0.85) * (0.7 + r() * 0.4),
          shape: Math.floor(r() * LEAF_PATHS.length),
          delay: -(r() * 7),
          dur: 5 + r() * 4,
          flutterAmp: 1.8 + r() * 1.6,
          ambient: true,
          scene: b.scene,
        });
      }
    });

    // Drifting motes
    const motes = [];
    const nMotes = dense ? 10 : 18;
    for (let i = 0; i < nMotes; i++) {
      const r = lmtPrng(i * 97 + 311);
      motes.push({
        x: 24 + r() * (width - 48),
        y: trunkTopY + r() * (groundY - trunkTopY - 20),
        size: 1.1 + r() * 1.8,
        delay: -(r() * 14),
        dur: 9 + r() * 7,
        drift: (r() - 0.5) * 26,
      });
    }

    return { trunkPath, trunkInner, mainBranches, subBranches, leaves, motes, tx };
  }, [scenes, events, width, height, dense]);

  // Unique IDs so multiple LifeTrees on a page don't collide on filters/gradients.
  const uid = lmem(() => 'lt' + Math.random().toString(36).slice(2, 8), []);

  // Per-leaf inline keyframes (slight, varied flutter) — keeps motion subtle.
  const animCss = `
    @keyframes ${uid}_sway {
      0%   { transform: rotate(-0.6deg) translateY(0); }
      50%  { transform: rotate( 0.5deg) translateY(-1.2px); }
      100% { transform: rotate(-0.4deg) translateY(0); }
    }
    @keyframes ${uid}_breathe {
      0%, 100% { transform: scale(1); }
      50%      { transform: scale(1.012); }
    }
    @keyframes ${uid}_flutter {
      0%   { transform: rotate(-2deg) translateY(0); }
      50%  { transform: rotate( 2deg) translateY(-0.6px); }
      100% { transform: rotate(-2deg) translateY(0); }
    }
    @keyframes ${uid}_halo {
      0%, 100% { opacity: 0.55; }
      50%      { opacity: 0.95; }
    }
    @keyframes ${uid}_mote {
      0%   { transform: translate(0, 0);            opacity: 0; }
      10%  {                                        opacity: 0.7; }
      85%  {                                        opacity: 0.55; }
      100% { transform: translate(var(--dx, 0), -90px); opacity: 0; }
    }
    .${uid}-body    { transform-box: view-box; transform-origin: 50% 100%; animation: ${uid}_sway 11s ease-in-out infinite; }
    .${uid}-breathe { transform-box: view-box; transform-origin: 50% 100%; animation: ${uid}_breathe 8s ease-in-out infinite; }
    .${uid}-leaf    { transform-box: fill-box; transform-origin: 50% 100%; animation: ${uid}_flutter ease-in-out infinite; will-change: transform; }
    .${uid}-halo    { transform-box: view-box; transform-origin: 50% 38%; animation: ${uid}_halo 7s ease-in-out infinite; }
    .${uid}-mote    { animation: ${uid}_mote linear infinite; }
    @media (prefers-reduced-motion: reduce) {
      .${uid}-body, .${uid}-breathe, .${uid}-leaf, .${uid}-halo, .${uid}-mote { animation: none !important; }
    }
  `;

  return (
    <svg viewBox={`0 0 ${width} ${height}`} width="100%" height="100%" style={{ display: 'block', borderRadius: 16, background: '#fbfbf7' }}>
      <defs>
        <style>{animCss}</style>
        <radialGradient id={`${uid}_dawn`} cx="0.5" cy="0.25" r="0.85">
          <stop offset="0"    stopColor="#fff7ec" stopOpacity="1"/>
          <stop offset="0.45" stopColor="#fbfbf7" stopOpacity="1"/>
          <stop offset="1"    stopColor="#eef1ec" stopOpacity="1"/>
        </radialGradient>
        <radialGradient id={`${uid}_aura`} cx="0.5" cy="0.4" r="0.55">
          <stop offset="0"   stopColor="#bbf7d0" stopOpacity="0.55"/>
          <stop offset="0.6" stopColor="#a7d2ff" stopOpacity="0.18"/>
          <stop offset="1"   stopColor="#22c55e" stopOpacity="0"/>
        </radialGradient>
        <radialGradient id={`${uid}_moon`} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0"   stopColor="#fff7ec" stopOpacity="0.95"/>
          <stop offset="0.6" stopColor="#fff7ec" stopOpacity="0.35"/>
          <stop offset="1"   stopColor="#fff7ec" stopOpacity="0"/>
        </radialGradient>
        <radialGradient id={`${uid}_ground`} cx="0.5" cy="1" r="0.6">
          <stop offset="0" stopColor="#d8e3d3" stopOpacity="0.55"/>
          <stop offset="1" stopColor="#d8e3d3" stopOpacity="0"/>
        </radialGradient>
        <radialGradient id={`${uid}_halo`} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#22c55e" stopOpacity="0.55"/>
          <stop offset="1" stopColor="#22c55e" stopOpacity="0"/>
        </radialGradient>
        <linearGradient id={`${uid}_trunk`} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0"   stopColor="#0d1410"/>
          <stop offset="0.5" stopColor="#2a2622"/>
          <stop offset="1"   stopColor="#0d1410"/>
        </linearGradient>
        <pattern id={`${uid}_dots`} width="22" height="22" patternUnits="userSpaceOnUse">
          <circle cx="1" cy="1" r="0.7" fill="#0d1410" opacity="0.05"/>
        </pattern>
        <filter id={`${uid}_soft`} x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur stdDeviation="14"/>
        </filter>
        <filter id={`${uid}_leafshadow`} x="-50%" y="-50%" width="200%" height="200%">
          <feDropShadow dx="0" dy="0.5" stdDeviation="0.5" floodColor="#0d1410" floodOpacity="0.22"/>
        </filter>
      </defs>

      {/* Sky / paper */}
      <rect x="0" y="0" width={width} height={height} fill={`url(#${uid}_dawn)`}/>
      <rect x="0" y="0" width={width} height={height} fill={`url(#${uid}_dots)`}/>

      {/* Surreal moon disk floating above the canopy */}
      <g className={`${uid}-halo`}>
        <circle cx={width * 0.78} cy={height * 0.18} r={Math.min(width, height) * 0.11} fill={`url(#${uid}_moon)`}/>
        <circle cx={width * 0.78} cy={height * 0.18} r={Math.min(width, height) * 0.055} fill="#fff7ec" opacity="0.55"/>
      </g>

      {/* Ambient aura behind the canopy */}
      <ellipse
        cx={cx} cy={height * 0.42}
        rx={width * 0.42} ry={height * 0.34}
        fill={`url(#${uid}_aura)`}
        className={`${uid}-halo`}
      />

      {/* Ground mist (soft, dreamy) */}
      <rect x="0" y={groundY - 36} width={width} height={60} fill={`url(#${uid}_ground)`}/>
      <line x1="0" y1={groundY + 0.5} x2={width} y2={groundY + 0.5} stroke="#0d1410" strokeOpacity="0.10"/>
      {/* Soft shadow puddle under the trunk */}
      <ellipse cx={cx} cy={groundY + 4} rx={width * 0.22} ry={6} fill="#0d1410" opacity="0.10"/>

      {/* Drifting motes — start behind the tree */}
      {tree.motes.slice(0, Math.ceil(tree.motes.length / 2)).map((m, i) => (
        <circle key={`m1-${i}`} cx={m.x} cy={m.y} r={m.size} fill="#fff7ec"
          className={`${uid}-mote`} opacity="0.7"
          style={{ animationDelay: `${m.delay}s`, animationDuration: `${m.dur}s`, ['--dx']: `${m.drift}px` }}/>
      ))}

      {/* TREE BODY — everything inside sways together around the trunk base */}
      <g className={`${uid}-body`}>
        <g className={`${uid}-breathe`}>
          {/* Trunk (thick stroke + inner highlight) */}
          <path d={tree.trunkPath} stroke={`url(#${uid}_trunk)`} strokeWidth={dense ? 8 : 10} fill="none" strokeLinecap="round"/>
          <path d={tree.trunkInner} stroke="#3a352e" strokeWidth={dense ? 2.2 : 2.8} fill="none" strokeLinecap="round" opacity="0.85"/>

          {/* Main branches */}
          {tree.mainBranches.map((b, i) => (
            <g key={`b-${i}`}>
              <path d={b.path} stroke="#1c1917" strokeWidth={Math.max(2.4, 4.8 - i * 0.45)} fill="none" strokeLinecap="round"/>
              {/* a couple of small tip twigs */}
              <line x1={b.tipX} y1={b.tipY} x2={b.tipX + b.side * 14} y2={b.tipY - 10} stroke="#1c1917" strokeWidth="1.3" strokeLinecap="round"/>
              <line x1={b.tipX} y1={b.tipY} x2={b.tipX + b.side * 4}  y2={b.tipY - 16} stroke="#1c1917" strokeWidth="1.1" strokeLinecap="round" opacity="0.85"/>
            </g>
          ))}

          {/* Sub-branches */}
          {tree.subBranches.map((s, i) => (
            <path key={`sb-${i}`} d={s.path}
              stroke="#1c1917" strokeWidth="1.6" fill="none" strokeLinecap="round" opacity="0.92"/>
          ))}

          {/* Leaves */}
          {tree.leaves.map((l, i) => {
            const c = l.event ? LMT.leafFor(l.event.daysSince) : (l.scene?.color || '#84cc16');
            const isHi = l.event && highlight === l.event.id;
            const opacity = l.ambient ? 0.78 : (l.event && l.event.daysSince > 365 ? 0.65 : 1);
            const dur = l.dur;
            const delay = l.delay;
            return (
              <g key={`l-${i}-${l.event?.id || 'amb'}`} transform={`translate(${l.x}, ${l.y}) rotate(${l.rotate})`}>
                {isHi && <circle r="22" fill={`url(#${uid}_halo)`}/>}
                <g className={`${uid}-leaf`} style={{ animationDuration: `${dur}s`, animationDelay: `${delay}s` }}>
                  <g transform={`scale(${l.size})`} filter={`url(#${uid}_leafshadow)`} opacity={opacity}>
                    <path d={LEAF_PATHS[l.shape]} fill={c}/>
                    <path d={LEAF_PATHS[l.shape]} fill="none" stroke={c} strokeOpacity="0.55" strokeWidth="0.5"/>
                    {/* midrib catches the light */}
                    <line x1="0" y1="-1" x2="0" y2={l.shape === 2 ? -20 : -16} stroke="rgba(255,255,255,0.55)" strokeWidth="0.7" strokeLinecap="round"/>
                  </g>
                  {l.event?.fragile && (
                    <circle r="2" cx="0" cy="-9" fill="#dc2626" stroke="#fbfbf7" strokeWidth="0.8"/>
                  )}
                </g>
              </g>
            );
          })}

          {/* Scene labels — anchored to branch tips, sway with the tree */}
          {showLabels && tree.mainBranches.map((b, i) => {
            const padX = 8;
            const text = b.scene.title;
            const w = Math.max(72, text.length * 5.2 + 22);
            const offset = b.side * (12 + 0);
            const lx = b.tipX + offset;
            const ly = b.tipY - 18;
            const rectX = b.side === -1 ? -w : 0;
            return (
              <g key={`lbl-${i}`} transform={`translate(${lx}, ${ly})`}>
                <line x1="0" y1="0" x2={b.side * 4} y2="2" stroke="#0d1410" strokeOpacity="0.3" strokeWidth="0.6"/>
                <rect x={rectX} y={-10} width={w} height="20" rx="10" fill="#0d1410"/>
                <circle cx={rectX + 12} cy="0" r="3" fill={b.scene.color}/>
                <text x={rectX + 22} y="3.5" fontFamily="Geist, sans-serif" fontSize="9.5" fontWeight="500" fill="#ffffff" letterSpacing="-0.01em">{text}</text>
              </g>
            );
          })}
        </g>
      </g>

      {/* Foreground motes — float over the tree */}
      {tree.motes.slice(Math.ceil(tree.motes.length / 2)).map((m, i) => (
        <circle key={`m2-${i}`} cx={m.x} cy={m.y} r={m.size} fill="#fff7ec"
          className={`${uid}-mote`} opacity="0.85"
          style={{ animationDelay: `${m.delay}s`, animationDuration: `${m.dur}s`, ['--dx']: `${m.drift}px` }}/>
      ))}

      {/* Legend — modern dark pill, single row of dot + label */}
      {showLabels && (() => {
        const items = [
          { d: 'this week', c: '#15803d' },
          { d: '< 30d',     c: '#22c55e' },
          { d: '< 90d',     c: '#84cc16' },
          { d: '< 1y',      c: '#eab308' },
          { d: 'older',     c: '#a8a29e' },
        ];
        const fs = dense ? 9 : 10;
        const itemW = dense ? 56 : 68;
        const padX = 14;
        const H = dense ? 24 : 28;
        const W = padX * 2 + items.length * itemW;
        return (
          <g transform={`translate(${dense ? 12 : 18}, ${dense ? 12 : 18})`}>
            <rect x="0" y="0" width={W} height={H} rx={H/2} fill="#0d1410"/>
            {items.map((r, i) => (
              <g key={i} transform={`translate(${padX + i * itemW}, ${H/2})`}>
                <circle cx="3" cy="0" r="3" fill={r.c}/>
                <text x="11" y="3.5" fontFamily="Geist, sans-serif" fontSize={fs} fontWeight="500" fill="#ffffff" letterSpacing="-0.01em">{r.d}</text>
              </g>
            ))}
          </g>
        );
      })()}
    </svg>
  );
}

// Cubic Bezier scalar
function bez(t, p0, p1, p2, p3) {
  const u = 1 - t;
  return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
}

// ─── LifeGraph ─────────────────────────────────────────────────────────────
// Pre-laid out force-directed graph; positions are stable + readable.
function LifeGraph({ width = 540, height = 540, highlight = null, eventsData, showLabels = true, dense = false }) {
  const events = eventsData || LMTData.events;
  // Stable pre-baked positions arranged in soft concentric clusters by scene.
  const positions = useMemo(() => {
    const cx = width / 2, cy = height / 2;
    const radius = Math.min(width, height) * 0.36;
    const angleByScene = {};
    let i = 0;
    LMTData.scenes.forEach((s, idx) => { angleByScene[s.title] = (idx / LMTData.scenes.length) * Math.PI * 2; });
    return events.map((e, idx) => {
      const a = (angleByScene[e.scene] || 0) + ((idx % 3) - 1) * 0.18;
      const r = radius * (0.55 + (idx % 5) * 0.08);
      return { ...e, x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r };
    });
  }, [events, width, height]);

  // Synthetic edges — connect events within the same scene + a couple of cross-scene relations.
  const edges = useMemo(() => {
    const out = [];
    const byScene = {};
    positions.forEach(p => { (byScene[p.scene] = byScene[p.scene] || []).push(p); });
    Object.values(byScene).forEach(arr => {
      for (let i = 0; i < arr.length - 1; i++) {
        out.push({ a: arr[i], b: arr[i+1], rel: 'follows' });
      }
    });
    // a few flagship cross-scene relations
    const find = id => positions.find(p => p.id === id);
    [
      ['e03', 'e08', 'part-of'],
      ['e10', 'e02', 'leads-to'],
      ['e05', 'e09', 'causes'],
      ['e11', 'e07', 'same-theme'],
      ['e12', 'e04', 'inspires'],
    ].forEach(([a, b, r]) => {
      const A = find(a), B = find(b);
      if (A && B) out.push({ a: A, b: B, rel: r });
    });
    return out;
  }, [positions]);

  const cx = width / 2, cy = height / 2;

  return (
    <svg viewBox={`0 0 ${width} ${height}`} width="100%" height="100%" style={{ display: 'block', borderRadius: 16, background: '#fbfbf7' }}>
      <defs>
        <radialGradient id="graphHalo" cx="0.5" cy="0.5">
          <stop offset="0" stopColor="#22c55e" stopOpacity="0.45"/>
          <stop offset="1" stopColor="#22c55e" stopOpacity="0"/>
        </radialGradient>
        <pattern id="paperGrid" width="24" height="24" patternUnits="userSpaceOnUse">
          <circle cx="1" cy="1" r="0.7" fill="#0d1410" opacity="0.06"/>
        </pattern>
      </defs>
      <rect x="0" y="0" width={width} height={height} fill="url(#paperGrid)"/>

      {/* faint orbital rings (clusters) */}
      {[0.45, 0.65, 0.85].map((r, i) => (
        <circle key={i} cx={cx} cy={cy} r={Math.min(width, height) * r / 2} fill="none" stroke="#b6bbb6" strokeWidth="0.5" strokeDasharray="2 6" opacity="0.5"/>
      ))}

      {/* edges */}
      {edges.map((e, i) => {
        const c = LMT.linkColor(e.rel.replace('-', ''));
        const isHi = highlight && (e.a.id === highlight || e.b.id === highlight);
        const dx = e.b.x - e.a.x, dy = e.b.y - e.a.y;
        const len = Math.sqrt(dx*dx + dy*dy);
        const mx = (e.a.x + e.b.x) / 2;
        const my = (e.a.y + e.b.y) / 2;
        const norm = { x: -dy / len, y: dx / len };
        const curveOff = e.rel === 'follows' ? 0 : (i % 2 === 0 ? 22 : -22);
        const c1x = mx + norm.x * curveOff;
        const c1y = my + norm.y * curveOff;
        return (
          <path key={i} d={`M ${e.a.x} ${e.a.y} Q ${c1x} ${c1y}, ${e.b.x} ${e.b.y}`}
            stroke={c} strokeWidth={isHi ? 1.8 : 1.1} fill="none" opacity={isHi ? 0.95 : 0.45}
            strokeDasharray={e.rel === 'similar' || e.rel === 'same-theme' ? '3 4' : 'none'}/>
        );
      })}

      {/* nodes */}
      {positions.map((p) => {
        const c = LMT.leafFor(p.daysSince);
        const isHi = highlight === p.id;
        const r = 8 + Math.min(p.links, 6) * 1.2;
        return (
          <g key={p.id} transform={`translate(${p.x}, ${p.y})`}>
            {isHi && <circle r={r + 18} fill="url(#graphHalo)"/>}
            <circle r={r + 2} fill="#ffffff"/>
            <circle r={r} fill={c} opacity={0.92} stroke="#0d1410" strokeOpacity={isHi ? 0.4 : 0.1}/>
            {p.fragile && <circle r="2" cx={r * 0.7} cy={-r * 0.7} fill="#dc2626" stroke="#ffffff" strokeWidth="1"/>}
            {showLabels && (isHi || p.links >= 4) && (
              <g transform={`translate(${r + 4}, -2)`}>
                <text fontFamily="Geist, sans-serif" fontSize="10.5" fontWeight="500" fill="#0d1410" letterSpacing="-0.012em">{p.title.length > 24 ? p.title.slice(0, 23) + '…' : p.title}</text>
              </g>
            )}
          </g>
        );
      })}

      {/* legend — modern dark pill */}
      {showLabels && (
        <g transform={`translate(${width - (dense ? 164 : 184)}, ${dense ? 12 : 16})`}>
          <rect x="0" y="0" width={dense ? 156 : 176} height={dense ? 90 : 102} rx="12" fill="#0d1410"/>
          <text x="14" y="18" fontFamily="JetBrains Mono" fontSize="8" fill="#8d958f" letterSpacing="0.1em">RELATIONS</text>
          {[
            { l: 'follows',     c: LMT.color.follows,  dash: 'none'  },
            { l: 'causes',      c: LMT.color.causes,   dash: 'none'  },
            { l: 'part-of',     c: LMT.color.partOf,   dash: 'none'  },
            { l: 'same-theme',  c: LMT.color.similar,  dash: '3 4'   },
            { l: 'leads-to',    c: LMT.color.leadsTo,  dash: 'none'  },
          ].map((r, i) => (
            <g key={i} transform={`translate(14, ${30 + i * 13})`}>
              <line x1="0" y1="3" x2="22" y2="3" stroke={r.c} strokeWidth="2" strokeLinecap="round" strokeDasharray={r.dash}/>
              <text x="30" y="6" fontFamily="Geist, sans-serif" fontWeight="500" fontSize="10" fill="#ffffff" letterSpacing="-0.01em">{r.l}</text>
            </g>
          ))}
        </g>
      )}
    </svg>
  );
}

const useMemo = lmem;
Object.assign(window, { LifeTree, LifeGraph });
