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 <noreply@anthropic.com>
This commit is contained in:
parent
b643c0be3e
commit
24db26177e
1 changed files with 293 additions and 0 deletions
293
src/components/islands/ParticleField.tsx
Normal file
293
src/components/islands/ParticleField.tsx
Normal file
|
|
@ -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<HTMLCanvasElement | null>(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 (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 0,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue