Flow Field
Open the flow field. Drag the circular vortex handle or use arrow keys to navigate the disturbance center. Adjust vortex strength, toggle pause/play, or regenerate random streamline offsets.
Vector Field Formulation
The particle motion is governed by an analytical 2D velocity field . The field combines a baseline horizontal laminar flow, a sinusoidal wave component, and an interactive vortex perturbation centered at source point :
- Squared distance with smoothing kernel: where prevents singularities when a particle passes through the exact vortex core.
- Horizontal and vertical velocities: where represents vortex strength and controls vertical wave phase.
- Normalized directional step: The velocity vector is normalized and scaled to a uniform integration step size of :
const field = (x, y) => {
const {source: s, strength: k, seed: currentSeed} = settings.current;
const dx = x - s.x;
const dy = y - s.y;
const radius = dx * dx + dy * dy + 0.025;
const vx = 0.8 - dy * 0.22 * k / radius;
const vy = 0.13 * Math.sin(x * 9 + currentSeed % 19) + dx * 0.22 * k / radius;
const length = Math.hypot(vx, vy) || 1;
return {x: vx / length * 0.009, y: vy / length * 0.009};
};
Two-Stage Rendering with Offscreen Buffers
Integrating 62 streamlines for 250 steps each on every animation frame would require vector field evaluations per frame, consuming excessive CPU time.
To maintain 60 FPS animation, the renderer separates static streamline curves from dynamic particle heads:
- Static streamline precomputation (
p5.Graphics): When parameters change or the canvas resizes, 62 streamlines are integrated once and drawn into an offscreen graphics buffer (paper):- Row colors alternate between the primary instrument red and neutral metal tone.
- Points along each streamline are retained in an in-memory trajectory array.
- Live particle animation: On each frame, the canvas draws the cached offscreen image (
p.image(paper, 0, 0)) and renders small particle circles advancing along the precomputed point arrays using modular indexing:
const trace = () => {
const css = getComputedStyle(node);
paper.background(css.getPropertyValue('--instrument-panel').trim());
ink = css.getPropertyValue('--instrument-red').trim();
const metal = css.getPropertyValue('--instrument-metal').trim();
paper.noFill();
paper.strokeWeight(0.8);
p.randomSeed(settings.current.seed);
paths = [];
for (let row = 0; row < 62; row++) {
let x = -0.1 + p.random(-0.025, 0.025);
let y = row / 51 - 0.1;
const points = [];
const color = p.color(row % 6 === 0 ? ink : metal);
color.setAlpha(row % 6 === 0 ? 145 : 78);
paper.stroke(color);
paper.beginShape();
for (let step = 0; step < 250; step++) {
points.push({x: x * p.width, y: y * p.height});
paper.vertex(x * p.width, y * p.height);
const v = field(x, y);
x += v.x; y += v.y;
if (x > 1.1 || y < -0.3 || y > 1.3) break;
}
paper.endShape();
paths.push(points);
}
dirty = false;
};
p.draw = () => {
if (!paper || disposed) return;
if (dirty) trace();
p.image(paper, 0, 0); // Blit cached streamlines in one call
p.noStroke(); p.fill(ink);
paths.forEach((points, index) => {
const point = points[(frame + index * 17) % points.length];
p.circle(point.x, point.y, index % 6 === 0 ? 3 : 1.6);
});
if (settings.current.playing) frame++;
};
Interaction and Accessibility
- Pointer capture: The vortex handle captures pointer events (
setPointerCapture), allowing uninterrupted manipulation even when dragging beyond container bounds. - Keyboard navigation: The handle accepts arrow keys (
ArrowLeft,ArrowRight,ArrowUp,ArrowDown) with step size , making the flow field fully accessible without a pointer device. - Autoplay: The homepage preview and full experiment page start animating even when the system requests reduced motion. The preview has no playback button; the full page provides pause and play controls. Animation pauses while offscreen or while the browser tab is hidden.
- Cleanup: On unmount, event listeners, observers, offscreen graphics buffers (
paper.remove()), and the p5 instance (sketch.remove()) are thoroughly disposed.