All loaders

Canvas 2D · 4.0 KB gzip

Flow Field

Particles advected by a smooth noise flow field, leaving fading trails: converges on done, turns turbulent on error.

flowfieldparticlesgenerativenoise
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/flow-field';

<orbux-flow-field state="thinking"></orbux-flow-field>

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

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

<orbux-flow-field state="thinking"></orbux-flow-field>

Component source flow-field.ts

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

const TAU = Math.PI * 2;

interface Particle {
  x: number;
  y: number;
  tint: number;
  seed: number;
}

interface Tune {
  speed: number;
  pull: number; // toward center (converge)
  turb: number; // random turbulence
  fade: number; // trail fade
}

const STATE_TUNE: Record<AgentState, Tune> = {
  idle: { speed: 0.25, pull: 0, turb: 0, fade: 0.06 },
  thinking: { speed: 0.55, pull: 0, turb: 0, fade: 0.1 },
  streaming: { speed: 1.2, pull: 0, turb: 0, fade: 0.16 },
  'tool-calling': { speed: 0.9, pull: 0.12, turb: 0, fade: 0.14 },
  waiting: { speed: 0.3, pull: 0, turb: 0, fade: 0.07 },
  // pull stays under the wrap threshold so particles remain on-canvas when settled
  done: { speed: 0.45, pull: 0.28, turb: 0, fade: 0.12 },
  error: { speed: 0.8, pull: 0, turb: 0.8, fade: 0.14 },
};

/**
 * Flow Field: particles advected by a smooth pseudo-noise flow field, leaving
 * short fading trails. Elegant generative motion that converges on `done` and
 * turns turbulent on `error`.
 */
export class OrbuxFlowField extends Canvas2DElement {
  #particles: Particle[] = [];
  #cur: Tune = { ...STATE_TUNE.thinking };

  protected override setup(): void {
    this.#seed();
  }
  protected override onResize(): void {
    this.#seed();
  }

  #seed(): void {
    const count = clamp(Math.round(Math.min(this.width, this.height) * 1.0), 40, 150);
    this.#particles = Array.from({ length: count }, (_, i) => ({
      x: Math.random() * this.width,
      y: Math.random() * this.height,
      tint: i % 2,
      seed: i + 1,
    }));
  }

  #flow(nx: number, ny: number, time: number): number {
    return (
      (Math.sin(nx * 3.2 + time * 0.4) +
        Math.cos(ny * 3.0 - time * 0.3) +
        Math.sin((nx + ny) * 4.0 + time * 0.2)) *
      1.4
    );
  }

  protected draw(info: OrbuxFrameInfo): void {
    const ctx = this.ctx;
    if (!ctx) 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.speed = lerp(this.#cur.speed, t.speed, k);
    this.#cur.pull = lerp(this.#cur.pull, t.pull, k);
    this.#cur.turb = lerp(this.#cur.turb, t.turb, k);
    this.#cur.fade = lerp(this.#cur.fade, t.fade, k);

    const c1 = this.cssColor('--orbux-color', '#6366f1');
    const c2 = 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 w = this.width;
    const h = this.height;
    const reach = Math.min(w, h);
    const spd = reach * 0.525 * this.#cur.speed * info.speed;

    // fade previous frame (keeps transparency, leaves trails)
    ctx.globalCompositeOperation = 'destination-out';
    // Static frames (pause / reduced-motion / offscreen) pass dt === 0; apply a
    // single meaningful fade so trails do not freeze as opaque smudges.
    const fadeAlpha =
      info.dt <= 0
        ? Math.min(0.92, Math.max(0.12, this.#cur.fade * 6))
        : 1 - (1 - this.#cur.fade) ** (info.dt * 60);
    ctx.fillStyle = `rgba(0,0,0,${fadeAlpha})`;
    ctx.fillRect(0, 0, w, h);

    ctx.globalCompositeOperation = 'lighter';
    for (const p of this.#particles) {
      const nx = p.x / w;
      const ny = p.y / h;
      const ang = this.#flow(nx, ny, info.elapsed);
      let vx = Math.cos(ang);
      let vy = Math.sin(ang);
      if (this.#cur.pull > 0) {
        vx += (0.5 - nx) * this.#cur.pull * 3;
        vy += (0.5 - ny) * this.#cur.pull * 3;
      }
      if (this.#cur.turb > 0) {
        vx += noise1D(p.seed, info.elapsed * 7) * this.#cur.turb;
        vy += noise1D(p.seed + 1000, info.elapsed * 7) * this.#cur.turb;
      }
      p.x += vx * spd * info.dt;
      p.y += vy * spd * info.dt;

      // wrap unless strongly converging
      if (this.#cur.pull < 0.3) {
        if (p.x < 0) p.x += w;
        else if (p.x > w) p.x -= w;
        if (p.y < 0) p.y += h;
        else if (p.y > h) p.y -= h;
      }

      const color = terminal ?? (p.tint ? c2 : c1);
      const s = Math.max(1, reach * 0.012);
      ctx.beginPath();
      ctx.arc(p.x, p.y, s, 0, TAU);
      ctx.fillStyle = color;
      ctx.shadowBlur = s * 2.5;
      ctx.shadowColor = color;
      ctx.fill();
    }

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

if (typeof customElements !== 'undefined' && !customElements.get('orbux-flow-field')) {
  customElements.define('orbux-flow-field', OrbuxFlowField);
}

declare global {
  interface HTMLElementTagNameMap {
    'orbux-flow-field': OrbuxFlowField;
  }
}

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.