From f4b8e1a7d8401a826d06af5fb82b378821e8b92a Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Thu, 23 Jul 2026 23:14:04 +1000 Subject: [PATCH] particle field planning --- .../2026-06-03-particle-field-greebling.md | 848 ++++++++++++++++++ ...6-06-03-particle-field-greebling-design.md | 119 +++ reference/particle-field-demo/README.md | 161 ++++ reference/particle-field-demo/demo.html | 138 +++ .../particle-field-demo/particleNetwork.js | 419 +++++++++ 5 files changed, 1685 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-particle-field-greebling.md create mode 100644 docs/superpowers/specs/2026-06-03-particle-field-greebling-design.md create mode 100644 reference/particle-field-demo/README.md create mode 100644 reference/particle-field-demo/demo.html create mode 100644 reference/particle-field-demo/particleNetwork.js diff --git a/docs/superpowers/plans/2026-06-03-particle-field-greebling.md b/docs/superpowers/plans/2026-06-03-particle-field-greebling.md new file mode 100644 index 0000000..a13c2de --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-particle-field-greebling.md @@ -0,0 +1,848 @@ +# Particle Field + Greebling Restructure — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a full-viewport ambient particle field background layer and restructure existing accent marks to anchor at section edges instead of floating at random viewport positions. + +**Architecture:** A new `ParticleField.tsx` React island renders a fixed, transparent `` below the content stage (z-index 0, pointer-events none). Marks change from `position: fixed` (viewport-based) to `position: absolute` (section-relative) by a single CSS rule change, with each mark moved from the `` into its host section. + +**Tech Stack:** Astro, React (islands), HTML Canvas API, Perlin/fBm noise (ported from reference), CSS positioning + +**Spec:** `docs/superpowers/specs/2026-06-03-particle-field-greebling-design.md` + +--- + +## File Map + +| File | Action | +|---|---| +| `src/components/islands/ParticleField.tsx` | **Create** — full-viewport particle canvas island | +| `src/styles/site.css` | **Modify** — `.mark` positioning: fixed → absolute | +| `src/layouts/BaseLayout.astro` | **Modify** — import + render ParticleField; clear slot defaults | +| `src/pages/index.astro` | **Modify** — remove slot Fragment; add marks inside sections | +| `src/pages/blog/index.astro` | **Modify** — remove slot Fragment; add marks inside sections | +| `src/pages/code/index.astro` | **Modify** — remove slot Fragment; add marks inside sections | +| `src/pages/art/index.astro` | **Modify** — remove slot Fragment; add marks inside sections | +| `src/pages/404.astro` | **Modify** — remove slot Fragment; add marks inside section | +| `src/layouts/PostLayout.astro` | **Modify** — remove slot Fragment; add marks inside sections | + +--- + +## Task 1: Create ParticleField.tsx + +**Files:** +- Create: `src/components/islands/ParticleField.tsx` + +This ports the Perlin/fBm engine from `reference/particle-field-demo/particleNetwork.js` directly into a self-contained React component. The canvas is transparent (no background fill), full-viewport, fixed, and non-interactive. All tunable values live in `PARTICLE_CONFIG`. + +Key differences from the reference: +- Canvas is full `window.innerWidth × window.innerHeight` (no aspect-ratio constraint) +- No background fill — `clearRect` instead of `fillRect` +- Mouse tracking on `window` (not canvas, which has `pointer-events: none`) +- Warm neutral colour palette replacing the reference's cool blues + +- [ ] **Step 1: Create the component file** + +```tsx +import { useEffect, useRef } from "react"; + +// ---- Perlin noise helpers --------------------------------------------------- + +function fade(t: number) { return t * t * t * (t * (t * 6 - 15) + 10); } +function lerp(a: number, b: number, t: number) { return a + t * (b - a); } +function grad(h: number, x: number, y: number) { + h &= 7; + const u = h < 4 ? x : y, v = h < 4 ? y : x; + return ((h & 1) ? -u : u) + ((h & 2) ? -v : v); +} +function buildPermutation(): number[] { + 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: number[], x: number, y: number): number { + 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: number[], x: number, y: number, octaves = 4): number { + 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; +} + +// ---- Config — all tunable values in one place ------------------------------ + +const PARTICLE_CONFIG = { + count: 40, // number of particles + reach: 130, // max connection distance (px) + flowSpeed: 1.8, // field drift speed (1–10 scale) + fieldScale: 4, // noise zoom level + dimAtMin: 0.55, // opacity reduction at field minimum (0–1) + + particleSpeed: 0.28, + particleMinR: 1.2, + particleMaxR: 2.4, + + fieldOffscreenW: 80, + fieldOffscreenH: 52, + fieldUpdateMs: 50, + fieldOctaves: 4, + + timeConstantX: 0.000014 * 60, + timeConstantY: 0.0000085 * 37, + + // Warm neutral palette — fg-on-dark-2/3 register (#AE9B7F range) + lineColor: "174,140,100", + lineColorMouse: "210,185,150", + glowColorInner: "200,170,130", + glowColorMid: "155,120,82", + + // Dot colour at fv=0 (dim) and per-unit boost at fv=1 (bright) + dotBaseR: 174, dotBaseG: 145, dotBaseB: 110, + dotBoostR: 30, dotBoostG: 35, dotBoostB: 40, +}; + +// ---- Types ----------------------------------------------------------------- + +interface Particle { x: number; y: number; vx: number; vy: number; r: number; } +type DrawNode = { x: number; y: number; r: number; isMouse?: boolean }; + +// ---- Component ------------------------------------------------------------- + +export default function ParticleField() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const cfg = PARTICLE_CONFIG; + + const off = document.createElement("canvas"); + off.width = cfg.fieldOffscreenW; + off.height = cfg.fieldOffscreenH; + const oCtx = off.getContext("2d")!; + const fieldCache = new Float32Array(cfg.fieldOffscreenW * cfg.fieldOffscreenH); + + const P = buildPermutation(); + let particles: Particle[] = []; + const mouse = { x: -9999, y: -9999 }; + let elapsed = 0; + let lastTs: number | null = null; + let fieldAge = 9999; + let rafId = 0; + let W = 0, H = 0; + + function resize() { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight; + syncCount(); + } + + function makeParticle(): Particle { + return { + x: Math.random() * W, + y: Math.random() * H, + vx: (Math.random() - 0.5) * cfg.particleSpeed, + vy: (Math.random() - 0.5) * cfg.particleSpeed, + r: cfg.particleMinR + Math.random() * (cfg.particleMaxR - cfg.particleMinR), + }; + } + + function syncCount() { + while (particles.length < cfg.count) particles.push(makeParticle()); + while (particles.length > cfg.count) particles.pop(); + } + + function sampleField(x: number, y: number): number { + const oW = cfg.fieldOffscreenW, oH = cfg.fieldOffscreenH; + const cx = Math.max(0, Math.min(oW - 1, Math.round((x / W) * (oW - 1)))); + const cy = Math.max(0, Math.min(oH - 1, Math.round((y / H) * (oH - 1)))); + return fieldCache[cy * oW + cx]; + } + + function fieldToOpacity(fv: number): number { + const minMult = 1 - cfg.dimAtMin; + return minMult + (1 - minMult) * fv; + } + + function updateField() { + const { fieldOffscreenW: oW, fieldOffscreenH: oH, fieldScale, + fieldOctaves, timeConstantX, timeConstantY } = cfg; + const scale = fieldScale * 0.0018; + const ox = elapsed * timeConstantX; + const oy = elapsed * timeConstantY; + const imgData = 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(P, px * scale + ox, py * scale + oy, fieldOctaves); + const v = Math.max(0, Math.min(1, raw * 1.8 + 0.5)); + 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; + } + } + oCtx.putImageData(imgData, 0, 0); + } + + function draw() { + const { reach, lineColor, lineColorMouse, glowColorInner, glowColorMid, + dotBaseR, dotBaseG, dotBaseB, dotBoostR, dotBoostG, dotBoostB } = cfg; + const reach2 = reach * reach; + + ctx.clearRect(0, 0, W, H); + + for (const p of 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; + } + + const all: DrawNode[] = [ + ...particles, + { x: mouse.x, y: mouse.y, r: 0, isMouse: true }, + ]; + const n = all.length; + const fv = all.map(p => sampleField(p.x, p.y)); + const fo = fv.map(v => fieldToOpacity(v)); + + 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) ** 3; + const lineMult = Math.sqrt(fo[i] * fo[j]); + 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(); + } + } + + for (let i = 0; i < particles.length; i++) { + const p = 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); + + 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(); + + const dR = Math.round(dotBaseR + f * dotBoostR); + const dG = Math.round(dotBaseG + f * dotBoostG); + const dB = Math.round(dotBaseB + f * dotBoostB); + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2); + ctx.fillStyle = `rgba(${dR},${dG},${dB},${gA})`; + ctx.fill(); + } + } + + function loop(ts: number) { + if (lastTs === null) lastTs = ts; + const dt = ts - lastTs; + lastTs = ts; + elapsed += dt * (cfg.flowSpeed / 3); + fieldAge += dt; + if (fieldAge >= cfg.fieldUpdateMs) { + updateField(); + fieldAge = 0; + } + draw(); + rafId = requestAnimationFrame(loop); + } + + const onMouseMove = (e: MouseEvent) => { mouse.x = e.clientX; mouse.y = e.clientY; }; + const onMouseLeave = () => { mouse.x = -9999; mouse.y = -9999; }; + const onResize = () => resize(); + + resize(); + window.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseleave", onMouseLeave); + window.addEventListener("resize", onResize); + + if (reduce) { + updateField(); + draw(); + } else { + rafId = requestAnimationFrame(loop); + } + + return () => { + cancelAnimationFrame(rafId); + window.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseleave", onMouseLeave); + window.removeEventListener("resize", onResize); + }; + }, []); + + return ( + + ); +} +``` + +- [ ] **Step 2: Run type-check to verify no TypeScript errors** + +```bash +npx tsc --noEmit +``` + +Expected: no errors referencing `ParticleField.tsx`. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/islands/ParticleField.tsx +git commit -m "feat(islands): add ambient ParticleField canvas background" +``` + +--- + +## Task 2: Wire ParticleField into BaseLayout and clear mark slot defaults + +**Files:** +- Modify: `src/layouts/BaseLayout.astro` + +- [ ] **Step 1: Add the ParticleField import and render it** + +In `src/layouts/BaseLayout.astro`, add the import at the top of the frontmatter: + +```astro +--- +import "~/styles/colors_and_type.css"; +import "~/styles/site.css"; +import TopBar from "~/components/organisms/TopBar.astro"; +import SiteFoot from "~/components/organisms/SiteFoot.astro"; +import Mark from "~/components/atoms/Mark.astro"; +import ParticleField from "~/components/islands/ParticleField"; +// ... rest of frontmatter unchanged +--- +``` + +In the ``, add `` before `.stage` and clear the slot defaults: + +```astro + + + +
+ + + +
+ +``` + +Note: `` stays (with no defaults) so individual pages can still optionally inject fixed marks if needed in future. The default marks are removed — they will be placed inline in each page's sections instead. + +- [ ] **Step 2: Start the dev server and verify the particle field renders** + +```bash +npm run dev +``` + +Open `http://localhost:4321`. Confirm: +- Particles and connecting lines are visible as a warm, dim background layer +- The particle canvas is entirely behind all page content (nav, text, hero zone) +- Clicking links and buttons still works (pointer events pass through) +- No console errors + +- [ ] **Step 3: Commit** + +```bash +git add src/layouts/BaseLayout.astro +git commit -m "feat(layout): integrate ParticleField as sitewide background layer" +``` + +--- + +## Task 3: Change Mark positioning from fixed to absolute + +**Files:** +- Modify: `src/styles/site.css` (line 42) + +- [ ] **Step 1: Change the CSS rule** + +In `src/styles/site.css`, find the `.mark` rule (around line 41) and change `position: fixed` to `position: absolute`: + +```css +.mark { + position: absolute; pointer-events: none; z-index: 0; + filter: var(--mark-filter-dark); + opacity: var(--mark-rest); + transition: opacity var(--dur-slow) var(--ease-out); +} +``` + +- [ ] **Step 2: Verify marks are no longer floating** + +With the dev server still running, check that existing marks on any page have collapsed to their section (they will be mispositioned until Task 4–9 move them into sections, but they should no longer be floating over the viewport at incorrect positions). + +- [ ] **Step 3: Commit** + +```bash +git add src/styles/site.css +git commit -m "refactor(marks): switch Mark from fixed to absolute positioning" +``` + +--- + +## Task 4: Migrate marks — Home page (index.astro) + +**Files:** +- Modify: `src/pages/index.astro` + +Marks move from the `` into the content sections. Four sections host one mark each. The fifth `ringdots` (bottom:20% left:44%) is dropped — four marks is enough. + +- [ ] **Step 1: Remove the slot Fragment and move marks into sections** + +Replace the entire `...` block with nothing (delete it). + +Inside `.intro`, add a mark as the **first child**: +```astro +
+ + Engineer · Sydney + ... +
+``` + +Inside `.hero`, add two marks as children before ``: +```astro +
+ + + +
+``` + +Inside `.index`, add a mark as the first child: +```astro +
+ + Index + ... +
+``` + +- [ ] **Step 2: Add `position: relative` to the host sections in the page styles** + +In the ` + + + +
+
+ +
particle network
+
+
+ +
noise field
+
+
+ +
+ + + + + + + + + + + + +
+ + + 60% +
+
+ + + + + diff --git a/reference/particle-field-demo/particleNetwork.js b/reference/particle-field-demo/particleNetwork.js new file mode 100644 index 0000000..2f5018b --- /dev/null +++ b/reference/particle-field-demo/particleNetwork.js @@ -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 (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)); + } +}