/* global React */
function Hero() {
  return (
    <section className="hero" id="top">
      <div className="page">
        <div className="hero__grid">

          <div className="hero__text">
            <h1 className="h1 hero__heading">
              Secure the data. Enable AI.
            </h1>
            <p className="lead hero__lead">
              Our solution suite enables you to operationalise agents in a secure, privacy-preserving manner, while giving you full visibility and control over what your agents are doing.
            </p>
            <a href="/contact" className="btn-accent hero__cta">Request access</a>
          </div>

          <div className="hero__media" aria-hidden="true" style={{ overflow: "hidden" }}>
            <svg
              id="kg-svg"
              viewBox="0 0 1000 920"
              preserveAspectRatio="xMidYMid meet"
              role="presentation"
              style={{
                width: '100%',
                height: '100%',
                overflow: 'visible',
                display: 'block'
              }}
            />
          </div>

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

window.Hero = Hero;

/* =====================================================================
   Knowledge-graph engine v2.

   Everything from v1 (build stagger, completion ripple, lit edge
   trails, cascade activations, hover, accent spine wave) plus:

     [SAT]     Satellite tier: small secondary nodes in the outer band
               between the main graph and the bleed edges, each tied
               in with one thin low-opacity edge. Graph reads denser
               and deeper.

     [BGDOTS]  Faint background dots (opacity ~0.15-0.2) scattered
               between main nodes, suggesting a much larger underlying
               graph. Slow breathe only, no edges, no interaction.

     [STREAK]  Pulses are now data streaks: a leading dot with a
               fading tail that inherits the edge colour. Tail length
               is configurable via cfg.tails (segments; 0 disables).

     [FLARE]   Node activation on pulse arrival: radius bumps ~25%
               (hubs ~40%), opacity pulses to full, settles over 0.45s.

     [SPLIT]   Every 8-12s a pulse leaves the centre hub and splits at
               the first junction into two smaller simultaneous child
               pulses travelling different directions.

   Tuning from the console: GraphFX.render({ tails: 0, bgDots: 60, ... })
   re-renders with any DEFAULTS key overridden.
   ===================================================================== */
setTimeout(function () {
  "use strict";
  var NS = "http://www.w3.org/2000/svg";
  var INK = "#111111", ACCENT = "rgb(224, 95, 49)", BG = "#f5f5f5";
  var VBW = 1000, VBH = 920, CX = 500, CY = 452;

  var reduced = false;

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

  var DEFAULTS = {
    speed: 1.5, density: 32, breathe: 1,
    dashRatio: 0.32, accent: "balanced", pulses: true, bleed: true,
    satellites: 14, bgDots: 0, tails: 3
  };

  var rafId = null;

  function render(userCfg) {
    var cfg = Object.assign({}, DEFAULTS, userCfg || {});
    var svg = document.getElementById("kg-svg");
    if (!svg) return;
    if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
    while (svg.firstChild) svg.removeChild(svg.firstChild);

    var rnd = mulberry32(20260602);

    // ---- nodes ----
    var nodes = [];
    function pushNode(o) { o.deg = 0; nodes.push(o); return nodes.length - 1; }

    var hubs = [
      { x: 506, y: 446, r: 19, kind: "accentFill" },
      { x: 360, y: 332, r: 14, kind: "darkFill" },
      { x: 686, y: 540, r: 15, kind: "outline" },
      { x: 432, y: 654, r: 13, kind: "darkFill" },
      { x: 668, y: 304, r: 12, kind: "outline" },
      { x: 566, y: 566, r: 12, kind: "accentOutline" }
    ];
    hubs.forEach(function (h) { h.hub = true; pushNode(h); });

    function tooClose(x, y, min) {
      for (var i = 0; i < nodes.length; i++) {
        var dx = nodes[i].x - x, dy = nodes[i].y - y;
        if (dx * dx + dy * dy < min * min) return true;
      }
      return false;
    }

    var TARGET = Math.round(cfg.density);
    var guard = 0;
    while (nodes.length < TARGET + hubs.length && guard < 9000) {
      guard++;
      var x = -34 + rnd() * (VBW + 68);
      var y = -24 + rnd() * (VBH + 48);
      var d = Math.hypot(x - CX, (y - CY) * 1.04);
      var p = Math.exp(-Math.pow(d / 335, 2));
      if (rnd() > p) continue;
      if (tooClose(x, y, 56)) continue;
      pushNode({ x: x, y: y, r: 7.5 + rnd() * 3, hub: false });
    }

    /* ============== Manual position overrides ==============
       To move a HUB: edit the hubs array above (indices 0-5).
       To move a PERIPHERAL: set DEBUG_LABELS = true once to see each
       node's index AND current coordinates drawn on top of it.

         34: { dx: 200, dy: 0 }      // relative: nudge from current pos
         34: { x: 700, y: 215 }      // absolute: place at exact coords

       Coordinates are in viewBox units (0-1000 wide, 0-920 tall).
       A "nudge" is ~20-40 units; a noticeable move is ~80-150;
       a major reposition is 200+.

       NOTE: satellites are appended AFTER this block, so existing
       indices are unchanged from v1. */
    var DEBUG_LABELS = false;
    var positionOverrides = {
      34: { dx: 100, dy: 0 },
      31: { dx: -40, dy: 0 },
      32: { dx: 100, dy: 20 },
      27: { dx: 30, dy: 0 },
      15: { dx: 30, dy: -65 },
      23: { dx: -15, dy: 25 },
      20: { dx: 20, dy: 0 },
      33: { dx: -20, dy: 50 },
      25: { dx: 10, dy: -15 },
      37: { dx: -15, dy: -20 },
      10: { dx: 30, dy: 15 },
      21: { dx: 95, dy: -40 },
      28: { dx: -35, dy: 0 },
      14: { dx: -15, dy: 0 }
    };
    Object.keys(positionOverrides).forEach(function (k) {
      var idx = parseInt(k, 10);
      var o = positionOverrides[k];
      if (!nodes[idx] || !o) return;
      if (typeof o.dx === "number") nodes[idx].x += o.dx;
      else if (typeof o.x === "number") nodes[idx].x = o.x;
      if (typeof o.dy === "number") nodes[idx].y += o.dy;
      else if (typeof o.y === "number") nodes[idx].y = o.y;
    });

    /* ============== [SAT] Satellite tier ==============
       Small secondary nodes in the outer band (between the main graph
       body and the bleed region). Appended after overrides so original
       node indices are stable. Each will get exactly one thin edge to
       its nearest non-satellite node. */
    var satStart = nodes.length;
    var satGuard = 0, satCount = 0;
    while (satCount < cfg.satellites && satGuard < 6000) {
      satGuard++;
      var ang = rnd() * Math.PI * 2;
      var dist = 300 + rnd() * 150;
      var sx = CX + Math.cos(ang) * dist;
      var sy = CY + Math.sin(ang) * dist * 0.92;
      if (sx < -20 || sx > VBW + 20 || sy < -10 || sy > VBH + 10) continue;
      if (tooClose(sx, sy, 44)) continue;
      pushNode({ x: sx, y: sy, r: 3.5 + rnd() * 1.5, hub: false, sat: true });
      satCount++;
    }

    // ---- edges ----
    var edges = [], edgeSet = {};
    function key(i, j) { return i < j ? i + "" + j : j + "" + i; }
    function addEdge(i, j, thin) {
      if (i === j) return;
      var k = key(i, j);
      if (edgeSet[k]) return;
      edgeSet[k] = true;
      edges.push({ a: i, b: j, thin: !!thin });
      nodes[i].deg++; nodes[j].deg++;
    }

    for (var hi = 0; hi < hubs.length; hi++) {
      var ord = [];
      for (var hj = 0; hj < hubs.length; hj++) {
        if (hi === hj) continue;
        ord.push({ j: hj, d: Math.hypot(nodes[hi].x - nodes[hj].x, nodes[hi].y - nodes[hj].y) });
      }
      ord.sort(function (a, b) { return a.d - b.d; });
      addEdge(hi, ord[0].j); addEdge(hi, ord[1].j);
    }

    for (var n = hubs.length; n < satStart; n++) {
      var cands = [];
      for (var m = 0; m < satStart; m++) {
        if (m === n) continue;
        var dd = Math.hypot(nodes[n].x - nodes[m].x, nodes[n].y - nodes[m].y);
        var eff = dd * (nodes[m].hub ? 0.5 : (nodes[m].deg >= 3 ? 0.78 : 1));
        cands.push({ m: m, eff: eff, d: dd });
      }
      cands.sort(function (a, b) { return a.eff - b.eff; });
      addEdge(n, cands[0].m);
      if (rnd() < 0.4 && cands[1] && cands[1].d < 360) addEdge(n, cands[1].m);
    }

    // [SAT] one thin edge per satellite to nearest non-satellite node
    for (var sn = satStart; sn < nodes.length; sn++) {
      var best = -1, bestD = Infinity;
      for (var sm = 0; sm < satStart; sm++) {
        var sd = Math.hypot(nodes[sn].x - nodes[sm].x, nodes[sn].y - nodes[sm].y);
        if (sd < bestD) { bestD = sd; best = sm; }
      }
      if (best >= 0) addEdge(sn, best, true); // always connect: no floating satellites
      // occasional second satellite-to-satellite link for texture
      if (rnd() < 0.25) {
        var b2 = -1, b2d = Infinity;
        for (var s2 = satStart; s2 < nodes.length; s2++) {
          if (s2 === sn) continue;
          var d2 = Math.hypot(nodes[sn].x - nodes[s2].x, nodes[sn].y - nodes[s2].y);
          if (d2 < b2d) { b2d = d2; b2 = s2; }
        }
        if (b2 >= 0 && b2d < 200) addEdge(sn, b2, true);
      }
    }

    /* ============== [BGDOTS] background dots ==============
       Not part of the graph at all - pure depth texture. */
    var bgDots = [];
    var bgGuard = 0;
    while (bgDots.length < cfg.bgDots && bgGuard < 6000) {
      bgGuard++;
      var bx = -20 + rnd() * (VBW + 40);
      var by = -10 + rnd() * (VBH + 20);
      var bd = Math.hypot(bx - CX, (by - CY) * 1.04);
      var bp = Math.exp(-Math.pow(bd / 430, 2));
      if (rnd() > bp) continue;
      // keep clear of real nodes so they read as "behind", not colliding
      var clash = false;
      for (var bi = 0; bi < nodes.length; bi++) {
        var bdx = nodes[bi].x - bx, bdy = nodes[bi].y - by;
        if (bdx * bdx + bdy * bdy < 34 * 34) { clash = true; break; }
      }
      if (clash) continue;
      bgDots.push({
        x: bx, y: by, r: 1.4 + rnd() * 1.4,
        op: 0.13 + rnd() * 0.07,
        bw: 0.18 + rnd() * 0.2,
        bp2: rnd() * Math.PI * 2
      });
    }

    // ---- node kinds ----
    var accentMax = cfg.accent === "bold" ? 4 : (cfg.accent === "minimal" ? 0 : 2);
    var accentSpr = 0;
    nodes.forEach(function (nd) {
      if (nd.hub) {
        if (cfg.accent === "minimal" && /accent/.test(nd.kind)) {
          nd.kind = nd.kind === "accentFill" ? "darkFill" : "outline";
        }
        return;
      }
      if (nd.sat) { nd.kind = "dot"; return; } // satellites stay small dots
      nd.r = Math.min(12, nd.r + nd.deg * 0.9);
      if (nd.deg >= 4) nd.kind = "darkFill";
      else if (nd.deg === 3) nd.kind = "outline";
      else if (nd.deg === 2) nd.kind = rnd() < 0.5 ? "outline" : "dot";
      else nd.kind = "dot";
      if (accentSpr < accentMax && nd.deg >= 3 && rnd() < 0.55) {
        nd.kind = "accentOutline"; accentSpr++;
      }
    });

    // ---- off-frame stubs ----
    var stubs = [];
    if (cfg.bleed) {
      nodes.forEach(function (nd, i) {
        var nearEdge = nd.x < 80 || nd.x > VBW - 80 || nd.y < 70 || nd.y > VBH - 70;
        if (!nearEdge || nd.hub || nd.deg > 2) return;
        if (rnd() > 0.7) return;
        var ang2 = Math.atan2(nd.y - CY, nd.x - CX);
        var len = 90 + rnd() * 70;
        stubs.push({ i: i, x2: nd.x + Math.cos(ang2) * len, y2: nd.y + Math.sin(ang2) * len });
      });
    }

    // ---- edge styles ----
    var accentEdgeProb = cfg.accent === "bold" ? 0.9 : (cfg.accent === "minimal" ? 0 : 0.75);
    edges.forEach(function (e) {
      if (e.thin) { e.style = "thin"; return; }
      var touchesAccent = /accent/.test(nodes[e.a].kind) || /accent/.test(nodes[e.b].kind);
      if (touchesAccent && rnd() < accentEdgeProb) e.style = "accent";
      else if (rnd() < cfg.dashRatio) e.style = "dashed";
      else e.style = "solid";
    });

    /* ============== Adjacency, neighbour sets, accent spine ============== */

    var adj = {};
    var nodeNeighbours = {};
    for (var ai = 0; ai < nodes.length; ai++) {
      adj[ai] = [];
      nodeNeighbours[ai] = {};
    }
    edges.forEach(function (e, idx) {
      adj[e.a].push({ edgeIdx: idx, other: e.b });
      adj[e.b].push({ edgeIdx: idx, other: e.a });
      nodeNeighbours[e.a][e.b] = true;
      nodeNeighbours[e.b][e.a] = true;
    });

    var hubIndices = [];
    for (var hh = 0; hh < hubs.length; hh++) hubIndices.push(hh);

    var accentEdgeIndices = [];
    edges.forEach(function (e, i) { if (e.style === "accent") accentEdgeIndices.push(i); });

    function orderAccentBackbone() {
      if (accentEdgeIndices.length === 0) return [];
      var remaining = accentEdgeIndices.slice();
      var ordered = [];

      var startIdx = 0;
      for (var i = 0; i < remaining.length; i++) {
        var e = edges[remaining[i]];
        var aH = nodes[e.a].hub && /accent/.test(nodes[e.a].kind);
        var bH = nodes[e.b].hub && /accent/.test(nodes[e.b].kind);
        if (aH || bH) { startIdx = i; break; }
      }

      var firstEdgeIdx = remaining[startIdx];
      ordered.push(firstEdgeIdx);
      remaining.splice(startIdx, 1);
      var currentNode = edges[firstEdgeIdx].b;

      while (remaining.length > 0) {
        var nextIdx = -1;
        for (var j = 0; j < remaining.length; j++) {
          var e2 = edges[remaining[j]];
          if (e2.a === currentNode) { nextIdx = j; currentNode = e2.b; break; }
          if (e2.b === currentNode) { nextIdx = j; currentNode = e2.a; break; }
        }
        if (nextIdx === -1) {
          nextIdx = 0;
          currentNode = edges[remaining[0]].b;
        }
        ordered.push(remaining[nextIdx]);
        remaining.splice(nextIdx, 1);
      }
      return ordered;
    }

    var accentOrder = orderAccentBackbone();
    var accentRank = {};
    for (var er = 0; er < edges.length; er++) accentRank[er] = -1;
    accentOrder.forEach(function (edgeIdx, pos) { accentRank[edgeIdx] = pos; });

    /* ============== DOM groups ==============
       Paint order (bottom to top):
         gBg        - faint background dots
         gEdges     - visible edges
         gStubs     - visible off-frame stubs
         gPulses    - moving pulse streaks
         gHitsEdges - transparent wide-stroke hit targets for edges
         gNodes     - visible nodes
         gHitsNodes - transparent larger hit targets for nodes */
    var gBg        = document.createElementNS(NS, "g");
    var gEdges     = document.createElementNS(NS, "g");
    var gStubs     = document.createElementNS(NS, "g");
    var gPulses    = document.createElementNS(NS, "g");
    var gHitsEdges = document.createElementNS(NS, "g");
    var gNodes     = document.createElementNS(NS, "g");
    var gHitsNodes = document.createElementNS(NS, "g");
    svg.appendChild(gBg);
    svg.appendChild(gEdges);
    svg.appendChild(gStubs);
    svg.appendChild(gPulses);
    svg.appendChild(gHitsEdges);
    svg.appendChild(gNodes);
    svg.appendChild(gHitsNodes);

    function edgeBaseOpacity(s) {
      if (s === "accent") return 0.30;
      if (s === "dashed") return 0.17;
      if (s === "thin") return 0.10;
      return 0.15;
    }

    /* ============== Hover state ============== */
    var hoveredNode = -1;
    var hoveredEdge = -1;

    edges.forEach(function (e, idx) {
      var A = nodes[e.a], B = nodes[e.b];

      var ln = document.createElementNS(NS, "line");
      ln.setAttribute("x1", A.x); ln.setAttribute("y1", A.y);
      ln.setAttribute("x2", B.x); ln.setAttribute("y2", B.y);
      ln.setAttribute("stroke", e.style === "accent" ? ACCENT : INK);
      ln.setAttribute("stroke-width",
        e.style === "accent" ? 3.4 : (e.style === "thin" ? 2 : 3));
      ln.setAttribute("stroke-linecap", "round");
      ln.setAttribute("fill", "none");
      ln.setAttribute("opacity", 0);
      ln.style.pointerEvents = "none";
      e.len = Math.hypot(B.x - A.x, B.y - A.y);
      e.baseOp = edgeBaseOpacity(e.style);
      e.baseStrokeWidth = e.style === "accent" ? 3.4 : (e.style === "thin" ? 2 : 3);
      e.sw = 0.5 + rnd() * 0.5;
      e.sp = rnd() * Math.PI * 2;
      e.el = ln;
      e.hoverInt = 0;
      e.litAt = -Infinity;
      gEdges.appendChild(ln);

      var hit = document.createElementNS(NS, "line");
      hit.setAttribute("x1", A.x); hit.setAttribute("y1", A.y);
      hit.setAttribute("x2", B.x); hit.setAttribute("y2", B.y);
      hit.setAttribute("stroke", "transparent");
      hit.setAttribute("stroke-width", 14);
      hit.setAttribute("fill", "none");
      hit.style.cursor = "pointer";
      hit.style.pointerEvents = "stroke";
      hit.addEventListener("mouseenter", function () { hoveredEdge = idx; });
      hit.addEventListener("mouseleave", function () {
        if (hoveredEdge === idx) hoveredEdge = -1;
      });
      gHitsEdges.appendChild(hit);
    });

    stubs.forEach(function (s) {
      var A = nodes[s.i];
      var ln = document.createElementNS(NS, "line");
      ln.setAttribute("x1", A.x); ln.setAttribute("y1", A.y);
      ln.setAttribute("x2", s.x2); ln.setAttribute("y2", s.y2);
      ln.setAttribute("stroke", INK);
      ln.setAttribute("stroke-width", 2.8);
      ln.setAttribute("stroke-dasharray", "2 6");
      ln.setAttribute("stroke-linecap", "round");
      ln.setAttribute("fill", "none");
      ln.setAttribute("opacity", 0);
      ln.style.pointerEvents = "none";
      s.el = ln;
      gStubs.appendChild(ln);
    });

    // [BGDOTS] DOM
    bgDots.forEach(function (b) {
      var c = document.createElementNS(NS, "circle");
      c.setAttribute("cx", b.x); c.setAttribute("cy", b.y); c.setAttribute("r", b.r);
      c.setAttribute("fill", INK);
      c.setAttribute("opacity", 0);
      c.style.pointerEvents = "none";
      b.el = c;
      gBg.appendChild(c);
    });

    nodes.forEach(function (nd, idx) {
      var c = document.createElementNS(NS, "circle");
      c.setAttribute("cx", nd.x); c.setAttribute("cy", nd.y); c.setAttribute("r", nd.r);
      if (nd.kind === "accentFill") c.setAttribute("fill", ACCENT);
      else if (nd.kind === "darkFill" || nd.kind === "dot") c.setAttribute("fill", INK);
      else if (nd.kind === "accentOutline") {
        c.setAttribute("fill", BG); c.setAttribute("stroke", ACCENT); c.setAttribute("stroke-width", 2.2);
      } else {
        c.setAttribute("fill", BG); c.setAttribute("stroke", INK); c.setAttribute("stroke-width", 2.0);
      }
      c.setAttribute("opacity", 0);
      c.style.pointerEvents = "none";
      nd.el = c;
      nd.baseOp = nd.sat ? 0.55 : ((nd.kind === "dot") ? 0.9 : 1);
      nd.baseStrokeWidth = (nd.kind === "accentOutline") ? 2.2 :
                           (nd.kind === "outline") ? 2.0 : 0;
      nd.bw = 0.45 + rnd() * 0.5;
      nd.bp = rnd() * Math.PI * 2;
      nd.hoverInt = 0;
      nd.flareAt = -Infinity;   // [FLARE]
      gNodes.appendChild(c);

      var hit = document.createElementNS(NS, "circle");
      hit.setAttribute("cx", nd.x); hit.setAttribute("cy", nd.y);
      hit.setAttribute("r", Math.max(14, nd.r * 1.8));
      hit.setAttribute("fill", "transparent");
      hit.style.cursor = "pointer";
      hit.style.pointerEvents = "auto";
      hit.addEventListener("mouseenter", function () { hoveredNode = idx; });
      hit.addEventListener("mouseleave", function () {
        if (hoveredNode === idx) hoveredNode = -1;
      });
      gHitsNodes.appendChild(hit);
    });

    if (DEBUG_LABELS) {
      nodes.forEach(function (nd, i) {
        var label = document.createElementNS(NS, "text");
        label.setAttribute("x", nd.x + 10);
        label.setAttribute("y", nd.y - 6);
        label.setAttribute("font-size", "13");
        label.setAttribute("font-family", "monospace");
        label.setAttribute("font-weight", "bold");
        label.setAttribute("fill", "#ff0066");
        label.setAttribute("pointer-events", "none");
        label.textContent = i;
        svg.appendChild(label);

        var coord = document.createElementNS(NS, "text");
        coord.setAttribute("x", nd.x + 10);
        coord.setAttribute("y", nd.y + 8);
        coord.setAttribute("font-size", "9");
        coord.setAttribute("font-family", "monospace");
        coord.setAttribute("fill", "#ff0066");
        coord.setAttribute("opacity", "0.75");
        coord.setAttribute("pointer-events", "none");
        coord.textContent = Math.round(nd.x) + "," + Math.round(nd.y);
        svg.appendChild(coord);
      });
    }

    /* ============== Build timing ============== */
    var sp = cfg.speed || 1;
    var FADE = 0.62 / sp, STAGGER = 0.135 / sp, EDGE_LAG = 0.42 / sp, EDGE_DRAW = 0.92 / sp;
    var BR_AMP = 0.05 * cfg.breathe;

    var order2 = nodes.slice().sort(function (a, b) {
      return Math.hypot(a.x - CX, a.y - CY) - Math.hypot(b.x - CX, b.y - CY);
    });
    var lastIdx = Math.max(1, order2.length - 1);
    order2.forEach(function (nd, idx) {
      var t01 = idx / lastIdx;
      var eased = Math.pow(t01, 1.3);
      nd.appear = (0.3 / sp) + eased * lastIdx * STAGGER;
    });

    // Background dots fade in late, slow, unobtrusive
    bgDots.forEach(function (b, i) {
      b.appear = (1.2 / sp) + (i / Math.max(1, bgDots.length - 1)) * (1.6 / sp);
    });

    var nonAccentDrawEnd = 0;
    edges.forEach(function (e) {
      e.drawStart = Math.max(nodes[e.a].appear, nodes[e.b].appear) + EDGE_LAG;
      if (e.style !== "accent") {
        nonAccentDrawEnd = Math.max(nonAccentDrawEnd, e.drawStart + EDGE_DRAW);
      }
    });
    var ACCENT_SPACING = 0.22 / sp;
    accentOrder.forEach(function (edgeIdx, pos) {
      edges[edgeIdx].drawStart = nonAccentDrawEnd + 0.25 / sp + pos * ACCENT_SPACING;
    });

    var buildEnd = 0;
    edges.forEach(function (e) {
      buildEnd = Math.max(buildEnd, e.drawStart + EDGE_DRAW);
    });
    stubs.forEach(function (s) {
      s.drawStart = nodes[s.i].appear + EDGE_LAG + 0.3;
      buildEnd = Math.max(buildEnd, s.drawStart + EDGE_DRAW);
    });

    /* ============== Completion ripple ============== */
    var maxNodeDist = 0;
    nodes.forEach(function (nd) {
      var dd = Math.hypot(nd.x - CX, nd.y - CY);
      if (dd > maxNodeDist) maxNodeDist = dd;
      nd._distFromCentre = dd;
    });
    var RIPPLE_TRAVEL = 0.6;
    var RIPPLE_PULSE  = 0.55;

    function rippleAt(nd, t) {
      var delay = (nd._distFromCentre / maxNodeDist) * RIPPLE_TRAVEL;
      var age = t - buildEnd - delay;
      if (age < 0 || age > RIPPLE_PULSE) return 0;
      return Math.sin((age / RIPPLE_PULSE) * Math.PI);
    }
    function rippleEdgeAt(e, t) {
      return 0.5 * (rippleAt(nodes[e.a], t) + rippleAt(nodes[e.b], t));
    }

    /* ============== [STREAK] Traversal pulses with tails ============== */
    var activePulses = [];
    var nextPulseSpawn = buildEnd + 0.4;
    var PULSE_BASE_GAP = 1.1 / sp;
    var TAIL_SEGS = Math.max(0, Math.round(cfg.tails));  // tail circles per streak (0 = plain dots)
    var TAIL_SPACING = 5;                                // samples between tail segments

    function buildPath(startNode, maxHops, firstEdgeForce) {
      var path = [];
      var current = startNode;
      var lastNode = -1;

      for (var h = 0; h < maxHops; h++) {
        var options = adj[current];
        if (!options || options.length === 0) break;
        var forward = options.filter(function (o) { return o.other !== lastNode; });
        if (forward.length === 0) forward = options;

        var pick;
        if (h === 0 && firstEdgeForce) {
          pick = firstEdgeForce;
        } else {
          // avoid routing onto satellites too often; bias toward hubs
          var weights = forward.map(function (o) {
            if (nodes[o.other].hub) return 2;
            if (nodes[o.other].sat) return 0.4;
            return 1;
          });
          var total = weights.reduce(function (a, b) { return a + b; }, 0);
          var roll = rnd() * total;
          pick = forward[0];
          for (var k = 0; k < forward.length; k++) {
            roll -= weights[k];
            if (roll <= 0) { pick = forward[k]; break; }
          }
        }

        path.push({ edgeIdx: pick.edgeIdx, fromNode: current, toNode: pick.other });
        lastNode = current;
        current = pick.other;
      }
      return path;
    }

    function makePulseDOM(r, firstEdgeIdx) {
      var lead = document.createElementNS(NS, "circle");
      lead.setAttribute("r", r);
      lead.setAttribute("fill", edges[firstEdgeIdx].style === "accent" ? ACCENT : INK);
      lead.setAttribute("opacity", 0);
      lead.style.pointerEvents = "none";
      gPulses.appendChild(lead);

      var tails = [];
      for (var ti = 0; ti < TAIL_SEGS; ti++) {
        var tc = document.createElementNS(NS, "circle");
        tc.setAttribute("r", r * (0.75 - ti * 0.18));
        tc.setAttribute("fill", lead.getAttribute("fill"));
        tc.setAttribute("opacity", 0);
        tc.style.pointerEvents = "none";
        gPulses.appendChild(tc);
        tails.push(tc);
      }
      return { lead: lead, tails: tails };
    }

    function spawnPulse(t, opts) {
      opts = opts || {};
      var startNode = opts.startNode !== undefined
        ? opts.startNode
        : hubIndices[Math.floor(rnd() * hubIndices.length)];
      var maxHops = opts.maxHops || (4 + Math.floor(rnd() * 3));
      var path = buildPath(startNode, maxHops, opts.firstEdgeForce);
      if (path.length === 0) return;

      var r = opts.r || 3.2;
      var dom = makePulseDOM(r, path[0].edgeIdx);

      activePulses.push({
        path: path,
        currentHop: 0,
        hopStart: t,
        hopDuration: 0.7 + rnd() * 0.3,
        el: dom.lead,
        tails: dom.tails,
        samples: [],
        r: r
      });
    }

    function removePulse(pu) {
      if (pu.el.parentNode) pu.el.parentNode.removeChild(pu.el);
      pu.tails.forEach(function (tc) {
        if (tc.parentNode) tc.parentNode.removeChild(tc);
      });
    }

    /* ============== [SPLIT] Parallel-path splits ============== */
    var nextSplitTime = buildEnd + 5 + rnd() * 3;

    function maybeSpawnSplit(t) {
      if (t < nextSplitTime) return;
      nextSplitTime = t + 8 + rnd() * 4;

      var origin = 0; // centre accent hub
      var options = adj[origin];
      if (!options || options.length < 2) return;

      // pick two distinct first edges heading in different directions
      var shuffled = options.slice();
      for (var i = shuffled.length - 1; i > 0; i--) {
        var j = Math.floor(rnd() * (i + 1));
        var tmp = shuffled[i]; shuffled[i] = shuffled[j]; shuffled[j] = tmp;
      }
      var first = shuffled[0];
      var second = null;
      var ax = nodes[first.other].x - nodes[origin].x;
      var ay = nodes[first.other].y - nodes[origin].y;
      for (var s = 1; s < shuffled.length; s++) {
        var bx2 = nodes[shuffled[s].other].x - nodes[origin].x;
        var by2 = nodes[shuffled[s].other].y - nodes[origin].y;
        var dot = ax * bx2 + ay * by2;
        if (dot < 0) { second = shuffled[s]; break; } // roughly opposite direction
      }
      if (!second) second = shuffled[1];

      spawnPulse(t, { startNode: origin, firstEdgeForce: first,  r: 2.5, maxHops: 3 + Math.floor(rnd() * 3) });
      spawnPulse(t, { startNode: origin, firstEdgeForce: second, r: 2.5, maxHops: 3 + Math.floor(rnd() * 3) });
    }

    /* ============== Cascade activations ============== */
    var nextCascadeTime = buildEnd + 3.5 + rnd() * 2;
    var currentCascade = null;
    var CASCADE_RING_DELAY = 0.4;
    var CASCADE_PULSE_WIDTH = 0.9;
    var CASCADE_MAX_RINGS = 3;

    function maybeStartCascade(t) {
      if (currentCascade || t < nextCascadeTime) return;
      var origin = hubIndices[Math.floor(rnd() * hubIndices.length)];

      var dist = {};
      dist[origin] = 0;
      var queue = [origin];
      while (queue.length > 0) {
        var cur = queue.shift();
        if (dist[cur] >= CASCADE_MAX_RINGS) continue;
        var neighbours = adj[cur];
        for (var ni = 0; ni < neighbours.length; ni++) {
          var nb = neighbours[ni].other;
          if (dist[nb] === undefined) {
            dist[nb] = dist[cur] + 1;
            queue.push(nb);
          }
        }
      }
      currentCascade = {
        origin: origin,
        startTime: t,
        nodeDist: dist,
        duration: CASCADE_RING_DELAY * CASCADE_MAX_RINGS + CASCADE_PULSE_WIDTH + 0.2
      };
    }
    function updateCascade(t) {
      if (!currentCascade) return;
      if (t > currentCascade.startTime + currentCascade.duration) {
        currentCascade = null;
        nextCascadeTime = t + 4 + rnd() * 2;
      }
    }
    function cascadeNodeBoost(nodeIdx, t) {
      if (!currentCascade) return 0;
      var d = currentCascade.nodeDist[nodeIdx];
      if (d === undefined || d > CASCADE_MAX_RINGS) return 0;
      var localT = (t - currentCascade.startTime) - d * CASCADE_RING_DELAY;
      if (localT < 0 || localT > CASCADE_PULSE_WIDTH) return 0;
      var env = Math.sin((localT / CASCADE_PULSE_WIDTH) * Math.PI);
      return env * (1 - d * 0.18);
    }
    function cascadeEdgeBoost(e, t) {
      if (!currentCascade) return 0;
      var ba = cascadeNodeBoost(e.a, t);
      var bb = cascadeNodeBoost(e.b, t);
      return Math.max(ba, bb);
    }

    /* ============== Accent spine wave ============== */
    var nextWaveTime = buildEnd + 6 + rnd() * 4;
    var currentWave = null;
    var WAVE_EDGE_DELAY = 0.35 / sp;
    var WAVE_EDGE_DURATION = 0.8 / sp;

    function maybeStartWave(t) {
      if (currentWave || accentOrder.length === 0 || t < nextWaveTime) return;
      currentWave = {
        startTime: t,
        duration: accentOrder.length * WAVE_EDGE_DELAY + WAVE_EDGE_DURATION
      };
    }
    function updateWave(t) {
      if (!currentWave) return;
      if (t > currentWave.startTime + currentWave.duration) {
        currentWave = null;
        nextWaveTime = t + 14 + rnd() * 8;
      }
    }
    function waveBoostFor(edgeIdx, t) {
      if (!currentWave) return 0;
      var rank = accentRank[edgeIdx];
      if (rank < 0) return 0;
      var localT = (t - currentWave.startTime) - rank * WAVE_EDGE_DELAY;
      if (localT < 0 || localT > WAVE_EDGE_DURATION) return 0;
      return Math.sin((localT / WAVE_EDGE_DURATION) * Math.PI) * 0.6;
    }

    /* ============== [FLARE] arrival flares ============== */
    var FLARE_DURATION = 0.45;
    function flareAt(nd, t) {
      var age = t - nd.flareAt;
      if (age < 0 || age > FLARE_DURATION) return 0;
      return Math.sin((age / FLARE_DURATION) * Math.PI);
    }

    /* ============== Helpers ============== */
    function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
    function clamp01(t) { return t < 0 ? 0 : t > 1 ? 1 : t; }
    function smoothApproach(current, target, rate, dt) {
      return current + (target - current) * (1 - Math.exp(-rate * dt));
    }

    if (reduced) {
      nodes.forEach(function (nd) { nd.el.setAttribute("opacity", nd.baseOp); });
      edges.forEach(function (e) {
        e.el.setAttribute("opacity", e.baseOp);
        if (e.style === "dashed") e.el.setAttribute("stroke-dasharray", "5 7");
      });
      stubs.forEach(function (s) { s.el.setAttribute("opacity", 0.1); });
      bgDots.forEach(function (b) { b.el.setAttribute("opacity", b.op); });
      return;
    }

    var t0 = null;
    var lastFrameNow = null;
    function frame(now) {
      if (t0 === null) t0 = now;
      var t = (now - t0) / 1000;
      var dt = lastFrameNow !== null ? Math.min(0.1, (now - lastFrameNow) / 1000) : 1 / 60;
      lastFrameNow = now;

      maybeStartCascade(t);
      updateCascade(t);
      maybeStartWave(t);
      updateWave(t);
      maybeSpawnSplit(t);

      if (t > buildEnd && t > nextPulseSpawn) {
        spawnPulse(t);
        nextPulseSpawn = t + PULSE_BASE_GAP * (0.85 + rnd() * 0.3);
      }

      /* ---- Background dots ---- */
      for (var bi2 = 0; bi2 < bgDots.length; bi2++) {
        var b = bgDots[bi2];
        var bfa = clamp01((t - b.appear) / (FADE * 1.6));
        var bop = easeOut(bfa) * b.op;
        // slow breathe on opacity rather than radius - subtler
        if (bfa >= 1) bop = b.op * (1 + 0.18 * Math.sin(t * b.bw + b.bp2));
        b.el.setAttribute("opacity", bop.toFixed(3));
      }

      /* ---- Nodes ---- */
      for (var i = 0; i < nodes.length; i++) {
        var nd = nodes[i];
        var fa = clamp01((t - nd.appear) / FADE);
        var baseOp = easeOut(fa) * nd.baseOp;
        var r = nd.r;
        if (t > nd.appear + FADE && BR_AMP > 0) {
          r = nd.r * (1 + BR_AMP * Math.sin(t * nd.bw + nd.bp));
        }

        var targetNodeHover = 0;
        if (hoveredNode === i) targetNodeHover = 1;
        else if (hoveredNode !== -1 && nodeNeighbours[hoveredNode][i]) targetNodeHover = 0.55;
        nd.hoverInt = smoothApproach(nd.hoverInt, targetNodeHover, 10, dt);

        r *= (1 + nd.hoverInt * 0.30);
        r *= (1 + cascadeNodeBoost(i, t) * 0.18);
        r *= (1 + rippleAt(nd, t) * 0.20);

        // [FLARE] arrival flare - hubs flare harder
        var fl = flareAt(nd, t);
        if (fl > 0) {
          r *= (1 + fl * (nd.hub ? 0.40 : 0.25));
        }

        if (nd.baseStrokeWidth > 0) {
          var swExtra = nd.hoverInt * 0.8 + fl * 0.6;
          nd.el.setAttribute("stroke-width", (nd.baseStrokeWidth + swExtra).toFixed(2));
        }

        var op = baseOp + (1 - baseOp) * Math.max(nd.hoverInt, fl) * easeOut(fa);

        nd.el.setAttribute("opacity", op.toFixed(3));
        nd.el.setAttribute("r", r.toFixed(2));
      }

      /* ---- Edges ---- */
      for (var j = 0; j < edges.length; j++) {
        var e = edges[j];
        var prog = clamp01((t - e.drawStart) / EDGE_DRAW);
        if (prog <= 0) { e.el.setAttribute("opacity", 0); continue; }

        if (prog < 1) {
          e.el.setAttribute("opacity", e.baseOp.toFixed(3));
          e.el.setAttribute("stroke-dasharray", e.len);
          e.el.setAttribute("stroke-dashoffset", (e.len * (1 - easeOut(prog))).toFixed(2));
          continue;
        }

        if (!e.settled) {
          if (e.style === "dashed") e.el.setAttribute("stroke-dasharray", "5 7");
          else e.el.setAttribute("stroke-dasharray", "none");
          e.el.setAttribute("stroke-dashoffset", 0);
          e.settled = true;
        }

        var sh = e.baseOp * (1 + 0.24 * Math.sin(t * e.sw + e.sp));

        var litAge = t - e.litAt;
        if (litAge >= 0 && litAge < 0.6) {
          var litStrength = 1 - litAge / 0.6;
          sh += e.baseOp * 3.0 * litStrength;
        }

        sh += e.baseOp * waveBoostFor(j, t);
        sh += e.baseOp * cascadeEdgeBoost(e, t) * 1.5;
        sh += e.baseOp * rippleEdgeAt(e, t) * 1.2;

        var targetEdgeHover = 0;
        if (hoveredEdge === j) targetEdgeHover = 1;
        else if (hoveredNode !== -1 && (e.a === hoveredNode || e.b === hoveredNode)) {
          targetEdgeHover = 0.75;
        }
        e.hoverInt = smoothApproach(e.hoverInt, targetEdgeHover, 10, dt);

        sh = sh + (1 - sh) * e.hoverInt * 0.9;
        var strokeBoost = e.hoverInt * 0.5;
        e.el.setAttribute("stroke-width", (e.baseStrokeWidth + strokeBoost).toFixed(2));

        if (sh > 1) sh = 1;
        if (sh < 0) sh = 0;
        e.el.setAttribute("opacity", sh.toFixed(3));
      }

      /* ---- Stubs ---- */
      for (var ks = 0; ks < stubs.length; ks++) {
        var s = stubs[ks];
        var spg = clamp01((t - s.drawStart) / EDGE_DRAW);
        s.el.setAttribute("opacity", (0.12 * easeOut(spg)).toFixed(3));
      }

      /* ---- [STREAK] pulses, tails, lit trails, flares ---- */
      for (var pi = activePulses.length - 1; pi >= 0; pi--) {
        var pu = activePulses[pi];

        while (pu.currentHop < pu.path.length && t >= pu.hopStart + pu.hopDuration) {
          // [FLARE] the node we just arrived at flares
          var arrived = pu.path[pu.currentHop].toNode;
          nodes[arrived].flareAt = t;

          pu.hopStart += pu.hopDuration;
          pu.hopDuration = 0.7 + rnd() * 0.3;
          pu.currentHop++;
        }

        if (pu.currentHop >= pu.path.length) {
          removePulse(pu);
          activePulses.splice(pi, 1);
          continue;
        }

        var hop = pu.path[pu.currentHop];
        var hopT = clamp01((t - pu.hopStart) / pu.hopDuration);
        var A = nodes[hop.fromNode];
        var B = nodes[hop.toNode];
        var eased = hopT * hopT * (3 - 2 * hopT);
        var px = A.x + (B.x - A.x) * eased;
        var py = A.y + (B.y - A.y) * eased;

        edges[hop.edgeIdx].litAt = t;

        var currentEdge = edges[hop.edgeIdx];
        var fillColor = currentEdge.style === "accent" ? ACCENT : INK;
        pu.el.setAttribute("fill", fillColor);

        var totalT = pu.currentHop + hopT;
        var totalLen = pu.path.length;
        var pop;
        if (totalT < 0.5) pop = totalT / 0.5;
        else if (totalT > totalLen - 0.8) pop = (totalLen - totalT) / 0.8;
        else pop = 1;
        if (pop < 0) pop = 0; if (pop > 1) pop = 1;

        pu.el.setAttribute("cx", px.toFixed(2));
        pu.el.setAttribute("cy", py.toFixed(2));
        pu.el.setAttribute("opacity", (0.85 * pop).toFixed(3));

        // [STREAK] record sample, place tail segments at older samples
        pu.samples.push({ x: px, y: py });
        if (pu.samples.length > (TAIL_SEGS + 1) * TAIL_SPACING + 2) pu.samples.shift();

        for (var ti2 = 0; ti2 < pu.tails.length; ti2++) {
          var tailEl = pu.tails[ti2];
          var sIdx = pu.samples.length - 1 - (ti2 + 1) * TAIL_SPACING;
          if (sIdx >= 0) {
            var smp = pu.samples[sIdx];
            tailEl.setAttribute("cx", smp.x.toFixed(2));
            tailEl.setAttribute("cy", smp.y.toFixed(2));
            tailEl.setAttribute("fill", fillColor);
            var tailOp = 0.85 * pop * (1 - (ti2 + 1) / (TAIL_SEGS + 1.2)) * 0.6;
            tailEl.setAttribute("opacity", tailOp.toFixed(3));
          } else {
            tailEl.setAttribute("opacity", 0);
          }
        }
      }

      rafId = requestAnimationFrame(frame);
    }
    rafId = requestAnimationFrame(frame);
  }

  window.GraphFX = { render: render };

  // The svg is created by React, and on slow or cold loads (Safari, iPad,
  // first visit) the mount can land well after this script runs. A one-shot
  // render() here silently no-ops if the svg isn't in the DOM yet, so poll
  // until it appears (capped at ~15s), then render once.
  (function boot(tries) {
    if (document.getElementById("kg-svg")) { render(); return; }
    if (tries < 300) setTimeout(function () { boot(tries + 1); }, 50);
  })(0);
}, 150);