← back to Living Fractal Tree
Living Fractal Tree — growing, wind-swaying canvas tree with seasonal falling leaves and depth slider
ffc15aead36f3b8d628a485d104bb98d0b9307ca · 2026-07-23 07:56:04 -0700 · Steve Abrams
Files touched
Diff
commit ffc15aead36f3b8d628a485d104bb98d0b9307ca
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 23 07:56:04 2026 -0700
Living Fractal Tree — growing, wind-swaying canvas tree with seasonal falling leaves and depth slider
---
.gitignore | 8 ++
index.html | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 271 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..39545be
--- /dev/null
+++ b/index.html
@@ -0,0 +1,263 @@
+<!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; }
+ canvas { display: block; }
+ #ui {
+ position: fixed; top: 16px; left: 16px; z-index: 2;
+ background: rgba(10, 16, 28, 0.72); backdrop-filter: blur(6px);
+ border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 10px;
+ padding: 12px 16px; color: #dfe8f2;
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ user-select: none;
+ }
+ #ui label { display: flex; align-items: center; gap: 10px; }
+ #depthVal { min-width: 2ch; text-align: right; font-variant-numeric: tabular-nums; }
+ input[type="range"] { width: 160px; accent-color: #7fb26a; }
+ #season { margin-top: 6px; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.75; }
+</style>
+</head>
+<body>
+<div id="ui">
+ <label>Branch depth
+ <input type="range" id="depth" min="4" max="12" step="1" value="9">
+ <span id="depthVal">9</span>
+ </label>
+ <div id="season">Spring</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 seasonEl = document.getElementById('season');
+
+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) ----------
+function rnd(seed) { const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }
+
+// ---------- tunables ----------
+let maxDepth = 9;
+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 = 12; // seconds per season
+const SEASONS = ['Spring', 'Summer', 'Autumn', 'Winter'];
+
+// leaf palettes per season [light, dark]
+const LEAF_COLORS = [
+ ['#9fd977', '#6fae4e'], // spring
+ ['#4e9e46', '#2f7a35'], // summer
+ ['#e8a13c', '#c65b28'], // autumn
+ ['#b8c4c9', '#8fa0a8'], // winter (sparse, pale)
+];
+// leaf density + fall rate (particles/sec) per season
+const LEAF_DENSITY = [0.85, 1.0, 0.8, 0.12];
+const FALL_RATE = [2, 4, 46, 8];
+
+depthSlider.addEventListener('input', () => {
+ maxDepth = +depthSlider.value;
+ depthVal.textContent = depthSlider.value;
+});
+
+// ---------- state ----------
+let grow = 0; // rises toward maxDepth+1; new depths animate in
+let tips = []; // leaf tip positions collected each frame
+const particles = []; // falling leaves
+const MAX_PARTICLES = 420;
+let spawnCarry = 0;
+
+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;
+}
+
+// ---------- 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 leaf particles ----------
+function spawnParticles(dt, seasonIdx, seasonMix) {
+ if (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;
+ 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,
+ });
+ }
+ 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) {
+ p.vy = Math.min(p.vy + 26 * dt, 46);
+ p.vx += (gust * 34 + Math.sin(t * 3 + p.phase) * 16) * dt; // wind drift + flutter
+ p.vx *= 0.985;
+ p.x += p.vx * dt;
+ p.y += p.vy * dt + Math.sin(t * 4 + p.phase) * 0.35; // fluttering descent
+ p.rot += p.vr * dt;
+ } else {
+ p.life -= dt * 0.4; // settle and fade
+ }
+ if (p.life <= 0 || p.x < -30 || p.x > W + 30) particles.splice(i, 1);
+ }
+}
+
+function drawParticles() {
+ for (const p of particles) {
+ ctx.save();
+ ctx.translate(p.x, Math.min(p.y, H - 8));
+ ctx.rotate(p.rot);
+ ctx.globalAlpha = clamp(p.life, 0, 1) * 0.9;
+ ctx.fillStyle = p.color;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, p.size, p.size * 0.55, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+ ctx.globalAlpha = 1;
+}
+
+// ---------- background ----------
+function drawBackground(seasonIdx, seasonMix) {
+ const skyTop = ['#1a2a45', '#1c3050', '#2a2438', '#141c2b'];
+ const skyBot = ['#3c5a72', '#41628a', '#6a4a4c', '#2c3a4d'];
+ const g = ctx.createLinearGradient(0, 0, 0, H);
+ g.addColorStop(0, mixHex(skyTop[seasonIdx], skyTop[(seasonIdx + 1) % 4], seasonMix));
+ g.addColorStop(1, mixHex(skyBot[seasonIdx], skyBot[(seasonIdx + 1) % 4], seasonMix));
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, W, H);
+ // ground
+ ctx.fillStyle = 'rgba(20, 26, 22, 0.85)';
+ ctx.fillRect(0, H - 8, W, 8);
+}
+
+// ---------- main loop ----------
+const t0 = performance.now();
+let last = t0;
+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);
+
+ const seasonPhase = (t / SEASON_LEN) % 4;
+ const seasonIdx = seasonPhase | 0;
+ const seasonMix = clamp((seasonPhase - seasonIdx - 0.8) / 0.2, 0, 1); // blend in last 20%
+ seasonEl.textContent = SEASONS[seasonIdx];
+
+ drawBackground(seasonIdx, seasonMix);
+
+ 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);
+</script>
+</body>
+</html>
(oldest)
·
back to Living Fractal Tree
·
Extend Living Fractal Tree: wind control, manual/auto season 2d6124e →