← back to Voronoi Shatter
Voronoi Shatter: interactive weighted-Voronoi shatter effect
7bc7a7a27209c381d6fd69e5cf4390d566a01d91 · 2026-07-24 23:28:52 -0700 · Steve
Built from the Model Arena artifact into a real single-file app. Kept the
power-diagram growth animation; added explode/reassemble, point removal,
drag-paint, 6 palettes, crack toggle, undo/clear/scatter, PNG export,
keyboard shortcuts, touch support, and a nearest-first clipping early-out.
Headless Playwright verified: renders, interactions, zero runtime errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A .gitignoreA README.mdA index.html
Diff
commit 7bc7a7a27209c381d6fd69e5cf4390d566a01d91
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 24 23:28:52 2026 -0700
Voronoi Shatter: interactive weighted-Voronoi shatter effect
Built from the Model Arena artifact into a real single-file app. Kept the
power-diagram growth animation; added explode/reassemble, point removal,
drag-paint, 6 palettes, crack toggle, undo/clear/scatter, PNG export,
keyboard shortcuts, touch support, and a nearest-first clipping early-out.
Headless Playwright verified: renders, interactions, zero runtime errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 8 +
README.md | 63 ++++++
index.html | 682 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 753 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..edf17e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,63 @@
+# Voronoi Shatter
+
+An interactive Voronoi "shatter" effect in a single self-contained HTML file —
+no external libraries, no build step. Open `index.html` in any modern browser.
+
+Points scattered on a canvas form Voronoi cells; each cell is a shard with a
+radial gradient fill and dark crack edges. Clicking adds a point and the diagram
+re-tessellates with a smooth growth animation.
+
+## How it works
+
+The re-tessellation animation is driven by a **power (weighted) Voronoi
+diagram**. When a new point is added its cell starts with a large negative
+weight (a single point) and eases up to weight 0 (a plain Voronoi cell), so the
+new shard grows outward while its neighbours yield ground — a real geometric
+tween, not a crossfade.
+
+Each cell is computed by clipping the screen rectangle against the half-plane
+bisector to every other site (Sutherland–Hodgman). Sites are visited
+nearest-first with an early-out once no remaining site can reach the cell, which
+keeps it fast as the point count climbs. The result is exact at rest, where the
+final displayed diagram lives.
+
+## Controls
+
+| Action | Mouse / touch | Key |
+|---|---|---|
+| Add a point (re-shatter) | Click / tap | — |
+| Paint many points | Click-drag | — |
+| Remove nearest point | Right-click or Shift-click | — |
+| Toggle seed points | `Seeds` button | `S` |
+| Toggle crack edges | `Cracks` button | `C` |
+| Shatter (explode & reassemble) | `Shatter` button | `E` |
+| Cycle color palette | `Palette` button | `P` |
+| Scatter random points | `Scatter` button | — |
+| Undo last point | `Undo` button | `Z` |
+| Reset scene | `Reset` button | `R` |
+| Clear all | `Clear` button | `X` |
+| Save PNG | `Save PNG` button | — |
+| Hide / show panel | `Hide` button | `H` |
+
+Palettes: Spectrum, Sunset, Ocean, Ember, Forest, Mono.
+
+## Improvements over the original artifact
+
+- **Shatter/explode** — shards fly outward from center, spin, and reassemble
+ (`Sin`-eased 0→1→0), revealing the dark ground between them.
+- **Remove points** — right-click/Shift-click deletes the nearest site; the
+ neighbour that inherits the void grows into it with the same smooth animation.
+- **Drag to paint** multiple points; **undo**, **clear**, **scatter**.
+- **Six color palettes**, including a monochrome lightness ramp.
+- **Crack-edge toggle**, collapsible control panel, keyboard shortcuts.
+- **Save PNG** export (always renders a clean at-rest frame).
+- **Performance**: nearest-first cell clipping with a geometric early-out
+ (exact at rest), and idle frames skip the render loop entirely.
+- **Touch support** via pointer events + `touch-action: none`; responsive panel.
+
+## Verification
+
+Rendered and interaction-tested headlessly with Playwright (Chromium): initial
+tessellation fills the canvas, add/remove update the cell count correctly, the
+explode animation separates then reassembles the shards, palette cycling and
+seed toggle work, and there are **zero console/page errors**.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f12343e
--- /dev/null
+++ b/index.html
@@ -0,0 +1,682 @@
+<!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));
+ 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);
+ }
+
+ // -------- 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>
(oldest)
·
back to Voronoi Shatter
·
Voronoi Shatter: single-file interactive power-diagram shatt 0225bb9 →