← 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
M README.mdM graphics.jsonA graphics/voronoi-shatter/VoronoiShatter.aaA graphics/voronoi-shatter/index.htmlM tests/e2e.mjs
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 & 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 & 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 →