← back to Graphics Agentabrams

graphics/voronoi-shatter/VoronoiShatter.aa

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 &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>