Canvas 2D · 5.3 KB gzip
Flow Field
Particles advected by a smooth noise flow field, leaving fading trails: converges on done, turns turbulent on error.
Usage
Packages are @vikast908/core and
@vikast908/loaders. Prefer 0.2.2+
(0.1.0 loaders is broken on npm).
npm install @vikast908/loaders@0.2.2 @vikast908/core@0.2.2
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;
life: 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 },
cancelled: { speed: 0.25, pull: 0.2, turb: 0, 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 };
#mask: CanvasGradient | null = null;
#maskKey = '';
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,
life: 1.5 + Math.random() * 3.5,
}));
}
#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
);
}
/** The angle field has sinks; expired particles rewind to a fresh spot so the
* swarm keeps covering the canvas instead of collapsing onto one attractor. */
#respawn(p: Particle): void {
p.x = Math.random() * this.width;
p.y = Math.random() * this.height;
p.life = 2 + Math.random() * 3.5;
}
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';
ctx.globalAlpha = 0.5;
const converging = this.#cur.pull >= 0.3;
for (const p of this.#particles) {
const nx = p.x / w;
const ny = p.y / h;
// per-particle angle offset decorrelates paths so the swarm doesn't
// condense into a single bright worm along the field's attractor
const ang = this.#flow(nx, ny, info.elapsed) + noise1D(p.seed, info.elapsed * 0.25) * 0.9;
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;
p.life -= info.dt;
if (p.life <= 0 && !converging) this.#respawn(p);
// wrap unless strongly converging
if (!converging) {
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;
ctx.shadowColor = color;
ctx.fill();
}
// Contain the field to the inscribed circle: without this, trails clip at
// the canvas bounds and the loader reads as a hard-edged square tile on
// light backgrounds. The interior stays at alpha 1, so applying the mask to
// the persistent trail buffer every frame is stable; only the rim fades.
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = 'destination-in';
const maskKey = `${w}:${h}`;
if (!this.#mask || this.#maskKey !== maskKey) {
const radius = Math.min(w, h) / 2;
const mask = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, radius);
mask.addColorStop(0, 'rgba(0,0,0,1)');
mask.addColorStop(0.82, 'rgba(0,0,0,1)');
mask.addColorStop(1, 'rgba(0,0,0,0)');
this.#mask = mask;
this.#maskKey = maskKey;
}
ctx.fillStyle = this.#mask;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'source-over';
}
}
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 · cancelled |
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. |
quiet | boolean | Silence the ARIA live region (name stays current). |
show-label | boolean | Render a visible caption beneath the loader. |
pulse() | method | Feed a data-activity burst (e.g. per streamed token). |
Respects prefers-reduced-motion, exposes
status/progressbar semantics with a debounced live label, and auto-pauses (sets
data-paused) when offscreen or the tab is hidden. Emits
orbux:statechange plus
orbux:done /
orbux:error /
orbux:cancelled. Map an agent SDK on with
@vikast908/core/adapters.