/** * 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 (1–10 scale) * fieldScale: 4, // noise zoom level (1–10 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} 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 1–10 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)); } }