← back to Graphics Agentabrams
auto-save: 2026-07-24T23:30:18 (4 files) — graphics.json tests/e2e.mjs graphics/flowfield/ graphics/living-fractal-tree/
3a038a7cbea3c3d17326dcd9580015fcafad718c · 2026-07-24 23:31:41 -0700 · Steve Abrams
Files touched
M graphics.jsonA graphics/flowfield/FlowField.aaA graphics/flowfield/index.htmlA graphics/living-fractal-tree/LivingFractalTree.aaA graphics/living-fractal-tree/index.htmlM tests/e2e.mjs
Diff
commit 3a038a7cbea3c3d17326dcd9580015fcafad718c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 24 23:31:41 2026 -0700
auto-save: 2026-07-24T23:30:18 (4 files) — graphics.json tests/e2e.mjs graphics/flowfield/ graphics/living-fractal-tree/
---
graphics.json | 16 +
graphics/flowfield/FlowField.aa | 493 ++++++++++++++++++++++
graphics/flowfield/index.html | 493 ++++++++++++++++++++++
graphics/living-fractal-tree/LivingFractalTree.aa | 385 +++++++++++++++++
graphics/living-fractal-tree/index.html | 385 +++++++++++++++++
tests/e2e.mjs | 29 ++
6 files changed, 1801 insertions(+)
diff --git a/graphics.json b/graphics.json
index 5592ba7..58e395a 100644
--- a/graphics.json
+++ b/graphics.json
@@ -7,6 +7,22 @@
"desc": "Animated hypotrochoid plotter — two gear radii + pen offset draw curves live with rainbow color cycling, presets, glow, gear schematic, and save-PNG.",
"path": "graphics/spirograph/",
"added": "2026-07-23"
+ },
+ {
+ "id": "living-fractal-tree",
+ "file": "LivingFractalTree.aa",
+ "title": "Living Fractal Tree",
+ "desc": "Recursive fractal tree that grows on load and sways in simulated wind, with seasonal foliage — spring blossoms, summer greens, autumn fall, winter snow. Branch-depth + wind sliders, manual/auto season control, ground leaf pile.",
+ "path": "graphics/living-fractal-tree/",
+ "added": "2026-07-24"
+ },
+ {
+ "id": "flowfield",
+ "file": "FlowField.aa",
+ "title": "Flow Field",
+ "desc": "Thousands of particles flow along a seeded Perlin noise field, painting harmonious colored trails. Move the mouse to steer the flow, drag for a stronger swirl (Tab toggles repel), arrow keys pan the field, Space reseeds a new palette + field, S saves a PNG.",
+ "path": "graphics/flowfield/",
+ "added": "2026-07-24"
}
]
}
diff --git a/graphics/flowfield/FlowField.aa b/graphics/flowfield/FlowField.aa
new file mode 100644
index 0000000..fbc7163
--- /dev/null
+++ b/graphics/flowfield/FlowField.aa
@@ -0,0 +1,493 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>Flow Field — Generative Art Canvas</title>
+<style>
+ :root { --panel-bg: rgba(14,14,18,0.55); }
+ * { box-sizing: border-box; }
+ html, body {
+ margin: 0; padding: 0; height: 100%;
+ overflow: hidden; background: #000;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ }
+ canvas { display: block; position: fixed; inset: 0; cursor: crosshair; outline: none; }
+
+ #ui {
+ position: fixed; top: 16px; left: 16px; z-index: 10;
+ display: flex; flex-direction: column; gap: 10px;
+ padding: 14px 14px 12px;
+ background: var(--panel-bg);
+ backdrop-filter: blur(14px) saturate(1.2);
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
+ border: 1px solid rgba(255,255,255,0.10);
+ border-radius: 14px;
+ color: #f4f4f6;
+ box-shadow: 0 10px 40px rgba(0,0,0,0.45);
+ user-select: none;
+ transition: opacity .4s ease;
+ max-width: 264px;
+ }
+ #ui.dim { opacity: 0.12; }
+ #ui:hover { opacity: 1; }
+
+ #title { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; opacity: .65; margin-bottom: 2px; }
+ #palette-name { font-size: 13px; font-weight: 600; opacity: .95; min-height: 16px; }
+
+ .row { display: flex; gap: 8px; }
+ button {
+ flex: 1; padding: 9px 12px; font-size: 13px; font-weight: 600;
+ color: #f4f4f6; background: rgba(255,255,255,0.07);
+ border: 1px solid rgba(255,255,255,0.14); border-radius: 9px;
+ cursor: pointer; transition: background .15s, transform .05s;
+ -webkit-tap-highlight-color: transparent;
+ }
+ button:hover { background: rgba(255,255,255,0.16); }
+ button:active { transform: translateY(1px); }
+ button.on { background: rgba(94,234,212,0.22); border-color: rgba(94,234,212,0.5); }
+
+ label.control { display: flex; flex-direction: column; gap: 5px; font-size: 11px; letter-spacing: .05em; opacity: .8; }
+ label.control .val { opacity: .6; }
+ input[type=range] {
+ -webkit-appearance: none; appearance: none; width: 100%; height: 4px;
+ background: rgba(255,255,255,0.18); border-radius: 3px; outline: none;
+ }
+ input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none; appearance: none; width: 15px; height: 15px;
+ border-radius: 50%; background: #fff; cursor: pointer; border: none;
+ box-shadow: 0 1px 4px rgba(0,0,0,.4);
+ }
+ input[type=range]::-moz-range-thumb {
+ width: 15px; height: 15px; border-radius: 50%; background: #fff; cursor: pointer; border: none;
+ }
+ .hint { font-size: 10.5px; opacity: .5; line-height: 1.65; margin-top: 2px; }
+ kbd {
+ font-family: ui-monospace, Menlo, monospace; font-size: 10px;
+ background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.15);
+ border-radius: 4px; padding: 0 4px; margin: 0 1px; white-space: nowrap;
+ }
+</style>
+</head>
+<body>
+ <canvas id="canvas" tabindex="0"></canvas>
+
+ <div id="ui">
+ <div id="title">Flow Field</div>
+ <div id="palette-name">—</div>
+
+ <div class="row">
+ <button id="btn-reseed" title="New palette + new flow field (Space)">New Field</button>
+ <button id="btn-save" title="Download a PNG (S)">Save PNG</button>
+ </div>
+ <div class="row">
+ <button id="btn-pause" title="Pause / resume (P)">Pause</button>
+ <button id="btn-clear" title="Repaint background, keep field (C)">Clear</button>
+ </div>
+ <div class="row">
+ <button id="btn-mode" title="Toggle attract / repel for the pointer (Tab)">Pointer: Swirl</button>
+ </div>
+
+ <label class="control">Particles <span class="val" id="v-count"></span>
+ <input id="s-count" type="range" min="500" max="8000" step="250" value="4000">
+ </label>
+ <label class="control">Flow scale <span class="val" id="v-scale"></span>
+ <input id="s-scale" type="range" min="4" max="40" step="1" value="14">
+ </label>
+ <label class="control">Speed <span class="val" id="v-speed"></span>
+ <input id="s-speed" type="range" min="5" max="45" step="1" value="18">
+ </label>
+
+ <div class="hint">
+ <kbd>Move mouse</kbd> steer the flow · <kbd>Drag</kbd> stronger<br>
+ <kbd>← ↑ → ↓</kbd> pan the field · <kbd>Tab</kbd> swirl/repel<br>
+ <kbd>Space</kbd> new · <kbd>S</kbd> save · <kbd>P</kbd> pause · <kbd>C</kbd> clear
+ </div>
+ </div>
+
+<script>
+"use strict";
+
+const canvas = document.getElementById('canvas');
+const ctx = canvas.getContext('2d', { alpha: false });
+
+// ---------- Seeded PRNG (mulberry32) ----------
+function makeRng(seed) {
+ let s = seed >>> 0;
+ return function () {
+ s |= 0; s = (s + 0x6D2B79F5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// ---------- Seeded 2D Perlin noise ----------
+function makeNoise(seed) {
+ const rng = makeRng(seed);
+ const perm = new Uint8Array(512);
+ const p = new Uint8Array(256);
+ for (let i = 0; i < 256; i++) p[i] = i;
+ for (let i = 255; i > 0; i--) {
+ const j = (rng() * (i + 1)) | 0;
+ const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
+ }
+ for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
+
+ const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
+ const lerp = (a, b, t) => a + t * (b - a);
+ function grad(h, x, y) {
+ switch (h & 3) {
+ case 0: return x + y;
+ case 1: return -x + y;
+ case 2: return x - y;
+ default:return -x - y;
+ }
+ }
+ return function (x, y) { // ~[-1, 1]
+ 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 = perm[perm[X] + Y], ab = perm[perm[X] + Y + 1];
+ const ba = perm[perm[X + 1] + Y], bb = perm[perm[X + 1] + Y + 1];
+ return lerp(
+ lerp(grad(aa, x, y), grad(ba, x - 1, y), u),
+ lerp(grad(ab, x, y - 1), grad(bb, x - 1, y - 1), u),
+ v
+ );
+ };
+}
+
+// ---------- Harmonious palette generation ----------
+const SCHEMES = [
+ { name: 'Analogous', offs: [-32, -16, 0, 16, 32] },
+ { name: 'Complementary', offs: [-18, 0, 18, 180, 198] },
+ { name: 'Triadic', offs: [0, 20, 120, 140, 240] },
+ { name: 'Split', offs: [0, 15, 150, 165, 210] },
+ { name: 'Monochrome', offs: [0, 0, 0, 0, 0] },
+];
+const MOODS = ['Dawn','Ember','Aurora','Nocturne','Coral','Verdant','Cobalt','Sorbet','Ink','Meadow','Dusk','Lagoon'];
+
+function generatePalette(rng) {
+ const scheme = SCHEMES[(rng() * SCHEMES.length) | 0];
+ const baseHue = rng() * 360;
+ const baseSat = 55 + rng() * 40;
+ const mono = scheme.name === 'Monochrome';
+ const shadesPerHue = 5;
+ const colors = [];
+
+ for (const off of scheme.offs) {
+ const hue = (baseHue + off + 360) % 360;
+ for (let k = 0; k < shadesPerHue; k++) {
+ const light = mono ? 12 + k * 15
+ : 42 + k * 9 + (rng() - 0.5) * 6;
+ const sat = mono ? baseSat - k * 4 : baseSat + (rng() - 0.5) * 10;
+ colors.push('hsl(' + hue.toFixed(1) + ',' +
+ Math.max(20, Math.min(100, sat)).toFixed(0) + '%,' +
+ Math.max(20, Math.min(88, light)).toFixed(0) + '%)');
+ }
+ }
+ const bg = 'hsl(' + baseHue.toFixed(1) + ',' + (18 + rng() * 22).toFixed(0) + '%,' + (5 + rng() * 5).toFixed(0) + '%)';
+ const name = MOODS[(rng() * MOODS.length) | 0] + ' · ' + scheme.name;
+ return { colors, bg, name };
+}
+
+// ---------- State ----------
+let W = 0, H = 0, dpr = 1;
+let noise, palette, seed;
+let particles = null;
+let running = true;
+let rafId = null;
+
+let COUNT = 4000;
+let SCALE = 14 / 10000;
+let SPEED = 1.8;
+const LIFE_MIN = 40, LIFE_MAX = 220;
+const TURN = Math.PI * 2 * 1.4;
+
+// Field panning (arrow keys) + pointer interaction (mouse/touch)
+let fieldOX = 0, fieldOY = 0; // noise-space offset, in px
+const heldKeys = new Set();
+const PAN_ACCEL = 0.6, PAN_MAX = 6, PAN_FRICTION = 0.86;
+let panVX = 0, panVY = 0;
+
+const pointer = { x: 0, y: 0, active: false, down: false, repel: false, strength: 0, radius: 150 };
+
+let _rng = Math.random;
+let _buckets = [];
+
+function allocParticles(n) {
+ particles = {
+ x: new Float32Array(n),
+ y: new Float32Array(n),
+ life: new Float32Array(n),
+ col: new Uint16Array(n),
+ n
+ };
+ for (let i = 0; i < n; i++) respawn(i);
+}
+
+function respawn(i) {
+ const P = particles;
+ P.x[i] = _rng() * W;
+ P.y[i] = _rng() * H;
+ P.life[i] = LIFE_MIN + _rng() * (LIFE_MAX - LIFE_MIN);
+ P.col[i] = (_rng() * palette.colors.length) | 0;
+}
+
+function rebuildBuckets() {
+ _buckets = [];
+ for (let c = 0; c < palette.colors.length; c++) _buckets.push([]);
+}
+
+// ---------- Canvas sizing (preserve art on resize) ----------
+function resize() {
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
+ const cw = window.innerWidth, ch = window.innerHeight;
+
+ let snap = null;
+ if (canvas.width) {
+ snap = document.createElement('canvas');
+ snap.width = canvas.width; snap.height = canvas.height;
+ snap.getContext('2d').drawImage(canvas, 0, 0);
+ }
+
+ canvas.width = Math.floor(cw * dpr);
+ canvas.height = Math.floor(ch * dpr);
+ canvas.style.width = cw + 'px';
+ canvas.style.height = ch + 'px';
+ W = cw; H = ch;
+ pointer.radius = Math.max(120, Math.min(W, H) * 0.22);
+
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ if (snap) {
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.drawImage(snap, 0, 0, snap.width, snap.height, 0, 0, canvas.width, canvas.height);
+ ctx.restore();
+ } else if (palette) {
+ paintBg();
+ }
+}
+
+function paintBg() {
+ ctx.save();
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.fillStyle = palette.bg;
+ ctx.fillRect(0, 0, W, H);
+ ctx.restore();
+}
+
+// ---------- Reseed / clear ----------
+function reseed() {
+ seed = (Math.random() * 0xffffffff) >>> 0;
+ _rng = makeRng(seed);
+ noise = makeNoise(seed);
+ palette = generatePalette(_rng);
+ fieldOX = fieldOY = 0; panVX = panVY = 0;
+ document.getElementById('palette-name').textContent = palette.name;
+ rebuildBuckets();
+ paintBg();
+ allocParticles(COUNT);
+}
+
+function clearKeepField() {
+ paintBg();
+ if (particles) for (let i = 0; i < particles.n; i++) respawn(i);
+}
+
+// ---------- Animation ----------
+function step() {
+ // Apply arrow-key panning (momentum + friction).
+ let ax = 0, ay = 0;
+ if (heldKeys.has('ArrowLeft')) ax -= 1;
+ if (heldKeys.has('ArrowRight')) ax += 1;
+ if (heldKeys.has('ArrowUp')) ay -= 1;
+ if (heldKeys.has('ArrowDown')) ay += 1;
+ panVX = Math.max(-PAN_MAX, Math.min(PAN_MAX, panVX + ax * PAN_ACCEL));
+ panVY = Math.max(-PAN_MAX, Math.min(PAN_MAX, panVY + ay * PAN_ACCEL));
+ if (ax === 0) panVX *= PAN_FRICTION;
+ if (ay === 0) panVY *= PAN_FRICTION;
+ fieldOX += panVX; fieldOY += panVY;
+
+ // Ease pointer strength in/out so influence feels smooth.
+ const target = pointer.active ? (pointer.down ? 1.6 : 0.9) : 0;
+ pointer.strength += (target - pointer.strength) * 0.12;
+
+ const P = particles;
+ const n = P.n;
+ const nColors = palette.colors.length;
+ const buckets = _buckets;
+ for (let c = 0; c < nColors; c++) buckets[c].length = 0;
+
+ const R = pointer.radius, R2 = R * R;
+ const pInf = pointer.strength;
+ const pActive = pInf > 0.01;
+ const pSign = pointer.repel ? -1 : 1;
+
+ for (let i = 0; i < n; i++) {
+ const px = P.x[i], py = P.y[i];
+ const a = noise((px + fieldOX) * SCALE, (py + fieldOY) * SCALE) * TURN;
+ let vx = Math.cos(a), vy = Math.sin(a);
+
+ // Pointer interaction: swirl + gentle attract/repel within radius.
+ if (pActive) {
+ const dx = pointer.x - px, dy = pointer.y - py;
+ const d2 = dx * dx + dy * dy;
+ if (d2 < R2) {
+ const d = Math.sqrt(d2) || 1;
+ const inf = (1 - d / R) * pInf;
+ const ux = dx / d, uy = dy / d; // toward pointer
+ const sx = -uy, sy = ux; // perpendicular (swirl)
+ vx += pSign * (ux * 0.55 + sx) * inf;
+ vy += pSign * (uy * 0.55 + sy) * inf;
+ }
+ }
+
+ const len = Math.hypot(vx, vy) || 1;
+ const nx = px + (vx / len) * SPEED;
+ const ny = py + (vy / len) * SPEED;
+
+ let life = P.life[i] - 1;
+ const off = nx < -2 || nx > W + 2 || ny < -2 || ny > H + 2;
+
+ if (!off) buckets[P.col[i]].push(px, py, nx, ny);
+
+ if (life <= 0 || off) { respawn(i); continue; }
+
+ P.x[i] = nx; P.y[i] = ny; P.life[i] = life;
+ }
+
+ ctx.globalAlpha = 0.5;
+ ctx.lineWidth = 1.15;
+ for (let c = 0; c < nColors; c++) {
+ const b = buckets[c];
+ if (!b.length) continue;
+ ctx.strokeStyle = palette.colors[c];
+ ctx.beginPath();
+ for (let k = 0; k < b.length; k += 4) {
+ ctx.moveTo(b[k], b[k + 1]);
+ ctx.lineTo(b[k + 2], b[k + 3]);
+ }
+ ctx.stroke();
+ }
+ ctx.globalAlpha = 1;
+}
+
+function loop() {
+ if (running) step();
+ rafId = requestAnimationFrame(loop);
+}
+
+// ---------- Save PNG ----------
+function savePNG() {
+ canvas.toBlob(function (blob) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ a.download = 'flow-field-' + ts + '.png';
+ a.href = url;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }, 'image/png');
+}
+
+// ---------- Pointer input (mouse + touch) ----------
+function setPointerFromEvent(e) {
+ const t = (e.touches && e.touches[0]) || e;
+ const rect = canvas.getBoundingClientRect();
+ pointer.x = t.clientX - rect.left;
+ pointer.y = t.clientY - rect.top;
+ pointer.active = true;
+}
+canvas.addEventListener('mousemove', setPointerFromEvent);
+canvas.addEventListener('mouseenter', setPointerFromEvent);
+canvas.addEventListener('mouseleave', () => { pointer.active = false; pointer.down = false; });
+canvas.addEventListener('mousedown', (e) => { canvas.focus(); setPointerFromEvent(e); pointer.down = true; });
+window.addEventListener('mouseup', () => { pointer.down = false; });
+canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // right-click reserved
+canvas.addEventListener('touchstart', (e) => { canvas.focus(); setPointerFromEvent(e); pointer.down = true; }, { passive: true });
+canvas.addEventListener('touchmove', (e) => { setPointerFromEvent(e); e.preventDefault(); }, { passive: false });
+canvas.addEventListener('touchend', () => { pointer.down = false; pointer.active = false; });
+
+// ---------- UI wiring ----------
+const els = {
+ count: document.getElementById('s-count'),
+ scale: document.getElementById('s-scale'),
+ speed: document.getElementById('s-speed'),
+ vcount: document.getElementById('v-count'),
+ vscale: document.getElementById('v-scale'),
+ vspeed: document.getElementById('v-speed'),
+ pause: document.getElementById('btn-pause'),
+ mode: document.getElementById('btn-mode'),
+ ui: document.getElementById('ui'),
+};
+
+function syncLabels() {
+ els.vcount.textContent = COUNT.toLocaleString();
+ els.vscale.textContent = els.scale.value;
+ els.vspeed.textContent = SPEED.toFixed(1);
+}
+
+els.count.addEventListener('input', () => { COUNT = +els.count.value; syncLabels(); allocParticles(COUNT); });
+els.scale.addEventListener('input', () => { SCALE = (+els.scale.value) / 10000; syncLabels(); });
+els.speed.addEventListener('input', () => { SPEED = (+els.speed.value) / 10; syncLabels(); });
+
+document.getElementById('btn-reseed').addEventListener('click', reseed);
+document.getElementById('btn-save').addEventListener('click', savePNG);
+document.getElementById('btn-clear').addEventListener('click', clearKeepField);
+els.pause.addEventListener('click', togglePause);
+els.mode.addEventListener('click', toggleMode);
+
+function togglePause() {
+ running = !running;
+ els.pause.textContent = running ? 'Pause' : 'Resume';
+}
+function toggleMode() {
+ pointer.repel = !pointer.repel;
+ els.mode.textContent = 'Pointer: ' + (pointer.repel ? 'Repel' : 'Swirl');
+ els.mode.classList.toggle('on', pointer.repel);
+}
+
+// Keyboard: actions + arrow-key field panning. Works standalone and inside the hub iframe.
+const ARROWS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
+window.addEventListener('keydown', (e) => {
+ if (e.target.tagName === 'INPUT') return;
+ if (ARROWS.has(e.key)) { heldKeys.add(e.key); e.preventDefault(); return; }
+ const k = e.key.toLowerCase();
+ if (k === ' ') { e.preventDefault(); reseed(); }
+ else if (k === 's') { e.preventDefault(); savePNG(); }
+ else if (k === 'p') togglePause();
+ else if (k === 'c') clearKeepField();
+ else if (e.key === 'Tab') { e.preventDefault(); toggleMode(); }
+});
+window.addEventListener('keyup', (e) => { if (ARROWS.has(e.key)) heldKeys.delete(e.key); });
+window.addEventListener('blur', () => heldKeys.clear());
+
+// Auto-dim the panel when idle so the art breathes.
+let dimTimer = null;
+function scheduleDim() {
+ els.ui.classList.remove('dim');
+ clearTimeout(dimTimer);
+ dimTimer = setTimeout(() => els.ui.classList.add('dim'), 3500);
+}
+['mousemove', 'touchstart', 'keydown'].forEach(ev => window.addEventListener(ev, scheduleDim, { passive: true }));
+
+window.addEventListener('resize', resize);
+
+// ---------- Boot ----------
+resize();
+reseed();
+syncLabels();
+scheduleDim();
+canvas.focus();
+loop();
+</script>
+</body>
+</html>
diff --git a/graphics/flowfield/index.html b/graphics/flowfield/index.html
new file mode 100644
index 0000000..fbc7163
--- /dev/null
+++ b/graphics/flowfield/index.html
@@ -0,0 +1,493 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+<title>Flow Field — Generative Art Canvas</title>
+<style>
+ :root { --panel-bg: rgba(14,14,18,0.55); }
+ * { box-sizing: border-box; }
+ html, body {
+ margin: 0; padding: 0; height: 100%;
+ overflow: hidden; background: #000;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ }
+ canvas { display: block; position: fixed; inset: 0; cursor: crosshair; outline: none; }
+
+ #ui {
+ position: fixed; top: 16px; left: 16px; z-index: 10;
+ display: flex; flex-direction: column; gap: 10px;
+ padding: 14px 14px 12px;
+ background: var(--panel-bg);
+ backdrop-filter: blur(14px) saturate(1.2);
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
+ border: 1px solid rgba(255,255,255,0.10);
+ border-radius: 14px;
+ color: #f4f4f6;
+ box-shadow: 0 10px 40px rgba(0,0,0,0.45);
+ user-select: none;
+ transition: opacity .4s ease;
+ max-width: 264px;
+ }
+ #ui.dim { opacity: 0.12; }
+ #ui:hover { opacity: 1; }
+
+ #title { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; opacity: .65; margin-bottom: 2px; }
+ #palette-name { font-size: 13px; font-weight: 600; opacity: .95; min-height: 16px; }
+
+ .row { display: flex; gap: 8px; }
+ button {
+ flex: 1; padding: 9px 12px; font-size: 13px; font-weight: 600;
+ color: #f4f4f6; background: rgba(255,255,255,0.07);
+ border: 1px solid rgba(255,255,255,0.14); border-radius: 9px;
+ cursor: pointer; transition: background .15s, transform .05s;
+ -webkit-tap-highlight-color: transparent;
+ }
+ button:hover { background: rgba(255,255,255,0.16); }
+ button:active { transform: translateY(1px); }
+ button.on { background: rgba(94,234,212,0.22); border-color: rgba(94,234,212,0.5); }
+
+ label.control { display: flex; flex-direction: column; gap: 5px; font-size: 11px; letter-spacing: .05em; opacity: .8; }
+ label.control .val { opacity: .6; }
+ input[type=range] {
+ -webkit-appearance: none; appearance: none; width: 100%; height: 4px;
+ background: rgba(255,255,255,0.18); border-radius: 3px; outline: none;
+ }
+ input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none; appearance: none; width: 15px; height: 15px;
+ border-radius: 50%; background: #fff; cursor: pointer; border: none;
+ box-shadow: 0 1px 4px rgba(0,0,0,.4);
+ }
+ input[type=range]::-moz-range-thumb {
+ width: 15px; height: 15px; border-radius: 50%; background: #fff; cursor: pointer; border: none;
+ }
+ .hint { font-size: 10.5px; opacity: .5; line-height: 1.65; margin-top: 2px; }
+ kbd {
+ font-family: ui-monospace, Menlo, monospace; font-size: 10px;
+ background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.15);
+ border-radius: 4px; padding: 0 4px; margin: 0 1px; white-space: nowrap;
+ }
+</style>
+</head>
+<body>
+ <canvas id="canvas" tabindex="0"></canvas>
+
+ <div id="ui">
+ <div id="title">Flow Field</div>
+ <div id="palette-name">—</div>
+
+ <div class="row">
+ <button id="btn-reseed" title="New palette + new flow field (Space)">New Field</button>
+ <button id="btn-save" title="Download a PNG (S)">Save PNG</button>
+ </div>
+ <div class="row">
+ <button id="btn-pause" title="Pause / resume (P)">Pause</button>
+ <button id="btn-clear" title="Repaint background, keep field (C)">Clear</button>
+ </div>
+ <div class="row">
+ <button id="btn-mode" title="Toggle attract / repel for the pointer (Tab)">Pointer: Swirl</button>
+ </div>
+
+ <label class="control">Particles <span class="val" id="v-count"></span>
+ <input id="s-count" type="range" min="500" max="8000" step="250" value="4000">
+ </label>
+ <label class="control">Flow scale <span class="val" id="v-scale"></span>
+ <input id="s-scale" type="range" min="4" max="40" step="1" value="14">
+ </label>
+ <label class="control">Speed <span class="val" id="v-speed"></span>
+ <input id="s-speed" type="range" min="5" max="45" step="1" value="18">
+ </label>
+
+ <div class="hint">
+ <kbd>Move mouse</kbd> steer the flow · <kbd>Drag</kbd> stronger<br>
+ <kbd>← ↑ → ↓</kbd> pan the field · <kbd>Tab</kbd> swirl/repel<br>
+ <kbd>Space</kbd> new · <kbd>S</kbd> save · <kbd>P</kbd> pause · <kbd>C</kbd> clear
+ </div>
+ </div>
+
+<script>
+"use strict";
+
+const canvas = document.getElementById('canvas');
+const ctx = canvas.getContext('2d', { alpha: false });
+
+// ---------- Seeded PRNG (mulberry32) ----------
+function makeRng(seed) {
+ let s = seed >>> 0;
+ return function () {
+ s |= 0; s = (s + 0x6D2B79F5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// ---------- Seeded 2D Perlin noise ----------
+function makeNoise(seed) {
+ const rng = makeRng(seed);
+ const perm = new Uint8Array(512);
+ const p = new Uint8Array(256);
+ for (let i = 0; i < 256; i++) p[i] = i;
+ for (let i = 255; i > 0; i--) {
+ const j = (rng() * (i + 1)) | 0;
+ const tmp = p[i]; p[i] = p[j]; p[j] = tmp;
+ }
+ for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
+
+ const fade = t => t * t * t * (t * (t * 6 - 15) + 10);
+ const lerp = (a, b, t) => a + t * (b - a);
+ function grad(h, x, y) {
+ switch (h & 3) {
+ case 0: return x + y;
+ case 1: return -x + y;
+ case 2: return x - y;
+ default:return -x - y;
+ }
+ }
+ return function (x, y) { // ~[-1, 1]
+ 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 = perm[perm[X] + Y], ab = perm[perm[X] + Y + 1];
+ const ba = perm[perm[X + 1] + Y], bb = perm[perm[X + 1] + Y + 1];
+ return lerp(
+ lerp(grad(aa, x, y), grad(ba, x - 1, y), u),
+ lerp(grad(ab, x, y - 1), grad(bb, x - 1, y - 1), u),
+ v
+ );
+ };
+}
+
+// ---------- Harmonious palette generation ----------
+const SCHEMES = [
+ { name: 'Analogous', offs: [-32, -16, 0, 16, 32] },
+ { name: 'Complementary', offs: [-18, 0, 18, 180, 198] },
+ { name: 'Triadic', offs: [0, 20, 120, 140, 240] },
+ { name: 'Split', offs: [0, 15, 150, 165, 210] },
+ { name: 'Monochrome', offs: [0, 0, 0, 0, 0] },
+];
+const MOODS = ['Dawn','Ember','Aurora','Nocturne','Coral','Verdant','Cobalt','Sorbet','Ink','Meadow','Dusk','Lagoon'];
+
+function generatePalette(rng) {
+ const scheme = SCHEMES[(rng() * SCHEMES.length) | 0];
+ const baseHue = rng() * 360;
+ const baseSat = 55 + rng() * 40;
+ const mono = scheme.name === 'Monochrome';
+ const shadesPerHue = 5;
+ const colors = [];
+
+ for (const off of scheme.offs) {
+ const hue = (baseHue + off + 360) % 360;
+ for (let k = 0; k < shadesPerHue; k++) {
+ const light = mono ? 12 + k * 15
+ : 42 + k * 9 + (rng() - 0.5) * 6;
+ const sat = mono ? baseSat - k * 4 : baseSat + (rng() - 0.5) * 10;
+ colors.push('hsl(' + hue.toFixed(1) + ',' +
+ Math.max(20, Math.min(100, sat)).toFixed(0) + '%,' +
+ Math.max(20, Math.min(88, light)).toFixed(0) + '%)');
+ }
+ }
+ const bg = 'hsl(' + baseHue.toFixed(1) + ',' + (18 + rng() * 22).toFixed(0) + '%,' + (5 + rng() * 5).toFixed(0) + '%)';
+ const name = MOODS[(rng() * MOODS.length) | 0] + ' · ' + scheme.name;
+ return { colors, bg, name };
+}
+
+// ---------- State ----------
+let W = 0, H = 0, dpr = 1;
+let noise, palette, seed;
+let particles = null;
+let running = true;
+let rafId = null;
+
+let COUNT = 4000;
+let SCALE = 14 / 10000;
+let SPEED = 1.8;
+const LIFE_MIN = 40, LIFE_MAX = 220;
+const TURN = Math.PI * 2 * 1.4;
+
+// Field panning (arrow keys) + pointer interaction (mouse/touch)
+let fieldOX = 0, fieldOY = 0; // noise-space offset, in px
+const heldKeys = new Set();
+const PAN_ACCEL = 0.6, PAN_MAX = 6, PAN_FRICTION = 0.86;
+let panVX = 0, panVY = 0;
+
+const pointer = { x: 0, y: 0, active: false, down: false, repel: false, strength: 0, radius: 150 };
+
+let _rng = Math.random;
+let _buckets = [];
+
+function allocParticles(n) {
+ particles = {
+ x: new Float32Array(n),
+ y: new Float32Array(n),
+ life: new Float32Array(n),
+ col: new Uint16Array(n),
+ n
+ };
+ for (let i = 0; i < n; i++) respawn(i);
+}
+
+function respawn(i) {
+ const P = particles;
+ P.x[i] = _rng() * W;
+ P.y[i] = _rng() * H;
+ P.life[i] = LIFE_MIN + _rng() * (LIFE_MAX - LIFE_MIN);
+ P.col[i] = (_rng() * palette.colors.length) | 0;
+}
+
+function rebuildBuckets() {
+ _buckets = [];
+ for (let c = 0; c < palette.colors.length; c++) _buckets.push([]);
+}
+
+// ---------- Canvas sizing (preserve art on resize) ----------
+function resize() {
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
+ const cw = window.innerWidth, ch = window.innerHeight;
+
+ let snap = null;
+ if (canvas.width) {
+ snap = document.createElement('canvas');
+ snap.width = canvas.width; snap.height = canvas.height;
+ snap.getContext('2d').drawImage(canvas, 0, 0);
+ }
+
+ canvas.width = Math.floor(cw * dpr);
+ canvas.height = Math.floor(ch * dpr);
+ canvas.style.width = cw + 'px';
+ canvas.style.height = ch + 'px';
+ W = cw; H = ch;
+ pointer.radius = Math.max(120, Math.min(W, H) * 0.22);
+
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ if (snap) {
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.drawImage(snap, 0, 0, snap.width, snap.height, 0, 0, canvas.width, canvas.height);
+ ctx.restore();
+ } else if (palette) {
+ paintBg();
+ }
+}
+
+function paintBg() {
+ ctx.save();
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.fillStyle = palette.bg;
+ ctx.fillRect(0, 0, W, H);
+ ctx.restore();
+}
+
+// ---------- Reseed / clear ----------
+function reseed() {
+ seed = (Math.random() * 0xffffffff) >>> 0;
+ _rng = makeRng(seed);
+ noise = makeNoise(seed);
+ palette = generatePalette(_rng);
+ fieldOX = fieldOY = 0; panVX = panVY = 0;
+ document.getElementById('palette-name').textContent = palette.name;
+ rebuildBuckets();
+ paintBg();
+ allocParticles(COUNT);
+}
+
+function clearKeepField() {
+ paintBg();
+ if (particles) for (let i = 0; i < particles.n; i++) respawn(i);
+}
+
+// ---------- Animation ----------
+function step() {
+ // Apply arrow-key panning (momentum + friction).
+ let ax = 0, ay = 0;
+ if (heldKeys.has('ArrowLeft')) ax -= 1;
+ if (heldKeys.has('ArrowRight')) ax += 1;
+ if (heldKeys.has('ArrowUp')) ay -= 1;
+ if (heldKeys.has('ArrowDown')) ay += 1;
+ panVX = Math.max(-PAN_MAX, Math.min(PAN_MAX, panVX + ax * PAN_ACCEL));
+ panVY = Math.max(-PAN_MAX, Math.min(PAN_MAX, panVY + ay * PAN_ACCEL));
+ if (ax === 0) panVX *= PAN_FRICTION;
+ if (ay === 0) panVY *= PAN_FRICTION;
+ fieldOX += panVX; fieldOY += panVY;
+
+ // Ease pointer strength in/out so influence feels smooth.
+ const target = pointer.active ? (pointer.down ? 1.6 : 0.9) : 0;
+ pointer.strength += (target - pointer.strength) * 0.12;
+
+ const P = particles;
+ const n = P.n;
+ const nColors = palette.colors.length;
+ const buckets = _buckets;
+ for (let c = 0; c < nColors; c++) buckets[c].length = 0;
+
+ const R = pointer.radius, R2 = R * R;
+ const pInf = pointer.strength;
+ const pActive = pInf > 0.01;
+ const pSign = pointer.repel ? -1 : 1;
+
+ for (let i = 0; i < n; i++) {
+ const px = P.x[i], py = P.y[i];
+ const a = noise((px + fieldOX) * SCALE, (py + fieldOY) * SCALE) * TURN;
+ let vx = Math.cos(a), vy = Math.sin(a);
+
+ // Pointer interaction: swirl + gentle attract/repel within radius.
+ if (pActive) {
+ const dx = pointer.x - px, dy = pointer.y - py;
+ const d2 = dx * dx + dy * dy;
+ if (d2 < R2) {
+ const d = Math.sqrt(d2) || 1;
+ const inf = (1 - d / R) * pInf;
+ const ux = dx / d, uy = dy / d; // toward pointer
+ const sx = -uy, sy = ux; // perpendicular (swirl)
+ vx += pSign * (ux * 0.55 + sx) * inf;
+ vy += pSign * (uy * 0.55 + sy) * inf;
+ }
+ }
+
+ const len = Math.hypot(vx, vy) || 1;
+ const nx = px + (vx / len) * SPEED;
+ const ny = py + (vy / len) * SPEED;
+
+ let life = P.life[i] - 1;
+ const off = nx < -2 || nx > W + 2 || ny < -2 || ny > H + 2;
+
+ if (!off) buckets[P.col[i]].push(px, py, nx, ny);
+
+ if (life <= 0 || off) { respawn(i); continue; }
+
+ P.x[i] = nx; P.y[i] = ny; P.life[i] = life;
+ }
+
+ ctx.globalAlpha = 0.5;
+ ctx.lineWidth = 1.15;
+ for (let c = 0; c < nColors; c++) {
+ const b = buckets[c];
+ if (!b.length) continue;
+ ctx.strokeStyle = palette.colors[c];
+ ctx.beginPath();
+ for (let k = 0; k < b.length; k += 4) {
+ ctx.moveTo(b[k], b[k + 1]);
+ ctx.lineTo(b[k + 2], b[k + 3]);
+ }
+ ctx.stroke();
+ }
+ ctx.globalAlpha = 1;
+}
+
+function loop() {
+ if (running) step();
+ rafId = requestAnimationFrame(loop);
+}
+
+// ---------- Save PNG ----------
+function savePNG() {
+ canvas.toBlob(function (blob) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ a.download = 'flow-field-' + ts + '.png';
+ a.href = url;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }, 'image/png');
+}
+
+// ---------- Pointer input (mouse + touch) ----------
+function setPointerFromEvent(e) {
+ const t = (e.touches && e.touches[0]) || e;
+ const rect = canvas.getBoundingClientRect();
+ pointer.x = t.clientX - rect.left;
+ pointer.y = t.clientY - rect.top;
+ pointer.active = true;
+}
+canvas.addEventListener('mousemove', setPointerFromEvent);
+canvas.addEventListener('mouseenter', setPointerFromEvent);
+canvas.addEventListener('mouseleave', () => { pointer.active = false; pointer.down = false; });
+canvas.addEventListener('mousedown', (e) => { canvas.focus(); setPointerFromEvent(e); pointer.down = true; });
+window.addEventListener('mouseup', () => { pointer.down = false; });
+canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // right-click reserved
+canvas.addEventListener('touchstart', (e) => { canvas.focus(); setPointerFromEvent(e); pointer.down = true; }, { passive: true });
+canvas.addEventListener('touchmove', (e) => { setPointerFromEvent(e); e.preventDefault(); }, { passive: false });
+canvas.addEventListener('touchend', () => { pointer.down = false; pointer.active = false; });
+
+// ---------- UI wiring ----------
+const els = {
+ count: document.getElementById('s-count'),
+ scale: document.getElementById('s-scale'),
+ speed: document.getElementById('s-speed'),
+ vcount: document.getElementById('v-count'),
+ vscale: document.getElementById('v-scale'),
+ vspeed: document.getElementById('v-speed'),
+ pause: document.getElementById('btn-pause'),
+ mode: document.getElementById('btn-mode'),
+ ui: document.getElementById('ui'),
+};
+
+function syncLabels() {
+ els.vcount.textContent = COUNT.toLocaleString();
+ els.vscale.textContent = els.scale.value;
+ els.vspeed.textContent = SPEED.toFixed(1);
+}
+
+els.count.addEventListener('input', () => { COUNT = +els.count.value; syncLabels(); allocParticles(COUNT); });
+els.scale.addEventListener('input', () => { SCALE = (+els.scale.value) / 10000; syncLabels(); });
+els.speed.addEventListener('input', () => { SPEED = (+els.speed.value) / 10; syncLabels(); });
+
+document.getElementById('btn-reseed').addEventListener('click', reseed);
+document.getElementById('btn-save').addEventListener('click', savePNG);
+document.getElementById('btn-clear').addEventListener('click', clearKeepField);
+els.pause.addEventListener('click', togglePause);
+els.mode.addEventListener('click', toggleMode);
+
+function togglePause() {
+ running = !running;
+ els.pause.textContent = running ? 'Pause' : 'Resume';
+}
+function toggleMode() {
+ pointer.repel = !pointer.repel;
+ els.mode.textContent = 'Pointer: ' + (pointer.repel ? 'Repel' : 'Swirl');
+ els.mode.classList.toggle('on', pointer.repel);
+}
+
+// Keyboard: actions + arrow-key field panning. Works standalone and inside the hub iframe.
+const ARROWS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']);
+window.addEventListener('keydown', (e) => {
+ if (e.target.tagName === 'INPUT') return;
+ if (ARROWS.has(e.key)) { heldKeys.add(e.key); e.preventDefault(); return; }
+ const k = e.key.toLowerCase();
+ if (k === ' ') { e.preventDefault(); reseed(); }
+ else if (k === 's') { e.preventDefault(); savePNG(); }
+ else if (k === 'p') togglePause();
+ else if (k === 'c') clearKeepField();
+ else if (e.key === 'Tab') { e.preventDefault(); toggleMode(); }
+});
+window.addEventListener('keyup', (e) => { if (ARROWS.has(e.key)) heldKeys.delete(e.key); });
+window.addEventListener('blur', () => heldKeys.clear());
+
+// Auto-dim the panel when idle so the art breathes.
+let dimTimer = null;
+function scheduleDim() {
+ els.ui.classList.remove('dim');
+ clearTimeout(dimTimer);
+ dimTimer = setTimeout(() => els.ui.classList.add('dim'), 3500);
+}
+['mousemove', 'touchstart', 'keydown'].forEach(ev => window.addEventListener(ev, scheduleDim, { passive: true }));
+
+window.addEventListener('resize', resize);
+
+// ---------- Boot ----------
+resize();
+reseed();
+syncLabels();
+scheduleDim();
+canvas.focus();
+loop();
+</script>
+</body>
+</html>
diff --git a/graphics/living-fractal-tree/LivingFractalTree.aa b/graphics/living-fractal-tree/LivingFractalTree.aa
new file mode 100644
index 0000000..cb55d8d
--- /dev/null
+++ b/graphics/living-fractal-tree/LivingFractalTree.aa
@@ -0,0 +1,385 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Living Fractal Tree</title>
+<style>
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #0d1420;
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+ canvas { display: block; }
+
+ /* season badge — top centre */
+ #season {
+ position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 3;
+ background: rgba(10, 16, 28, 0.66); backdrop-filter: blur(8px);
+ border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 999px;
+ padding: 7px 18px; color: #eef3fa; letter-spacing: 0.09em; text-transform: uppercase;
+ display: flex; align-items: center; gap: 9px; user-select: none;
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35);
+ }
+ #season .dot { width: 9px; height: 9px; border-radius: 50%;
+ box-shadow: 0 0 10px currentColor; transition: color .8s; }
+
+ #hint { position: fixed; top: 20px; right: 20px; z-index: 3;
+ color: rgba(255, 255, 255, 0.28); font-size: 11px; letter-spacing: 0.04em; user-select: none; }
+
+ /* control dock — bottom centre */
+ #ui {
+ position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); z-index: 3;
+ background: rgba(10, 16, 28, 0.78); backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 16px;
+ padding: 13px 20px; color: #dfe8f2;
+ display: flex; align-items: center; gap: 20px; flex-wrap: wrap; justify-content: center;
+ max-width: 94vw; box-shadow: 0 10px 38px rgba(0, 0, 0, 0.45); user-select: none;
+ }
+ #ui .grp { display: flex; align-items: center; gap: 9px; }
+ #ui label { color: #c5cfda; white-space: nowrap; }
+ #ui .val { min-width: 2.4ch; text-align: right; font-variant-numeric: tabular-nums; color: #9fd977; }
+ #ui .sep { width: 1px; height: 24px; background: rgba(255, 255, 255, 0.1); }
+ input[type="range"] { width: 130px; accent-color: #7fb26a; cursor: pointer; vertical-align: middle; }
+ #ui button { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.13);
+ color: #dfe8f2; padding: 7px 13px; border-radius: 9px; cursor: pointer; letter-spacing: 0.02em;
+ transition: background .15s, border-color .15s; white-space: nowrap; font-size: 12.5px; }
+ #ui button:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.22); }
+ #ui button.on { background: rgba(127, 178, 106, 0.22); border-color: rgba(127, 178, 106, 0.5); color: #bfe6bf; }
+ @media (max-width: 560px) {
+ #ui { gap: 11px; padding: 11px 13px; } input[type="range"] { width: 92px; } #hint { display: none; }
+ }
+</style>
+</head>
+<body>
+<div id="season"><span class="dot" id="seasonDot"></span><span id="seasonName">Spring</span></div>
+<div id="hint">press <b>S</b> for next season</div>
+
+<div id="ui">
+ <div class="grp">
+ <label>Branch depth</label>
+ <input type="range" id="depth" min="4" max="12" step="1" value="9">
+ <span class="val" id="depthVal">9</span>
+ </div>
+ <div class="sep"></div>
+ <div class="grp">
+ <label>Wind</label>
+ <input type="range" id="wind" min="0" max="200" step="1" value="100">
+ <span class="val" id="windVal">1.0</span>
+ </div>
+ <div class="sep"></div>
+ <div class="grp">
+ <button id="seasonBtn">Next season</button>
+ <button id="autoBtn" class="on">Auto cycle</button>
+ <button id="regrowBtn">Regrow</button>
+ </div>
+</div>
+
+<canvas id="c"></canvas>
+<script>
+'use strict';
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const depthSlider = document.getElementById('depth');
+const depthVal = document.getElementById('depthVal');
+const windSlider = document.getElementById('wind');
+const windVal = document.getElementById('windVal');
+const seasonNameEl = document.getElementById('seasonName');
+const seasonDotEl = document.getElementById('seasonDot');
+
+let W = 0, H = 0;
+function resize() {
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ W = window.innerWidth; H = window.innerHeight;
+ canvas.width = W * dpr; canvas.height = H * dpr;
+ canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+}
+window.addEventListener('resize', resize);
+resize();
+
+// ---------- deterministic per-branch randomness (stable tree shape) ----------
+// Hashing the branch seed (instead of Math.random each frame) is what keeps the
+// tree from strobing: identical structure every frame, only wind animates it.
+function rnd(seed) { const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
+
+// ---------- tunables ----------
+let maxDepth = 9;
+let windStrength = 1.0;
+const GROW_SPEED = 1.9; // depth levels per second
+const LEN_RATIO = 0.72;
+const SPREAD = 0.52; // base branching angle (radians)
+const SEASON_LEN = 13; // seconds per season (auto cycle)
+const SEASONS = ['Spring', 'Summer', 'Autumn', 'Winter'];
+const SEASON_DOT = ['#9fd977', '#4e9e46', '#ef7d2a', '#cfe4f2'];
+
+// leaf palettes per season [light, dark]
+const LEAF_COLORS = [
+ ['#9fd977', '#6fae4e'], // spring
+ ['#4e9e46', '#2f7a35'], // summer
+ ['#e8a13c', '#c65b28'], // autumn
+ ['#c3d0d6', '#9aabb3'], // winter (sparse, pale)
+];
+// leaf density on the tree + fall rate (particles/sec) per season
+const LEAF_DENSITY = [0.85, 1.0, 0.8, 0.10];
+const FALL_RATE = [3, 4, 48, 34]; // winter is high but spawns SNOW, not leaves
+const SNOW_SEASON = 3;
+
+// ---------- state ----------
+let grow = 0; // rises toward maxDepth+1; new depths animate in
+let tips = []; // leaf tip positions collected each frame
+const particles = []; // falling leaves / snow
+const pile = []; // leaves settled on the ground
+const MAX_PARTICLES = 460;
+const MAX_PILE = 460;
+let spawnCarry = 0;
+
+// season timeline — advances only while auto-cycling, so a manual pick holds
+let seasonClock = 0;
+let autoCycle = true;
+
+function lerp(a, b, t) { return a + (b - a) * t; }
+function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
+function hexToRgb(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; }
+function mixHex(a, b, t) {
+ const ca = hexToRgb(a), cb = hexToRgb(b);
+ return `rgb(${ca.map((v, i) => Math.round(lerp(v, cb[i], t))).join(',')})`;
+}
+
+// ---------- wind ----------
+let gust = 0;
+function windAt(t) {
+ return (Math.sin(t * 0.55) * 0.45 + Math.sin(t * 0.23 + 1.7) * 0.35 + Math.sin(t * 1.4 + 0.4) * 0.12) * windStrength;
+}
+
+// ---------- tree ----------
+function branch(x, y, angle, len, width, depth, seed, t) {
+ const lg = clamp(grow - depth, 0, 1);
+ if (lg <= 0) return;
+ const ease = lg * lg * (3 - 2 * lg); // smoothstep growth
+
+ // thinner/higher branches sway more; each branch has its own phase
+ const flex = Math.pow((depth + 1) / (maxDepth + 1), 1.7);
+ const sway = gust * 0.22 * flex * (0.7 + rnd(seed) * 0.6)
+ + Math.sin(t * (1.6 + rnd(seed + 9) * 1.4) + seed) * 0.012 * flex;
+ angle += sway;
+
+ const L = len * ease;
+ const x2 = x + Math.cos(angle) * L;
+ const y2 = y + Math.sin(angle) * L;
+
+ ctx.strokeStyle = mixHex('#5a4330', '#2e2118', depth / (maxDepth || 1));
+ ctx.lineWidth = Math.max(width * ease, 0.5);
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+
+ if (depth >= maxDepth) {
+ if (ease > 0.85) tips.push(x2, y2);
+ return;
+ }
+
+ const spread = SPREAD * (0.85 + rnd(seed + 1) * 0.35);
+ const bend = (rnd(seed + 2) - 0.5) * 0.25;
+ const nl = len * LEN_RATIO * (0.92 + rnd(seed + 3) * 0.16);
+ const nw = width * 0.67;
+ branch(x2, y2, angle - spread + bend, nl, nw, depth + 1, seed * 2 + 1, t);
+ branch(x2, y2, angle + spread + bend, nl, nw, depth + 1, seed * 2 + 2, t);
+ // occasional third middle branch for fullness
+ if (rnd(seed + 4) > 0.72 && depth > 1) {
+ branch(x2, y2, angle + bend * 2, nl * 0.8, nw * 0.8, depth + 1, seed * 3 + 3, t);
+ }
+}
+
+function drawLeaves(t, seasonIdx, seasonMix) {
+ const density = lerp(LEAF_DENSITY[seasonIdx], LEAF_DENSITY[(seasonIdx + 1) % 4], seasonMix);
+ const [liA, daA] = LEAF_COLORS[seasonIdx];
+ const [liB, daB] = LEAF_COLORS[(seasonIdx + 1) % 4];
+ for (let i = 0; i < tips.length; i += 2) {
+ const seed = i * 0.731 + 5;
+ if (rnd(seed) > density) continue;
+ const x = tips[i], y = tips[i + 1];
+ const s = 2.2 + rnd(seed + 1) * 2.6;
+ const wob = Math.sin(t * 2.1 + i) * 1.4 * Math.abs(gust);
+ ctx.fillStyle = mixHex(rnd(seed + 2) > 0.5 ? liA : daA, rnd(seed + 2) > 0.5 ? liB : daB, seasonMix);
+ ctx.globalAlpha = 0.85;
+ ctx.beginPath();
+ ctx.ellipse(x + wob, y, s, s * 0.62, rnd(seed + 3) * Math.PI, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+}
+
+// ---------- falling particles: leaves shed from tips, or snow from the sky ----
+function spawnParticles(dt, seasonIdx, seasonMix) {
+ const isSnow = seasonIdx === SNOW_SEASON;
+ if (!isSnow && tips.length === 0) return;
+ const rate = lerp(FALL_RATE[seasonIdx], FALL_RATE[(seasonIdx + 1) % 4], seasonMix);
+ spawnCarry += rate * dt;
+ while (spawnCarry >= 1 && particles.length < MAX_PARTICLES) {
+ spawnCarry -= 1;
+ if (isSnow) {
+ particles.push({
+ x: Math.random() * W, y: -10,
+ vx: (Math.random() - 0.5) * 8, vy: 18 + Math.random() * 22,
+ rot: 0, vr: 0, size: 1.3 + Math.random() * 2.4,
+ color: Math.random() > 0.5 ? '#eef5fb' : '#cddced',
+ phase: Math.random() * Math.PI * 2, life: 1, snow: true,
+ });
+ } else {
+ const i = (Math.random() * (tips.length / 2) | 0) * 2;
+ const [li, da] = LEAF_COLORS[seasonIdx];
+ particles.push({
+ x: tips[i], y: tips[i + 1],
+ vx: (Math.random() - 0.5) * 12, vy: 6 + Math.random() * 14,
+ rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 4,
+ size: 2.4 + Math.random() * 3,
+ color: Math.random() > 0.5 ? li : da,
+ phase: Math.random() * Math.PI * 2, life: 1, snow: false,
+ });
+ }
+ }
+ spawnCarry = Math.min(spawnCarry, 4);
+}
+
+function updateParticles(dt, t) {
+ const ground = H - 8;
+ for (let i = particles.length - 1; i >= 0; i--) {
+ const p = particles[i];
+ if (p.y < ground) {
+ const term = p.snow ? 40 : 46;
+ p.vy = Math.min(p.vy + (p.snow ? 12 : 26) * dt, term);
+ p.vx += (gust * (p.snow ? 20 : 34) + Math.sin(t * 3 + p.phase) * (p.snow ? 8 : 16)) * dt;
+ p.vx *= 0.985;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt + Math.sin(t * 4 + p.phase) * (p.snow ? 0.2 : 0.35);
+ p.rot += p.vr * dt;
+ } else {
+ // landed: leaves join the ground pile, snow just melts away
+ if (!p.snow && pile.length < MAX_PILE) {
+ pile.push({ x: p.x, y: ground - Math.random() * 5, size: p.size, color: p.color });
+ }
+ particles.splice(i, 1);
+ continue;
+ }
+ if (p.x < -30) p.x = W + 30; else if (p.x > W + 30) p.x = -30;
+ }
+}
+
+function drawParticles() {
+ for (const p of particles) {
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.globalAlpha = 0.9;
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ if (p.snow) {
+ ctx.arc(0, 0, p.size, 0, Math.PI * 2);
+ } else {
+ ctx.rotate(p.rot);
+ ctx.ellipse(0, 0, p.size, p.size * 0.55, 0, 0, Math.PI * 2);
+ }
+ ctx.fill();
+ ctx.restore();
+ }
+ ctx.globalAlpha = 1;
+}
+
+function drawPile() {
+ ctx.globalAlpha = 0.6;
+ for (const p of pile) {
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ ctx.ellipse(p.x, p.y, p.size * 1.25, p.size * 0.5, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+}
+
+// ---------- background ----------
+const SKY_TOP = ['#1a2a45', '#1c3050', '#2a2438', '#141c2b'];
+const SKY_BOT = ['#3c5a72', '#41628a', '#6a4a4c', '#2c3a4d'];
+function drawBackground(seasonIdx, seasonMix) {
+ const g = ctx.createLinearGradient(0, 0, 0, H);
+ g.addColorStop(0, mixHex(SKY_TOP[seasonIdx], SKY_TOP[(seasonIdx + 1) % 4], seasonMix));
+ g.addColorStop(1, mixHex(SKY_BOT[seasonIdx], SKY_BOT[(seasonIdx + 1) % 4], seasonMix));
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, W, H);
+ ctx.fillStyle = 'rgba(20, 26, 22, 0.85)';
+ ctx.fillRect(0, H - 8, W, 8);
+}
+
+// ---------- season helpers ----------
+function currentSeason() {
+ const phase = (seasonClock / SEASON_LEN) % 4;
+ const idx = phase | 0;
+ // cross-blend only in the last 20% of a season, and only while auto-cycling
+ const mix = autoCycle ? clamp((phase - idx - 0.8) / 0.2, 0, 1) : 0;
+ return { idx, mix };
+}
+function updateSeasonBadge(idx) {
+ seasonNameEl.textContent = SEASONS[idx];
+ seasonDotEl.style.color = SEASON_DOT[idx];
+}
+function nextSeason() {
+ // snap to the start of the next season so the pick reads cleanly
+ seasonClock = (Math.floor(seasonClock / SEASON_LEN) + 1) * SEASON_LEN;
+}
+
+// ---------- main loop ----------
+const t0 = performance.now();
+let last = t0;
+let badgeIdx = -1;
+function frame(now) {
+ const t = (now - t0) / 1000;
+ const dt = Math.min((now - last) / 1000, 0.05);
+ last = now;
+
+ grow = Math.min(grow + GROW_SPEED * dt, maxDepth + 1);
+ gust = windAt(t);
+ if (autoCycle) seasonClock += dt;
+
+ const { idx: seasonIdx, mix: seasonMix } = currentSeason();
+ if (seasonIdx !== badgeIdx) { badgeIdx = seasonIdx; updateSeasonBadge(seasonIdx); }
+
+ drawBackground(seasonIdx, seasonMix);
+ drawPile();
+
+ tips = [];
+ ctx.lineCap = 'round';
+ const baseLen = Math.min(H * 0.24, 210);
+ branch(W / 2, H - 8, -Math.PI / 2, baseLen, Math.max(baseLen * 0.075, 6), 0, 1, t);
+
+ drawLeaves(t, seasonIdx, seasonMix);
+ spawnParticles(dt, seasonIdx, seasonMix);
+ updateParticles(dt, t);
+ drawParticles();
+
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+// ---------- controls ----------
+depthSlider.addEventListener('input', () => {
+ maxDepth = +depthSlider.value;
+ depthVal.textContent = depthSlider.value;
+ grow = 0; // regrow so new depths animate in cleanly
+ pile.length = 0;
+});
+windSlider.addEventListener('input', () => {
+ windStrength = +windSlider.value / 100;
+ windVal.textContent = windStrength.toFixed(1);
+});
+document.getElementById('seasonBtn').addEventListener('click', nextSeason);
+const autoBtn = document.getElementById('autoBtn');
+autoBtn.addEventListener('click', () => {
+ autoCycle = !autoCycle;
+ autoBtn.classList.toggle('on', autoCycle);
+});
+document.getElementById('regrowBtn').addEventListener('click', () => {
+ grow = 0; particles.length = 0; pile.length = 0; spawnCarry = 0;
+});
+window.addEventListener('keydown', (e) => {
+ if (e.key === 's' || e.key === 'S') nextSeason();
+});
+</script>
+</body>
+</html>
diff --git a/graphics/living-fractal-tree/index.html b/graphics/living-fractal-tree/index.html
new file mode 100644
index 0000000..cb55d8d
--- /dev/null
+++ b/graphics/living-fractal-tree/index.html
@@ -0,0 +1,385 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Living Fractal Tree</title>
+<style>
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #0d1420;
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+ canvas { display: block; }
+
+ /* season badge — top centre */
+ #season {
+ position: fixed; top: 18px; left: 50%; transform: translateX(-50%); z-index: 3;
+ background: rgba(10, 16, 28, 0.66); backdrop-filter: blur(8px);
+ border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 999px;
+ padding: 7px 18px; color: #eef3fa; letter-spacing: 0.09em; text-transform: uppercase;
+ display: flex; align-items: center; gap: 9px; user-select: none;
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35);
+ }
+ #season .dot { width: 9px; height: 9px; border-radius: 50%;
+ box-shadow: 0 0 10px currentColor; transition: color .8s; }
+
+ #hint { position: fixed; top: 20px; right: 20px; z-index: 3;
+ color: rgba(255, 255, 255, 0.28); font-size: 11px; letter-spacing: 0.04em; user-select: none; }
+
+ /* control dock — bottom centre */
+ #ui {
+ position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); z-index: 3;
+ background: rgba(10, 16, 28, 0.78); backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 16px;
+ padding: 13px 20px; color: #dfe8f2;
+ display: flex; align-items: center; gap: 20px; flex-wrap: wrap; justify-content: center;
+ max-width: 94vw; box-shadow: 0 10px 38px rgba(0, 0, 0, 0.45); user-select: none;
+ }
+ #ui .grp { display: flex; align-items: center; gap: 9px; }
+ #ui label { color: #c5cfda; white-space: nowrap; }
+ #ui .val { min-width: 2.4ch; text-align: right; font-variant-numeric: tabular-nums; color: #9fd977; }
+ #ui .sep { width: 1px; height: 24px; background: rgba(255, 255, 255, 0.1); }
+ input[type="range"] { width: 130px; accent-color: #7fb26a; cursor: pointer; vertical-align: middle; }
+ #ui button { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.13);
+ color: #dfe8f2; padding: 7px 13px; border-radius: 9px; cursor: pointer; letter-spacing: 0.02em;
+ transition: background .15s, border-color .15s; white-space: nowrap; font-size: 12.5px; }
+ #ui button:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.22); }
+ #ui button.on { background: rgba(127, 178, 106, 0.22); border-color: rgba(127, 178, 106, 0.5); color: #bfe6bf; }
+ @media (max-width: 560px) {
+ #ui { gap: 11px; padding: 11px 13px; } input[type="range"] { width: 92px; } #hint { display: none; }
+ }
+</style>
+</head>
+<body>
+<div id="season"><span class="dot" id="seasonDot"></span><span id="seasonName">Spring</span></div>
+<div id="hint">press <b>S</b> for next season</div>
+
+<div id="ui">
+ <div class="grp">
+ <label>Branch depth</label>
+ <input type="range" id="depth" min="4" max="12" step="1" value="9">
+ <span class="val" id="depthVal">9</span>
+ </div>
+ <div class="sep"></div>
+ <div class="grp">
+ <label>Wind</label>
+ <input type="range" id="wind" min="0" max="200" step="1" value="100">
+ <span class="val" id="windVal">1.0</span>
+ </div>
+ <div class="sep"></div>
+ <div class="grp">
+ <button id="seasonBtn">Next season</button>
+ <button id="autoBtn" class="on">Auto cycle</button>
+ <button id="regrowBtn">Regrow</button>
+ </div>
+</div>
+
+<canvas id="c"></canvas>
+<script>
+'use strict';
+const canvas = document.getElementById('c');
+const ctx = canvas.getContext('2d');
+const depthSlider = document.getElementById('depth');
+const depthVal = document.getElementById('depthVal');
+const windSlider = document.getElementById('wind');
+const windVal = document.getElementById('windVal');
+const seasonNameEl = document.getElementById('seasonName');
+const seasonDotEl = document.getElementById('seasonDot');
+
+let W = 0, H = 0;
+function resize() {
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ W = window.innerWidth; H = window.innerHeight;
+ canvas.width = W * dpr; canvas.height = H * dpr;
+ canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+}
+window.addEventListener('resize', resize);
+resize();
+
+// ---------- deterministic per-branch randomness (stable tree shape) ----------
+// Hashing the branch seed (instead of Math.random each frame) is what keeps the
+// tree from strobing: identical structure every frame, only wind animates it.
+function rnd(seed) { const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
+
+// ---------- tunables ----------
+let maxDepth = 9;
+let windStrength = 1.0;
+const GROW_SPEED = 1.9; // depth levels per second
+const LEN_RATIO = 0.72;
+const SPREAD = 0.52; // base branching angle (radians)
+const SEASON_LEN = 13; // seconds per season (auto cycle)
+const SEASONS = ['Spring', 'Summer', 'Autumn', 'Winter'];
+const SEASON_DOT = ['#9fd977', '#4e9e46', '#ef7d2a', '#cfe4f2'];
+
+// leaf palettes per season [light, dark]
+const LEAF_COLORS = [
+ ['#9fd977', '#6fae4e'], // spring
+ ['#4e9e46', '#2f7a35'], // summer
+ ['#e8a13c', '#c65b28'], // autumn
+ ['#c3d0d6', '#9aabb3'], // winter (sparse, pale)
+];
+// leaf density on the tree + fall rate (particles/sec) per season
+const LEAF_DENSITY = [0.85, 1.0, 0.8, 0.10];
+const FALL_RATE = [3, 4, 48, 34]; // winter is high but spawns SNOW, not leaves
+const SNOW_SEASON = 3;
+
+// ---------- state ----------
+let grow = 0; // rises toward maxDepth+1; new depths animate in
+let tips = []; // leaf tip positions collected each frame
+const particles = []; // falling leaves / snow
+const pile = []; // leaves settled on the ground
+const MAX_PARTICLES = 460;
+const MAX_PILE = 460;
+let spawnCarry = 0;
+
+// season timeline — advances only while auto-cycling, so a manual pick holds
+let seasonClock = 0;
+let autoCycle = true;
+
+function lerp(a, b, t) { return a + (b - a) * t; }
+function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
+function hexToRgb(h) { return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; }
+function mixHex(a, b, t) {
+ const ca = hexToRgb(a), cb = hexToRgb(b);
+ return `rgb(${ca.map((v, i) => Math.round(lerp(v, cb[i], t))).join(',')})`;
+}
+
+// ---------- wind ----------
+let gust = 0;
+function windAt(t) {
+ return (Math.sin(t * 0.55) * 0.45 + Math.sin(t * 0.23 + 1.7) * 0.35 + Math.sin(t * 1.4 + 0.4) * 0.12) * windStrength;
+}
+
+// ---------- tree ----------
+function branch(x, y, angle, len, width, depth, seed, t) {
+ const lg = clamp(grow - depth, 0, 1);
+ if (lg <= 0) return;
+ const ease = lg * lg * (3 - 2 * lg); // smoothstep growth
+
+ // thinner/higher branches sway more; each branch has its own phase
+ const flex = Math.pow((depth + 1) / (maxDepth + 1), 1.7);
+ const sway = gust * 0.22 * flex * (0.7 + rnd(seed) * 0.6)
+ + Math.sin(t * (1.6 + rnd(seed + 9) * 1.4) + seed) * 0.012 * flex;
+ angle += sway;
+
+ const L = len * ease;
+ const x2 = x + Math.cos(angle) * L;
+ const y2 = y + Math.sin(angle) * L;
+
+ ctx.strokeStyle = mixHex('#5a4330', '#2e2118', depth / (maxDepth || 1));
+ ctx.lineWidth = Math.max(width * ease, 0.5);
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+
+ if (depth >= maxDepth) {
+ if (ease > 0.85) tips.push(x2, y2);
+ return;
+ }
+
+ const spread = SPREAD * (0.85 + rnd(seed + 1) * 0.35);
+ const bend = (rnd(seed + 2) - 0.5) * 0.25;
+ const nl = len * LEN_RATIO * (0.92 + rnd(seed + 3) * 0.16);
+ const nw = width * 0.67;
+ branch(x2, y2, angle - spread + bend, nl, nw, depth + 1, seed * 2 + 1, t);
+ branch(x2, y2, angle + spread + bend, nl, nw, depth + 1, seed * 2 + 2, t);
+ // occasional third middle branch for fullness
+ if (rnd(seed + 4) > 0.72 && depth > 1) {
+ branch(x2, y2, angle + bend * 2, nl * 0.8, nw * 0.8, depth + 1, seed * 3 + 3, t);
+ }
+}
+
+function drawLeaves(t, seasonIdx, seasonMix) {
+ const density = lerp(LEAF_DENSITY[seasonIdx], LEAF_DENSITY[(seasonIdx + 1) % 4], seasonMix);
+ const [liA, daA] = LEAF_COLORS[seasonIdx];
+ const [liB, daB] = LEAF_COLORS[(seasonIdx + 1) % 4];
+ for (let i = 0; i < tips.length; i += 2) {
+ const seed = i * 0.731 + 5;
+ if (rnd(seed) > density) continue;
+ const x = tips[i], y = tips[i + 1];
+ const s = 2.2 + rnd(seed + 1) * 2.6;
+ const wob = Math.sin(t * 2.1 + i) * 1.4 * Math.abs(gust);
+ ctx.fillStyle = mixHex(rnd(seed + 2) > 0.5 ? liA : daA, rnd(seed + 2) > 0.5 ? liB : daB, seasonMix);
+ ctx.globalAlpha = 0.85;
+ ctx.beginPath();
+ ctx.ellipse(x + wob, y, s, s * 0.62, rnd(seed + 3) * Math.PI, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+}
+
+// ---------- falling particles: leaves shed from tips, or snow from the sky ----
+function spawnParticles(dt, seasonIdx, seasonMix) {
+ const isSnow = seasonIdx === SNOW_SEASON;
+ if (!isSnow && tips.length === 0) return;
+ const rate = lerp(FALL_RATE[seasonIdx], FALL_RATE[(seasonIdx + 1) % 4], seasonMix);
+ spawnCarry += rate * dt;
+ while (spawnCarry >= 1 && particles.length < MAX_PARTICLES) {
+ spawnCarry -= 1;
+ if (isSnow) {
+ particles.push({
+ x: Math.random() * W, y: -10,
+ vx: (Math.random() - 0.5) * 8, vy: 18 + Math.random() * 22,
+ rot: 0, vr: 0, size: 1.3 + Math.random() * 2.4,
+ color: Math.random() > 0.5 ? '#eef5fb' : '#cddced',
+ phase: Math.random() * Math.PI * 2, life: 1, snow: true,
+ });
+ } else {
+ const i = (Math.random() * (tips.length / 2) | 0) * 2;
+ const [li, da] = LEAF_COLORS[seasonIdx];
+ particles.push({
+ x: tips[i], y: tips[i + 1],
+ vx: (Math.random() - 0.5) * 12, vy: 6 + Math.random() * 14,
+ rot: Math.random() * Math.PI * 2, vr: (Math.random() - 0.5) * 4,
+ size: 2.4 + Math.random() * 3,
+ color: Math.random() > 0.5 ? li : da,
+ phase: Math.random() * Math.PI * 2, life: 1, snow: false,
+ });
+ }
+ }
+ spawnCarry = Math.min(spawnCarry, 4);
+}
+
+function updateParticles(dt, t) {
+ const ground = H - 8;
+ for (let i = particles.length - 1; i >= 0; i--) {
+ const p = particles[i];
+ if (p.y < ground) {
+ const term = p.snow ? 40 : 46;
+ p.vy = Math.min(p.vy + (p.snow ? 12 : 26) * dt, term);
+ p.vx += (gust * (p.snow ? 20 : 34) + Math.sin(t * 3 + p.phase) * (p.snow ? 8 : 16)) * dt;
+ p.vx *= 0.985;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt + Math.sin(t * 4 + p.phase) * (p.snow ? 0.2 : 0.35);
+ p.rot += p.vr * dt;
+ } else {
+ // landed: leaves join the ground pile, snow just melts away
+ if (!p.snow && pile.length < MAX_PILE) {
+ pile.push({ x: p.x, y: ground - Math.random() * 5, size: p.size, color: p.color });
+ }
+ particles.splice(i, 1);
+ continue;
+ }
+ if (p.x < -30) p.x = W + 30; else if (p.x > W + 30) p.x = -30;
+ }
+}
+
+function drawParticles() {
+ for (const p of particles) {
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.globalAlpha = 0.9;
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ if (p.snow) {
+ ctx.arc(0, 0, p.size, 0, Math.PI * 2);
+ } else {
+ ctx.rotate(p.rot);
+ ctx.ellipse(0, 0, p.size, p.size * 0.55, 0, 0, Math.PI * 2);
+ }
+ ctx.fill();
+ ctx.restore();
+ }
+ ctx.globalAlpha = 1;
+}
+
+function drawPile() {
+ ctx.globalAlpha = 0.6;
+ for (const p of pile) {
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ ctx.ellipse(p.x, p.y, p.size * 1.25, p.size * 0.5, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+}
+
+// ---------- background ----------
+const SKY_TOP = ['#1a2a45', '#1c3050', '#2a2438', '#141c2b'];
+const SKY_BOT = ['#3c5a72', '#41628a', '#6a4a4c', '#2c3a4d'];
+function drawBackground(seasonIdx, seasonMix) {
+ const g = ctx.createLinearGradient(0, 0, 0, H);
+ g.addColorStop(0, mixHex(SKY_TOP[seasonIdx], SKY_TOP[(seasonIdx + 1) % 4], seasonMix));
+ g.addColorStop(1, mixHex(SKY_BOT[seasonIdx], SKY_BOT[(seasonIdx + 1) % 4], seasonMix));
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, W, H);
+ ctx.fillStyle = 'rgba(20, 26, 22, 0.85)';
+ ctx.fillRect(0, H - 8, W, 8);
+}
+
+// ---------- season helpers ----------
+function currentSeason() {
+ const phase = (seasonClock / SEASON_LEN) % 4;
+ const idx = phase | 0;
+ // cross-blend only in the last 20% of a season, and only while auto-cycling
+ const mix = autoCycle ? clamp((phase - idx - 0.8) / 0.2, 0, 1) : 0;
+ return { idx, mix };
+}
+function updateSeasonBadge(idx) {
+ seasonNameEl.textContent = SEASONS[idx];
+ seasonDotEl.style.color = SEASON_DOT[idx];
+}
+function nextSeason() {
+ // snap to the start of the next season so the pick reads cleanly
+ seasonClock = (Math.floor(seasonClock / SEASON_LEN) + 1) * SEASON_LEN;
+}
+
+// ---------- main loop ----------
+const t0 = performance.now();
+let last = t0;
+let badgeIdx = -1;
+function frame(now) {
+ const t = (now - t0) / 1000;
+ const dt = Math.min((now - last) / 1000, 0.05);
+ last = now;
+
+ grow = Math.min(grow + GROW_SPEED * dt, maxDepth + 1);
+ gust = windAt(t);
+ if (autoCycle) seasonClock += dt;
+
+ const { idx: seasonIdx, mix: seasonMix } = currentSeason();
+ if (seasonIdx !== badgeIdx) { badgeIdx = seasonIdx; updateSeasonBadge(seasonIdx); }
+
+ drawBackground(seasonIdx, seasonMix);
+ drawPile();
+
+ tips = [];
+ ctx.lineCap = 'round';
+ const baseLen = Math.min(H * 0.24, 210);
+ branch(W / 2, H - 8, -Math.PI / 2, baseLen, Math.max(baseLen * 0.075, 6), 0, 1, t);
+
+ drawLeaves(t, seasonIdx, seasonMix);
+ spawnParticles(dt, seasonIdx, seasonMix);
+ updateParticles(dt, t);
+ drawParticles();
+
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
+
+// ---------- controls ----------
+depthSlider.addEventListener('input', () => {
+ maxDepth = +depthSlider.value;
+ depthVal.textContent = depthSlider.value;
+ grow = 0; // regrow so new depths animate in cleanly
+ pile.length = 0;
+});
+windSlider.addEventListener('input', () => {
+ windStrength = +windSlider.value / 100;
+ windVal.textContent = windStrength.toFixed(1);
+});
+document.getElementById('seasonBtn').addEventListener('click', nextSeason);
+const autoBtn = document.getElementById('autoBtn');
+autoBtn.addEventListener('click', () => {
+ autoCycle = !autoCycle;
+ autoBtn.classList.toggle('on', autoCycle);
+});
+document.getElementById('regrowBtn').addEventListener('click', () => {
+ grow = 0; particles.length = 0; pile.length = 0; spawnCarry = 0;
+});
+window.addEventListener('keydown', (e) => {
+ if (e.key === 's' || e.key === 'S') nextSeason();
+});
+</script>
+</body>
+</html>
diff --git a/tests/e2e.mjs b/tests/e2e.mjs
index 5cfeaad..d6db715 100644
--- a/tests/e2e.mjs
+++ b/tests/e2e.mjs
@@ -96,6 +96,35 @@ await page.goto(url + '#spirograph');
await page.waitForSelector('.item.active');
check('hash deep-link selects graphic', await page.locator('.item.active').count() === 1);
+// ---- Living Fractal Tree ----
+check('Living Fractal Tree listed', await page.locator('.item .fname', { hasText: 'LivingFractalTree.aa' }).count() === 1);
+
+await page.goto(url + '#living-fractal-tree');
+await page.waitForSelector('.item.active');
+const treeFrame = page.frameLocator('#frame');
+await treeFrame.locator('#c').waitFor({ timeout: 15000 });
+check('tree iframe loads its canvas', await treeFrame.locator('#c').count() === 1);
+
+const tree = page.frames().find(f => f.url().includes('living-fractal-tree'));
+const treeDims = await tree.evaluate(() => { const c = document.getElementById('c'); return { w: c.width, h: c.height }; });
+check('tree canvas backing store sized (hi-DPI)', treeDims.w >= 760 && treeDims.h >= 760);
+
+// the tree actually draws — coloured (non-background) pixels appear as it grows
+const treeDrew = await tree.evaluate(async () => {
+ const c = document.getElementById('c');
+ const g = c.getContext('2d');
+ await new Promise(r => setTimeout(r, 2500)); // let it grow in
+ const d = g.getImageData(0, 0, c.width, c.height).data;
+ let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > 8) n++;
+ return n;
+});
+check('tree animates and grows (pixels drawn)', treeDrew > 5000);
+
+// tree controls present
+const treeControls = await tree.evaluate(() => ['depth','wind','seasonBtn','autoBtn','regrowBtn']
+ .every(id => document.getElementById(id)));
+check('tree controls present', treeControls);
+
await page.screenshot({ path: path.join(root, 'tests', 'screenshot.png') });
check('no console/page errors', errors.length === 0);
if (errors.length) console.log(errors.join('\n'));
← d0e05ec deploy: bootstrap-safe first deploy (stage HTTP-only config
·
back to Graphics Agentabrams
·
Add Voronoi Shatter graphic (interactive power-diagram tesse 4d3ec89 →