Compare commits

..

No commits in common. "ae60a7c85611031bf34a2a4638ab10160d46a308" and "698ac14dd7d982c04ec37f25cdcb90c08c5bdb9c" have entirely different histories.

9 changed files with 50 additions and 317 deletions

View file

@ -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 (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 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
<meta property="og:url" content={canonicalHref} />
</head>
<body class="dark">
<ParticleField client:idle />
<!-- sprinkled accent marks — loose, non-rigid placement, per page-of-page overrides via slot -->
<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">
<TopBar current={current} showCoord={showCoordInTopbar} />

View file

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

View file

@ -6,10 +6,14 @@ import { home } from "~/lib/urls";
---
<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">
<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>
<h1>Not yet here<span class="accent">.</span></h1>
<p class="lead">
@ -25,7 +29,6 @@ import { home } from "~/lib/urls";
<style>
.missing {
position: relative;
flex: 1;
display: flex;
flex-direction: column;

View file

@ -10,15 +10,18 @@ import TideStudyCanvas from "~/components/islands/TideStudyCanvas";
description="A real-time field of warm rings, seeded each morning by the tide chart off Manly."
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">
<section class="work">
<TideStudyCanvas client:load />
</section>
<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>
<p class="eyebrow series-line">Tide studies · generative</p>
<h1>Tide Study <em>No. 7</em></h1>
@ -122,7 +125,6 @@ import TideStudyCanvas from "~/components/islands/TideStudyCanvas";
}
.caption {
position: relative;
display: flex;
flex-direction: column;
}

View file

@ -59,8 +59,14 @@ for (const post of posts) {
description="Short pieces on craft, the ocean, and the quiet discipline of making small software."
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">
<Mark kind="ringdots" width={72} top="-12px" right="-14px" rotate={-4} />
<div>
<Eyebrow>Journal · field notes</Eyebrow>
<h1 class="title">Writing<span style="color:var(--accent-deep)">.</span></h1>
@ -103,8 +109,6 @@ for (const post of posts) {
}
<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]) => (
<>
@ -131,7 +135,6 @@ for (const post of posts) {
<style>
.masthead {
position: relative;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px);
@ -253,7 +256,6 @@ for (const post of posts) {
}
.archive {
position: relative;
padding: clamp(34px, 6vh, 72px) 0 0;
}

View file

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

View file

@ -31,21 +31,26 @@ const indexItems = [
---
<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">
<section class="intro">
<Mark kind="ringdots" width={84} top="-10px" left="-24px" rotate={-5} />
<Eyebrow>engineer errant · Sydney</Eyebrow>
<h2 class="statement">
Deliberate work.<br /><em>Restless inquiry</em><span class="accent">.</span>
</h2>
<p class="lead">
Software, pondering, and the occasional ink mark in a quiet corner of the internet.
Software, pondering, and the occasional ink mark in my quiet corner of the internet.
</p>
</section>
<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 />
</section>
@ -63,7 +68,6 @@ const indexItems = [
}
<section class="index">
<Mark kind="concentric" width={42} bottom="-14px" right="-10px" />
<Eyebrow>Index</Eyebrow>
<nav>
{indexItems.map((item) => (
@ -96,7 +100,6 @@ const indexItems = [
}
.intro {
grid-area: intro;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
@ -140,7 +143,6 @@ const indexItems = [
.hero {
grid-area: hero;
position: relative;
display: flex;
}
/* Styles for .hzframe + its children come from the React island's class names */
@ -213,7 +215,6 @@ const indexItems = [
.index {
grid-area: index;
position: relative;
text-align: left;
}
.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 {
position: absolute; pointer-events: none; z-index: 0;
position: fixed; pointer-events: none; z-index: 0;
filter: var(--mark-filter-dark);
opacity: var(--mark-rest);
transition: opacity var(--dur-slow) var(--ease-out);