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,161 @@
# particle-network
A canvas animation of drifting particles connected by proximity lines, with
opacity modulated by a slowly-rolling fBm (fractal Brownian motion) noise field.
No external dependencies. Pure ES module.
---
## Files
| File | Purpose |
|------|---------|
| `particleNetwork.js` | The module — import this into your project |
| `demo.html` | Minimal working example (requires a local server for ES module import) |
---
## Quick start
Serve the folder with any static server, e.g.:
```bash
npx serve .
# or
python3 -m http.server
```
Then open `demo.html`.
---
## Integration
### 1. Minimal — full-page background
```html
<canvas id="net"></canvas>
<script type="module">
import { ParticleNetwork } from './particleNetwork.js';
const canvas = document.getElementById('net');
// Size the canvas to the viewport yourself, or let the module
// read the parent element's bounding rect (default behaviour).
canvas.style.display = 'block';
canvas.style.width = '100%';
const net = new ParticleNetwork(canvas, null);
net.start();
</script>
```
### 2. With field preview (debug / design mode)
```html
<canvas id="net"></canvas>
<canvas id="field"></canvas>
<script type="module">
import { ParticleNetwork } from './particleNetwork.js';
const net = new ParticleNetwork(
document.getElementById('net'),
document.getElementById('field'),
{ count: 60, dimAtMin: 0.5 }
);
net.start();
</script>
```
### 3. Bundler / framework
Copy `particleNetwork.js` anywhere in your `src/` tree and import normally:
```js
import { ParticleNetwork } from '@/lib/particleNetwork.js';
```
No build config changes needed — it uses only standard browser APIs.
---
## Constructor
```js
new ParticleNetwork(mainCanvas, fieldCanvas, options)
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `mainCanvas` | `HTMLCanvasElement` | The particle animation canvas |
| `fieldCanvas` | `HTMLCanvasElement \| null` | Optional debug field view |
| `options` | `object` | See below |
---
## Options
All options can be passed at construction time and updated at runtime via
`setOption(key, value)`.
| Key | Default | Description |
|-----|---------|-------------|
| `count` | `55` | Number of particles |
| `reach` | `140` | Max connection distance in canvas px |
| `flowSpeed` | `2` | Field drift speed on a 110 scale. At 1, motion takes several minutes to traverse the field — clearly present but takes time to perceive. |
| `fieldScale` | `4` | Noise zoom on a 110 scale. Lower = larger rolling blobs. |
| `dimAtMin` | `0.60` | Opacity reduction at field minimum. `0` = field has no opacity effect. `1` = particles in troughs are fully invisible. |
| `particleSpeed` | `0.35` | Base particle velocity (px/frame) |
| `particleMinR` | `1.4` | Minimum particle radius (px) |
| `particleMaxR` | `2.8` | Maximum particle radius (px) |
| `fieldUpdateMs` | `50` | How often the noise field recalculates (ms). Lower = smoother but more CPU. |
| `fieldOctaves` | `4` | fBm octaves. More = finer detail, more CPU. |
| `bgColor` | `'#040e1f'` | Canvas background colour |
| `lineColor` | `'80,160,240'` | RGB string for particleparticle lines |
| `lineColorMouse` | `'160,220,255'` | RGB string for mouse-proximity lines |
| `canvasAspect` | `0.65` | Canvas height = width × aspect |
| `timeConstantX` | `0.00084` | Primary axis time advance rate. Halve to slow further. |
| `timeConstantY` | `0.0003145` | Secondary axis rate — kept at a different ratio for the swirling character. |
---
## Methods
```js
net.start() // begin animation loop
net.stop() // pause (preserves state)
net.setOption(key, value) // update any option at runtime
net.sampleField(x, y) // returns [0,1] field value at canvas coords
net.destroy() // stop loop and remove all event listeners
```
---
## How it works
**Noise field** — a 4-octave fBm Perlin noise function is evaluated on a tiny
80×52 offscreen canvas every 50ms, then scaled up via `drawImage`. This is
~100× cheaper than evaluating at display resolution.
**Opacity mapping** — each particle's field sample `fv ∈ [0,1]` maps to an
opacity multiplier via:
```
multiplier = (1 - dimAtMin) + dimAtMin * fv
```
At `fv = 1` (field peak): multiplier = 1.0 — full brightness.
At `fv = 0` (field trough): multiplier = 1 - dimAtMin.
For connecting lines the multiplier is the geometric mean of both endpoint
values, so a line straddling a bright and dark zone stays dim.
**Distance falloff** — cubic: `(1 - dist/reach)³`, giving a sharp fade near
the connection threshold rather than a linear fade across the full range.
**Glow** — each particle renders a `createRadialGradient` halo sized to
`r × (2.5 + fieldValue × 1.5)`, so dots in bright field regions bloom larger.

View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Particle Network — demo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #040e1f;
color: #a0c4f0;
font-family: system-ui, sans-serif;
font-size: 13px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
}
.canvases {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
width: 100%;
max-width: 960px;
}
.canvas-wrap {
position: relative;
border: 0.5px solid rgba(80, 140, 220, 0.25);
border-radius: 8px;
overflow: hidden;
}
canvas { display: block; width: 100%; }
.label {
position: absolute;
top: 10px; left: 12px;
font-size: 11px;
color: rgba(120, 180, 255, 0.45);
pointer-events: none;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
align-items: center;
width: 100%;
max-width: 960px;
}
.controls label { color: rgba(160, 200, 255, 0.6); }
.controls input[type=range] { width: 100px; accent-color: #5090d0; }
.dim-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.dim-row input { flex: 1; max-width: 300px; }
.dim-row span { min-width: 36px; }
</style>
</head>
<body>
<div class="canvases">
<div class="canvas-wrap">
<canvas id="mainCanvas"></canvas>
<div class="label">particle network</div>
</div>
<div class="canvas-wrap">
<canvas id="fieldCanvas"></canvas>
<div class="label">noise field</div>
</div>
</div>
<div class="controls">
<label>nodes</label>
<input type="range" id="sCount" min="20" max="100" value="55" step="1">
<label>reach</label>
<input type="range" id="sReach" min="60" max="260" value="140" step="1">
<label>flow</label>
<input type="range" id="sSpeed" min="1" max="10" value="2" step="1">
<label>scale</label>
<input type="range" id="sScale" min="1" max="10" value="4" step="1">
<div class="dim-row">
<label>dim at minimum</label>
<input type="range" id="sDim" min="0" max="100" value="60" step="1">
<span id="sDimOut">60%</span>
</div>
</div>
<script type="module">
import { ParticleNetwork } from './particleNetwork.js';
const net = new ParticleNetwork(
document.getElementById('mainCanvas'),
document.getElementById('fieldCanvas'),
{ count: 55, reach: 140, flowSpeed: 2, fieldScale: 4, dimAtMin: 0.60 }
);
net.start();
// Wire up controls
document.getElementById('sCount').addEventListener('input', e =>
net.setOption('count', parseInt(e.target.value)));
document.getElementById('sReach').addEventListener('input', e =>
net.setOption('reach', parseInt(e.target.value)));
document.getElementById('sSpeed').addEventListener('input', e =>
net.setOption('flowSpeed', parseFloat(e.target.value)));
document.getElementById('sScale').addEventListener('input', e =>
net.setOption('fieldScale', parseFloat(e.target.value)));
const dimSlider = document.getElementById('sDim');
const dimOut = document.getElementById('sDimOut');
dimSlider.addEventListener('input', e => {
const v = parseInt(e.target.value);
dimOut.textContent = v + '%';
net.setOption('dimAtMin', v / 100);
});
</script>
</body>
</html>

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));
}
}