feat(visual): particle field background + section-anchored accent marks

This commit is contained in:
Josh Bairstow 2026-06-03 16:39:44 +10:00
commit 27e743bffd
9 changed files with 316 additions and 49 deletions

View file

@ -0,0 +1,283 @@
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 (110 scale)
fieldScale: 1, // noise zoom level
dimAtMin: 0.3, // opacity reduction at field minimum (01)
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 (01); 0.5 = half brightness
masterOpacity: 0.4,
// Max brightness boost on particles near the cursor (01); 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<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 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 (
<canvas
ref={canvasRef}
style={{
position: "fixed",
inset: 0,
width: "100vw",
height: "100vh",
pointerEvents: "none",
zIndex: 0,
display: "block",
}}
/>
);
}

View file

@ -3,7 +3,7 @@ import "~/styles/colors_and_type.css";
import "~/styles/site.css"; import "~/styles/site.css";
import TopBar from "~/components/organisms/TopBar.astro"; import TopBar from "~/components/organisms/TopBar.astro";
import SiteFoot from "~/components/organisms/SiteFoot.astro"; import SiteFoot from "~/components/organisms/SiteFoot.astro";
import Mark from "~/components/atoms/Mark.astro"; import ParticleField from "~/components/islands/ParticleField";
type CurrentSection = "home" | "blog" | "art" | "code"; type CurrentSection = "home" | "blog" | "art" | "code";
@ -50,13 +50,7 @@ const canonicalHref = canonical ?? new URL(Astro.url.pathname, Astro.site).toStr
<meta property="og:url" content={canonicalHref} /> <meta property="og:url" content={canonicalHref} />
</head> </head>
<body class="dark"> <body class="dark">
<!-- sprinkled accent marks — loose, non-rigid placement, per page-of-page overrides via slot --> <ParticleField client:idle />
<slot name="marks">
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
<Mark kind="dots" width={52} top="26%" right="3.5%" />
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
</slot>
<div class="stage"> <div class="stage">
<TopBar current={current} showCoord={showCoordInTopbar} /> <TopBar current={current} showCoord={showCoordInTopbar} />

View file

@ -23,14 +23,10 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
description={description ?? standfirst} description={description ?? standfirst}
current="blog" current="blog"
> >
<Fragment slot="marks">
<Mark kind="ringdots" width={70} top="16%" left="4%" rotate={-6} />
<Mark kind="dots" width={48} top="58%" right="3.5%" />
<Mark kind="pipes" width={26} bottom="14%" left="6%" rotate={5} />
</Fragment>
<article> <article>
<header class="posthead col"> <header class="posthead col">
<Mark kind="ringdots" width={70} top="-12px" left="-16px" rotate={-6} />
<Mark kind="dots" width={48} top="-12px" right="-12px" />
{(tag || series) && ( {(tag || series) && (
<span class="kicker"> <span class="kicker">
{tag && <span class="tag">{tag}</span>} {tag && <span class="tag">{tag}</span>}
@ -53,6 +49,7 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
</div> </div>
<nav class="postnav col-wide"> <nav class="postnav col-wide">
<Mark kind="pipes" width={26} bottom="-8px" left="-8px" rotate={5} />
<a class="back" href={blog("/")}> <a class="back" href={blog("/")}>
<span class="lbl">← Back to writing</span> <span class="lbl">← Back to writing</span>
</a> </a>
@ -66,6 +63,7 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
.col-wide { max-width: 940px; margin-left: auto; margin-right: auto; } .col-wide { max-width: 940px; margin-left: auto; margin-right: auto; }
.posthead { .posthead {
position: relative;
padding: clamp(40px, 8vh, 104px) 0 clamp(26px, 4vh, 44px); padding: clamp(40px, 8vh, 104px) 0 clamp(26px, 4vh, 44px);
text-align: center; text-align: center;
} }
@ -203,6 +201,7 @@ const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric
:global(body:not(.dark)) .prose :global(blockquote) { color: var(--fg-1); } :global(body:not(.dark)) .prose :global(blockquote) { color: var(--fg-1); }
.postnav { .postnav {
position: relative;
padding: clamp(40px, 6vh, 72px) 0 clamp(20px, 3vh, 32px); padding: clamp(40px, 6vh, 72px) 0 clamp(20px, 3vh, 32px);
margin-top: clamp(30px, 5vh, 56px); margin-top: clamp(30px, 5vh, 56px);
border-top: 1px solid var(--hairline-on-dark); border-top: 1px solid var(--hairline-on-dark);

View file

@ -6,14 +6,10 @@ import { home } from "~/lib/urls";
--- ---
<BaseLayout title="Not yet here — Josh Bairstow" description="The page you were looking for doesn't exist yet."> <BaseLayout title="Not yet here — Josh Bairstow" description="The page you were looking for doesn't exist yet.">
<Fragment slot="marks">
<Mark kind="ringdots" width={92} top="18%" left="6%" rotate={-8} />
<Mark kind="dots" width={50} top="64%" right="6%" />
<Mark kind="pipes" width={28} bottom="18%" left="40%" rotate={10} />
<Mark kind="concentric" width={44} bottom="22%" right="14%" />
</Fragment>
<main class="missing"> <main class="missing">
<Mark kind="ringdots" width={92} top="-14px" left="-16px" rotate={-8} />
<Mark kind="dots" width={50} top="-12px" right="-12px" />
<Mark kind="concentric" width={44} bottom="-14px" right="-12px" />
<p class="num">404</p> <p class="num">404</p>
<h1>Not yet here<span class="accent">.</span></h1> <h1>Not yet here<span class="accent">.</span></h1>
<p class="lead"> <p class="lead">
@ -29,6 +25,7 @@ import { home } from "~/lib/urls";
<style> <style>
.missing { .missing {
position: relative;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -10,18 +10,15 @@ import TideStudyCanvas from "~/components/islands/TideStudyCanvas";
description="A real-time field of warm rings, seeded each morning by the tide chart off Manly." description="A real-time field of warm rings, seeded each morning by the tide chart off Manly."
current="art" current="art"
> >
<Fragment slot="marks">
<Mark kind="dots" width={50} top="20%" right="4%" />
<Mark kind="pipes" width={26} bottom="16%" left="4%" rotate={6} />
<Mark kind="concentric" width={38} top="70%" right="8%" />
</Fragment>
<main class="gallery"> <main class="gallery">
<section class="work"> <section class="work">
<TideStudyCanvas client:load /> <TideStudyCanvas client:load />
</section> </section>
<aside class="caption"> <aside class="caption">
<Mark kind="dots" width={50} top="-14px" right="-12px" />
<Mark kind="pipes" width={26} bottom="-8px" left="-8px" rotate={6} />
<Mark kind="concentric" width={38} top="40%" right="-14px" />
<span class="idx">Work 07 / 12</span> <span class="idx">Work 07 / 12</span>
<p class="eyebrow series-line">Tide studies · generative</p> <p class="eyebrow series-line">Tide studies · generative</p>
<h1>Tide Study <em>No. 7</em></h1> <h1>Tide Study <em>No. 7</em></h1>
@ -125,6 +122,7 @@ import TideStudyCanvas from "~/components/islands/TideStudyCanvas";
} }
.caption { .caption {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }

View file

@ -59,14 +59,8 @@ for (const post of posts) {
description="Short pieces on craft, the ocean, and the quiet discipline of making small software." description="Short pieces on craft, the ocean, and the quiet discipline of making small software."
current="blog" current="blog"
> >
<Fragment slot="marks">
<Mark kind="dots" width={54} top="18%" right="4%" />
<Mark kind="concentric" width={40} top="64%" left="3%" rotate={6} />
<Mark kind="pipes" width={26} bottom="12%" right="9%" />
<Mark kind="ringdots" width={72} bottom="26%" left="46%" rotate={-4} />
</Fragment>
<header class="masthead"> <header class="masthead">
<Mark kind="ringdots" width={72} top="-12px" right="-14px" rotate={-4} />
<div> <div>
<Eyebrow>Journal · field notes</Eyebrow> <Eyebrow>Journal · field notes</Eyebrow>
<h1 class="title">Writing<span style="color:var(--accent-deep)">.</span></h1> <h1 class="title">Writing<span style="color:var(--accent-deep)">.</span></h1>
@ -109,6 +103,8 @@ for (const post of posts) {
} }
<section class="archive"> <section class="archive">
<Mark kind="dots" width={54} top="-14px" right="-10px" />
<Mark kind="concentric" width={40} bottom="-12px" left="-12px" rotate={6} />
{ {
yearGroups.map(([year, items]) => ( yearGroups.map(([year, items]) => (
<> <>
@ -135,6 +131,7 @@ for (const post of posts) {
<style> <style>
.masthead { .masthead {
position: relative;
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px); gap: clamp(30px, 6vw, 80px);
@ -256,6 +253,7 @@ for (const post of posts) {
} }
.archive { .archive {
position: relative;
padding: clamp(34px, 6vh, 72px) 0 0; padding: clamp(34px, 6vh, 72px) 0 0;
} }

View file

@ -11,13 +11,8 @@ import { blog, home } from "~/lib/urls";
description="Experiments and small tools. Coming online piece by piece." description="Experiments and small tools. Coming online piece by piece."
current="code" current="code"
> >
<Fragment slot="marks">
<Mark kind="pipes" width={30} top="22%" left="6%" rotate={-4} />
<Mark kind="dots" width={48} top="68%" right="6%" />
<Mark kind="concentric" width={40} bottom="12%" left="44%" />
</Fragment>
<header class="masthead"> <header class="masthead">
<Mark kind="pipes" width={30} top="-10px" left="-10px" rotate={-4} />
<div> <div>
<Eyebrow>Workshop · in progress</Eyebrow> <Eyebrow>Workshop · in progress</Eyebrow>
<h1 class="title">Code<span class="accent">.</span></h1> <h1 class="title">Code<span class="accent">.</span></h1>
@ -30,6 +25,8 @@ import { blog, home } from "~/lib/urls";
</header> </header>
<section class="placeholder"> <section class="placeholder">
<Mark kind="dots" width={48} top="-14px" right="-8px" />
<Mark kind="concentric" width={40} bottom="-12px" left="-8px" />
<p> <p>
In the meantime, the writing index is the closest thing to a working log: In the meantime, the writing index is the closest thing to a working log:
</p> </p>
@ -45,6 +42,7 @@ import { blog, home } from "~/lib/urls";
<style> <style>
.masthead { .masthead {
position: relative;
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px); gap: clamp(30px, 6vw, 80px);
@ -82,6 +80,7 @@ import { blog, home } from "~/lib/urls";
:global(body:not(.dark)) .masthead .blurb { color: var(--fg-2); } :global(body:not(.dark)) .masthead .blurb { color: var(--fg-2); }
.placeholder { .placeholder {
position: relative;
padding: clamp(34px, 6vh, 72px) 0 0; padding: clamp(34px, 6vh, 72px) 0 0;
border-top: 1px solid var(--hairline-on-dark); border-top: 1px solid var(--hairline-on-dark);
display: flex; display: flex;

View file

@ -31,16 +31,9 @@ const indexItems = [
--- ---
<BaseLayout title="Josh Bairstow" current="home" showCoordInTopbar={true}> <BaseLayout title="Josh Bairstow" current="home" showCoordInTopbar={true}>
<Fragment slot="marks">
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
<Mark kind="dots" width={52} top="26%" right="3.5%" />
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
<Mark kind="ringdots" width={66} bottom="20%" left="44%" rotate={-3} />
</Fragment>
<main class="home" data-layout="stack"> <main class="home" data-layout="stack">
<section class="intro"> <section class="intro">
<Mark kind="ringdots" width={84} top="-10px" left="-24px" rotate={-5} />
<Eyebrow>engineer errant · Sydney</Eyebrow> <Eyebrow>engineer errant · Sydney</Eyebrow>
<h2 class="statement"> <h2 class="statement">
Deliberate work.<br /><em>Restless inquiry</em><span class="accent">.</span> Deliberate work.<br /><em>Restless inquiry</em><span class="accent">.</span>
@ -51,6 +44,8 @@ const indexItems = [
</section> </section>
<section class="hero"> <section class="hero">
<Mark kind="dots" width={52} top="-16px" right="-12px" />
<Mark kind="pipes" width={28} bottom="-8px" left="-6px" rotate={8} />
<HeroZone client:load /> <HeroZone client:load />
</section> </section>
@ -68,6 +63,7 @@ const indexItems = [
} }
<section class="index"> <section class="index">
<Mark kind="concentric" width={42} bottom="-14px" right="-10px" />
<Eyebrow>Index</Eyebrow> <Eyebrow>Index</Eyebrow>
<nav> <nav>
{indexItems.map((item) => ( {indexItems.map((item) => (
@ -100,6 +96,7 @@ const indexItems = [
} }
.intro { .intro {
grid-area: intro; grid-area: intro;
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -143,6 +140,7 @@ const indexItems = [
.hero { .hero {
grid-area: hero; grid-area: hero;
position: relative;
display: flex; display: flex;
} }
/* Styles for .hzframe + its children come from the React island's class names */ /* Styles for .hzframe + its children come from the React island's class names */
@ -215,6 +213,7 @@ const indexItems = [
.index { .index {
grid-area: index; grid-area: index;
position: relative;
text-align: left; text-align: left;
} }
.index :global(.eyebrow) { .index :global(.eyebrow) {

View file

@ -39,7 +39,7 @@ body:not(.dark)::before { opacity: 0.045; mix-blend-mode: normal; }
--mark-rest: 0.16; --mark-rest: 0.16;
} }
.mark { .mark {
position: fixed; pointer-events: none; z-index: 0; position: absolute; pointer-events: none; z-index: 0;
filter: var(--mark-filter-dark); filter: var(--mark-filter-dark);
opacity: var(--mark-rest); opacity: var(--mark-rest);
transition: opacity var(--dur-slow) var(--ease-out); transition: opacity var(--dur-slow) var(--ease-out);