diff --git a/src/components/islands/ParticleField.tsx b/src/components/islands/ParticleField.tsx deleted file mode 100644 index 081f72d..0000000 --- a/src/components/islands/ParticleField.tsx +++ /dev/null @@ -1,283 +0,0 @@ -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: 600, // max connection distance (px) - flowSpeed: 1.0, // field drift speed (1–10 scale) - fieldScale: 1, // noise zoom level - dimAtMin: 0.3, // opacity reduction at field minimum (0–1) - - particleSpeed: 0.14, - particleMinR: 0.8, - particleMaxR: 1.6, - - fieldOffscreenW: 80, - fieldOffscreenH: 52, - fieldUpdateMs: 50, - fieldOctaves: 4, - - timeConstantX: 0.000014 * 30, - timeConstantY: 0.0000085 * 20, - - // Warm neutral palette — fg-on-dark-2/3 register (#AE9B7F range) - lineColor: "174,140,100", - 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, - - // Glow: base radius multiplier (relative to particle r); field peaks expand by +1.5× - glowScale: 1.2, - - // Master opacity applied to the entire field (0–1); 0.5 = half brightness - masterOpacity: 0.4, - // Max brightness boost on particles near the cursor (0–1); 0.30 = 30% increase - cursorBoostMax: 1.0, -}; - -// ---- Types ----------------------------------------------------------------- - -interface Particle { x: number; y: number; vx: number; vy: number; r: number; } - -// ---- 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 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; - 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); - fieldCache[py * oW + px] = Math.max(0, Math.min(1, raw * 1.8 + 0.5)); - } - } - } - - function draw() { - const { reach, lineColor, glowColorInner, glowColorMid, - dotBaseR, dotBaseG, dotBaseB, dotBoostR, dotBoostG, dotBoostB, - glowScale, masterOpacity, cursorBoostMax } = cfg; - const reach2 = reach * reach; - - c.clearRect(0, 0, W, H); - c.globalAlpha = masterOpacity; - - 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 fv = particles.map(p => sampleField(p.x, p.y)); - const fo = fv.map(v => fieldToOpacity(v)); - - for (let i = 0; i < particles.length; i++) { - for (let j = i + 1; j < particles.length; j++) { - const a = particles[i], b = particles[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; - c.beginPath(); - c.moveTo(a.x, a.y); - c.lineTo(b.x, b.y); - c.strokeStyle = `rgba(${lineColor},${alpha * 0.7})`; - c.lineWidth = 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 proximity = md < reach ? 1 - md / reach : 0; - const cursorBoost = cursorBoostMax * proximity ** 3; - const gA = Math.max(0.02, opMult * 0.85 * (1 + cursorBoost)); - const glowR = p.r * (glowScale + 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(); - } - - c.globalAlpha = 1; - } - - 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 ( - - ); -} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index eda175f..12a4a8f 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -3,7 +3,7 @@ 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 ParticleField from "~/components/islands/ParticleField"; +import Mark from "~/components/atoms/Mark.astro"; type CurrentSection = "home" | "blog" | "art" | "code"; @@ -50,7 +50,13 @@ const canonicalHref = canonical ?? new URL(Astro.url.pathname, Astro.site).toStr - + + + + + + +
diff --git a/src/layouts/PostLayout.astro b/src/layouts/PostLayout.astro index 13cd0a0..6828d3b 100644 --- a/src/layouts/PostLayout.astro +++ b/src/layouts/PostLayout.astro @@ -23,10 +23,14 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric description={description ?? standfirst} current="blog" > + + + + + +
- - {(tag || series) && ( {tag && {tag}} @@ -49,7 +53,6 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric