← back to Graphics Agentabrams
graphics/voronoi-shatter/index.html
692 lines
<!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>