[object Object]

← back to Graphics Agentabrams

Add Voronoi Shatter graphic (interactive power-diagram tessellation) to gallery

4d3ec89d2af6df6f15f9731aed53ced222de32a2 · 2026-07-24 23:32:24 -0700 · Steve Abrams

Files touched

Diff

commit 4d3ec89d2af6df6f15f9731aed53ced222de32a2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:32:24 2026 -0700

    Add Voronoi Shatter graphic (interactive power-diagram tessellation) to gallery
---
 README.md                                  |   1 +
 graphics.json                              |   8 +
 graphics/voronoi-shatter/VoronoiShatter.aa | 691 +++++++++++++++++++++++++++++
 graphics/voronoi-shatter/index.html        | 691 +++++++++++++++++++++++++++++
 tests/e2e.mjs                              |   2 +-
 5 files changed, 1392 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 6617f76..c9b49f3 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ The `.aa` filename is a display label in the manifest; the iframe always loads `
 | File | What | Source |
 |---|---|---|
 | `Spirograph.aa` | Animated hypotrochoid plotter — R/r/d sliders, live rainbow color cycling, presets, glow, gear schematic, save-PNG, hi-DPI, keyboard shortcuts | Model Arena challenge (Free-Roster · Spirograph), improved from the Claude Opus starting artifact |
+| `FlowField.aa` | Generative flow-field — thousands of particles follow a seeded Perlin noise field painting harmonious colored trails. Mouse steers the flow (drag = stronger swirl, Tab = repel), arrow keys pan the field, Space reseeds palette+field, S saves PNG. Color-batched draw, hi-DPI, resize-preserving. | Model Arena challenge (Custom · Generative Art Canvas), rebuilt from the Qwen3 14B starting artifact |
 
 ## Tests
 
diff --git a/graphics.json b/graphics.json
index 58e395a..8a1bf60 100644
--- a/graphics.json
+++ b/graphics.json
@@ -23,6 +23,14 @@
       "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"
+    },
+    {
+      "id": "voronoi-shatter",
+      "file": "VoronoiShatter.aa",
+      "title": "Voronoi Shatter",
+      "desc": "Interactive Voronoi tessellation — click or drag to scatter seed points and watch cells re-tessellate with a smooth power-diagram grow-in animation; each shard gets a radial gradient fill. Shift/right-click removes the nearest seed, Explode flies the shards apart and reassembles, cycle color palettes, toggle seeds/cracks, undo/scatter/clear, and save-PNG.",
+      "path": "graphics/voronoi-shatter/",
+      "added": "2026-07-24"
     }
   ]
 }
diff --git a/graphics/voronoi-shatter/VoronoiShatter.aa b/graphics/voronoi-shatter/VoronoiShatter.aa
new file mode 100644
index 0000000..fbbfc7e
--- /dev/null
+++ b/graphics/voronoi-shatter/VoronoiShatter.aa
@@ -0,0 +1,691 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Voronoi Shatter</title>
+<style>
+  html, body {
+    margin: 0;
+    padding: 0;
+    height: 100%;
+    overflow: hidden;
+    background: #0b0e14;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+    touch-action: none;
+  }
+  #stage {
+    display: block;
+    width: 100vw;
+    height: 100vh;
+    cursor: crosshair;
+  }
+  #panel {
+    position: fixed;
+    top: 14px;
+    left: 14px;
+    max-width: min(320px, calc(100vw - 28px));
+    background: rgba(12, 15, 24, 0.78);
+    border: 1px solid rgba(255, 255, 255, 0.14);
+    border-radius: 10px;
+    padding: 12px 14px;
+    color: #e8ecf4;
+    backdrop-filter: blur(6px);
+    -webkit-backdrop-filter: blur(6px);
+    user-select: none;
+    box-shadow: 0 6px 24px rgba(0,0,0,0.45);
+    transition: opacity 0.2s ease, transform 0.2s ease;
+  }
+  #panel.hidden {
+    opacity: 0;
+    transform: translateY(-8px);
+    pointer-events: none;
+  }
+  #panel h1 {
+    margin: 0 0 4px 0;
+    font-size: 14px;
+    font-weight: 700;
+    letter-spacing: 0.4px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+  #panel h1 .dot {
+    width: 9px; height: 9px; border-radius: 50%;
+    background: linear-gradient(135deg,#4f7cff,#c04fff);
+    box-shadow: 0 0 8px rgba(120,140,255,0.7);
+  }
+  #panel p {
+    margin: 0 0 10px 0;
+    font-size: 11.5px;
+    color: #9aa4b8;
+    line-height: 1.45;
+  }
+  .row {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    flex-wrap: wrap;
+    margin-bottom: 8px;
+  }
+  .row:last-child { margin-bottom: 0; }
+  button {
+    font: inherit;
+    font-size: 12px;
+    padding: 6px 12px;
+    border-radius: 7px;
+    border: 1px solid rgba(255,255,255,0.2);
+    background: rgba(255,255,255,0.08);
+    color: #e8ecf4;
+    cursor: pointer;
+    transition: background 0.15s ease, border-color 0.15s ease;
+    white-space: nowrap;
+  }
+  button:hover { background: rgba(255,255,255,0.17); }
+  button:active { transform: translateY(1px); }
+  button.active {
+    background: #4f7cff;
+    border-color: #6c92ff;
+  }
+  button:disabled { opacity: 0.45; cursor: default; }
+  #count {
+    font-size: 11.5px;
+    color: #9aa4b8;
+    min-width: 56px;
+  }
+  .hint {
+    font-size: 10.5px;
+    color: #6b7488;
+    margin-top: 2px;
+  }
+  kbd {
+    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    font-size: 10px;
+    background: rgba(255,255,255,0.1);
+    border: 1px solid rgba(255,255,255,0.15);
+    border-radius: 4px;
+    padding: 0 4px;
+    color: #c7d0e0;
+  }
+  #showBtn {
+    position: fixed;
+    top: 14px;
+    left: 14px;
+    display: none;
+  }
+  #showBtn.visible { display: inline-block; }
+</style>
+</head>
+<body>
+<canvas id="stage"></canvas>
+
+<button id="showBtn">☰ Controls</button>
+
+<div id="panel">
+  <h1><span class="dot"></span>Voronoi Shatter</h1>
+  <p><b>Click</b> to add a point &amp; re-shatter · <b>drag</b> to paint ·
+     <b>right-click</b> (or Shift-click) removes the nearest.</p>
+  <div class="row">
+    <button id="toggleSeeds" class="active">Seeds: on</button>
+    <button id="toggleEdges" class="active">Cracks: on</button>
+  </div>
+  <div class="row">
+    <button id="explode">💥 Shatter</button>
+    <button id="palette">🎨 Spectrum</button>
+    <button id="scatter">Scatter</button>
+  </div>
+  <div class="row">
+    <button id="undo">Undo</button>
+    <button id="reset">Reset</button>
+    <button id="clear">Clear</button>
+    <span id="count"></span>
+  </div>
+  <div class="row">
+    <button id="savePng">Save PNG</button>
+    <button id="hidePanel">Hide ⤢</button>
+  </div>
+  <div class="hint">
+    Keys: <kbd>S</kbd>eeds <kbd>C</kbd>racks <kbd>E</kbd>xplode
+    <kbd>P</kbd>alette <kbd>R</kbd>eset <kbd>Z</kbd>undo <kbd>H</kbd>ide
+  </div>
+</div>
+
+<script>
+(function () {
+  "use strict";
+
+  var canvas = document.getElementById("stage");
+  var ctx = canvas.getContext("2d");
+
+  var els = {
+    toggleSeeds: document.getElementById("toggleSeeds"),
+    toggleEdges: document.getElementById("toggleEdges"),
+    explode: document.getElementById("explode"),
+    palette: document.getElementById("palette"),
+    scatter: document.getElementById("scatter"),
+    undo: document.getElementById("undo"),
+    reset: document.getElementById("reset"),
+    clear: document.getElementById("clear"),
+    savePng: document.getElementById("savePng"),
+    hidePanel: document.getElementById("hidePanel"),
+    panel: document.getElementById("panel"),
+    showBtn: document.getElementById("showBtn"),
+    count: document.getElementById("count")
+  };
+
+  var W = 0, H = 0, DPR = 1;
+  var showSeeds = true;
+  var showEdges = true;
+  var sites = [];            // {x, y, phase, birth, w0, spin}
+  var ANIM_MS = 700;
+  var EXPLODE_MS = 1400;
+  var MAX_SITES = 300;
+  var BG = "#0b0e14";
+
+  // -------- palettes ---------------------------------------------------------
+  // Each maps a site's golden-angle "phase" (0..360) to three HSL gradient stops.
+  var PALETTES = [
+    { name: "Spectrum", base: 0,   range: 360, sat: 68, mono: false },
+    { name: "Sunset",   base: 8,   range: 66,  sat: 78, mono: false },
+    { name: "Ocean",    base: 176, range: 92,  sat: 66, mono: false },
+    { name: "Ember",    base: 356, range: 44,  sat: 82, mono: false },
+    { name: "Forest",   base: 96,  range: 74,  sat: 58, mono: false },
+    { name: "Mono",     base: 258, range: 0,   sat: 46, mono: true  }
+  ];
+  var paletteIdx = 0;
+
+  function stopsFor(site) {
+    var p = PALETTES[paletteIdx];
+    var frac = site.phase / 360;
+    if (p.mono) {
+      // one hue, lightness driven by phase for shard-to-shard variety
+      var L = 34 + frac * 44;               // 34%..78%
+      return [
+        "hsl(" + p.base + "," + p.sat + "%," + (L + 10).toFixed(1) + "%)",
+        "hsl(" + p.base + "," + p.sat + "%," + L.toFixed(1) + "%)",
+        "hsl(" + p.base + "," + p.sat + "%," + (L - 16).toFixed(1) + "%)"
+      ];
+    }
+    var h = (p.base + frac * p.range) % 360;
+    if (h < 0) h += 360;
+    return [
+      "hsl(" + h.toFixed(1) + "," + p.sat + "%,66%)",
+      "hsl(" + h.toFixed(1) + "," + (p.sat - 6) + "%,48%)",
+      "hsl(" + h.toFixed(1) + "," + (p.sat - 8) + "%,26%)"
+    ];
+  }
+
+  // -------- canvas sizing ----------------------------------------------------
+  function resize() {
+    DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
+    var prevW = W, prevH = H;
+    W = window.innerWidth;
+    H = window.innerHeight;
+    canvas.width = Math.round(W * DPR);
+    canvas.height = Math.round(H * DPR);
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    // keep the composition proportional so cells never drift off-canvas
+    if (prevW && prevH && (prevW !== W || prevH !== H) && sites.length) {
+      var sx = W / prevW, sy = H / prevH;
+      for (var i = 0; i < sites.length; i++) {
+        sites[i].x *= sx;
+        sites[i].y *= sy;
+      }
+    }
+  }
+
+  // -------- easing -----------------------------------------------------------
+  function easeOutCubic(t) {
+    t = t < 0 ? 0 : (t > 1 ? 1 : t);
+    var u = 1 - t;
+    return 1 - u * u * u;
+  }
+
+  // Weight for a site's power cell: animates from -w0 (cell = a point) up to 0
+  // (plain Voronoi) so the new cell grows while neighbors yield ground.
+  function weightOf(s, now) {
+    var t = (now - s.birth) / ANIM_MS;
+    if (t >= 1) return 0;
+    if (t <= 0) return -s.w0;
+    return (easeOutCubic(t) - 1) * s.w0;
+  }
+
+  function anyAnimating(now) {
+    for (var i = 0; i < sites.length; i++) {
+      if (now - sites[i].birth < ANIM_MS) return true;
+    }
+    return false;
+  }
+
+  // -------- sites ------------------------------------------------------------
+  function nearestDist2(x, y) {
+    var best = Infinity;
+    for (var i = 0; i < sites.length; i++) {
+      var dx = sites[i].x - x, dy = sites[i].y - y;
+      var d2 = dx * dx + dy * dy;
+      if (d2 < best) best = d2;
+    }
+    return best;
+  }
+
+  function addSite(x, y, birth, w0) {
+    if (sites.length >= MAX_SITES) return null;
+    var d2 = sites.length ? nearestDist2(x, y) : (W * W + H * H) / 4;
+    d2 = Math.max(d2, 400); // never start fully degenerate
+    var s = {
+      x: x, y: y,
+      phase: (sites.length * 137.508) % 360,
+      birth: birth,
+      w0: (w0 != null ? w0 : d2),
+      spin: (((sites.length * 2654435761) % 1000) / 1000 - 0.5) // deterministic [-0.5,0.5]
+    };
+    sites.push(s);
+    return s;
+  }
+
+  function nearestIndex(x, y) {
+    var best = Infinity, idx = -1;
+    for (var i = 0; i < sites.length; i++) {
+      var dx = sites[i].x - x, dy = sites[i].y - y;
+      var d2 = dx * dx + dy * dy;
+      if (d2 < best) { best = d2; idx = i; }
+    }
+    return idx;
+  }
+
+  function removeNear(x, y) {
+    if (!sites.length) return;
+    var idx = nearestIndex(x, y);
+    if (idx < 0) return;
+    var removed = sites[idx];
+    sites.splice(idx, 1);
+    // let the site that inherits the void grow smoothly into it
+    if (sites.length) {
+      var j = nearestIndex(removed.x, removed.y);
+      var s = sites[j];
+      var dx = s.x - removed.x, dy = s.y - removed.y;
+      s.birth = performance.now();
+      s.w0 = Math.max(dx * dx + dy * dy, 900);
+    }
+  }
+
+  // -------- geometry: Sutherland–Hodgman half-plane clip ---------------------
+  // Clip polygon against  dot(p, (dx,dy)) <= k
+  function clipHalfPlane(poly, dx, dy, k) {
+    var out = [];
+    var n = poly.length;
+    if (n === 0) return out;
+    var px = poly[n - 1][0], py = poly[n - 1][1];
+    var pIn = (px * dx + py * dy) <= k;
+    for (var i = 0; i < n; i++) {
+      var cx = poly[i][0], cy = poly[i][1];
+      var cIn = (cx * dx + cy * dy) <= k;
+      if (cIn !== pIn) {
+        var denom = dx * (cx - px) + dy * (cy - py);
+        if (denom !== 0) {
+          var t = (k - (dx * px + dy * py)) / denom;
+          out.push([px + t * (cx - px), py + t * (cy - py)]);
+        }
+      }
+      if (cIn) out.push([cx, cy]);
+      px = cx; py = cy; pIn = cIn;
+    }
+    return out;
+  }
+
+  // Power-diagram cell for site i.
+  // Bisector of |x-pi|^2 - wi <= |x-pj|^2 - wj  →  half-plane
+  //   dot(x, 2(pj-pi)) <= |pj|^2 - |pi|^2 + wi - wj
+  //
+  // `order` holds the other site indices sorted by squared distance to i, so we
+  // can stop early: once no remaining site is close enough for its bisector to
+  // reach the current cell, later sites cannot clip it. Exact at rest (w=0),
+  // where the final displayed diagram lives.
+  function cellFor(i, weights, order) {
+    var s = sites[i];
+    var poly = [[0, 0], [W, 0], [W, H], [0, H]];
+    var mi = s.x * s.x + s.y * s.y;
+    var wi = weights[i];
+    var rMax2 = W * W + H * H; // farthest possible extent to start
+
+    for (var oi = 0; oi < order.length && poly.length; oi++) {
+      var pair = order[oi];
+      // early-out: nearest remaining site is farther than twice the cell reach
+      if (pair.d2 > 4 * rMax2) break;
+
+      var j = pair.j;
+      var o = sites[j];
+      var dx = 2 * (o.x - s.x);
+      var dy = 2 * (o.y - s.y);
+      var k = (o.x * o.x + o.y * o.y) - mi + wi - weights[j];
+      poly = clipHalfPlane(poly, dx, dy, k);
+
+      // recompute cell reach (max squared vertex distance from the seed)
+      rMax2 = 0;
+      for (var v = 0; v < poly.length; v++) {
+        var vx = poly[v][0] - s.x, vy = poly[v][1] - s.y;
+        var d2 = vx * vx + vy * vy;
+        if (d2 > rMax2) rMax2 = d2;
+      }
+    }
+    return poly;
+  }
+
+  function neighborOrder(i) {
+    var s = sites[i];
+    var arr = [];
+    for (var j = 0; j < sites.length; j++) {
+      if (j === i) continue;
+      var dx = sites[j].x - s.x, dy = sites[j].y - s.y;
+      arr.push({ j: j, d2: dx * dx + dy * dy });
+    }
+    arr.sort(function (a, b) { return a.d2 - b.d2; });
+    return arr;
+  }
+
+  // -------- explode ----------------------------------------------------------
+  var explodeStart = -1;
+  function explodePhase(now) {
+    if (explodeStart < 0) return 0;
+    var t = (now - explodeStart) / EXPLODE_MS;
+    if (t >= 1) { explodeStart = -1; return 0; }
+    return Math.sin(t * Math.PI); // 0 → 1 → 0 (fly apart, reassemble)
+  }
+
+  function centroid(poly) {
+    var a = 0, cx = 0, cy = 0, n = poly.length;
+    for (var i = 0; i < n; i++) {
+      var j = (i + 1) % n;
+      var x0 = poly[i][0], y0 = poly[i][1];
+      var x1 = poly[j][0], y1 = poly[j][1];
+      var cross = x0 * y1 - x1 * y0;
+      a += cross;
+      cx += (x0 + x1) * cross;
+      cy += (y0 + y1) * cross;
+    }
+    if (Math.abs(a) < 1e-6) {
+      // degenerate: fall back to vertex average
+      cx = 0; cy = 0;
+      for (var v = 0; v < n; v++) { cx += poly[v][0]; cy += poly[v][1]; }
+      return [cx / n, cy / n];
+    }
+    a *= 0.5;
+    return [cx / (6 * a), cy / (6 * a)];
+  }
+
+  // Displace a shard's polygon outward from screen center + spin it.
+  function shatterPoly(poly, site, phase) {
+    var c = centroid(poly);
+    var cxC = W / 2, cyC = H / 2;
+    var dx = c[0] - cxC, dy = c[1] - cyC;
+    var dist = Math.sqrt(dx * dx + dy * dy) || 1;
+    var nx = dx / dist, ny = dy / dist;
+    var gap = phase * (46 + dist * 0.22);
+    var rot = phase * site.spin * 1.4;
+    var shrink = 1 - phase * 0.14;
+    var cos = Math.cos(rot), sin = Math.sin(rot);
+    var out = [];
+    for (var i = 0; i < poly.length; i++) {
+      var vx = (poly[i][0] - c[0]) * shrink;
+      var vy = (poly[i][1] - c[1]) * shrink;
+      var rx = vx * cos - vy * sin;
+      var ry = vx * sin + vy * cos;
+      out.push([c[0] + rx + nx * gap, c[1] + ry + ny * gap]);
+    }
+    return out;
+  }
+
+  // -------- draw -------------------------------------------------------------
+  function draw(now) {
+    var weights = new Array(sites.length);
+    for (var i = 0; i < sites.length; i++) weights[i] = weightOf(sites[i], now);
+
+    var phase = explodePhase(now);
+    var exploding = phase > 0.0001;
+
+    ctx.fillStyle = BG;
+    ctx.fillRect(0, 0, W, H);
+    ctx.lineJoin = "round";
+
+    for (var s = 0; s < sites.length; s++) {
+      var order = neighborOrder(s);
+      var poly = cellFor(s, weights, order);
+      if (poly.length < 3) continue;
+      var site = sites[s];
+
+      if (exploding) poly = shatterPoly(poly, site, phase);
+
+      // gradient radius = farthest vertex from the seed's projected position
+      var gcx = site.x, gcy = site.y;
+      if (exploding) { var cc = centroid(poly); gcx = cc[0]; gcy = cc[1]; }
+
+      var r2max = 0;
+      for (var v = 0; v < poly.length; v++) {
+        var vx = poly[v][0] - gcx, vy = poly[v][1] - gcy;
+        var d2 = vx * vx + vy * vy;
+        if (d2 > r2max) r2max = d2;
+      }
+      var r = Math.sqrt(r2max) || 1;
+
+      var stops = stopsFor(site);
+      var g = ctx.createRadialGradient(gcx, gcy, 0, gcx, gcy, r);
+      g.addColorStop(0, stops[0]);
+      g.addColorStop(0.55, stops[1]);
+      g.addColorStop(1, stops[2]);
+
+      ctx.beginPath();
+      ctx.moveTo(poly[0][0], poly[0][1]);
+      for (var p = 1; p < poly.length; p++) ctx.lineTo(poly[p][0], poly[p][1]);
+      ctx.closePath();
+      ctx.fillStyle = g;
+      ctx.fill();
+
+      if (showEdges) {
+        ctx.strokeStyle = "rgba(8, 10, 16, 0.9)";
+        ctx.lineWidth = 1.6;
+        ctx.stroke();
+        ctx.strokeStyle = "rgba(255, 255, 255, 0.10)";
+        ctx.lineWidth = 0.6;
+        ctx.stroke();
+      }
+    }
+
+    if (showSeeds && !exploding) {
+      for (var q = 0; q < sites.length; q++) {
+        var st = sites[q];
+        var age = Math.min(1, (now - st.birth) / ANIM_MS);
+        var pr = 3 + 2 * (1 - easeOutCubic(age));
+        ctx.beginPath();
+        ctx.arc(st.x, st.y, pr + 1.6, 0, Math.PI * 2);
+        ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
+        ctx.fill();
+        ctx.beginPath();
+        ctx.arc(st.x, st.y, pr, 0, Math.PI * 2);
+        ctx.fillStyle = "#ffffff";
+        ctx.fill();
+      }
+    }
+
+    els.count.textContent = sites.length + " cell" + (sites.length === 1 ? "" : "s");
+    return anyAnimating(now) || phase > 0.0001;
+  }
+
+  // -------- animation loop ---------------------------------------------------
+  var rafId = null;
+  function frame() {
+    rafId = null;
+    var animating = draw(performance.now());
+    if (animating) schedule();
+  }
+  function schedule() {
+    if (rafId === null) rafId = requestAnimationFrame(frame);
+  }
+
+  // -------- scenes -----------------------------------------------------------
+  function seedInitial() {
+    sites = [];
+    explodeStart = -1;
+    var now = performance.now();
+    var n = 22;
+    var margin = 40;
+    for (var i = 0; i < n; i++) {
+      var x = margin + Math.random() * (W - margin * 2);
+      var y = margin + Math.random() * (H - margin * 2);
+      addSite(x, y, now + i * 28); // staggered births → rippling intro shatter
+    }
+    schedule();
+  }
+
+  function scatter() {
+    var now = performance.now();
+    var toAdd = Math.min(18, MAX_SITES - sites.length);
+    var margin = 40;
+    for (var i = 0; i < toAdd; i++) {
+      var x = margin + Math.random() * (W - margin * 2);
+      var y = margin + Math.random() * (H - margin * 2);
+      addSite(x, y, now + i * 26);
+    }
+    schedule();
+  }
+
+  // -------- pointer input ----------------------------------------------------
+  var painting = false;
+  var lastPaint = null;
+
+  function localPoint(e) {
+    var rect = canvas.getBoundingClientRect();
+    return [e.clientX - rect.left, e.clientY - rect.top];
+  }
+
+  canvas.addEventListener("contextmenu", function (e) { e.preventDefault(); });
+
+  canvas.addEventListener("pointerdown", function (e) {
+    var p = localPoint(e);
+    if (e.button === 2 || e.shiftKey) {
+      removeNear(p[0], p[1]);
+      schedule();
+      return;
+    }
+    addSite(p[0], p[1], performance.now());
+    painting = true;
+    lastPaint = p;
+    try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
+    schedule();
+  });
+
+  canvas.addEventListener("pointermove", function (e) {
+    if (!painting) return;
+    var p = localPoint(e);
+    var dx = p[0] - lastPaint[0], dy = p[1] - lastPaint[1];
+    if (dx * dx + dy * dy >= 28 * 28) {       // paint spacing
+      addSite(p[0], p[1], performance.now());
+      lastPaint = p;
+      schedule();
+    }
+  });
+
+  function endPaint(e) {
+    painting = false;
+    try { canvas.releasePointerCapture(e.pointerId); } catch (err) {}
+  }
+  canvas.addEventListener("pointerup", endPaint);
+  canvas.addEventListener("pointercancel", endPaint);
+
+  // -------- controls ---------------------------------------------------------
+  function setSeeds(v) {
+    showSeeds = v;
+    els.toggleSeeds.textContent = "Seeds: " + (v ? "on" : "off");
+    els.toggleSeeds.classList.toggle("active", v);
+    schedule();
+  }
+  function setEdges(v) {
+    showEdges = v;
+    els.toggleEdges.textContent = "Cracks: " + (v ? "on" : "off");
+    els.toggleEdges.classList.toggle("active", v);
+    schedule();
+  }
+  function cyclePalette() {
+    paletteIdx = (paletteIdx + 1) % PALETTES.length;
+    els.palette.textContent = "🎨 " + PALETTES[paletteIdx].name;
+    schedule();
+  }
+  function doExplode() {
+    if (sites.length < 2) return;
+    explodeStart = performance.now();
+    schedule();
+  }
+  function undo() {
+    if (!sites.length) return;
+    sites.pop();
+    schedule();
+  }
+  function clearAll() {
+    sites = [];
+    explodeStart = -1;
+    schedule();
+  }
+  function savePng() {
+    // repaint at rest so the export is a clean frame, then download
+    var prev = explodeStart; explodeStart = -1;
+    draw(performance.now());
+    var url = canvas.toDataURL("image/png");
+    explodeStart = prev;
+    var a = document.createElement("a");
+    a.href = url;
+    a.download = "voronoi-shatter.png";
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    schedule();
+  }
+  function hidePanel() {
+    els.panel.classList.add("hidden");
+    els.showBtn.classList.add("visible");
+  }
+  function showPanel() {
+    els.panel.classList.remove("hidden");
+    els.showBtn.classList.remove("visible");
+  }
+
+  els.toggleSeeds.addEventListener("click", function () { setSeeds(!showSeeds); });
+  els.toggleEdges.addEventListener("click", function () { setEdges(!showEdges); });
+  els.palette.addEventListener("click", cyclePalette);
+  els.explode.addEventListener("click", doExplode);
+  els.scatter.addEventListener("click", scatter);
+  els.undo.addEventListener("click", undo);
+  els.reset.addEventListener("click", seedInitial);
+  els.clear.addEventListener("click", clearAll);
+  els.savePng.addEventListener("click", savePng);
+  els.hidePanel.addEventListener("click", hidePanel);
+  els.showBtn.addEventListener("click", showPanel);
+
+  document.addEventListener("keydown", function (e) {
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    switch (e.key.toLowerCase()) {
+      case "s": setSeeds(!showSeeds); break;
+      case "c": setEdges(!showEdges); break;
+      case "e": doExplode(); break;
+      case "p": cyclePalette(); break;
+      case "r": seedInitial(); break;
+      case "z": undo(); break;
+      case "h":
+        if (els.panel.classList.contains("hidden")) showPanel(); else hidePanel();
+        break;
+      case "x": clearAll(); break;
+      default: return;
+    }
+    e.preventDefault();
+  });
+
+  window.addEventListener("resize", function () {
+    resize();
+    schedule();
+  });
+
+  // -------- boot -------------------------------------------------------------
+  resize();
+  seedInitial();
+})();
+</script>
+</body>
+</html>
diff --git a/graphics/voronoi-shatter/index.html b/graphics/voronoi-shatter/index.html
new file mode 100644
index 0000000..fbbfc7e
--- /dev/null
+++ b/graphics/voronoi-shatter/index.html
@@ -0,0 +1,691 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Voronoi Shatter</title>
+<style>
+  html, body {
+    margin: 0;
+    padding: 0;
+    height: 100%;
+    overflow: hidden;
+    background: #0b0e14;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+    touch-action: none;
+  }
+  #stage {
+    display: block;
+    width: 100vw;
+    height: 100vh;
+    cursor: crosshair;
+  }
+  #panel {
+    position: fixed;
+    top: 14px;
+    left: 14px;
+    max-width: min(320px, calc(100vw - 28px));
+    background: rgba(12, 15, 24, 0.78);
+    border: 1px solid rgba(255, 255, 255, 0.14);
+    border-radius: 10px;
+    padding: 12px 14px;
+    color: #e8ecf4;
+    backdrop-filter: blur(6px);
+    -webkit-backdrop-filter: blur(6px);
+    user-select: none;
+    box-shadow: 0 6px 24px rgba(0,0,0,0.45);
+    transition: opacity 0.2s ease, transform 0.2s ease;
+  }
+  #panel.hidden {
+    opacity: 0;
+    transform: translateY(-8px);
+    pointer-events: none;
+  }
+  #panel h1 {
+    margin: 0 0 4px 0;
+    font-size: 14px;
+    font-weight: 700;
+    letter-spacing: 0.4px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+  #panel h1 .dot {
+    width: 9px; height: 9px; border-radius: 50%;
+    background: linear-gradient(135deg,#4f7cff,#c04fff);
+    box-shadow: 0 0 8px rgba(120,140,255,0.7);
+  }
+  #panel p {
+    margin: 0 0 10px 0;
+    font-size: 11.5px;
+    color: #9aa4b8;
+    line-height: 1.45;
+  }
+  .row {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    flex-wrap: wrap;
+    margin-bottom: 8px;
+  }
+  .row:last-child { margin-bottom: 0; }
+  button {
+    font: inherit;
+    font-size: 12px;
+    padding: 6px 12px;
+    border-radius: 7px;
+    border: 1px solid rgba(255,255,255,0.2);
+    background: rgba(255,255,255,0.08);
+    color: #e8ecf4;
+    cursor: pointer;
+    transition: background 0.15s ease, border-color 0.15s ease;
+    white-space: nowrap;
+  }
+  button:hover { background: rgba(255,255,255,0.17); }
+  button:active { transform: translateY(1px); }
+  button.active {
+    background: #4f7cff;
+    border-color: #6c92ff;
+  }
+  button:disabled { opacity: 0.45; cursor: default; }
+  #count {
+    font-size: 11.5px;
+    color: #9aa4b8;
+    min-width: 56px;
+  }
+  .hint {
+    font-size: 10.5px;
+    color: #6b7488;
+    margin-top: 2px;
+  }
+  kbd {
+    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    font-size: 10px;
+    background: rgba(255,255,255,0.1);
+    border: 1px solid rgba(255,255,255,0.15);
+    border-radius: 4px;
+    padding: 0 4px;
+    color: #c7d0e0;
+  }
+  #showBtn {
+    position: fixed;
+    top: 14px;
+    left: 14px;
+    display: none;
+  }
+  #showBtn.visible { display: inline-block; }
+</style>
+</head>
+<body>
+<canvas id="stage"></canvas>
+
+<button id="showBtn">☰ Controls</button>
+
+<div id="panel">
+  <h1><span class="dot"></span>Voronoi Shatter</h1>
+  <p><b>Click</b> to add a point &amp; re-shatter · <b>drag</b> to paint ·
+     <b>right-click</b> (or Shift-click) removes the nearest.</p>
+  <div class="row">
+    <button id="toggleSeeds" class="active">Seeds: on</button>
+    <button id="toggleEdges" class="active">Cracks: on</button>
+  </div>
+  <div class="row">
+    <button id="explode">💥 Shatter</button>
+    <button id="palette">🎨 Spectrum</button>
+    <button id="scatter">Scatter</button>
+  </div>
+  <div class="row">
+    <button id="undo">Undo</button>
+    <button id="reset">Reset</button>
+    <button id="clear">Clear</button>
+    <span id="count"></span>
+  </div>
+  <div class="row">
+    <button id="savePng">Save PNG</button>
+    <button id="hidePanel">Hide ⤢</button>
+  </div>
+  <div class="hint">
+    Keys: <kbd>S</kbd>eeds <kbd>C</kbd>racks <kbd>E</kbd>xplode
+    <kbd>P</kbd>alette <kbd>R</kbd>eset <kbd>Z</kbd>undo <kbd>H</kbd>ide
+  </div>
+</div>
+
+<script>
+(function () {
+  "use strict";
+
+  var canvas = document.getElementById("stage");
+  var ctx = canvas.getContext("2d");
+
+  var els = {
+    toggleSeeds: document.getElementById("toggleSeeds"),
+    toggleEdges: document.getElementById("toggleEdges"),
+    explode: document.getElementById("explode"),
+    palette: document.getElementById("palette"),
+    scatter: document.getElementById("scatter"),
+    undo: document.getElementById("undo"),
+    reset: document.getElementById("reset"),
+    clear: document.getElementById("clear"),
+    savePng: document.getElementById("savePng"),
+    hidePanel: document.getElementById("hidePanel"),
+    panel: document.getElementById("panel"),
+    showBtn: document.getElementById("showBtn"),
+    count: document.getElementById("count")
+  };
+
+  var W = 0, H = 0, DPR = 1;
+  var showSeeds = true;
+  var showEdges = true;
+  var sites = [];            // {x, y, phase, birth, w0, spin}
+  var ANIM_MS = 700;
+  var EXPLODE_MS = 1400;
+  var MAX_SITES = 300;
+  var BG = "#0b0e14";
+
+  // -------- palettes ---------------------------------------------------------
+  // Each maps a site's golden-angle "phase" (0..360) to three HSL gradient stops.
+  var PALETTES = [
+    { name: "Spectrum", base: 0,   range: 360, sat: 68, mono: false },
+    { name: "Sunset",   base: 8,   range: 66,  sat: 78, mono: false },
+    { name: "Ocean",    base: 176, range: 92,  sat: 66, mono: false },
+    { name: "Ember",    base: 356, range: 44,  sat: 82, mono: false },
+    { name: "Forest",   base: 96,  range: 74,  sat: 58, mono: false },
+    { name: "Mono",     base: 258, range: 0,   sat: 46, mono: true  }
+  ];
+  var paletteIdx = 0;
+
+  function stopsFor(site) {
+    var p = PALETTES[paletteIdx];
+    var frac = site.phase / 360;
+    if (p.mono) {
+      // one hue, lightness driven by phase for shard-to-shard variety
+      var L = 34 + frac * 44;               // 34%..78%
+      return [
+        "hsl(" + p.base + "," + p.sat + "%," + (L + 10).toFixed(1) + "%)",
+        "hsl(" + p.base + "," + p.sat + "%," + L.toFixed(1) + "%)",
+        "hsl(" + p.base + "," + p.sat + "%," + (L - 16).toFixed(1) + "%)"
+      ];
+    }
+    var h = (p.base + frac * p.range) % 360;
+    if (h < 0) h += 360;
+    return [
+      "hsl(" + h.toFixed(1) + "," + p.sat + "%,66%)",
+      "hsl(" + h.toFixed(1) + "," + (p.sat - 6) + "%,48%)",
+      "hsl(" + h.toFixed(1) + "," + (p.sat - 8) + "%,26%)"
+    ];
+  }
+
+  // -------- canvas sizing ----------------------------------------------------
+  function resize() {
+    DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
+    var prevW = W, prevH = H;
+    W = window.innerWidth;
+    H = window.innerHeight;
+    canvas.width = Math.round(W * DPR);
+    canvas.height = Math.round(H * DPR);
+    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
+    // keep the composition proportional so cells never drift off-canvas
+    if (prevW && prevH && (prevW !== W || prevH !== H) && sites.length) {
+      var sx = W / prevW, sy = H / prevH;
+      for (var i = 0; i < sites.length; i++) {
+        sites[i].x *= sx;
+        sites[i].y *= sy;
+      }
+    }
+  }
+
+  // -------- easing -----------------------------------------------------------
+  function easeOutCubic(t) {
+    t = t < 0 ? 0 : (t > 1 ? 1 : t);
+    var u = 1 - t;
+    return 1 - u * u * u;
+  }
+
+  // Weight for a site's power cell: animates from -w0 (cell = a point) up to 0
+  // (plain Voronoi) so the new cell grows while neighbors yield ground.
+  function weightOf(s, now) {
+    var t = (now - s.birth) / ANIM_MS;
+    if (t >= 1) return 0;
+    if (t <= 0) return -s.w0;
+    return (easeOutCubic(t) - 1) * s.w0;
+  }
+
+  function anyAnimating(now) {
+    for (var i = 0; i < sites.length; i++) {
+      if (now - sites[i].birth < ANIM_MS) return true;
+    }
+    return false;
+  }
+
+  // -------- sites ------------------------------------------------------------
+  function nearestDist2(x, y) {
+    var best = Infinity;
+    for (var i = 0; i < sites.length; i++) {
+      var dx = sites[i].x - x, dy = sites[i].y - y;
+      var d2 = dx * dx + dy * dy;
+      if (d2 < best) best = d2;
+    }
+    return best;
+  }
+
+  function addSite(x, y, birth, w0) {
+    if (sites.length >= MAX_SITES) return null;
+    var d2 = sites.length ? nearestDist2(x, y) : (W * W + H * H) / 4;
+    d2 = Math.max(d2, 400); // never start fully degenerate
+    var s = {
+      x: x, y: y,
+      phase: (sites.length * 137.508) % 360,
+      birth: birth,
+      w0: (w0 != null ? w0 : d2),
+      spin: (((sites.length * 2654435761) % 1000) / 1000 - 0.5) // deterministic [-0.5,0.5]
+    };
+    sites.push(s);
+    return s;
+  }
+
+  function nearestIndex(x, y) {
+    var best = Infinity, idx = -1;
+    for (var i = 0; i < sites.length; i++) {
+      var dx = sites[i].x - x, dy = sites[i].y - y;
+      var d2 = dx * dx + dy * dy;
+      if (d2 < best) { best = d2; idx = i; }
+    }
+    return idx;
+  }
+
+  function removeNear(x, y) {
+    if (!sites.length) return;
+    var idx = nearestIndex(x, y);
+    if (idx < 0) return;
+    var removed = sites[idx];
+    sites.splice(idx, 1);
+    // let the site that inherits the void grow smoothly into it
+    if (sites.length) {
+      var j = nearestIndex(removed.x, removed.y);
+      var s = sites[j];
+      var dx = s.x - removed.x, dy = s.y - removed.y;
+      s.birth = performance.now();
+      s.w0 = Math.max(dx * dx + dy * dy, 900);
+    }
+  }
+
+  // -------- geometry: Sutherland–Hodgman half-plane clip ---------------------
+  // Clip polygon against  dot(p, (dx,dy)) <= k
+  function clipHalfPlane(poly, dx, dy, k) {
+    var out = [];
+    var n = poly.length;
+    if (n === 0) return out;
+    var px = poly[n - 1][0], py = poly[n - 1][1];
+    var pIn = (px * dx + py * dy) <= k;
+    for (var i = 0; i < n; i++) {
+      var cx = poly[i][0], cy = poly[i][1];
+      var cIn = (cx * dx + cy * dy) <= k;
+      if (cIn !== pIn) {
+        var denom = dx * (cx - px) + dy * (cy - py);
+        if (denom !== 0) {
+          var t = (k - (dx * px + dy * py)) / denom;
+          out.push([px + t * (cx - px), py + t * (cy - py)]);
+        }
+      }
+      if (cIn) out.push([cx, cy]);
+      px = cx; py = cy; pIn = cIn;
+    }
+    return out;
+  }
+
+  // Power-diagram cell for site i.
+  // Bisector of |x-pi|^2 - wi <= |x-pj|^2 - wj  →  half-plane
+  //   dot(x, 2(pj-pi)) <= |pj|^2 - |pi|^2 + wi - wj
+  //
+  // `order` holds the other site indices sorted by squared distance to i, so we
+  // can stop early: once no remaining site is close enough for its bisector to
+  // reach the current cell, later sites cannot clip it. Exact at rest (w=0),
+  // where the final displayed diagram lives.
+  function cellFor(i, weights, order) {
+    var s = sites[i];
+    var poly = [[0, 0], [W, 0], [W, H], [0, H]];
+    var mi = s.x * s.x + s.y * s.y;
+    var wi = weights[i];
+    var rMax2 = W * W + H * H; // farthest possible extent to start
+
+    for (var oi = 0; oi < order.length && poly.length; oi++) {
+      var pair = order[oi];
+      // early-out: nearest remaining site is farther than twice the cell reach
+      if (pair.d2 > 4 * rMax2) break;
+
+      var j = pair.j;
+      var o = sites[j];
+      var dx = 2 * (o.x - s.x);
+      var dy = 2 * (o.y - s.y);
+      var k = (o.x * o.x + o.y * o.y) - mi + wi - weights[j];
+      poly = clipHalfPlane(poly, dx, dy, k);
+
+      // recompute cell reach (max squared vertex distance from the seed)
+      rMax2 = 0;
+      for (var v = 0; v < poly.length; v++) {
+        var vx = poly[v][0] - s.x, vy = poly[v][1] - s.y;
+        var d2 = vx * vx + vy * vy;
+        if (d2 > rMax2) rMax2 = d2;
+      }
+    }
+    return poly;
+  }
+
+  function neighborOrder(i) {
+    var s = sites[i];
+    var arr = [];
+    for (var j = 0; j < sites.length; j++) {
+      if (j === i) continue;
+      var dx = sites[j].x - s.x, dy = sites[j].y - s.y;
+      arr.push({ j: j, d2: dx * dx + dy * dy });
+    }
+    arr.sort(function (a, b) { return a.d2 - b.d2; });
+    return arr;
+  }
+
+  // -------- explode ----------------------------------------------------------
+  var explodeStart = -1;
+  function explodePhase(now) {
+    if (explodeStart < 0) return 0;
+    var t = (now - explodeStart) / EXPLODE_MS;
+    if (t >= 1) { explodeStart = -1; return 0; }
+    return Math.sin(t * Math.PI); // 0 → 1 → 0 (fly apart, reassemble)
+  }
+
+  function centroid(poly) {
+    var a = 0, cx = 0, cy = 0, n = poly.length;
+    for (var i = 0; i < n; i++) {
+      var j = (i + 1) % n;
+      var x0 = poly[i][0], y0 = poly[i][1];
+      var x1 = poly[j][0], y1 = poly[j][1];
+      var cross = x0 * y1 - x1 * y0;
+      a += cross;
+      cx += (x0 + x1) * cross;
+      cy += (y0 + y1) * cross;
+    }
+    if (Math.abs(a) < 1e-6) {
+      // degenerate: fall back to vertex average
+      cx = 0; cy = 0;
+      for (var v = 0; v < n; v++) { cx += poly[v][0]; cy += poly[v][1]; }
+      return [cx / n, cy / n];
+    }
+    a *= 0.5;
+    return [cx / (6 * a), cy / (6 * a)];
+  }
+
+  // Displace a shard's polygon outward from screen center + spin it.
+  function shatterPoly(poly, site, phase) {
+    var c = centroid(poly);
+    var cxC = W / 2, cyC = H / 2;
+    var dx = c[0] - cxC, dy = c[1] - cyC;
+    var dist = Math.sqrt(dx * dx + dy * dy) || 1;
+    var nx = dx / dist, ny = dy / dist;
+    var gap = phase * (46 + dist * 0.22);
+    var rot = phase * site.spin * 1.4;
+    var shrink = 1 - phase * 0.14;
+    var cos = Math.cos(rot), sin = Math.sin(rot);
+    var out = [];
+    for (var i = 0; i < poly.length; i++) {
+      var vx = (poly[i][0] - c[0]) * shrink;
+      var vy = (poly[i][1] - c[1]) * shrink;
+      var rx = vx * cos - vy * sin;
+      var ry = vx * sin + vy * cos;
+      out.push([c[0] + rx + nx * gap, c[1] + ry + ny * gap]);
+    }
+    return out;
+  }
+
+  // -------- draw -------------------------------------------------------------
+  function draw(now) {
+    var weights = new Array(sites.length);
+    for (var i = 0; i < sites.length; i++) weights[i] = weightOf(sites[i], now);
+
+    var phase = explodePhase(now);
+    var exploding = phase > 0.0001;
+
+    ctx.fillStyle = BG;
+    ctx.fillRect(0, 0, W, H);
+    ctx.lineJoin = "round";
+
+    for (var s = 0; s < sites.length; s++) {
+      var order = neighborOrder(s);
+      var poly = cellFor(s, weights, order);
+      if (poly.length < 3) continue;
+      var site = sites[s];
+
+      if (exploding) poly = shatterPoly(poly, site, phase);
+
+      // gradient radius = farthest vertex from the seed's projected position
+      var gcx = site.x, gcy = site.y;
+      if (exploding) { var cc = centroid(poly); gcx = cc[0]; gcy = cc[1]; }
+
+      var r2max = 0;
+      for (var v = 0; v < poly.length; v++) {
+        var vx = poly[v][0] - gcx, vy = poly[v][1] - gcy;
+        var d2 = vx * vx + vy * vy;
+        if (d2 > r2max) r2max = d2;
+      }
+      var r = Math.sqrt(r2max) || 1;
+
+      var stops = stopsFor(site);
+      var g = ctx.createRadialGradient(gcx, gcy, 0, gcx, gcy, r);
+      g.addColorStop(0, stops[0]);
+      g.addColorStop(0.55, stops[1]);
+      g.addColorStop(1, stops[2]);
+
+      ctx.beginPath();
+      ctx.moveTo(poly[0][0], poly[0][1]);
+      for (var p = 1; p < poly.length; p++) ctx.lineTo(poly[p][0], poly[p][1]);
+      ctx.closePath();
+      ctx.fillStyle = g;
+      ctx.fill();
+
+      if (showEdges) {
+        ctx.strokeStyle = "rgba(8, 10, 16, 0.9)";
+        ctx.lineWidth = 1.6;
+        ctx.stroke();
+        ctx.strokeStyle = "rgba(255, 255, 255, 0.10)";
+        ctx.lineWidth = 0.6;
+        ctx.stroke();
+      }
+    }
+
+    if (showSeeds && !exploding) {
+      for (var q = 0; q < sites.length; q++) {
+        var st = sites[q];
+        var age = Math.min(1, (now - st.birth) / ANIM_MS);
+        var pr = 3 + 2 * (1 - easeOutCubic(age));
+        ctx.beginPath();
+        ctx.arc(st.x, st.y, pr + 1.6, 0, Math.PI * 2);
+        ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
+        ctx.fill();
+        ctx.beginPath();
+        ctx.arc(st.x, st.y, pr, 0, Math.PI * 2);
+        ctx.fillStyle = "#ffffff";
+        ctx.fill();
+      }
+    }
+
+    els.count.textContent = sites.length + " cell" + (sites.length === 1 ? "" : "s");
+    return anyAnimating(now) || phase > 0.0001;
+  }
+
+  // -------- animation loop ---------------------------------------------------
+  var rafId = null;
+  function frame() {
+    rafId = null;
+    var animating = draw(performance.now());
+    if (animating) schedule();
+  }
+  function schedule() {
+    if (rafId === null) rafId = requestAnimationFrame(frame);
+  }
+
+  // -------- scenes -----------------------------------------------------------
+  function seedInitial() {
+    sites = [];
+    explodeStart = -1;
+    var now = performance.now();
+    var n = 22;
+    var margin = 40;
+    for (var i = 0; i < n; i++) {
+      var x = margin + Math.random() * (W - margin * 2);
+      var y = margin + Math.random() * (H - margin * 2);
+      addSite(x, y, now + i * 28); // staggered births → rippling intro shatter
+    }
+    schedule();
+  }
+
+  function scatter() {
+    var now = performance.now();
+    var toAdd = Math.min(18, MAX_SITES - sites.length);
+    var margin = 40;
+    for (var i = 0; i < toAdd; i++) {
+      var x = margin + Math.random() * (W - margin * 2);
+      var y = margin + Math.random() * (H - margin * 2);
+      addSite(x, y, now + i * 26);
+    }
+    schedule();
+  }
+
+  // -------- pointer input ----------------------------------------------------
+  var painting = false;
+  var lastPaint = null;
+
+  function localPoint(e) {
+    var rect = canvas.getBoundingClientRect();
+    return [e.clientX - rect.left, e.clientY - rect.top];
+  }
+
+  canvas.addEventListener("contextmenu", function (e) { e.preventDefault(); });
+
+  canvas.addEventListener("pointerdown", function (e) {
+    var p = localPoint(e);
+    if (e.button === 2 || e.shiftKey) {
+      removeNear(p[0], p[1]);
+      schedule();
+      return;
+    }
+    addSite(p[0], p[1], performance.now());
+    painting = true;
+    lastPaint = p;
+    try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
+    schedule();
+  });
+
+  canvas.addEventListener("pointermove", function (e) {
+    if (!painting) return;
+    var p = localPoint(e);
+    var dx = p[0] - lastPaint[0], dy = p[1] - lastPaint[1];
+    if (dx * dx + dy * dy >= 28 * 28) {       // paint spacing
+      addSite(p[0], p[1], performance.now());
+      lastPaint = p;
+      schedule();
+    }
+  });
+
+  function endPaint(e) {
+    painting = false;
+    try { canvas.releasePointerCapture(e.pointerId); } catch (err) {}
+  }
+  canvas.addEventListener("pointerup", endPaint);
+  canvas.addEventListener("pointercancel", endPaint);
+
+  // -------- controls ---------------------------------------------------------
+  function setSeeds(v) {
+    showSeeds = v;
+    els.toggleSeeds.textContent = "Seeds: " + (v ? "on" : "off");
+    els.toggleSeeds.classList.toggle("active", v);
+    schedule();
+  }
+  function setEdges(v) {
+    showEdges = v;
+    els.toggleEdges.textContent = "Cracks: " + (v ? "on" : "off");
+    els.toggleEdges.classList.toggle("active", v);
+    schedule();
+  }
+  function cyclePalette() {
+    paletteIdx = (paletteIdx + 1) % PALETTES.length;
+    els.palette.textContent = "🎨 " + PALETTES[paletteIdx].name;
+    schedule();
+  }
+  function doExplode() {
+    if (sites.length < 2) return;
+    explodeStart = performance.now();
+    schedule();
+  }
+  function undo() {
+    if (!sites.length) return;
+    sites.pop();
+    schedule();
+  }
+  function clearAll() {
+    sites = [];
+    explodeStart = -1;
+    schedule();
+  }
+  function savePng() {
+    // repaint at rest so the export is a clean frame, then download
+    var prev = explodeStart; explodeStart = -1;
+    draw(performance.now());
+    var url = canvas.toDataURL("image/png");
+    explodeStart = prev;
+    var a = document.createElement("a");
+    a.href = url;
+    a.download = "voronoi-shatter.png";
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    schedule();
+  }
+  function hidePanel() {
+    els.panel.classList.add("hidden");
+    els.showBtn.classList.add("visible");
+  }
+  function showPanel() {
+    els.panel.classList.remove("hidden");
+    els.showBtn.classList.remove("visible");
+  }
+
+  els.toggleSeeds.addEventListener("click", function () { setSeeds(!showSeeds); });
+  els.toggleEdges.addEventListener("click", function () { setEdges(!showEdges); });
+  els.palette.addEventListener("click", cyclePalette);
+  els.explode.addEventListener("click", doExplode);
+  els.scatter.addEventListener("click", scatter);
+  els.undo.addEventListener("click", undo);
+  els.reset.addEventListener("click", seedInitial);
+  els.clear.addEventListener("click", clearAll);
+  els.savePng.addEventListener("click", savePng);
+  els.hidePanel.addEventListener("click", hidePanel);
+  els.showBtn.addEventListener("click", showPanel);
+
+  document.addEventListener("keydown", function (e) {
+    if (e.metaKey || e.ctrlKey || e.altKey) return;
+    switch (e.key.toLowerCase()) {
+      case "s": setSeeds(!showSeeds); break;
+      case "c": setEdges(!showEdges); break;
+      case "e": doExplode(); break;
+      case "p": cyclePalette(); break;
+      case "r": seedInitial(); break;
+      case "z": undo(); break;
+      case "h":
+        if (els.panel.classList.contains("hidden")) showPanel(); else hidePanel();
+        break;
+      case "x": clearAll(); break;
+      default: return;
+    }
+    e.preventDefault();
+  });
+
+  window.addEventListener("resize", function () {
+    resize();
+    schedule();
+  });
+
+  // -------- boot -------------------------------------------------------------
+  resize();
+  seedInitial();
+})();
+</script>
+</body>
+</html>
diff --git a/tests/e2e.mjs b/tests/e2e.mjs
index d6db715..38035de 100644
--- a/tests/e2e.mjs
+++ b/tests/e2e.mjs
@@ -105,7 +105,7 @@ 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 tree = page.frames().find(f => f.url().includes('graphics/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);
 

← 3a038a7 auto-save: 2026-07-24T23:30:18 (4 files) — graphics.json tes  ·  back to Graphics Agentabrams  ·  Add Living Fractal Tree to the graphics hub 4f4720e →