particle field planning
All checks were successful
deploy / build-and-deploy (push) Successful in 31s

This commit is contained in:
Josh Bairstow 2026-07-23 23:14:04 +10:00
parent ae60a7c856
commit f4b8e1a7d8
5 changed files with 1685 additions and 0 deletions

View file

@ -0,0 +1,419 @@
/**
* 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));
}
}