// scene.jsx — A cinematic, continuous transformation from a deterministic
// engineered network into a responsive, behavioral system.
//
// One scene, no cuts. A `phase` value (0..1) drives every parameter:
// node jitter, edge curvature, signal branching probability, color
// temperature, breathing amplitude, label visibility.

const DUR = 30;            // total seconds
const W = 1600, H = 900;

// Phase helpers ─────────────────────────────────────────────────────────
// All easings continuous, no abrupt cuts.
const phaseAt = (t) => clamp(t / DUR, 0, 1);

// Smooth ramps along the timeline. Each returns 0..1.
// Act I dominates 0..0.30, Act II 0.20..0.65, Act III 0.55..1.0
const smooth = (a, b, p) => {
  if (p <= a) return 0;
  if (p >= b) return 1;
  const x = (p - a) / (b - a);
  return x * x * (3 - 2 * x); // smoothstep
};

// Color lerp in oklch-ish space — we just lerp three perceptual channels
// and emit oklch() string. Looks great in modern browsers.
const oklch = (L, C, H, alpha = 1) =>
  `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(2)} / ${alpha.toFixed(3)})`;

// Background drifts: cool off-white → warm off-white (light mode)
const bgColor = (p) => {
  const L = 0.975 - 0.012 * smooth(0.5, 1.0, p);
  const C = 0.004 + 0.006 * smooth(0.4, 1.0, p);
  const H = 245 - 195 * smooth(0.3, 1.0, p); // 245 cool → 50 warm
  return oklch(L, C, H);
};

// Accent: deep cyan-blue → deep amber across the piece (saturated for light bg)
const accent = (p, alpha = 1, lift = 0) => {
  const L = 0.55 + lift;
  const C = 0.16 + 0.04 * smooth(0.4, 1.0, p);
  const H = 230 - 195 * smooth(0.25, 1.0, p);
  return oklch(L, C, H, alpha);
};

// Subtle secondary accent (used sparsely in act II/III)
const accent2 = (p, alpha = 1) => {
  const L = 0.62;
  const C = 0.13 + 0.05 * smooth(0.5, 1.0, p);
  const H = 260 - 200 * smooth(0.3, 1.0, p);
  return oklch(L, C, H, alpha);
};

// Stroke base (lines) — dark on light, gets a touch warmer over time
const lineColor = (p, alpha = 1) => {
  const L = 0.32 - 0.08 * smooth(0.5, 1.0, p);
  const C = 0.02 + 0.03 * smooth(0.5, 1.0, p);
  const H = 230 - 195 * smooth(0.3, 1.0, p);
  return oklch(L, C, H, alpha);
};

// ── Network topology ───────────────────────────────────────────────────
// Build a strict 7×4 grid of nodes. Each node has a target organic
// position it drifts to as phase advances. Edges connect right-neighbors
// and select diagonals. Some edges are "feedback" — appearing in act II.

const COLS = 9;
const ROWS = 5;
const MARGIN_X = 200;
const MARGIN_Y = 150;
const GRID_W = W - MARGIN_X * 2;
const GRID_H = H - MARGIN_Y * 2;

// Deterministic pseudo-random — same on every render
const hash = (i) => {
  let x = Math.sin(i * 9301 + 49297) * 233280;
  return x - Math.floor(x);
};

const NODES = (() => {
  const arr = [];
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const i = r * COLS + c;
      const gx = MARGIN_X + (c / (COLS - 1)) * GRID_W;
      const gy = MARGIN_Y + (r / (ROWS - 1)) * GRID_H;
      // Organic destination — small offset, biased into clusters
      const ang = hash(i) * Math.PI * 2;
      const rad = 30 + hash(i + 100) * 90;
      const ox = gx + Math.cos(ang) * rad + (hash(i + 200) - 0.5) * 80;
      const oy = gy + Math.sin(ang) * rad + (hash(i + 300) - 0.5) * 80;
      // Activation phase: each node has its own breathing offset
      const breathPhase = hash(i + 400) * Math.PI * 2;
      arr.push({ i, r, c, gx, gy, ox, oy, breathPhase });
    }
  }
  return arr;
})();

const node = (r, c) => NODES[r * COLS + c];

// Edges: deterministic horizontal flow + a few verticals + later, feedbacks.
const EDGES = (() => {
  const arr = [];
  // Horizontal (deterministic forward signal flow)
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS - 1; c++) {
      arr.push({ a: node(r, c), b: node(r, c + 1), kind: 'h', i: arr.length });
    }
  }
  // Verticals — every other column
  for (let c = 1; c < COLS; c += 2) {
    for (let r = 0; r < ROWS - 1; r++) {
      arr.push({ a: node(r, c), b: node(r + 1, c), kind: 'v', i: arr.length });
    }
  }
  // Diagonals — appear in act II as branching paths
  const diagPairs = [
    [0, 1, 1, 2], [2, 2, 3, 3], [1, 3, 2, 4], [3, 4, 4, 5], [2, 5, 1, 6],
    [0, 6, 1, 7], [4, 6, 3, 7], [1, 0, 2, 1], [4, 1, 3, 2],
    [0, 4, 1, 5], [3, 0, 4, 1], [2, 7, 3, 8], [4, 7, 3, 8],
  ];
  for (const [r1, c1, r2, c2] of diagPairs) {
    arr.push({ a: node(r1, c1), b: node(r2, c2), kind: 'd', i: arr.length });
  }
  // Feedback edges (long, curved, appear act II onward)
  const feedbackPairs = [
    [2, 6, 2, 2], [1, 7, 0, 3], [4, 5, 3, 1], [3, 8, 4, 4],
    [0, 7, 2, 4], [4, 8, 1, 5],
  ];
  for (const [r1, c1, r2, c2] of feedbackPairs) {
    arr.push({ a: node(r1, c1), b: node(r2, c2), kind: 'f', i: arr.length });
  }
  return arr;
})();

// Position interpolation — node drifts from grid → organic target
function nodePos(n, p, t) {
  const drift = smooth(0.30, 0.85, p);
  const x = n.gx + (n.ox - n.gx) * drift;
  const y = n.gy + (n.oy - n.gy) * drift;
  // Living micro-breath — only appears in act III
  const breath = smooth(0.55, 1.0, p);
  const bx = Math.cos(t * 0.45 + n.breathPhase) * 6 * breath;
  const by = Math.sin(t * 0.36 + n.breathPhase * 1.3) * 6 * breath;
  return [x + bx, y + by];
}

// Edge path — straight in act I, curved in act II/III
function edgePath(e, p, t) {
  const [ax, ay] = nodePos(e.a, p, t);
  const [bx, by] = nodePos(e.b, p, t);
  const curve = smooth(0.25, 0.85, p) * (e.kind === 'f' ? 1.4 : 1.0);
  // Perpendicular offset for curvature
  const dx = bx - ax, dy = by - ay;
  const len = Math.hypot(dx, dy) || 1;
  const nx = -dy / len, ny = dx / len;
  // Each edge has its own curvature direction (deterministic)
  const sign = (hash(e.i + 7) > 0.5 ? 1 : -1);
  const k = curve * (40 + hash(e.i) * 80) * sign;
  // Add a tiny living wobble in act III
  const wob = smooth(0.6, 1.0, p) * Math.sin(t * 0.28 + e.i) * 8;
  const cx = (ax + bx) / 2 + nx * (k + wob);
  const cy = (ay + by) / 2 + ny * (k + wob);
  return { ax, ay, bx, by, cx, cy };
}

// ── Signals ────────────────────────────────────────────────────────────
// In act I: pulses travel deterministically along horizontal edges, each
//   taking exactly 1.6s, perfectly staggered.
// In act II: pulses sometimes branch (split at a node), and feedback
//   pulses travel BACKWARD along feedback edges.
// In act III: pulses become diffuse glows that ripple between nodes.
//
// We compute each signal's position by parametric `u ∈ [0, 1]` along an
// edge sequence (a "path" of edges).

// Build a few pre-defined signal "routes" that branch/feedback as time
// progresses. Each route is a sequence of edges + per-segment timing.
// To keep it cinematic and not chaotic, we hand-tune ~12 signals.

const SIGNALS = (() => {
  const sigs = [];
  // Act I: 5 horizontal pulses, one per row, repeating.
  for (let r = 0; r < ROWS; r++) {
    sigs.push({
      kind: 'horizontal',
      row: r,
      offset: r * 0.40,           // staggered start
      period: 6.4,                // seconds
      speed: 1.0,
    });
  }
  // Act II: branching pulses — start at left edge, bifurcate mid-path.
  const branches = [
    { row: 1, branchCol: 4, branchTo: 2, offset: 0.5 },
    { row: 3, branchCol: 5, branchTo: 4, offset: 1.6 },
    { row: 0, branchCol: 6, branchTo: 1, offset: 2.4 },
  ];
  for (const b of branches) sigs.push({ kind: 'branching', ...b, period: 8.8, speed: 0.85 });
  // Act II/III: feedback loops — pulses travel back through curved arcs
  const feedbacks = [
    { fromRow: 2, fromCol: 6, toRow: 2, toCol: 2, offset: 0.0, period: 9.6 },
    { fromRow: 4, fromCol: 5, toRow: 3, toCol: 1, offset: 2.0, period: 10.4 },
    { fromRow: 1, fromCol: 7, toRow: 0, toCol: 3, offset: 3.6, period: 11.6 },
  ];
  for (const f of feedbacks) sigs.push({ kind: 'feedback', ...f, speed: 0.6 });
  // Act III: ambient ripples that emit from random nodes
  for (let i = 0; i < 6; i++) {
    sigs.push({
      kind: 'ripple',
      seedNode: NODES[(i * 7 + 3) % NODES.length],
      offset: i * 2.4,
      period: 9.6,
    });
  }
  return sigs;
})();

// ── Components ─────────────────────────────────────────────────────────

function Background() {
  const t = useTime();
  const p = phaseAt(t);
  // Subtle vignette + grid that fades out over time
  const gridAlpha = 0.16 * (1 - smooth(0.20, 0.55, p));
  const gridSpacing = 80;
  return (
    <div style={{ position: 'absolute', inset: 0, background: bgColor(p), overflow: 'hidden' }}>
      <svg width={W} height={H} style={{ position: 'absolute', inset: 0 }}>
        <defs>
          <pattern id="grid" width={gridSpacing} height={gridSpacing} patternUnits="userSpaceOnUse">
            <path d={`M ${gridSpacing} 0 L 0 0 0 ${gridSpacing}`} fill="none"
              stroke={oklch(0.30, 0.02, 230, gridAlpha)} strokeWidth="0.6" />
          </pattern>
          <radialGradient id="vignette" cx="50%" cy="50%" r="75%">
            <stop offset="55%" stopColor="rgba(0,0,0,0)" />
            <stop offset="100%" stopColor="rgba(20,20,30,0.10)" />
          </radialGradient>
        </defs>
        <rect width={W} height={H} fill="url(#grid)" />
        <rect width={W} height={H} fill="url(#vignette)" />
      </svg>
    </div>
  );
}

function Edges() {
  const t = useTime();
  const p = phaseAt(t);

  return (
    <svg width={W} height={H} style={{ position: 'absolute', inset: 0 }}>
      {EDGES.map((e) => {
        // Edge visibility ramps in by kind
        let alpha = 0;
        if (e.kind === 'h') alpha = 0.55 + 0.15 * smooth(0.55, 1.0, p);
        else if (e.kind === 'v') alpha = 0.30 + 0.10 * smooth(0.55, 1.0, p);
        else if (e.kind === 'd') alpha = 0.42 * smooth(0.18, 0.45, p) + 0.10 * smooth(0.55, 1.0, p);
        else if (e.kind === 'f') alpha = 0.35 * smooth(0.32, 0.65, p);

        // Edges become softer (lower opacity, lighter) in act III
        const fade = smooth(0.65, 1.0, p);
        alpha *= 1 - fade * 0.35;

        const { ax, ay, bx, by, cx, cy } = edgePath(e, p, t);
        const stroke = lineColor(p, alpha);
        const sw = 0.9 + 0.5 * smooth(0.5, 1.0, p);

        return (
          <path
            key={e.i}
            d={`M ${ax} ${ay} Q ${cx} ${cy} ${bx} ${by}`}
            fill="none"
            stroke={stroke}
            strokeWidth={sw}
            strokeLinecap="round"
          />
        );
      })}
    </svg>
  );
}

function Nodes() {
  const t = useTime();
  const p = phaseAt(t);

  return (
    <svg width={W} height={H} style={{ position: 'absolute', inset: 0 }}>
      {NODES.map((n) => {
        const [x, y] = nodePos(n, p, t);
        // Node "activation" — pulses with each horizontal signal pass
        const sigT = ((t * 1.0 / 6.4) + (n.c / COLS) * 0.0 + n.r * 0.06) % 1;
        // Fire when a horizontal signal reaches this column
        const colReach = ((t / 6.4) % 1);
        const fireWindow = Math.max(0, 1 - Math.abs(colReach - n.c / (COLS - 1)) * 8);
        const determActivation = fireWindow * (1 - smooth(0.45, 0.85, p));

        // Organic activation in act III — slow breathing pulses
        const organic = smooth(0.55, 1.0, p) *
          (0.5 + 0.5 * Math.sin(t * 0.6 + n.breathPhase * 1.7));

        const activation = Math.max(determActivation, organic * 0.7);

        const baseR = 4 + 1.5 * smooth(0.5, 1.0, p);
        const r = baseR + activation * 4;
        const haloR = r + 6 + activation * 18;

        // Solid dark fill on light bg, drifts warm with phase
        const fillL = 0.28 - 0.06 * smooth(0.5, 1.0, p);
        const fillC = 0.04 + 0.05 * smooth(0.5, 1.0, p);
        const fillH = 230 - 195 * smooth(0.3, 1.0, p);
        const fill = oklch(fillL, fillC, fillH, 0.95);

        const haloAlpha = 0.05 + 0.22 * activation;

        return (
          <g key={n.i}>
            <circle cx={x} cy={y} r={haloR} fill={accent(p, haloAlpha)} />
            <circle cx={x} cy={y} r={r} fill={fill} />
            <circle cx={x} cy={y} r={r * 0.35} fill={accent(p, 0.85, 0.20)} />
          </g>
        );
      })}
    </svg>
  );
}

// ── Signals layer ──────────────────────────────────────────────────────
// Renders per-signal moving glow + trail.
function Signals() {
  const t = useTime();
  const p = phaseAt(t);

  // Helper: get position along an edge at parametric u ∈ [0,1]
  const along = (e, u, p_, t_) => {
    const { ax, ay, bx, by, cx, cy } = edgePath(e, p_, t_);
    // quadratic bezier
    const x = (1 - u) * (1 - u) * ax + 2 * (1 - u) * u * cx + u * u * bx;
    const y = (1 - u) * (1 - u) * ay + 2 * (1 - u) * u * cy + u * u * by;
    return [x, y];
  };

  // Build live signal positions
  const dots = [];

  for (const s of SIGNALS) {
    if (s.kind === 'horizontal') {
      // Active throughout but strongest in act I
      const strength = (1 - smooth(0.55, 1.0, p)) * 0.7 + 0.3 * (1 - smooth(0.7, 1.0, p));
      if (strength <= 0.02) continue;
      const local = ((t + s.offset) % s.period) / s.period;
      const colF = local * (COLS - 1);
      const ci = Math.floor(colF);
      const cu = colF - ci;
      if (ci >= COLS - 1) continue;
      const e = EDGES.find(ed => ed.kind === 'h' && ed.a.r === s.row && ed.a.c === ci);
      if (!e) continue;
      const [x, y] = along(e, cu, p, t);
      dots.push({
        x, y,
        r: 5 + 2 * Math.sin(local * Math.PI),
        color: accent(p, strength * (0.6 + 0.4 * Math.sin(local * Math.PI))),
        glow: 18,
      });
    } else if (s.kind === 'branching') {
      const strength = smooth(0.20, 0.40, p) * (1 - smooth(0.75, 1.0, p));
      if (strength <= 0.02) continue;
      const local = ((t + s.offset) % s.period) / s.period;
      // Pulse traverses row from c=0 to branchCol, then branches: half continues, half jumps to branchTo row
      const totalLen = s.branchCol + (COLS - 1 - s.branchCol) + 1; // segments
      const segF = local * totalLen;
      const segI = Math.floor(segF);
      const segU = segF - segI;

      if (segI < s.branchCol) {
        // Pre-branch on row
        const e = EDGES.find(ed => ed.kind === 'h' && ed.a.r === s.row && ed.a.c === segI);
        if (e) {
          const [x, y] = along(e, segU, p, t);
          dots.push({ x, y, r: 5, color: accent(p, strength), glow: 16 });
        }
      } else if (segI === s.branchCol) {
        // Bifurcation point — emit two dots at the branch
        const e1 = EDGES.find(ed => ed.kind === 'h' && ed.a.r === s.row && ed.a.c === segI);
        const e2 = EDGES.find(ed => ed.kind === 'd' && ed.a === node(s.row, segI) && ed.b === node(s.branchTo, segI + 1));
        if (e1) { const [x, y] = along(e1, segU, p, t); dots.push({ x, y, r: 5, color: accent(p, strength), glow: 14 }); }
        if (e2) { const [x, y] = along(e2, segU, p, t); dots.push({ x, y, r: 4, color: accent2(p, strength * 0.85), glow: 12 }); }
      } else {
        // Post-branch on original row
        const e = EDGES.find(ed => ed.kind === 'h' && ed.a.r === s.row && ed.a.c === segI);
        if (e) {
          const [x, y] = along(e, segU, p, t);
          dots.push({ x, y, r: 4, color: accent(p, strength * 0.7), glow: 10 });
        }
        const e2 = EDGES.find(ed => ed.kind === 'h' && ed.a.r === s.branchTo && ed.a.c === segI);
        if (e2) {
          const [x, y] = along(e2, segU, p, t);
          dots.push({ x, y, r: 4, color: accent2(p, strength * 0.7), glow: 10 });
        }
      }
    } else if (s.kind === 'feedback') {
      const strength = smooth(0.32, 0.55, p) * (1 - smooth(0.85, 1.0, p));
      if (strength <= 0.02) continue;
      const local = ((t + s.offset) % s.period) / s.period;
      const e = EDGES.find(ed =>
        ed.kind === 'f' &&
        ed.a === node(s.fromRow, s.fromCol) &&
        ed.b === node(s.toRow, s.toCol)
      );
      if (!e) continue;
      // Travel from a → b but visually it reads as backward feedback
      const [x, y] = along(e, local, p, t);
      const pulse = 0.4 + 0.6 * Math.sin(local * Math.PI);
      dots.push({
        x, y,
        r: 4 + pulse * 2,
        color: accent2(p, strength * pulse),
        glow: 14 + pulse * 6,
      });
    } else if (s.kind === 'ripple') {
      const strength = smooth(0.55, 0.85, p);
      if (strength <= 0.02) continue;
      const local = ((t + s.offset) % s.period) / s.period;
      const [nx, ny] = nodePos(s.seedNode, p, t);
      const radius = local * 220;
      const rippleAlpha = (1 - local) * strength * 0.5;
      // Render as expanding ring — push as special dot
      dots.push({
        x: nx, y: ny,
        ring: radius,
        color: accent(p, rippleAlpha),
        r: 0, glow: 0,
      });
    }
  }

  return (
    <svg width={W} height={H} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
      <defs>
        <filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur stdDeviation="3" />
        </filter>
      </defs>
      {dots.map((d, i) => (
        d.ring != null ? (
          <circle key={i} cx={d.x} cy={d.y} r={d.ring} fill="none" stroke={d.color} strokeWidth="1.2" />
        ) : (
          <g key={i}>
            <circle cx={d.x} cy={d.y} r={d.r + d.glow * 0.6} fill={d.color} opacity={0.22} filter="url(#glow)" />
            <circle cx={d.x} cy={d.y} r={d.r + 1.5} fill={d.color} opacity={0.85} />
            <circle cx={d.x} cy={d.y} r={d.r * 0.5} fill={oklch(0.98, 0.005, 60, 0.95)} />
          </g>
        )
      ))}
    </svg>
  );
}

// ── Type / chapter labels ──────────────────────────────────────────────
// Three quiet labels appear and dissolve. Mono font in act I/II, soft
// transition by act III where label stays only as a faint reading of state.

function Labels() {
  const t = useTime();
  const p = phaseAt(t);

  const items = [
    { text: 'system / deterministic', from: 0.04, to: 0.22 },
    { text: 'feedback / branching',   from: 0.34, to: 0.55 },
    { text: 'behavior / response',    from: 0.66, to: 0.92 },
  ];

  // small running readout (top-right) showing "state" interpretation
  const readout = [
    { text: 'inputs → outputs',         from: 0.02, to: 0.28 },
    { text: 'outputs ⇄ inputs',         from: 0.30, to: 0.58 },
    { text: 'context shapes outcome',   from: 0.60, to: 0.97 },
  ];

  const fade = (from, to) => {
    const fadeIn = 0.04, fadeOut = 0.08;
    if (p < from || p > to) return 0;
    if (p < from + fadeIn) return (p - from) / fadeIn;
    if (p > to - fadeOut) return (to - p) / fadeOut;
    return 1;
  };

  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
      {items.map((it, i) => {
        const a = fade(it.from, it.to);
        if (a <= 0) return null;
        return (
          <div key={i} style={{
            position: 'absolute',
            left: 80, bottom: 80,
            opacity: a,
            color: lineColor(p, 0.95),
            fontFamily: 'JetBrains Mono, ui-monospace, monospace',
            fontSize: 22,
            letterSpacing: '0.2em',
            textTransform: 'uppercase',
            transform: `translateY(${(1 - a) * 8}px)`,
            transition: 'none',
          }}>
            <span style={{ opacity: 0.55 }}>0{i + 1} —&nbsp;</span>
            {it.text}
          </div>
        );
      })}

      {readout.map((it, i) => {
        const a = fade(it.from, it.to);
        if (a <= 0) return null;
        return (
          <div key={i} style={{
            position: 'absolute',
            right: 80, top: 80,
            opacity: a * 0.75,
            color: lineColor(p, 0.95),
            fontFamily: 'JetBrains Mono, ui-monospace, monospace',
            fontSize: 13,
            letterSpacing: '0.18em',
            textTransform: 'uppercase',
            textAlign: 'right',
          }}>
            {it.text}
          </div>
        );
      })}

      {/* Closing whisper, only at the very end */}
      {p > 0.93 && (
        <div style={{
          position: 'absolute',
          left: '50%', top: '50%',
          transform: 'translate(-50%, -50%)',
          opacity: smooth(0.93, 0.98, p) * (1 - smooth(0.985, 1.0, p)),
          color: oklch(0.22, 0.05, 60, 0.95),
          fontFamily: 'Inter, system-ui, sans-serif',
          fontSize: 26,
          fontWeight: 400,
          letterSpacing: '0.02em',
          textAlign: 'center',
          fontStyle: 'italic',
        }}>
          systems that compute &nbsp;·&nbsp; systems that understand
        </div>
      )}
    </div>
  );
}

// ── Frame chrome — the corner ticks that dissolve as engineering recedes
function FrameChrome() {
  const t = useTime();
  const p = phaseAt(t);
  const a = (1 - smooth(0.30, 0.65, p)) * 0.8;
  if (a <= 0.01) return null;
  const stroke = lineColor(p, a);
  const tickLen = 28;
  const inset = 56;
  const corner = (cx, cy, dx, dy, key) => (
    <g key={key}>
      <line x1={cx} y1={cy} x2={cx + dx * tickLen} y2={cy} stroke={stroke} strokeWidth="1" />
      <line x1={cx} y1={cy} x2={cx} y2={cy + dy * tickLen} stroke={stroke} strokeWidth="1" />
    </g>
  );
  return (
    <svg width={W} height={H} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
      {corner(inset, inset, 1, 1, 'tl')}
      {corner(W - inset, inset, -1, 1, 'tr')}
      {corner(inset, H - inset, 1, -1, 'bl')}
      {corner(W - inset, H - inset, -1, -1, 'br')}
      {/* Tiny scale tick under top-left, like a technical diagram */}
      <text x={inset + 38} y={inset + 14} fill={stroke}
        fontFamily="JetBrains Mono, monospace" fontSize="11" letterSpacing="0.2em">
        N · {String(NODES.length).padStart(2, '0')}
      </text>
      <text x={W - inset - 38} y={H - inset - 4} fill={stroke}
        fontFamily="JetBrains Mono, monospace" fontSize="11" letterSpacing="0.2em" textAnchor="end">
        E · {String(EDGES.length).padStart(2, '0')}
      </text>
    </svg>
  );
}

function Scene() {
  return (
    <React.Fragment>
      <Background />
      <Edges />
      <Signals />
      <Nodes />
      <FrameChrome />
      <Labels />
    </React.Fragment>
  );
}

window.Scene = Scene;
window.SCENE_DURATION = DUR;
window.SCENE_W = W;
window.SCENE_H = H;
