All loaders

Canvas 2D · 4.1 KB gzip

Neural Nodes

A network graph with pulses of light traveling its edges: pulse rate rises with activity; glows green on done, flickers red on error.

neuralgraphnodesnetworkreasoning
Interactive preview

Usage

Download HTML

Packages are @vikast908/core and @vikast908/loaders. Prefer 0.1.1+ (0.1.0 loaders is broken on npm).

npm install @vikast908/loaders@0.1.1 @vikast908/core@0.1.1
import '@vikast908/loaders/neural-nodes';

<orbux-neural-nodes state="thinking"></orbux-neural-nodes>

Browser-only alternative: Download HTML (self-contained) or load the hosted ESM bundle:

<script type="module" src="http://localhost:4321/orbux/neural-nodes.js"></script>

<orbux-neural-nodes state="thinking"></orbux-neural-nodes>

Component source neural-nodes.ts

import { type AgentState, Canvas2DElement, lerp, noise1D, type OrbuxFrameInfo } from '@vikast908/core';

const TAU = Math.PI * 2;

interface Node {
  x: number;
  y: number;
  glow: number;
}
interface Edge {
  a: number;
  b: number;
}
interface Pulse {
  edge: number;
  t: number;
  dir: 1 | -1;
}

interface Tune {
  rate: number; // pulses per second
  speed: number; // pulse travel speed
  base: number; // ambient edge/node brightness
}

const STATE_TUNE: Record<AgentState, Tune> = {
  idle: { rate: 0.6, speed: 0.4, base: 0.18 },
  thinking: { rate: 2.5, speed: 0.9, base: 0.28 },
  streaming: { rate: 6, speed: 1.7, base: 0.42 },
  'tool-calling': { rate: 4, speed: 1.3, base: 0.36 },
  waiting: { rate: 0.8, speed: 0.5, base: 0.22 },
  done: { rate: 0.4, speed: 0.6, base: 0.7 },
  error: { rate: 3, speed: 1.1, base: 0.3 },
};

/**
 * Neural Nodes: a small network graph with pulses of light traveling along its
 * edges. Pulse rate and speed rise with activity; the graph glows green on
 * `done` and flickers red on `error`.
 */
export class OrbuxNeuralNodes extends Canvas2DElement {
  #nodes: Node[] = [];
  #edges: Edge[] = [];
  #pulses: Pulse[] = [];
  #spawnAcc = 0;
  #cur: Tune = { ...STATE_TUNE.thinking };

  protected override setup(): void {
    // 1 center node + 6 ring nodes
    this.#nodes = [{ x: 0.5, y: 0.5, glow: 0 }];
    for (let i = 0; i < 6; i++) {
      const a = (i / 6) * TAU - Math.PI / 2;
      this.#nodes.push({
        x: 0.5 + Math.cos(a) * 0.34,
        y: 0.5 + Math.sin(a) * 0.34,
        glow: 0,
      });
    }
    this.#edges = [];
    for (let i = 1; i <= 6; i++) {
      this.#edges.push({ a: 0, b: i }); // spoke
      this.#edges.push({ a: i, b: (i % 6) + 1 }); // rim
    }
  }

  protected draw(info: OrbuxFrameInfo): void {
    const ctx = this.ctx;
    if (!ctx || this.#nodes.length === 0) return;
    const t = STATE_TUNE[info.state];
    const rate = info.state === 'done' || info.state === 'error' ? 18 : 3;
    const k = info.dt === 0 ? 1 : 1 - Math.exp(-rate * info.dt);
    this.#cur.rate = lerp(this.#cur.rate, t.rate, k);
    this.#cur.speed = lerp(this.#cur.speed, t.speed, k);
    this.#cur.base = lerp(this.#cur.base, t.base, k);

    const primary = this.cssColor('--orbux-color', '#6366f1');
    const secondary = this.cssColor('--orbux-color-2', '#a855f7');
    const terminal =
      info.state === 'done'
        ? this.cssColor('--orbux-color-success', '#22c55e')
        : info.state === 'error'
          ? this.cssColor('--orbux-color-error', '#ef4444')
          : null;
    const edgeColor = terminal ?? primary;
    const pulseColor = terminal ?? secondary;

    const px = (n: Node) => n.x * this.width;
    const py = (n: Node) => n.y * this.height;
    const r = Math.min(this.width, this.height);

    // spawn pulses
    if (info.state === 'done') {
      this.#spawnAcc = 0;
    } else {
      this.#spawnAcc += info.dt * this.#cur.rate * info.speed * 1.5;
      while (this.#spawnAcc >= 1) {
        this.#spawnAcc -= 1;
        const edge = Math.floor(Math.random() * this.#edges.length);
        this.#pulses.push({ edge, t: 0, dir: Math.random() < 0.5 ? 1 : -1 });
      }
    }

    // update + advance pulses
    for (const p of this.#pulses) {
      p.t += info.dt * this.#cur.speed * info.speed * 1.5;
      if (p.t >= 1) {
        const e = this.#edges[p.edge];
        if (e) {
          const target = this.#nodes[p.dir === 1 ? e.b : e.a];
          if (target) target.glow = 1;
        }
      }
    }
    this.#pulses = this.#pulses.filter((p) => p.t < 1);

    // decay glows
    for (const n of this.#nodes) {
      n.glow = Math.max(info.state === 'done' ? 0.85 : 0, n.glow - info.dt * 2.2);
    }

    this.clear();

    // edges
    ctx.lineWidth = Math.max(1, r * 0.006);
    for (let i = 0; i < this.#edges.length; i++) {
      const e = this.#edges[i];
      if (!e) continue;
      const a = this.#nodes[e.a];
      const b = this.#nodes[e.b];
      if (!a || !b) continue;
      const flicker = info.state === 'error' && noise1D(i + 1, info.elapsed * 9) > 0.72 ? 0 : 1;
      ctx.globalAlpha = this.#cur.base * flicker;
      ctx.strokeStyle = edgeColor;
      if (info.state === 'error') ctx.setLineDash([r * 0.03, r * 0.03]);
      else ctx.setLineDash([]);
      ctx.beginPath();
      ctx.moveTo(px(a), py(a));
      ctx.lineTo(px(b), py(b));
      ctx.stroke();
    }
    ctx.setLineDash([]);
    ctx.globalAlpha = 1;

    // pulses (additive)
    ctx.globalCompositeOperation = 'lighter';
    for (const p of this.#pulses) {
      const e = this.#edges[p.edge];
      if (!e) continue;
      const a = this.#nodes[e.a];
      const b = this.#nodes[e.b];
      if (!a || !b) continue;
      const tt = p.dir === 1 ? p.t : 1 - p.t;
      const x = lerp(px(a), px(b), tt);
      const y = lerp(py(a), py(b), tt);
      const s = r * 0.02;
      ctx.beginPath();
      ctx.arc(x, y, s, 0, TAU);
      ctx.fillStyle = pulseColor;
      ctx.shadowBlur = s * 3;
      ctx.shadowColor = pulseColor;
      ctx.fill();
    }

    // nodes
    for (const n of this.#nodes) {
      const s = r * (0.028 + 0.02 * n.glow);
      ctx.beginPath();
      ctx.arc(px(n), py(n), s, 0, TAU);
      ctx.fillStyle = n.glow > 0.05 ? pulseColor : edgeColor;
      ctx.shadowBlur = s * (1 + 3 * n.glow);
      ctx.shadowColor = n.glow > 0.05 ? pulseColor : edgeColor;
      ctx.globalAlpha = 0.5 + 0.5 * Math.max(this.#cur.base, n.glow);
      ctx.fill();
    }

    ctx.globalCompositeOperation = 'source-over';
    ctx.globalAlpha = 1;
    ctx.shadowBlur = 0;
  }
}

if (typeof customElements !== 'undefined' && !customElements.get('orbux-neural-nodes')) {
  customElements.define('orbux-neural-nodes', OrbuxNeuralNodes);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-neural-nodes': OrbuxNeuralNodes;
  }
}

Depends on @vikast908/core. Paste the source, add the base element, and set state from your agent code.

Attributes & properties

NameTypeDescription
stateAgentState idle · thinking · streaming · tool-calling · waiting · done · error
sizeCSS lengthOverall square size.
--orbux-widthCSS lengthOptional inline-size override.
--orbux-heightCSS lengthOptional block-size override.
speednumberAnimation speed multiplier (1 = default).
labelstringAccessible label (defaults per state).
pausedbooleanFreeze the animation.

Respects prefers-reduced-motion, exposes status/progressbar semantics with a live label, and auto-pauses (sets data-paused) when offscreen or the tab is hidden.