From 24db26177e60d539c3bcf1285e5ef2e33a9873eb Mon Sep 17 00:00:00 2001 From: Josh Bairstow Date: Wed, 3 Jun 2026 15:42:11 +1000 Subject: [PATCH] feat(islands): add ambient ParticleField canvas background Self-contained React canvas island with Perlin/fBm noise-driven particle field; warm ochre palette, transparent background, pointer-events none. Co-Authored-By: Claude Sonnet 4.6 --- src/components/islands/ParticleField.tsx | 293 +++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 src/components/islands/ParticleField.tsx diff --git a/src/components/islands/ParticleField.tsx b/src/components/islands/ParticleField.tsx new file mode 100644 index 0000000..d183574 --- /dev/null +++ b/src/components/islands/ParticleField.tsx @@ -0,0 +1,293 @@ +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; + // Re-bind as non-nullable so nested functions can reference them safely + const cvs: HTMLCanvasElement = canvas; + const c: CanvasRenderingContext2D = ctx; + + 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 = cvs.width = window.innerWidth; + H = cvs.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; + + c.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; + c.beginPath(); + c.moveTo(a.x, a.y); + c.lineTo(b.x, b.y); + c.strokeStyle = isMc + ? `rgba(${lineColorMouse},${alpha * 0.9})` + : `rgba(${lineColor},${alpha * 0.7})`; + c.lineWidth = isMc ? 0.9 : 0.5; + c.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 = c.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)"); + c.beginPath(); + c.arc(p.x, p.y, glowR * 3, 0, Math.PI * 2); + c.fillStyle = grd; + c.fill(); + + const dR = Math.round(dotBaseR + f * dotBoostR); + const dG = Math.round(dotBaseG + f * dotBoostG); + const dB = Math.round(dotBaseB + f * dotBoostB); + c.beginPath(); + c.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2); + c.fillStyle = `rgba(${dR},${dG},${dB},${gA})`; + c.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 ( + + ); +}