// Topology background — Canvas2D, scroll-linked.
// Many graph archetypes + rendering modes, driven by window.__tweaks.
// Tweak keys:
//   topology     : 'iso' | 'ortho' | 'dimetric' | 'radial' | 'neural' | 'constellation' | 'flow' | 'dag'
//   density      : 0.2..1 → node count
//   motion       : 0..1   → packet/animation speed
//   edgeStyle    : 'straight' | 'stepped' | 'curved' | 'arc'
//   nodeStyle    : 'diamond' | 'circle' | 'square' | 'ring'
//   packetStyle  : 'dot' | 'dash' | 'pulse' | 'off'
//   packetRate   : 0..1
//   lineWeight   : 0.5..2 px
//   lineOpacity  : 0.05..0.5
//   labels       : 'sparse' | 'frequent' | 'off' | 'coords'
//   vocab        : 'agent' | 'intent' | 'mix' | 'encode'
//   grain        : 0..1  → film grain overlay
//   vignette     : 0..1
//   breathe      : 0..1  → idle pulse intensity when not scrolling
//   accent       : hex
//   dark         : bool

const { useRef, useEffect } = React;

const NODE_VOCAB = {
  agent:        ['retrieve', 'plan', 'tool', 'verify', 'route', 'evaluate', 'ship', 'embed', 'rank', 'observe'],
  intent:       ['encode', 'map', 'decide', 'prioritise', 'align', 'deploy', 'monitor', 'judge', 'calibrate', 'resolve'],
  mix:          ['retrieve', 'encode', 'plan', 'decide', 'verify', 'align', 'route', 'prioritise', 'ship', 'monitor', 'tool', 'map'],
  encode:       ['judgment', 'values', 'wisdom', 'intent', 'signal', 'standards', 'priorities', 'context'],
};

// Seeded RNG
function mulberry32(a) {
  return function () {
    let t = (a += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function projForStyle(style) {
  // Returns { ux, uy, vx, vy, kind } where kind is 'grid' or 'polar' or 'free'
  if (style === 'ortho')       return { ux: 1, uy: 0, vx: 0, vy: 1, kind: 'grid' };
  if (style === 'dimetric')    return { ux: Math.cos(Math.PI / 7), uy: Math.sin(Math.PI / 7), vx: -Math.cos(Math.PI / 7), vy: Math.sin(Math.PI / 7), kind: 'grid' };
  if (style === 'iso')         return { ux: Math.cos(Math.PI / 6), uy: Math.sin(Math.PI / 6), vx: -Math.cos(Math.PI / 6), vy: Math.sin(Math.PI / 6), kind: 'grid' };
  return null; // radial / neural / constellation / flow / dag → use raw (i,j) as (x,y)
}

// ---------- GRAPH BUILDERS -------------------------------------------------

function buildGridGraph(style, density) {
  const rng = mulberry32(42);
  const cols = Math.round(10 + density * 12);
  const rows = Math.round(7 + density * 8);
  const nodes = [];
  const edges = [];
  for (let j = 0; j < rows; j++) {
    for (let i = 0; i < cols; i++) {
      if (rng() < 0.22) continue;
      nodes.push({
        i: i + (rng() - 0.5) * 0.25,
        j: j + (rng() - 0.5) * 0.25,
        r: rng() < 0.08 ? 6 : rng() < 0.3 ? 4 : 3,
        seed: rng(),
        hub: rng() < 0.08,
      });
    }
  }
  for (let n = 0; n < nodes.length; n++) {
    const a = nodes[n];
    const cands = [];
    for (let m = 0; m < nodes.length; m++) {
      if (m === n) continue;
      const b = nodes[m];
      const di = b.i - a.i, dj = b.j - a.j;
      const d2 = di * di + dj * dj;
      if (d2 < 4.5 && (di > -0.2 || dj > -0.2)) cands.push({ m, d2 });
    }
    cands.sort((x, y) => x.d2 - y.d2);
    const cap = a.hub ? 4 : 2;
    for (let k = 0; k < Math.min(cap, cands.length); k++) {
      if (rng() < 0.7) edges.push({ a: n, b: cands[k].m, seed: rng() });
    }
  }
  return { nodes, edges, cols, rows, kind: 'grid', style };
}

function buildRadialGraph(density) {
  // concentric rings of nodes with radial edges + ring edges
  const rng = mulberry32(101);
  const rings = Math.round(4 + density * 4);
  const nodes = [];
  const edges = [];
  // center node
  nodes.push({ i: 0, j: 0, r: 6, seed: rng(), hub: true });
  for (let ri = 1; ri <= rings; ri++) {
    const count = Math.round(6 + ri * (3 + density * 3));
    for (let k = 0; k < count; k++) {
      const ang = (k / count) * Math.PI * 2 + (rng() - 0.5) * 0.08;
      const rad = ri * (1 + (rng() - 0.5) * 0.1);
      nodes.push({ i: Math.cos(ang) * rad, j: Math.sin(ang) * rad, r: rng() < 0.12 ? 5 : 3, seed: rng(), hub: rng() < 0.06, ring: ri, angle: ang });
    }
  }
  // edges: each node → one node in next inner ring (radial)
  for (let n = 1; n < nodes.length; n++) {
    const a = nodes[n];
    if (!a.ring) continue;
    let best = -1, bestD = 1e9;
    for (let m = 0; m < nodes.length; m++) {
      if (m === n) continue;
      const b = nodes[m];
      if (b.ring !== a.ring - 1 && !(b.ring === undefined && a.ring === 1)) continue;
      const di = b.i - a.i, dj = b.j - a.j;
      const d2 = di * di + dj * dj;
      if (d2 < bestD) { bestD = d2; best = m; }
    }
    if (best >= 0 && rng() < 0.88) edges.push({ a: n, b: best, seed: rng() });
  }
  // ring edges: neighbors in same ring
  const byRing = {};
  for (let n = 0; n < nodes.length; n++) {
    const r = nodes[n].ring;
    if (r === undefined) continue;
    (byRing[r] = byRing[r] || []).push(n);
  }
  for (const ri in byRing) {
    const arr = byRing[ri].slice().sort((x, y) => nodes[x].angle - nodes[y].angle);
    for (let k = 0; k < arr.length; k++) {
      if (rng() < 0.55) edges.push({ a: arr[k], b: arr[(k + 1) % arr.length], seed: rng() });
    }
  }
  return { nodes, edges, cols: rings * 2, rows: rings * 2, kind: 'free', style: 'radial' };
}

function buildNeuralGraph(density) {
  // layered neural network: 4-6 layers, edges only layer→layer+1, fully connected (subsampled)
  const rng = mulberry32(202);
  const layers = 4 + Math.round(density * 3); // 4..7
  const nodes = [];
  const edges = [];
  const perLayer = [];
  for (let l = 0; l < layers; l++) {
    const count = Math.round(4 + Math.sin((l / (layers - 1)) * Math.PI) * (6 + density * 6));
    perLayer.push(count);
    for (let k = 0; k < count; k++) {
      const x = l * 2.4;
      const y = (k - (count - 1) / 2) * 1.1;
      nodes.push({ i: x, j: y, r: rng() < 0.18 ? 5 : 3, seed: rng(), hub: rng() < 0.1, layer: l, idx: k });
    }
  }
  // edges layer L → L+1
  for (let n = 0; n < nodes.length; n++) {
    const a = nodes[n];
    if (a.layer >= layers - 1) continue;
    for (let m = 0; m < nodes.length; m++) {
      const b = nodes[m];
      if (b.layer !== a.layer + 1) continue;
      if (rng() < 0.55) edges.push({ a: n, b: m, seed: rng() });
    }
  }
  return { nodes, edges, cols: layers * 2.4, rows: 10, kind: 'free', style: 'neural' };
}

function buildConstellationGraph(density) {
  // loose clusters of points with occasional long-distance edges (star-chart feel)
  const rng = mulberry32(303);
  const clusters = 6 + Math.round(density * 6);
  const nodes = [];
  const edges = [];
  for (let c = 0; c < clusters; c++) {
    const cx = (rng() - 0.5) * 20;
    const cy = (rng() - 0.5) * 12;
    const count = 4 + Math.floor(rng() * 6);
    const base = nodes.length;
    for (let k = 0; k < count; k++) {
      nodes.push({
        i: cx + (rng() - 0.5) * 2.8,
        j: cy + (rng() - 0.5) * 2.4,
        r: rng() < 0.12 ? 5 : rng() < 0.5 ? 3 : 2,
        seed: rng(),
        hub: rng() < 0.1,
        cluster: c,
      });
    }
    // intra-cluster edges (sparse)
    for (let k = 0; k < count; k++) {
      const kk = (k + 1 + Math.floor(rng() * (count - 1))) % count;
      if (kk !== k && rng() < 0.65) edges.push({ a: base + k, b: base + kk, seed: rng() });
    }
  }
  // rare inter-cluster edges
  for (let k = 0; k < clusters * 0.5; k++) {
    const a = Math.floor(rng() * nodes.length);
    const b = Math.floor(rng() * nodes.length);
    if (a !== b && nodes[a].cluster !== nodes[b].cluster) edges.push({ a, b, seed: rng(), long: true });
  }
  return { nodes, edges, cols: 20, rows: 12, kind: 'free', style: 'constellation' };
}

function buildFlowGraph(density) {
  // directed left→right flow with branches — pipeline / dataflow vibe
  const rng = mulberry32(404);
  const stages = 5 + Math.round(density * 3);
  const nodes = [];
  const edges = [];
  const perStage = [];
  for (let s = 0; s < stages; s++) {
    const w = 1 + Math.floor(rng() * (3 + density * 3));
    perStage.push(w);
    for (let k = 0; k < w; k++) {
      nodes.push({
        i: s * 3,
        j: (k - (w - 1) / 2) * 2,
        r: rng() < 0.2 ? 5 : 3,
        seed: rng(),
        hub: rng() < 0.2,
        stage: s,
      });
    }
  }
  // edges from stage S → S+1 (1-2 per source)
  for (let n = 0; n < nodes.length; n++) {
    const a = nodes[n];
    if (a.stage >= stages - 1) continue;
    const nexts = [];
    for (let m = 0; m < nodes.length; m++) if (nodes[m].stage === a.stage + 1) nexts.push(m);
    const picks = Math.max(1, Math.floor(rng() * 2) + 1);
    for (let k = 0; k < picks && nexts.length; k++) {
      const pick = nexts[Math.floor(rng() * nexts.length)];
      edges.push({ a: n, b: pick, seed: rng(), directed: true });
    }
  }
  return { nodes, edges, cols: stages * 3, rows: 10, kind: 'free', style: 'flow' };
}

function buildDagGraph(density) {
  // organic DAG: scatter nodes, connect forward in x
  const rng = mulberry32(505);
  const count = Math.round(30 + density * 50);
  const nodes = [];
  const edges = [];
  for (let k = 0; k < count; k++) {
    nodes.push({
      i: rng() * 22,
      j: (rng() - 0.5) * 12,
      r: rng() < 0.1 ? 5 : 3,
      seed: rng(),
      hub: rng() < 0.12,
    });
  }
  for (let n = 0; n < nodes.length; n++) {
    const a = nodes[n];
    const cands = [];
    for (let m = 0; m < nodes.length; m++) {
      if (m === n) continue;
      const b = nodes[m];
      const di = b.i - a.i, dj = b.j - a.j;
      const d2 = di * di + dj * dj;
      if (di > 0.4 && di < 4 && Math.abs(dj) < 3) cands.push({ m, d2 });
    }
    cands.sort((x, y) => x.d2 - y.d2);
    const cap = a.hub ? 3 : 2;
    for (let k = 0; k < Math.min(cap, cands.length); k++) {
      if (rng() < 0.8) edges.push({ a: n, b: cands[k].m, seed: rng(), directed: true });
    }
  }
  return { nodes, edges, cols: 22, rows: 12, kind: 'free', style: 'dag' };
}

function buildGraph(style, density) {
  if (style === 'radial')        return buildRadialGraph(density);
  if (style === 'neural')        return buildNeuralGraph(density);
  if (style === 'constellation') return buildConstellationGraph(density);
  if (style === 'flow')          return buildFlowGraph(density);
  if (style === 'dag')           return buildDagGraph(density);
  return buildGridGraph(style, density); // iso / ortho / dimetric
}

// ---------- Scroll phases ---------------------------------------------------
const PHASES = [
  { t: 0.00, focus: { fx: 0.15, fy: 0.50 }, scale: 1.30, spread: 0.22, packets: 0.20 },
  { t: 0.28, focus: { fx: 0.35, fy: 0.45 }, scale: 1.05, spread: 0.65, packets: 0.45 },
  { t: 0.55, focus: { fx: 0.55, fy: 0.50 }, scale: 0.92, spread: 1.00, packets: 0.85 },
  { t: 0.80, focus: { fx: 0.75, fy: 0.52 }, scale: 1.15, spread: 0.55, packets: 0.30 },
  { t: 1.00, focus: { fx: 0.88, fy: 0.50 }, scale: 1.60, spread: 0.15, packets: 0.15 },
];

function lerp(a, b, t) { return a + (b - a) * t; }
function lerpPhase(p) {
  for (let k = 0; k < PHASES.length - 1; k++) {
    const A = PHASES[k], B = PHASES[k + 1];
    if (p >= A.t && p <= B.t) {
      const u = (p - A.t) / (B.t - A.t);
      const e = u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
      return {
        focus: { fx: lerp(A.focus.fx, B.focus.fx, e), fy: lerp(A.focus.fy, B.focus.fy, e) },
        scale: lerp(A.scale, B.scale, e),
        spread: lerp(A.spread, B.spread, e),
        packets: lerp(A.packets, B.packets, e),
      };
    }
  }
  return PHASES[PHASES.length - 1];
}

// ---------- Render ----------------------------------------------------------
function Topology() {
  const canvasRef = useRef(null);
  const stateRef = useRef({
    graph: null,
    t0: performance.now(),
    mouse: { x: 0, y: 0, tx: 0, ty: 0 },
    lastStyle: null,
    lastDensity: null,
    grainCanvas: null,
  });

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    let raf = 0;
    let running = true;

    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = window.innerWidth * dpr;
      canvas.height = window.innerHeight * dpr;
      canvas.style.width = window.innerWidth + 'px';
      canvas.style.height = window.innerHeight + 'px';
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };

    const onMouse = (e) => {
      const s = stateRef.current;
      s.mouse.tx = (e.clientX / window.innerWidth - 0.5) * 2;
      s.mouse.ty = (e.clientY / window.innerHeight - 0.5) * 2;
    };
    const onVisibility = () => {
      running = !document.hidden;
      if (running) raf = requestAnimationFrame(loop);
    };

    window.addEventListener('resize', resize);
    window.addEventListener('mousemove', onMouse);
    document.addEventListener('visibilitychange', onVisibility);
    resize();

    // Pre-bake a grain tile
    const makeGrain = () => {
      const c = document.createElement('canvas');
      c.width = 180; c.height = 180;
      const cx = c.getContext('2d');
      const img = cx.createImageData(c.width, c.height);
      for (let i = 0; i < img.data.length; i += 4) {
        const v = Math.random() * 255;
        img.data[i] = v; img.data[i + 1] = v; img.data[i + 2] = v;
        img.data[i + 3] = 28;
      }
      cx.putImageData(img, 0, 0);
      return c;
    };
    stateRef.current.grainCanvas = makeGrain();

    const drawEdgePath = (ctx, ax, ay, bx, by, style) => {
      ctx.beginPath();
      if (style === 'stepped') {
        const mx = ax + (bx - ax) * 0.55;
        ctx.moveTo(ax, ay); ctx.lineTo(mx, ay); ctx.lineTo(mx, by); ctx.lineTo(bx, by);
      } else if (style === 'curved') {
        const mx = (ax + bx) * 0.5;
        const my = (ay + by) * 0.5;
        const dx = bx - ax, dy = by - ay;
        // perpendicular offset for a gentle curve
        const nx = -dy * 0.18, ny = dx * 0.18;
        ctx.moveTo(ax, ay);
        ctx.quadraticCurveTo(mx + nx, my + ny, bx, by);
      } else if (style === 'arc') {
        ctx.moveTo(ax, ay);
        const dx = bx - ax, dy = by - ay;
        const len = Math.sqrt(dx * dx + dy * dy);
        const mx = (ax + bx) * 0.5 - dy * 0.22;
        const my = (ay + by) * 0.5 + dx * 0.22;
        ctx.quadraticCurveTo(mx, my, bx, by);
      } else {
        ctx.moveTo(ax, ay); ctx.lineTo(bx, by);
      }
    };

    // Point along a stepped/curved edge at param u
    const edgePoint = (ax, ay, bx, by, u, style) => {
      if (style === 'stepped') {
        const mx = ax + (bx - ax) * 0.55;
        // split into two segments roughly proportional to their screen length
        const l1 = Math.abs(mx - ax);
        const l2 = Math.abs(by - ay);
        const l3 = Math.abs(bx - mx);
        const total = l1 + l2 + l3 || 1;
        const t1 = l1 / total, t2 = (l1 + l2) / total;
        if (u < t1) {
          const uu = u / t1;
          return [ax + (mx - ax) * uu, ay];
        } else if (u < t2) {
          const uu = (u - t1) / (t2 - t1);
          return [mx, ay + (by - ay) * uu];
        } else {
          const uu = (u - t2) / (1 - t2);
          return [mx + (bx - mx) * uu, by];
        }
      } else if (style === 'curved' || style === 'arc') {
        // approximate quadratic control point
        const mx = (ax + bx) * 0.5;
        const my = (ay + by) * 0.5;
        const dx = bx - ax, dy = by - ay;
        const f = style === 'arc' ? 0.22 : 0.18;
        const cx = style === 'arc' ? mx - dy * f : mx + (-dy) * f;
        const cy = style === 'arc' ? my + dx * f : my + dx * f;
        const oneMinus = 1 - u;
        const x = oneMinus * oneMinus * ax + 2 * oneMinus * u * cx + u * u * bx;
        const y = oneMinus * oneMinus * ay + 2 * oneMinus * u * cy + u * u * by;
        return [x, y];
      }
      return [ax + (bx - ax) * u, ay + (by - ay) * u];
    };

    const drawNodeShape = (ctx, x, y, r, style, fill, stroke) => {
      ctx.save();
      ctx.translate(x, y);
      if (style === 'circle') {
        ctx.fillStyle = stroke;
        ctx.beginPath(); ctx.arc(0, 0, r * 0.55, 0, Math.PI * 2); ctx.fill();
      } else if (style === 'square') {
        ctx.strokeStyle = stroke; ctx.fillStyle = fill; ctx.lineWidth = 1;
        ctx.beginPath(); ctx.rect(-r * 0.7, -r * 0.7, r * 1.4, r * 1.4);
        ctx.fill(); ctx.stroke();
      } else if (style === 'ring') {
        ctx.strokeStyle = stroke; ctx.lineWidth = 1;
        ctx.beginPath(); ctx.arc(0, 0, r * 0.75, 0, Math.PI * 2); ctx.stroke();
      } else {
        // diamond
        ctx.strokeStyle = stroke; ctx.fillStyle = fill; ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(0, -r); ctx.lineTo(r, 0); ctx.lineTo(0, r); ctx.lineTo(-r, 0);
        ctx.closePath();
        ctx.fill(); ctx.stroke();
      }
      ctx.restore();
    };

    const draw = (now) => {
      const s = stateRef.current;
      const tw = (window.__tweaks || {});
      const reduced = !!window.__reducedMotion;
      const dark = !!tw.dark;
      const accent = tw.accent || '#C8553D';
      const style = tw.topology || 'iso';
      const density = typeof tw.density === 'number' ? tw.density : 0.6;
      const intensity = reduced ? 0 : (typeof tw.motion === 'number' ? tw.motion : 0.5);
      const edgeStyle = tw.edgeStyle || 'straight';
      const nodeStyle = tw.nodeStyle || (style === 'iso' || style === 'dimetric' ? 'diamond' : 'circle');
      const packetStyle = tw.packetStyle || 'dot';
      const packetRate = typeof tw.packetRate === 'number' ? tw.packetRate : 0.5;
      const lineWeight = typeof tw.lineWeight === 'number' ? tw.lineWeight : 1;
      const lineOp = typeof tw.lineOpacity === 'number' ? tw.lineOpacity : 0.22;
      const labels = tw.labels || 'sparse';
      const vocab = NODE_VOCAB[tw.vocab || 'mix'] || NODE_VOCAB.mix;
      const grainAmt = typeof tw.grain === 'number' ? tw.grain : 0.0;
      const vignetteAmt = typeof tw.vignette === 'number' ? tw.vignette : 0.35;
      const breatheAmt = typeof tw.breathe === 'number' ? tw.breathe : 0.3;

      // Rebuild graph if style/density changed
      if (!s.graph || s.lastStyle !== style || Math.abs((s.lastDensity ?? -1) - density) > 0.05) {
        s.graph = buildGraph(style, density);
        s.lastStyle = style;
        s.lastDensity = density;
      }

      const W = window.innerWidth;
      const H = window.innerHeight;

      const baseBg = dark ? '#0B0B0C' : '#F6F3EC';
      const inkCol = dark ? '#EDE7D8' : '#0B0B0C';
      const lineCol = dark ? '#5a6373' : '#2A2F36';
      ctx.fillStyle = baseBg;
      ctx.fillRect(0, 0, W, H);

      const p = window.__scrollProgress ?? 0;
      const phase = lerpPhase(p);

      const g = s.graph;

      // Choose projection
      const proj = projForStyle(style);
      let ux, uy, vx, vy;
      if (proj) { ux = proj.ux; uy = proj.uy; vx = proj.vx; vy = proj.vy; }
      else      { ux = 1; uy = 0; vx = 0; vy = 1; } // free layouts use raw

      // Bounds
      let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
      for (const n of g.nodes) {
        const px = n.i * ux + n.j * vx;
        const py = n.i * uy + n.j * vy;
        if (px < minx) minx = px;
        if (px > maxx) maxx = px;
        if (py < miny) miny = py;
        if (py > maxy) maxy = py;
      }
      const spanX = Math.max(0.001, maxx - minx);
      const spanY = Math.max(0.001, maxy - miny);

      // Focal anchor — interpret phase.focus as fraction of graph bounds
      const focalPX = minx + spanX * phase.focus.fx;
      const focalPY = miny + spanY * phase.focus.fy;

      const marginX = W * 0.08;
      const marginY = H * 0.1;
      const fit = Math.min((W - 2 * marginX) / spanX, (H - 2 * marginY) / spanY);
      const scale = fit * phase.scale * lerp(0.6, 1.3, phase.spread);

      s.mouse.x += (s.mouse.tx - s.mouse.x) * 0.08;
      s.mouse.y += (s.mouse.ty - s.mouse.y) * 0.08;
      const paraX = -s.mouse.x * 8;
      const paraY = -s.mouse.y * 8;

      // Subtle idle breathe — a gentle scale pulse
      const t = (now - s.t0) * 0.001;
      const breathe = 1 + Math.sin(t * 0.4) * 0.015 * breatheAmt * intensity;
      const scaleB = scale * breathe;

      const cx = W * 0.5 + paraX;
      const cy = H * 0.5 + paraY;
      const tx = (px, py) => [(px - focalPX) * scaleB + cx, (py - focalPY) * scaleB + cy];

      // --- EDGES
      ctx.lineWidth = lineWeight;
      ctx.lineCap = 'round';
      for (let k = 0; k < g.edges.length; k++) {
        const e = g.edges[k];
        const A = g.nodes[e.a];
        const B = g.nodes[e.b];
        const [ax, ay] = tx(A.i * ux + A.j * vx, A.i * uy + A.j * vy);
        const [bx, by] = tx(B.i * ux + B.j * vx, B.i * uy + B.j * vy);
        if ((ax < -60 && bx < -60) || (ax > W + 60 && bx > W + 60)) continue;
        if ((ay < -60 && by < -60) || (ay > H + 60 && by > H + 60)) continue;

        const mx = (ax + bx) * 0.5;
        const my = (ay + by) * 0.5;
        const dx = (mx - W * 0.5) / W;
        const dy = (my - H * 0.5) / H;
        const d = Math.sqrt(dx * dx + dy * dy);
        const alpha = Math.max(0.04, lineOp - d * 0.18) * lerp(0.7, 1.0, phase.spread);

        ctx.strokeStyle = hexToRgba(lineCol, alpha);
        drawEdgePath(ctx, ax, ay, bx, by, edgeStyle);
        ctx.stroke();

        // packet
        if (packetStyle !== 'off') {
          const rate = phase.packets * intensity * packetRate;
          const hash = (e.seed * 997) % 1;
          if (rate > 0.01 && hash < rate * 0.45) {
            const speed = 0.08 + phase.packets * 0.35;
            const u = ((t * speed + e.seed * 7.3) % 1);
            const [px, py] = edgePoint(ax, ay, bx, by, u, edgeStyle);

            if (packetStyle === 'dot') {
              ctx.fillStyle = hexToRgba(accent, 0.8);
              ctx.beginPath(); ctx.arc(px, py, 2.3, 0, Math.PI * 2); ctx.fill();
              ctx.fillStyle = hexToRgba(accent, 0.22);
              ctx.beginPath(); ctx.arc(px, py, 4.5, 0, Math.PI * 2); ctx.fill();
            } else if (packetStyle === 'dash') {
              // short bright segment along edge
              const [qx, qy] = edgePoint(ax, ay, bx, by, Math.max(0, u - 0.08), edgeStyle);
              ctx.strokeStyle = hexToRgba(accent, 0.9);
              ctx.lineWidth = lineWeight + 1;
              ctx.beginPath(); ctx.moveTo(qx, qy); ctx.lineTo(px, py); ctx.stroke();
              ctx.lineWidth = lineWeight;
            } else if (packetStyle === 'pulse') {
              // expanding ring at packet location
              const ring = (u * 2) % 1;
              ctx.strokeStyle = hexToRgba(accent, (1 - ring) * 0.7);
              ctx.lineWidth = 1;
              ctx.beginPath(); ctx.arc(px, py, 2 + ring * 10, 0, Math.PI * 2); ctx.stroke();
              ctx.lineWidth = lineWeight;
            }
          }
        }
      }

      // --- NODES
      for (let k = 0; k < g.nodes.length; k++) {
        const n = g.nodes[k];
        const [x, y] = tx(n.i * ux + n.j * vx, n.i * uy + n.j * vy);
        if (x < -40 || x > W + 40 || y < -40 || y > H + 40) continue;

        const dx = (x - W * 0.5) / W;
        const dy = (y - H * 0.5) / H;
        const d = Math.sqrt(dx * dx + dy * dy);
        const baseAlpha = Math.max(0.15, 0.6 - d * 0.5);

        drawNodeShape(
          ctx, x, y, n.r, nodeStyle,
          hexToRgba(baseBg, 1),
          hexToRgba(inkCol, baseAlpha)
        );

        // Labels
        let showLabel = false;
        let labelText = null;
        if (labels === 'off') showLabel = false;
        else if (labels === 'coords') {
          if (n.hub && d < 0.45) { showLabel = true; labelText = `${n.i.toFixed(1)},${n.j.toFixed(1)}`; }
        } else if (labels === 'frequent') {
          if (d < 0.5 && n.r >= 3) {
            showLabel = true;
            const idx = Math.floor(t * 0.2 + n.seed * 13) % vocab.length;
            labelText = vocab[(idx + vocab.length) % vocab.length];
          }
        } else {
          // sparse
          if (n.hub && d < 0.35) {
            showLabel = true;
            const idx = Math.floor(t * 0.15 + n.seed * 13) % vocab.length;
            labelText = vocab[(idx + vocab.length) % vocab.length];
          }
        }
        if (showLabel && labelText) {
          const cyc = (t * 0.12 + n.seed * 6) % 1;
          const envelope = cyc < 0.5 ? Math.sin(cyc * Math.PI * 2) : 0;
          const fade = labels === 'frequent' ? 0.7 : Math.max(0, envelope);
          const labelAlpha = fade * Math.max(0, 0.55 - d * 0.8) * 0.9;
          if (labelAlpha > 0.04) {
            ctx.font = '10px "Geist Mono", ui-monospace, monospace';
            ctx.fillStyle = hexToRgba(inkCol, labelAlpha);
            ctx.textBaseline = 'middle';
            ctx.fillText(labelText, x + n.r + 6, y);
          }
        }
      }

      // --- VIGNETTE
      if (vignetteAmt > 0.01) {
        const grad = ctx.createRadialGradient(W * 0.5, H * 0.5, Math.min(W, H) * 0.2, W * 0.5, H * 0.5, Math.max(W, H) * 0.7);
        grad.addColorStop(0, hexToRgba(baseBg, 0));
        grad.addColorStop(1, hexToRgba(baseBg, vignetteAmt));
        ctx.fillStyle = grad;
        ctx.fillRect(0, 0, W, H);
      }

      // --- GRAIN
      if (grainAmt > 0.01 && s.grainCanvas) {
        ctx.save();
        ctx.globalAlpha = grainAmt * 0.9;
        const pat = ctx.createPattern(s.grainCanvas, 'repeat');
        ctx.fillStyle = pat;
        // subtle drift
        const ox = Math.floor((t * 23) % 180);
        const oy = Math.floor((t * 17) % 180);
        ctx.translate(-ox, -oy);
        ctx.fillRect(ox, oy, W, H);
        ctx.restore();
      }
    };

    const loop = (now) => {
      if (!running) return;
      draw(now);
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', resize);
      window.removeEventListener('mousemove', onMouse);
      document.removeEventListener('visibilitychange', onVisibility);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden="true"
      style={{
        position: 'fixed',
        inset: 0,
        width: '100vw',
        height: '100vh',
        zIndex: 0,
        pointerEvents: 'none',
      }}
    />
  );
}

function hexToRgba(hex, a) {
  const h = hex.replace('#', '');
  const r = parseInt(h.substring(0, 2), 16);
  const g = parseInt(h.substring(2, 4), 16);
  const b = parseInt(h.substring(4, 6), 16);
  return `rgba(${r},${g},${b},${a})`;
}

Object.assign(window, { Topology });
