particle field planning
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
This commit is contained in:
parent
ae60a7c856
commit
f4b8e1a7d8
5 changed files with 1685 additions and 0 deletions
848
docs/superpowers/plans/2026-06-03-particle-field-greebling.md
Normal file
848
docs/superpowers/plans/2026-06-03-particle-field-greebling.md
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
# Particle Field + Greebling Restructure — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a full-viewport ambient particle field background layer and restructure existing accent marks to anchor at section edges instead of floating at random viewport positions.
|
||||
|
||||
**Architecture:** A new `ParticleField.tsx` React island renders a fixed, transparent `<canvas>` below the content stage (z-index 0, pointer-events none). Marks change from `position: fixed` (viewport-based) to `position: absolute` (section-relative) by a single CSS rule change, with each mark moved from the `<slot name="marks">` into its host section.
|
||||
|
||||
**Tech Stack:** Astro, React (islands), HTML Canvas API, Perlin/fBm noise (ported from reference), CSS positioning
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-03-particle-field-greebling-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `src/components/islands/ParticleField.tsx` | **Create** — full-viewport particle canvas island |
|
||||
| `src/styles/site.css` | **Modify** — `.mark` positioning: fixed → absolute |
|
||||
| `src/layouts/BaseLayout.astro` | **Modify** — import + render ParticleField; clear slot defaults |
|
||||
| `src/pages/index.astro` | **Modify** — remove slot Fragment; add marks inside sections |
|
||||
| `src/pages/blog/index.astro` | **Modify** — remove slot Fragment; add marks inside sections |
|
||||
| `src/pages/code/index.astro` | **Modify** — remove slot Fragment; add marks inside sections |
|
||||
| `src/pages/art/index.astro` | **Modify** — remove slot Fragment; add marks inside sections |
|
||||
| `src/pages/404.astro` | **Modify** — remove slot Fragment; add marks inside section |
|
||||
| `src/layouts/PostLayout.astro` | **Modify** — remove slot Fragment; add marks inside sections |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create ParticleField.tsx
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/islands/ParticleField.tsx`
|
||||
|
||||
This ports the Perlin/fBm engine from `reference/particle-field-demo/particleNetwork.js` directly into a self-contained React component. The canvas is transparent (no background fill), full-viewport, fixed, and non-interactive. All tunable values live in `PARTICLE_CONFIG`.
|
||||
|
||||
Key differences from the reference:
|
||||
- Canvas is full `window.innerWidth × window.innerHeight` (no aspect-ratio constraint)
|
||||
- No background fill — `clearRect` instead of `fillRect`
|
||||
- Mouse tracking on `window` (not canvas, which has `pointer-events: none`)
|
||||
- Warm neutral colour palette replacing the reference's cool blues
|
||||
|
||||
- [ ] **Step 1: Create the component file**
|
||||
|
||||
```tsx
|
||||
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;
|
||||
|
||||
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 = canvas.width = window.innerWidth;
|
||||
H = canvas.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;
|
||||
|
||||
ctx.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;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = isMc
|
||||
? `rgba(${lineColorMouse},${alpha * 0.9})`
|
||||
: `rgba(${lineColor},${alpha * 0.7})`;
|
||||
ctx.lineWidth = isMc ? 0.9 : 0.5;
|
||||
ctx.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 = ctx.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)");
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, glowR * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fill();
|
||||
|
||||
const dR = Math.round(dotBaseR + f * dotBoostR);
|
||||
const dG = Math.round(dotBaseG + f * dotBoostG);
|
||||
const dB = Math.round(dotBaseB + f * dotBoostB);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${dR},${dG},${dB},${gA})`;
|
||||
ctx.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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run type-check to verify no TypeScript errors**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors referencing `ParticleField.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/islands/ParticleField.tsx
|
||||
git commit -m "feat(islands): add ambient ParticleField canvas background"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire ParticleField into BaseLayout and clear mark slot defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/layouts/BaseLayout.astro`
|
||||
|
||||
- [ ] **Step 1: Add the ParticleField import and render it**
|
||||
|
||||
In `src/layouts/BaseLayout.astro`, add the import at the top of the frontmatter:
|
||||
|
||||
```astro
|
||||
---
|
||||
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 Mark from "~/components/atoms/Mark.astro";
|
||||
import ParticleField from "~/components/islands/ParticleField";
|
||||
// ... rest of frontmatter unchanged
|
||||
---
|
||||
```
|
||||
|
||||
In the `<body>`, add `<ParticleField client:idle />` before `.stage` and clear the slot defaults:
|
||||
|
||||
```astro
|
||||
<body class="dark">
|
||||
<ParticleField client:idle />
|
||||
<slot name="marks" />
|
||||
<div class="stage">
|
||||
<TopBar current={current} showCoord={showCoordInTopbar} />
|
||||
<slot />
|
||||
<SiteFoot />
|
||||
</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
Note: `<slot name="marks" />` stays (with no defaults) so individual pages can still optionally inject fixed marks if needed in future. The default marks are removed — they will be placed inline in each page's sections instead.
|
||||
|
||||
- [ ] **Step 2: Start the dev server and verify the particle field renders**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:4321`. Confirm:
|
||||
- Particles and connecting lines are visible as a warm, dim background layer
|
||||
- The particle canvas is entirely behind all page content (nav, text, hero zone)
|
||||
- Clicking links and buttons still works (pointer events pass through)
|
||||
- No console errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/layouts/BaseLayout.astro
|
||||
git commit -m "feat(layout): integrate ParticleField as sitewide background layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Change Mark positioning from fixed to absolute
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/styles/site.css` (line 42)
|
||||
|
||||
- [ ] **Step 1: Change the CSS rule**
|
||||
|
||||
In `src/styles/site.css`, find the `.mark` rule (around line 41) and change `position: fixed` to `position: absolute`:
|
||||
|
||||
```css
|
||||
.mark {
|
||||
position: absolute; pointer-events: none; z-index: 0;
|
||||
filter: var(--mark-filter-dark);
|
||||
opacity: var(--mark-rest);
|
||||
transition: opacity var(--dur-slow) var(--ease-out);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify marks are no longer floating**
|
||||
|
||||
With the dev server still running, check that existing marks on any page have collapsed to their section (they will be mispositioned until Task 4–9 move them into sections, but they should no longer be floating over the viewport at incorrect positions).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/styles/site.css
|
||||
git commit -m "refactor(marks): switch Mark from fixed to absolute positioning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Migrate marks — Home page (index.astro)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/index.astro`
|
||||
|
||||
Marks move from the `<Fragment slot="marks">` into the content sections. Four sections host one mark each. The fifth `ringdots` (bottom:20% left:44%) is dropped — four marks is enough.
|
||||
|
||||
- [ ] **Step 1: Remove the slot Fragment and move marks into sections**
|
||||
|
||||
Replace the entire `<Fragment slot="marks">...</Fragment>` block with nothing (delete it).
|
||||
|
||||
Inside `.intro`, add a mark as the **first child**:
|
||||
```astro
|
||||
<section class="intro">
|
||||
<Mark kind="ringdots" width={84} top="-10px" left="-24px" rotate={-5} />
|
||||
<Eyebrow>Engineer · Sydney</Eyebrow>
|
||||
...
|
||||
</section>
|
||||
```
|
||||
|
||||
Inside `.hero`, add two marks as children before `<HeroZone client:load />`:
|
||||
```astro
|
||||
<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>
|
||||
```
|
||||
|
||||
Inside `.index`, add a mark as the first child:
|
||||
```astro
|
||||
<section class="index">
|
||||
<Mark kind="concentric" width={42} bottom="-14px" right="-10px" />
|
||||
<Eyebrow>Index</Eyebrow>
|
||||
...
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `position: relative` to the host sections in the page styles**
|
||||
|
||||
In the `<style>` block, add `position: relative` to `.intro`, `.hero`, and `.index`:
|
||||
|
||||
```css
|
||||
.intro {
|
||||
grid-area: intro;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.hero {
|
||||
grid-area: hero;
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.index {
|
||||
grid-area: index;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Visual check**
|
||||
|
||||
In the browser at `/`, confirm marks appear at the edges of their sections and scroll with the page (not fixed to viewport). Adjust offset values in the markup if positioning looks off — the values above are a starting point for visual iteration.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/index.astro
|
||||
git commit -m "refactor(marks): anchor home page marks to section edges"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migrate marks — Blog index (blog/index.astro)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/blog/index.astro`
|
||||
|
||||
Current marks: `dots` 54px top-right, `concentric` 40px mid-left, `pipes` 26px bottom-right, `ringdots` 72px bottom-center.
|
||||
|
||||
Mapping to sections: `.masthead`, `.feature` (or the archive section).
|
||||
|
||||
- [ ] **Step 1: Remove slot Fragment and move marks into sections**
|
||||
|
||||
Delete the `<Fragment slot="marks">...</Fragment>` block.
|
||||
|
||||
In the `<header class="masthead">`, add a mark as the first child:
|
||||
```astro
|
||||
<header class="masthead">
|
||||
<Mark kind="ringdots" width={72} top="-12px" right="-14px" rotate={-4} />
|
||||
<div>
|
||||
<Eyebrow>Journal · field notes</Eyebrow>
|
||||
...
|
||||
</div>
|
||||
...
|
||||
</header>
|
||||
```
|
||||
|
||||
In the `<section class="archive">`, add marks:
|
||||
```astro
|
||||
<section class="archive">
|
||||
<Mark kind="dots" width={54} top="-14px" right="-10px" />
|
||||
<Mark kind="concentric" width={40} bottom="-12px" left="-12px" rotate={6} />
|
||||
...
|
||||
</section>
|
||||
```
|
||||
|
||||
Drop the `pipes` 26px mark (three marks is enough for this page).
|
||||
|
||||
- [ ] **Step 2: Add `position: relative` to host sections in the styles**
|
||||
|
||||
```css
|
||||
.masthead {
|
||||
position: relative;
|
||||
display: grid;
|
||||
/* ... rest unchanged */
|
||||
}
|
||||
.archive {
|
||||
position: relative;
|
||||
padding: clamp(34px, 6vh, 72px) 0 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Visual check at `/blog/`**
|
||||
|
||||
Marks should appear at masthead and archive section edges, not floating across the viewport.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/blog/index.astro
|
||||
git commit -m "refactor(marks): anchor blog index marks to section edges"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Migrate marks — Code page (code/index.astro)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/code/index.astro`
|
||||
|
||||
Current marks: `pipes` 30px top-left, `dots` 48px mid-right, `concentric` 40px bottom-center.
|
||||
|
||||
- [ ] **Step 1: Remove slot Fragment and move marks into sections**
|
||||
|
||||
Delete the `<Fragment slot="marks">...</Fragment>` block.
|
||||
|
||||
In `<header class="masthead">`:
|
||||
```astro
|
||||
<header class="masthead">
|
||||
<Mark kind="pipes" width={30} top="-10px" left="-10px" rotate={-4} />
|
||||
<div>
|
||||
<Eyebrow>Workshop · in progress</Eyebrow>
|
||||
...
|
||||
</div>
|
||||
...
|
||||
</header>
|
||||
```
|
||||
|
||||
In `<section class="placeholder">`:
|
||||
```astro
|
||||
<section class="placeholder">
|
||||
<Mark kind="dots" width={48} top="-14px" right="-8px" />
|
||||
<Mark kind="concentric" width={40} bottom="-12px" left="-8px" />
|
||||
...
|
||||
</section>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `position: relative` in styles**
|
||||
|
||||
```css
|
||||
.masthead {
|
||||
position: relative;
|
||||
display: grid;
|
||||
/* ... rest unchanged */
|
||||
}
|
||||
.placeholder {
|
||||
position: relative;
|
||||
padding: clamp(34px, 6vh, 72px) 0 0;
|
||||
border-top: 1px solid var(--hairline-on-dark);
|
||||
/* ... rest unchanged */
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Visual check at `/code/`**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/code/index.astro
|
||||
git commit -m "refactor(marks): anchor code page marks to section edges"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Migrate marks — Art page (art/index.astro)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/art/index.astro`
|
||||
|
||||
Current marks: `dots` 50px top-right, `pipes` 26px bottom-left, `concentric` 38px mid-right. The art page has a two-column `.gallery` layout with a `.work` section and a `.caption` aside.
|
||||
|
||||
- [ ] **Step 1: Remove slot Fragment and move marks into sections**
|
||||
|
||||
Delete the `<Fragment slot="marks">...</Fragment>` block.
|
||||
|
||||
In `<main class="gallery">`, add marks targeting the caption column:
|
||||
```astro
|
||||
<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>
|
||||
...
|
||||
</aside>
|
||||
</main>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `position: relative` to `.caption` in styles**
|
||||
|
||||
```css
|
||||
.caption {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Visual check at `/art/`**
|
||||
|
||||
Confirm the TideStudyCanvas renders normally — the `ParticleField` is below `.stage` and TideStudyCanvas is inside `.work` inside `.stage`, so they do not conflict.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/art/index.astro
|
||||
git commit -m "refactor(marks): anchor art page marks to caption column"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Migrate marks — 404 page (404.astro)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/404.astro`
|
||||
|
||||
Current marks: `ringdots` 92px top-left, `dots` 50px mid-right, `pipes` 28px bottom-center, `concentric` 44px bottom-right. The 404 page has a single `.missing` section (flex column, centered).
|
||||
|
||||
- [ ] **Step 1: Remove slot Fragment and move marks into the section**
|
||||
|
||||
Delete the `<Fragment slot="marks">...</Fragment>` block.
|
||||
|
||||
In `<main class="missing">`:
|
||||
```astro
|
||||
<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>
|
||||
...
|
||||
</main>
|
||||
```
|
||||
|
||||
Drop the `pipes` 28px mark (three marks is enough).
|
||||
|
||||
- [ ] **Step 2: Add `position: relative` to `.missing` in styles**
|
||||
|
||||
```css
|
||||
.missing {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
/* ... rest unchanged */
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Visual check at `/404`** (navigate to any non-existent URL)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/404.astro
|
||||
git commit -m "refactor(marks): anchor 404 page marks to content section"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Migrate marks — Post layout (PostLayout.astro)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/layouts/PostLayout.astro`
|
||||
|
||||
Current marks: `ringdots` 70px top-left, `dots` 48px mid-right, `pipes` 26px bottom-left. The post layout has `<article>` containing `.posthead` and `.prose`.
|
||||
|
||||
- [ ] **Step 1: Remove slot Fragment and move marks into article sections**
|
||||
|
||||
Delete the `<Fragment slot="marks">...</Fragment>` block.
|
||||
|
||||
In `<article>`, wrap the marks around `.posthead` and `.postnav`:
|
||||
```astro
|
||||
<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" />
|
||||
...existing content...
|
||||
</header>
|
||||
|
||||
<div class="prose col">
|
||||
<slot />
|
||||
</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>
|
||||
</nav>
|
||||
</article>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `position: relative` to `.posthead` and `.postnav`**
|
||||
|
||||
```css
|
||||
.posthead {
|
||||
position: relative;
|
||||
padding: clamp(40px, 8vh, 104px) 0 clamp(26px, 4vh, 44px);
|
||||
text-align: center;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Visual check on a blog post**
|
||||
|
||||
Navigate to any post. Confirm marks appear at the head and footer of the post, not floating across the viewport.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/layouts/PostLayout.astro
|
||||
git commit -m "refactor(marks): anchor post layout marks to posthead and postnav"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Final cross-page verification
|
||||
|
||||
- [ ] **Step 1: Check all pages**
|
||||
|
||||
Visit each route and confirm:
|
||||
- `/` — particle field visible, 4 marks at section edges
|
||||
- `/blog/` — particle field, 3 marks
|
||||
- `/blog/posts/<any-post>/` — particle field, 3 marks
|
||||
- `/code/` — particle field, 3 marks
|
||||
- `/art/` — particle field, 3 marks; TideStudyCanvas unaffected
|
||||
- `/404` — particle field, 3 marks
|
||||
|
||||
For each page:
|
||||
- Marks scroll with page content (not fixed to viewport)
|
||||
- Particle canvas is always behind all content
|
||||
- No visual regressions in typography, nav, interactive zones, or layout
|
||||
|
||||
- [ ] **Step 2: Check reduced-motion**
|
||||
|
||||
In browser devtools → Rendering → "Emulate CSS media feature prefers-reduced-motion: reduce". Particle canvas should show a single static frame, not animate.
|
||||
|
||||
- [ ] **Step 3: Check light theme**
|
||||
|
||||
Toggle the light theme (if the site has a toggle). Marks should use `--mark-filter-light` (reduced contrast) and the particle field should remain visible against the lighter ground.
|
||||
|
||||
- [ ] **Step 4: Final commit if any tweaks were made**
|
||||
|
||||
```bash
|
||||
git add -p
|
||||
git commit -m "fix(visual): post-review mark offset tweaks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Visual Tuning Reference
|
||||
|
||||
After the initial pass, iterate on `PARTICLE_CONFIG` in `ParticleField.tsx`:
|
||||
|
||||
| Want more... | Adjust |
|
||||
|---|---|
|
||||
| Particles | `count: 40` → higher |
|
||||
| Denser mesh | `reach: 130` → higher |
|
||||
| Faster drift | `flowSpeed: 1.8` → higher |
|
||||
| More field contrast | `dimAtMin: 0.55` → higher (more dimming at troughs) |
|
||||
| Slower/larger blobs | `fieldScale: 4` → lower |
|
||||
| Brighter particles | `dotBaseR/G/B` → higher |
|
||||
|
||||
For mark placement, edit the `top/left/right/bottom` values inline in each page file. Values are CSS strings (`"px"` or `"%"` both work).
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
# Particle Field + Greebling Restructure
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Status:** Approved
|
||||
|
||||
## Context
|
||||
|
||||
The site currently uses fixed-position SVG accent marks (`Mark.astro`) placed at hardcoded viewport percentages. They feel scattered rather than connected to anything structural. Alongside this, a new ambient background visual — a floating particle field — is to be introduced to add depth and visual interest.
|
||||
|
||||
The two changes are complementary: both join the same atmospheric layer of the site. The marks become structurally grounded; the particle field adds a new living layer beneath everything.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: ParticleField Component
|
||||
|
||||
### What it does
|
||||
|
||||
A full-viewport `<canvas>` rendered as a fixed background layer below the content stage. Particles drift slowly across the canvas, connected by faint lines when within reach. An underlying Perlin/fBm noise field modulates opacity and glow per-particle, producing an organic pulsing quality. The cursor brightens nearby lines.
|
||||
|
||||
### Layer position
|
||||
|
||||
```
|
||||
.stage (relative, z-index: 1) ← content, unchanged
|
||||
ParticleField canvas (fixed, z-index: 0, pointer-events: none) ← new
|
||||
body::before grain (fixed, z-index: 0) ← unchanged
|
||||
```
|
||||
|
||||
The canvas sits below the stage (`z:0` vs `z:1`) and carries `pointer-events: none`. No existing display or interactive component is affected.
|
||||
|
||||
### New file
|
||||
|
||||
`src/components/islands/ParticleField.tsx`
|
||||
|
||||
- Canvas: `position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 0`
|
||||
- No background fill — canvas is transparent; `#17130E` page ground shows through
|
||||
- Perlin noise engine ported directly from `reference/particle-field-demo/particleNetwork.js`
|
||||
- All tunable values in a single `PARTICLE_CONFIG` object at the top of the file — nothing buried in render logic
|
||||
- `prefers-reduced-motion`: animation loop pauses, canvas renders one static frame
|
||||
- Lifecycle: `useEffect` mount/unmount, resize observer to keep canvas dimensions in sync
|
||||
|
||||
### Integration
|
||||
|
||||
Added to `src/layouts/BaseLayout.astro` as:
|
||||
|
||||
```astro
|
||||
<ParticleField client:idle />
|
||||
```
|
||||
|
||||
Placed in the DOM **before** `.stage` so stacking order is correct without needing a higher z-index on the stage.
|
||||
|
||||
### Colour palette
|
||||
|
||||
Warm neutral register, matching `--fg-on-dark-2` / `--fg-on-dark-3` range — embers, not highlights.
|
||||
|
||||
| Token | Value | Replaces (reference cool blue) |
|
||||
|---|---|---|
|
||||
| Particle fill | `rgb(200, 178, 148)` | `rgb(180, 200, 255)` |
|
||||
| Connecting lines | `174, 140, 100` | `80, 160, 240` |
|
||||
| Mouse lines | `210, 185, 150` | `160, 220, 255` |
|
||||
| Glow inner | `200, 170, 130` | `180, 220, 255` |
|
||||
| Glow mid | `155, 120, 82` | `100, 170, 255` |
|
||||
|
||||
Starting config values will be tuned down from the reference defaults (fewer particles, lower line alpha) for a content site context. All values live in `PARTICLE_CONFIG` for immediate visual iteration.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Mark Restructure
|
||||
|
||||
### What changes
|
||||
|
||||
`Mark.astro` switches from `position: fixed` to `position: absolute`. All existing props (`kind`, `width`, `top`, `left`, `right`, `bottom`, `rotate`) are unchanged — only the positioning context shifts from viewport to nearest `position: relative` ancestor.
|
||||
|
||||
The `<slot name="marks">` in `BaseLayout.astro` is cleared of its defaults. Marks migrate to live inside the specific section containers they visually belong near.
|
||||
|
||||
### Per-section anchoring
|
||||
|
||||
Each host section gets `position: relative` added. Marks use small offsets (including negative values) to sit at or just outside the section's visible edge. Absolutely-positioned elements are out of flow, so section content and layout are unaffected.
|
||||
|
||||
Example (home page):
|
||||
|
||||
| Mark | Section | Edge |
|
||||
|---|---|---|
|
||||
| `ringdots` 84px | `.intro` | top-left, slight outward offset |
|
||||
| `dots` 52px | `.hero` | top-right edge |
|
||||
| `pipes` 28px | `.hero` | bottom-left edge |
|
||||
| `concentric` 42px | `.index` | bottom-right edge |
|
||||
|
||||
The same anchoring logic applies to `404.astro`, `blog/index.astro`, `code/index.astro`, `art/index.astro`, and `PostLayout.astro`.
|
||||
|
||||
### Future enhancement
|
||||
|
||||
The current placement is a hand-curated first pass. A future `generateMarks(sections)` utility could sample from available section anchors and mark types with seeded randomness, removing the fingerprints of manual placement and producing layouts that feel discovered rather than designed. This is intentionally deferred — visual inspection of the curated pass will inform what the generative version should optimise for.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/components/atoms/Mark.astro` | `position: fixed` → `position: absolute` |
|
||||
| `src/layouts/BaseLayout.astro` | Clear slot defaults; add `<ParticleField client:idle />` |
|
||||
| `src/pages/index.astro` | Move marks into sections |
|
||||
| `src/pages/404.astro` | Move marks into sections |
|
||||
| `src/pages/blog/index.astro` | Move marks into sections |
|
||||
| `src/pages/code/index.astro` | Move marks into sections |
|
||||
| `src/pages/art/index.astro` | Move marks into sections |
|
||||
| `src/layouts/PostLayout.astro` | Move marks into sections |
|
||||
| `src/components/islands/ParticleField.tsx` | **New file** |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. Run `npm run dev` and inspect all pages — no visual regressions in nav, content, or islands
|
||||
2. Confirm particle canvas renders below all content and does not intercept pointer events (click through the canvas onto links/buttons)
|
||||
3. Confirm marks appear at section edges on each page, not scattered across the viewport
|
||||
4. Toggle `prefers-reduced-motion: reduce` in devtools — canvas should freeze, not disappear
|
||||
5. Resize viewport — canvas should fill; marks should reposition relative to their sections
|
||||
6. Check dark/light theme toggle — mark filters and opacity should still apply correctly
|
||||
161
reference/particle-field-demo/README.md
Normal file
161
reference/particle-field-demo/README.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# particle-network
|
||||
|
||||
A canvas animation of drifting particles connected by proximity lines, with
|
||||
opacity modulated by a slowly-rolling fBm (fractal Brownian motion) noise field.
|
||||
|
||||
No external dependencies. Pure ES module.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `particleNetwork.js` | The module — import this into your project |
|
||||
| `demo.html` | Minimal working example (requires a local server for ES module import) |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Serve the folder with any static server, e.g.:
|
||||
|
||||
```bash
|
||||
npx serve .
|
||||
# or
|
||||
python3 -m http.server
|
||||
```
|
||||
|
||||
Then open `demo.html`.
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### 1. Minimal — full-page background
|
||||
|
||||
```html
|
||||
<canvas id="net"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import { ParticleNetwork } from './particleNetwork.js';
|
||||
|
||||
const canvas = document.getElementById('net');
|
||||
|
||||
// Size the canvas to the viewport yourself, or let the module
|
||||
// read the parent element's bounding rect (default behaviour).
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = '100%';
|
||||
|
||||
const net = new ParticleNetwork(canvas, null);
|
||||
net.start();
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. With field preview (debug / design mode)
|
||||
|
||||
```html
|
||||
<canvas id="net"></canvas>
|
||||
<canvas id="field"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import { ParticleNetwork } from './particleNetwork.js';
|
||||
|
||||
const net = new ParticleNetwork(
|
||||
document.getElementById('net'),
|
||||
document.getElementById('field'),
|
||||
{ count: 60, dimAtMin: 0.5 }
|
||||
);
|
||||
|
||||
net.start();
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. Bundler / framework
|
||||
|
||||
Copy `particleNetwork.js` anywhere in your `src/` tree and import normally:
|
||||
|
||||
```js
|
||||
import { ParticleNetwork } from '@/lib/particleNetwork.js';
|
||||
```
|
||||
|
||||
No build config changes needed — it uses only standard browser APIs.
|
||||
|
||||
---
|
||||
|
||||
## Constructor
|
||||
|
||||
```js
|
||||
new ParticleNetwork(mainCanvas, fieldCanvas, options)
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `mainCanvas` | `HTMLCanvasElement` | The particle animation canvas |
|
||||
| `fieldCanvas` | `HTMLCanvasElement \| null` | Optional debug field view |
|
||||
| `options` | `object` | See below |
|
||||
|
||||
---
|
||||
|
||||
## Options
|
||||
|
||||
All options can be passed at construction time and updated at runtime via
|
||||
`setOption(key, value)`.
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `count` | `55` | Number of particles |
|
||||
| `reach` | `140` | Max connection distance in canvas px |
|
||||
| `flowSpeed` | `2` | Field drift speed on a 1–10 scale. At 1, motion takes several minutes to traverse the field — clearly present but takes time to perceive. |
|
||||
| `fieldScale` | `4` | Noise zoom on a 1–10 scale. Lower = larger rolling blobs. |
|
||||
| `dimAtMin` | `0.60` | Opacity reduction at field minimum. `0` = field has no opacity effect. `1` = particles in troughs are fully invisible. |
|
||||
| `particleSpeed` | `0.35` | Base particle velocity (px/frame) |
|
||||
| `particleMinR` | `1.4` | Minimum particle radius (px) |
|
||||
| `particleMaxR` | `2.8` | Maximum particle radius (px) |
|
||||
| `fieldUpdateMs` | `50` | How often the noise field recalculates (ms). Lower = smoother but more CPU. |
|
||||
| `fieldOctaves` | `4` | fBm octaves. More = finer detail, more CPU. |
|
||||
| `bgColor` | `'#040e1f'` | Canvas background colour |
|
||||
| `lineColor` | `'80,160,240'` | RGB string for particle–particle lines |
|
||||
| `lineColorMouse` | `'160,220,255'` | RGB string for mouse-proximity lines |
|
||||
| `canvasAspect` | `0.65` | Canvas height = width × aspect |
|
||||
| `timeConstantX` | `0.00084` | Primary axis time advance rate. Halve to slow further. |
|
||||
| `timeConstantY` | `0.0003145` | Secondary axis rate — kept at a different ratio for the swirling character. |
|
||||
|
||||
---
|
||||
|
||||
## Methods
|
||||
|
||||
```js
|
||||
net.start() // begin animation loop
|
||||
net.stop() // pause (preserves state)
|
||||
net.setOption(key, value) // update any option at runtime
|
||||
net.sampleField(x, y) // returns [0,1] field value at canvas coords
|
||||
net.destroy() // stop loop and remove all event listeners
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
**Noise field** — a 4-octave fBm Perlin noise function is evaluated on a tiny
|
||||
80×52 offscreen canvas every 50ms, then scaled up via `drawImage`. This is
|
||||
~100× cheaper than evaluating at display resolution.
|
||||
|
||||
**Opacity mapping** — each particle's field sample `fv ∈ [0,1]` maps to an
|
||||
opacity multiplier via:
|
||||
|
||||
```
|
||||
multiplier = (1 - dimAtMin) + dimAtMin * fv
|
||||
```
|
||||
|
||||
At `fv = 1` (field peak): multiplier = 1.0 — full brightness.
|
||||
At `fv = 0` (field trough): multiplier = 1 - dimAtMin.
|
||||
|
||||
For connecting lines the multiplier is the geometric mean of both endpoint
|
||||
values, so a line straddling a bright and dark zone stays dim.
|
||||
|
||||
**Distance falloff** — cubic: `(1 - dist/reach)³`, giving a sharp fade near
|
||||
the connection threshold rather than a linear fade across the full range.
|
||||
|
||||
**Glow** — each particle renders a `createRadialGradient` halo sized to
|
||||
`r × (2.5 + fieldValue × 1.5)`, so dots in bright field regions bloom larger.
|
||||
138
reference/particle-field-demo/demo.html
Normal file
138
reference/particle-field-demo/demo.html
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Particle Network — demo</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #040e1f;
|
||||
color: #a0c4f0;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.canvases {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
border: 0.5px solid rgba(80, 140, 220, 0.25);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
canvas { display: block; width: 100%; }
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 10px; left: 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(120, 180, 255, 0.45);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.controls label { color: rgba(160, 200, 255, 0.6); }
|
||||
.controls input[type=range] { width: 100px; accent-color: #5090d0; }
|
||||
|
||||
.dim-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.dim-row input { flex: 1; max-width: 300px; }
|
||||
.dim-row span { min-width: 36px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="canvases">
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="mainCanvas"></canvas>
|
||||
<div class="label">particle network</div>
|
||||
</div>
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="fieldCanvas"></canvas>
|
||||
<div class="label">noise field</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label>nodes</label>
|
||||
<input type="range" id="sCount" min="20" max="100" value="55" step="1">
|
||||
|
||||
<label>reach</label>
|
||||
<input type="range" id="sReach" min="60" max="260" value="140" step="1">
|
||||
|
||||
<label>flow</label>
|
||||
<input type="range" id="sSpeed" min="1" max="10" value="2" step="1">
|
||||
|
||||
<label>scale</label>
|
||||
<input type="range" id="sScale" min="1" max="10" value="4" step="1">
|
||||
|
||||
<div class="dim-row">
|
||||
<label>dim at minimum</label>
|
||||
<input type="range" id="sDim" min="0" max="100" value="60" step="1">
|
||||
<span id="sDimOut">60%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { ParticleNetwork } from './particleNetwork.js';
|
||||
|
||||
const net = new ParticleNetwork(
|
||||
document.getElementById('mainCanvas'),
|
||||
document.getElementById('fieldCanvas'),
|
||||
{ count: 55, reach: 140, flowSpeed: 2, fieldScale: 4, dimAtMin: 0.60 }
|
||||
);
|
||||
|
||||
net.start();
|
||||
|
||||
// Wire up controls
|
||||
document.getElementById('sCount').addEventListener('input', e =>
|
||||
net.setOption('count', parseInt(e.target.value)));
|
||||
|
||||
document.getElementById('sReach').addEventListener('input', e =>
|
||||
net.setOption('reach', parseInt(e.target.value)));
|
||||
|
||||
document.getElementById('sSpeed').addEventListener('input', e =>
|
||||
net.setOption('flowSpeed', parseFloat(e.target.value)));
|
||||
|
||||
document.getElementById('sScale').addEventListener('input', e =>
|
||||
net.setOption('fieldScale', parseFloat(e.target.value)));
|
||||
|
||||
const dimSlider = document.getElementById('sDim');
|
||||
const dimOut = document.getElementById('sDimOut');
|
||||
dimSlider.addEventListener('input', e => {
|
||||
const v = parseInt(e.target.value);
|
||||
dimOut.textContent = v + '%';
|
||||
net.setOption('dimAtMin', v / 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
419
reference/particle-field-demo/particleNetwork.js
Normal file
419
reference/particle-field-demo/particleNetwork.js
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* particleNetwork.js
|
||||
*
|
||||
* Particle network animation with slow-rolling fBm noise field opacity modulation.
|
||||
*
|
||||
* Usage:
|
||||
* import { ParticleNetwork } from './particleNetwork.js';
|
||||
*
|
||||
* const net = new ParticleNetwork(canvasEl, fieldCanvasEl, {
|
||||
* count: 55, // number of particles
|
||||
* reach: 140, // max connection distance (px)
|
||||
* flowSpeed: 2, // field drift speed (1–10 scale)
|
||||
* fieldScale: 4, // noise zoom level (1–10 scale)
|
||||
* dimAtMin: 0.60, // opacity multiplier at field minimum (0 = no dim, 1 = invisible)
|
||||
* });
|
||||
*
|
||||
* net.start();
|
||||
* net.stop();
|
||||
* net.setOption('count', 80);
|
||||
* net.destroy();
|
||||
*
|
||||
* fieldCanvasEl is optional. Pass null to skip the debug field view.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Perlin noise helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
||||
function _lerp(a, b, t) { return a + t * (b - a); }
|
||||
function _grad(h, x, y) {
|
||||
h &= 7;
|
||||
const u = h < 4 ? x : y, v = h < 4 ? y : x;
|
||||
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
|
||||
}
|
||||
|
||||
function _buildPermutation() {
|
||||
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, x, y) {
|
||||
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, x, y, octaves = 4) {
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULTS = {
|
||||
count: 55,
|
||||
reach: 140,
|
||||
flowSpeed: 2,
|
||||
fieldScale: 4,
|
||||
dimAtMin: 0.60,
|
||||
|
||||
// Particle motion
|
||||
particleSpeed: 0.35,
|
||||
particleMinR: 1.4,
|
||||
particleMaxR: 2.8,
|
||||
|
||||
// Field evaluation
|
||||
fieldOffscreenW: 80,
|
||||
fieldOffscreenH: 52,
|
||||
fieldUpdateMs: 50, // how often the noise field is recalculated (ms)
|
||||
fieldOctaves: 4,
|
||||
|
||||
// Time warp constants — tweak to change how the field drifts
|
||||
// elapsed is in ms; these produce a very slow traversal at flowSpeed=1
|
||||
timeConstantX: 0.000014 * 60, // primary axis
|
||||
timeConstantY: 0.0000085 * 37, // secondary axis (different ratio = swirl)
|
||||
|
||||
// Visual
|
||||
bgColor: '#040e1f',
|
||||
dotColorR: 180,
|
||||
dotColorG: 200, // G channel base (G = R + 20 in original; kept separate for clarity)
|
||||
lineColor: '80,160,240',
|
||||
lineColorMouse: '160,220,255',
|
||||
glowColorInner: '180,220,255',
|
||||
glowColorMid: '100,170,255',
|
||||
canvasAspect: 0.65, // H = W * aspect
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ParticleNetwork class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ParticleNetwork {
|
||||
/**
|
||||
* @param {HTMLCanvasElement} mainCanvas
|
||||
* @param {HTMLCanvasElement|null} fieldCanvas — pass null to disable field preview
|
||||
* @param {Partial<typeof DEFAULTS>} options
|
||||
*/
|
||||
constructor(mainCanvas, fieldCanvas = null, options = {}) {
|
||||
this._canvas = mainCanvas;
|
||||
this._fieldCanvas = fieldCanvas;
|
||||
this._opt = { ...DEFAULTS, ...options };
|
||||
|
||||
this._mCtx = mainCanvas.getContext('2d');
|
||||
this._fCtx = fieldCanvas ? fieldCanvas.getContext('2d') : null;
|
||||
|
||||
// Offscreen canvas for cheap noise evaluation
|
||||
this._off = document.createElement('canvas');
|
||||
this._off.width = this._opt.fieldOffscreenW;
|
||||
this._off.height = this._opt.fieldOffscreenH;
|
||||
this._oCtx = this._off.getContext('2d');
|
||||
this._fieldCache = new Float32Array(
|
||||
this._opt.fieldOffscreenW * this._opt.fieldOffscreenH
|
||||
);
|
||||
|
||||
this._P = _buildPermutation();
|
||||
this._particles = [];
|
||||
this._mouse = { x: -9999, y: -9999 };
|
||||
this._elapsed = 0;
|
||||
this._lastTs = null;
|
||||
this._fieldAge = 9999;
|
||||
this._rafId = null;
|
||||
this._W = 0;
|
||||
this._H = 0;
|
||||
|
||||
this._onMouseMove = this._handleMouseMove.bind(this);
|
||||
this._onMouseLeave = this._handleMouseLeave.bind(this);
|
||||
this._onResize = this._handleResize.bind(this);
|
||||
|
||||
this._attach();
|
||||
this._resize();
|
||||
this._syncCount();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
start() {
|
||||
if (this._rafId !== null) return;
|
||||
this._lastTs = null;
|
||||
this._rafId = requestAnimationFrame(ts => this._loop(ts));
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._rafId !== null) {
|
||||
cancelAnimationFrame(this._rafId);
|
||||
this._rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a single option at runtime. */
|
||||
setOption(key, value) {
|
||||
this._opt[key] = value;
|
||||
if (key === 'count') this._syncCount();
|
||||
}
|
||||
|
||||
/** Tear down all listeners and stop the loop. */
|
||||
destroy() {
|
||||
this.stop();
|
||||
this._detach();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_attach() {
|
||||
this._canvas.addEventListener('mousemove', this._onMouseMove);
|
||||
this._canvas.addEventListener('mouseleave', this._onMouseLeave);
|
||||
window.addEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
_detach() {
|
||||
this._canvas.removeEventListener('mousemove', this._onMouseMove);
|
||||
this._canvas.removeEventListener('mouseleave', this._onMouseLeave);
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
_handleMouseMove(e) {
|
||||
const r = this._canvas.getBoundingClientRect();
|
||||
this._mouse.x = (e.clientX - r.left) * (this._W / r.width);
|
||||
this._mouse.y = (e.clientY - r.top) * (this._H / r.height);
|
||||
}
|
||||
|
||||
_handleMouseLeave() {
|
||||
this._mouse.x = -9999;
|
||||
this._mouse.y = -9999;
|
||||
}
|
||||
|
||||
_handleResize() {
|
||||
this._resize();
|
||||
this._syncCount();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Setup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_resize() {
|
||||
const rect = this._canvas.parentElement.getBoundingClientRect();
|
||||
this._W = this._canvas.width = Math.floor(rect.width);
|
||||
this._H = this._canvas.height = Math.round(this._W * this._opt.canvasAspect);
|
||||
this._canvas.style.height = this._H + 'px';
|
||||
|
||||
if (this._fieldCanvas) {
|
||||
this._fieldCanvas.width = this._W;
|
||||
this._fieldCanvas.height = this._H;
|
||||
this._fieldCanvas.style.height = this._H + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
_makeParticle() {
|
||||
const { particleSpeed, particleMinR, particleMaxR } = this._opt;
|
||||
return {
|
||||
x: Math.random() * this._W,
|
||||
y: Math.random() * this._H,
|
||||
vx: (Math.random() - 0.5) * particleSpeed,
|
||||
vy: (Math.random() - 0.5) * particleSpeed,
|
||||
r: particleMinR + Math.random() * (particleMaxR - particleMinR),
|
||||
};
|
||||
}
|
||||
|
||||
_syncCount() {
|
||||
const target = this._opt.count;
|
||||
while (this._particles.length < target) this._particles.push(this._makeParticle());
|
||||
while (this._particles.length > target) this._particles.pop();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Field sampling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sample the cached noise field at canvas coordinates (x, y).
|
||||
* Returns a value in [0, 1].
|
||||
*/
|
||||
sampleField(x, y) {
|
||||
const { fieldOffscreenW: oW, fieldOffscreenH: oH } = this._opt;
|
||||
const cx = Math.max(0, Math.min(oW - 1, Math.round(x / this._W * (oW - 1))));
|
||||
const cy = Math.max(0, Math.min(oH - 1, Math.round(y / this._H * (oH - 1))));
|
||||
return this._fieldCache[cy * oW + cx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw field value [0,1] to an opacity multiplier.
|
||||
* At fv=1 (maximum): multiplier = 1.0 (no change).
|
||||
* At fv=0 (minimum): multiplier = 1 - dimAtMin.
|
||||
*/
|
||||
_fieldToOpacity(fv) {
|
||||
const minMult = 1 - this._opt.dimAtMin;
|
||||
return minMult + (1 - minMult) * fv;
|
||||
}
|
||||
|
||||
_updateField() {
|
||||
const {
|
||||
fieldOffscreenW: oW, fieldOffscreenH: oH,
|
||||
fieldScale, fieldOctaves,
|
||||
timeConstantX, timeConstantY,
|
||||
} = this._opt;
|
||||
|
||||
const scale = fieldScale * 0.0018;
|
||||
const ox = this._elapsed * timeConstantX;
|
||||
const oy = this._elapsed * timeConstantY;
|
||||
|
||||
const imgData = this._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(this._P, px * scale + ox, py * scale + oy, fieldOctaves);
|
||||
const v = Math.max(0, Math.min(1, raw * 1.8 + 0.5));
|
||||
this._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;
|
||||
}
|
||||
}
|
||||
|
||||
this._oCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
if (this._fCtx) {
|
||||
this._fCtx.fillStyle = this._opt.bgColor;
|
||||
this._fCtx.fillRect(0, 0, this._W, this._H);
|
||||
this._fCtx.drawImage(this._off, 0, 0, this._W, this._H);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Draw
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_drawMain() {
|
||||
const { bgColor, reach, lineColor, lineColorMouse,
|
||||
glowColorInner, glowColorMid } = this._opt;
|
||||
const { _mCtx: ctx, _W: W, _H: H, _mouse: mouse } = this;
|
||||
const reach2 = reach * reach;
|
||||
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Move particles
|
||||
for (const p of this._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;
|
||||
}
|
||||
|
||||
// Build working set including mouse phantom node
|
||||
const all = [...this._particles, { x: mouse.x, y: mouse.y, r: 0, isMouse: true }];
|
||||
const n = all.length;
|
||||
|
||||
// Sample field values and opacity multipliers for each node
|
||||
const fv = all.map(p => this.sampleField(p.x, p.y));
|
||||
const fo = fv.map(v => this._fieldToOpacity(v));
|
||||
|
||||
// Draw connecting lines
|
||||
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) * (1 - t01) * (1 - t01); // cubic falloff
|
||||
const lineMult = Math.sqrt(fo[i] * fo[j]); // geometric mean of field mults
|
||||
const alpha = expFade * lineMult * 0.85;
|
||||
if (alpha < 0.005) continue;
|
||||
|
||||
const isMc = a.isMouse || b.isMouse;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = isMc
|
||||
? `rgba(${lineColorMouse},${alpha * 0.9})`
|
||||
: `rgba(${lineColor},${alpha * 0.7})`;
|
||||
ctx.lineWidth = isMc ? 0.9 : 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw particles (glow + dot)
|
||||
for (let i = 0; i < this._particles.length; i++) {
|
||||
const p = this._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);
|
||||
|
||||
// Radial glow
|
||||
const grd = ctx.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)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, glowR * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fill();
|
||||
|
||||
// Hard dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2);
|
||||
const br = Math.round(180 + f * 75);
|
||||
ctx.fillStyle = `rgba(${br},${br + 20},255,${gA})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Loop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_loop(ts) {
|
||||
if (this._lastTs === null) this._lastTs = ts;
|
||||
const dt = ts - this._lastTs;
|
||||
this._lastTs = ts;
|
||||
|
||||
// flowSpeed is on a 1–10 scale; divide by 3 to centre the "default" feel
|
||||
const speedMult = this._opt.flowSpeed / 3;
|
||||
this._elapsed += dt * speedMult;
|
||||
|
||||
this._fieldAge += dt;
|
||||
if (this._fieldAge >= this._opt.fieldUpdateMs) {
|
||||
this._updateField();
|
||||
this._fieldAge = 0;
|
||||
}
|
||||
|
||||
this._drawMain();
|
||||
this._rafId = requestAnimationFrame(ts => this._loop(ts));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue