jb-website/reference/particle-field-demo/particleNetwork.js
Josh Bairstow f4b8e1a7d8
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
particle field planning
2026-07-23 23:14:04 +10:00

419 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* particleNetwork.js
*
* Particle network animation with slow-rolling fBm noise field opacity modulation.
*
* Usage:
* import { ParticleNetwork } from './particleNetwork.js';
*
* const net = new ParticleNetwork(canvasEl, fieldCanvasEl, {
* count: 55, // number of particles
* reach: 140, // max connection distance (px)
* flowSpeed: 2, // field drift speed (110 scale)
* fieldScale: 4, // noise zoom level (110 scale)
* dimAtMin: 0.60, // opacity multiplier at field minimum (0 = no dim, 1 = invisible)
* });
*
* net.start();
* net.stop();
* net.setOption('count', 80);
* net.destroy();
*
* fieldCanvasEl is optional. Pass null to skip the debug field view.
*/
// ---------------------------------------------------------------------------
// Perlin noise helpers
// ---------------------------------------------------------------------------
function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function _lerp(a, b, t) { return a + t * (b - a); }
function _grad(h, x, y) {
h &= 7;
const u = h < 4 ? x : y, v = h < 4 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
}
function _buildPermutation() {
const a = Array.from({ length: 256 }, (_, i) => i);
for (let i = 255; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return [...a, ...a];
}
function _noise2(P, x, y) {
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
x -= Math.floor(x); y -= Math.floor(y);
const u = _fade(x), v = _fade(y);
const aa = P[X] + Y, bb = P[X + 1] + Y;
return _lerp(
_lerp(_grad(P[aa], x, y), _grad(P[bb], x - 1, y), u),
_lerp(_grad(P[aa + 1], x, y - 1), _grad(P[bb + 1], x - 1, y - 1), u),
v
);
}
function _fbm(P, x, y, octaves = 4) {
let value = 0, amp = 0.5, freq = 1, max = 0;
for (let i = 0; i < octaves; i++) {
value += _noise2(P, x * freq, y * freq) * amp;
max += amp;
amp *= 0.5;
freq *= 2;
}
return value / max;
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULTS = {
count: 55,
reach: 140,
flowSpeed: 2,
fieldScale: 4,
dimAtMin: 0.60,
// Particle motion
particleSpeed: 0.35,
particleMinR: 1.4,
particleMaxR: 2.8,
// Field evaluation
fieldOffscreenW: 80,
fieldOffscreenH: 52,
fieldUpdateMs: 50, // how often the noise field is recalculated (ms)
fieldOctaves: 4,
// Time warp constants — tweak to change how the field drifts
// elapsed is in ms; these produce a very slow traversal at flowSpeed=1
timeConstantX: 0.000014 * 60, // primary axis
timeConstantY: 0.0000085 * 37, // secondary axis (different ratio = swirl)
// Visual
bgColor: '#040e1f',
dotColorR: 180,
dotColorG: 200, // G channel base (G = R + 20 in original; kept separate for clarity)
lineColor: '80,160,240',
lineColorMouse: '160,220,255',
glowColorInner: '180,220,255',
glowColorMid: '100,170,255',
canvasAspect: 0.65, // H = W * aspect
};
// ---------------------------------------------------------------------------
// ParticleNetwork class
// ---------------------------------------------------------------------------
export class ParticleNetwork {
/**
* @param {HTMLCanvasElement} mainCanvas
* @param {HTMLCanvasElement|null} fieldCanvas — pass null to disable field preview
* @param {Partial<typeof DEFAULTS>} options
*/
constructor(mainCanvas, fieldCanvas = null, options = {}) {
this._canvas = mainCanvas;
this._fieldCanvas = fieldCanvas;
this._opt = { ...DEFAULTS, ...options };
this._mCtx = mainCanvas.getContext('2d');
this._fCtx = fieldCanvas ? fieldCanvas.getContext('2d') : null;
// Offscreen canvas for cheap noise evaluation
this._off = document.createElement('canvas');
this._off.width = this._opt.fieldOffscreenW;
this._off.height = this._opt.fieldOffscreenH;
this._oCtx = this._off.getContext('2d');
this._fieldCache = new Float32Array(
this._opt.fieldOffscreenW * this._opt.fieldOffscreenH
);
this._P = _buildPermutation();
this._particles = [];
this._mouse = { x: -9999, y: -9999 };
this._elapsed = 0;
this._lastTs = null;
this._fieldAge = 9999;
this._rafId = null;
this._W = 0;
this._H = 0;
this._onMouseMove = this._handleMouseMove.bind(this);
this._onMouseLeave = this._handleMouseLeave.bind(this);
this._onResize = this._handleResize.bind(this);
this._attach();
this._resize();
this._syncCount();
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
start() {
if (this._rafId !== null) return;
this._lastTs = null;
this._rafId = requestAnimationFrame(ts => this._loop(ts));
}
stop() {
if (this._rafId !== null) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
}
/** Update a single option at runtime. */
setOption(key, value) {
this._opt[key] = value;
if (key === 'count') this._syncCount();
}
/** Tear down all listeners and stop the loop. */
destroy() {
this.stop();
this._detach();
}
// -------------------------------------------------------------------------
// Event handling
// -------------------------------------------------------------------------
_attach() {
this._canvas.addEventListener('mousemove', this._onMouseMove);
this._canvas.addEventListener('mouseleave', this._onMouseLeave);
window.addEventListener('resize', this._onResize);
}
_detach() {
this._canvas.removeEventListener('mousemove', this._onMouseMove);
this._canvas.removeEventListener('mouseleave', this._onMouseLeave);
window.removeEventListener('resize', this._onResize);
}
_handleMouseMove(e) {
const r = this._canvas.getBoundingClientRect();
this._mouse.x = (e.clientX - r.left) * (this._W / r.width);
this._mouse.y = (e.clientY - r.top) * (this._H / r.height);
}
_handleMouseLeave() {
this._mouse.x = -9999;
this._mouse.y = -9999;
}
_handleResize() {
this._resize();
this._syncCount();
}
// -------------------------------------------------------------------------
// Setup
// -------------------------------------------------------------------------
_resize() {
const rect = this._canvas.parentElement.getBoundingClientRect();
this._W = this._canvas.width = Math.floor(rect.width);
this._H = this._canvas.height = Math.round(this._W * this._opt.canvasAspect);
this._canvas.style.height = this._H + 'px';
if (this._fieldCanvas) {
this._fieldCanvas.width = this._W;
this._fieldCanvas.height = this._H;
this._fieldCanvas.style.height = this._H + 'px';
}
}
_makeParticle() {
const { particleSpeed, particleMinR, particleMaxR } = this._opt;
return {
x: Math.random() * this._W,
y: Math.random() * this._H,
vx: (Math.random() - 0.5) * particleSpeed,
vy: (Math.random() - 0.5) * particleSpeed,
r: particleMinR + Math.random() * (particleMaxR - particleMinR),
};
}
_syncCount() {
const target = this._opt.count;
while (this._particles.length < target) this._particles.push(this._makeParticle());
while (this._particles.length > target) this._particles.pop();
}
// -------------------------------------------------------------------------
// Field sampling
// -------------------------------------------------------------------------
/**
* Sample the cached noise field at canvas coordinates (x, y).
* Returns a value in [0, 1].
*/
sampleField(x, y) {
const { fieldOffscreenW: oW, fieldOffscreenH: oH } = this._opt;
const cx = Math.max(0, Math.min(oW - 1, Math.round(x / this._W * (oW - 1))));
const cy = Math.max(0, Math.min(oH - 1, Math.round(y / this._H * (oH - 1))));
return this._fieldCache[cy * oW + cx];
}
/**
* Map a raw field value [0,1] to an opacity multiplier.
* At fv=1 (maximum): multiplier = 1.0 (no change).
* At fv=0 (minimum): multiplier = 1 - dimAtMin.
*/
_fieldToOpacity(fv) {
const minMult = 1 - this._opt.dimAtMin;
return minMult + (1 - minMult) * fv;
}
_updateField() {
const {
fieldOffscreenW: oW, fieldOffscreenH: oH,
fieldScale, fieldOctaves,
timeConstantX, timeConstantY,
} = this._opt;
const scale = fieldScale * 0.0018;
const ox = this._elapsed * timeConstantX;
const oy = this._elapsed * timeConstantY;
const imgData = this._oCtx.createImageData(oW, oH);
const d = imgData.data;
for (let py = 0; py < oH; py++) {
for (let px = 0; px < oW; px++) {
const raw = _fbm(this._P, px * scale + ox, py * scale + oy, fieldOctaves);
const v = Math.max(0, Math.min(1, raw * 1.8 + 0.5));
this._fieldCache[py * oW + px] = v;
const idx = (py * oW + px) * 4;
d[idx] = Math.round(v * 30 + (1 - v) * 5);
d[idx + 1] = Math.round(v * 110 + (1 - v) * 18);
d[idx + 2] = Math.round(v * 210 + (1 - v) * 55);
d[idx + 3] = 255;
}
}
this._oCtx.putImageData(imgData, 0, 0);
if (this._fCtx) {
this._fCtx.fillStyle = this._opt.bgColor;
this._fCtx.fillRect(0, 0, this._W, this._H);
this._fCtx.drawImage(this._off, 0, 0, this._W, this._H);
}
}
// -------------------------------------------------------------------------
// Draw
// -------------------------------------------------------------------------
_drawMain() {
const { bgColor, reach, lineColor, lineColorMouse,
glowColorInner, glowColorMid } = this._opt;
const { _mCtx: ctx, _W: W, _H: H, _mouse: mouse } = this;
const reach2 = reach * reach;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, W, H);
// Move particles
for (const p of this._particles) {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > W) p.vx *= -1;
if (p.y < 0 || p.y > H) p.vy *= -1;
}
// Build working set including mouse phantom node
const all = [...this._particles, { x: mouse.x, y: mouse.y, r: 0, isMouse: true }];
const n = all.length;
// Sample field values and opacity multipliers for each node
const fv = all.map(p => this.sampleField(p.x, p.y));
const fo = fv.map(v => this._fieldToOpacity(v));
// Draw connecting lines
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const a = all[i], b = all[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 >= reach2) continue;
const dist = Math.sqrt(d2);
const t01 = dist / reach;
const expFade = (1 - t01) * (1 - t01) * (1 - t01); // cubic falloff
const lineMult = Math.sqrt(fo[i] * fo[j]); // geometric mean of field mults
const alpha = expFade * lineMult * 0.85;
if (alpha < 0.005) continue;
const isMc = a.isMouse || b.isMouse;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = isMc
? `rgba(${lineColorMouse},${alpha * 0.9})`
: `rgba(${lineColor},${alpha * 0.7})`;
ctx.lineWidth = isMc ? 0.9 : 0.5;
ctx.stroke();
}
}
// Draw particles (glow + dot)
for (let i = 0; i < this._particles.length; i++) {
const p = this._particles[i];
const f = fv[i];
const opMult = fo[i];
const dx = p.x - mouse.x, dy = p.y - mouse.y;
const md = Math.sqrt(dx * dx + dy * dy);
const mb = md < reach ? 0.4 * (1 - md / reach) : 0;
const gA = Math.max(0.02, opMult * 0.85 + mb);
const glowR = p.r * (2.5 + f * 1.5);
// Radial glow
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR * 3);
grd.addColorStop(0, `rgba(${glowColorInner},${gA * 0.55})`);
grd.addColorStop(0.35, `rgba(${glowColorMid},${gA * 0.22})`);
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(p.x, p.y, glowR * 3, 0, Math.PI * 2);
ctx.fillStyle = grd;
ctx.fill();
// Hard dot
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2);
const br = Math.round(180 + f * 75);
ctx.fillStyle = `rgba(${br},${br + 20},255,${gA})`;
ctx.fill();
}
}
// -------------------------------------------------------------------------
// Loop
// -------------------------------------------------------------------------
_loop(ts) {
if (this._lastTs === null) this._lastTs = ts;
const dt = ts - this._lastTs;
this._lastTs = ts;
// flowSpeed is on a 110 scale; divide by 3 to centre the "default" feel
const speedMult = this._opt.flowSpeed / 3;
this._elapsed += dt * speedMult;
this._fieldAge += dt;
if (this._fieldAge >= this._opt.fieldUpdateMs) {
this._updateField();
this._fieldAge = 0;
}
this._drawMain();
this._rafId = requestAnimationFrame(ts => this._loop(ts));
}
}