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
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
| Name | Type | Description |
|---|---|---|
state | AgentState | idle · thinking · streaming · tool-calling · waiting · done · error |
size | CSS length | Overall square size. |
--orbux-width | CSS length | Optional inline-size override. |
--orbux-height | CSS length | Optional block-size override. |
speed | number | Animation speed multiplier (1 = default). |
label | string | Accessible label (defaults per state). |
paused | boolean | Freeze 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.