[object Object]

← back to Particle Text

auto-save: 2026-07-25T09:05:11 (1 files) — index.html

7a7539534b348c2cebbf91da2aff4f9b253716ee · 2026-07-25 09:05:14 -0700 · Steve Abrams

Files touched

Diff

commit 7a7539534b348c2cebbf91da2aff4f9b253716ee
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 09:05:14 2026 -0700

    auto-save: 2026-07-25T09:05:11 (1 files) — index.html
---
 index.html | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 204 insertions(+)

diff --git a/index.html b/index.html
index 0c4f92a..e028b39 100644
--- a/index.html
+++ b/index.html
@@ -82,6 +82,16 @@
     z-index: 10; pointer-events: none;
     letter-spacing: .4px;
   }
+  .btn:disabled { opacity: .5; cursor: default; filter: none; }
+  .toast {
+    position: fixed; left: 50%; bottom: 92px; transform: translateX(-50%);
+    padding: 9px 15px; border-radius: 11px;
+    background: rgba(18,22,34,0.82); border: 1px solid rgba(255,255,255,0.14);
+    -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
+    color: #e8ecf4; font-size: 13px; letter-spacing: .2px;
+    z-index: 20; opacity: 0; pointer-events: none;
+    transition: opacity .25s; white-space: nowrap;
+  }
 </style>
 </head>
 <body>
@@ -93,7 +103,10 @@
     <button class="btn" id="go">Form</button>
     <button class="btn ghost" id="theme" title="Cycle color theme">Theme</button>
     <button class="btn ghost" id="rand" title="Random word">Shuffle</button>
+    <button class="btn ghost" id="gif" title="Save this motion graphic as an animated GIF">GIF</button>
+    <button class="btn ghost" id="code" title="Copy the single-file source code">Code</button>
   </div>
+  <div class="toast" id="toast" aria-live="polite"></div>
 
 <script>
 (function () {
@@ -380,6 +393,197 @@
     build(w);
   });
 
+  // ================= Save GIF + Copy code (dependency-free) =================
+  var gifBtn = document.getElementById("gif");
+  var codeBtn = document.getElementById("code");
+  var toastEl = document.getElementById("toast");
+  var recording = false;
+
+  function toast(msg, ms) {
+    toastEl.textContent = msg; toastEl.style.opacity = "1";
+    clearTimeout(toast._t); toast._t = setTimeout(function () { toastEl.style.opacity = "0"; }, ms || 1600);
+  }
+
+  // Re-scatter so the captured clip shows a full murmuration-into-text formation.
+  function scatterAll() {
+    for (var i = 0; i < particles.length; i++) {
+      var p = particles[i];
+      p.x = Math.random() * W; p.y = Math.random() * H;
+      p.vx = (Math.random() - 0.5) * 4; p.vy = (Math.random() - 0.5) * 4;
+    }
+  }
+
+  // --- median-cut quantizer -> 256-color palette (cols: packed 0xRRGGBB ints) ---
+  function buildPalette(cols) {
+    var boxes = [{ lo: 0, hi: cols.length }];
+    function ranges(box) {
+      var rmn = 255, rmx = 0, gmn = 255, gmx = 0, bmn = 255, bmx = 0;
+      for (var i = box.lo; i < box.hi; i++) {
+        var c = cols[i], r = (c >> 16) & 255, g = (c >> 8) & 255, b = c & 255;
+        if (r < rmn) rmn = r; if (r > rmx) rmx = r;
+        if (g < gmn) gmn = g; if (g > gmx) gmx = g;
+        if (b < bmn) bmn = b; if (b > bmx) bmx = b;
+      }
+      return { r: rmx - rmn, g: gmx - gmn, b: bmx - bmn };
+    }
+    while (boxes.length < 256) {
+      var bi = -1, brange = -1, bch = 16;
+      for (var i = 0; i < boxes.length; i++) {
+        var bx = boxes[i]; if (bx.hi - bx.lo < 2) continue;
+        var rg = ranges(bx), m = Math.max(rg.r, rg.g, rg.b);
+        if (m > brange) { brange = m; bi = i; bch = (rg.r >= rg.g && rg.r >= rg.b) ? 16 : (rg.g >= rg.b ? 8 : 0); }
+      }
+      if (bi < 0) break;
+      var box = boxes[bi];
+      var sub = cols.slice(box.lo, box.hi);
+      var sh = bch;
+      sub.sort(function (a, b) { return ((a >> sh) & 255) - ((b >> sh) & 255); });
+      for (var k = 0; k < sub.length; k++) cols[box.lo + k] = sub[k];
+      var mid = (box.lo + box.hi) >> 1;
+      boxes.splice(bi, 1, { lo: box.lo, hi: mid }, { lo: mid, hi: box.hi });
+    }
+    var pal = [];
+    for (var i2 = 0; i2 < boxes.length; i2++) {
+      var b2 = boxes[i2], rs = 0, gs = 0, bs = 0, n = b2.hi - b2.lo || 1;
+      for (var j = b2.lo; j < b2.hi; j++) { var c2 = cols[j]; rs += (c2 >> 16) & 255; gs += (c2 >> 8) & 255; bs += c2 & 255; }
+      pal.push([Math.round(rs / n), Math.round(gs / n), Math.round(bs / n)]);
+    }
+    while (pal.length < 256) pal.push([0, 0, 0]);
+    return pal;
+  }
+
+  function nearest(pal, r, g, b, cache) {
+    var key = ((r >> 2) << 12) | ((g >> 2) << 6) | (b >> 2);
+    var v = cache[key]; if (v >= 0) return v;
+    var best = 0, bd = 1e9;
+    for (var i = 0; i < 256; i++) {
+      var p = pal[i], dr = r - p[0], dg = g - p[1], db = b - p[2], d = dr * dr + dg * dg + db * db;
+      if (d < bd) { bd = d; best = i; if (d === 0) break; }
+    }
+    cache[key] = best; return best;
+  }
+
+  // --- standard GIF LZW encoder (variable code size, clear/EOI, dict reset at 4096) ---
+  function lzwEncode(minCode, idx) {
+    var out = [], cur = 0, bits = 0;
+    function put(code, size) { cur |= code << bits; bits += size; while (bits >= 8) { out.push(cur & 255); cur = cur >>> 8; bits -= 8; } }
+    var clear = 1 << minCode, eoi = clear + 1;
+    var size, next, dict;
+    function reset() { size = minCode + 1; next = eoi + 1; dict = new Map(); }
+    reset();
+    put(clear, size);
+    if (idx.length) {
+      var prev = idx[0];
+      for (var i = 1; i < idx.length; i++) {
+        var c = idx[i], key = (prev << 8) | c;
+        if (dict.has(key)) { prev = dict.get(key); }
+        else {
+          put(prev, size);
+          dict.set(key, next++);
+          if (next === (1 << size) && size < 12) size++;
+          if (next > 4095) { put(clear, size); reset(); }
+          prev = c;
+        }
+      }
+      put(prev, size);
+    }
+    put(eoi, size);
+    if (bits > 0) out.push(cur & 255);
+    return out;
+  }
+
+  // --- GIF89a writer (256-color global table, infinite loop) ---
+  function GifWriter(w, h, pal) {
+    var out = [];
+    function byte(v) { out.push(v & 255); }
+    function word(v) { out.push(v & 255, (v >> 8) & 255); }
+    function str(s) { for (var i = 0; i < s.length; i++) byte(s.charCodeAt(i)); }
+    str("GIF89a"); word(w); word(h); byte(0xF7); byte(0); byte(0);
+    for (var i = 0; i < 256; i++) { var c = pal[i] || [0, 0, 0]; byte(c[0]); byte(c[1]); byte(c[2]); }
+    byte(0x21); byte(0xFF); byte(0x0B); str("NETSCAPE2.0"); byte(0x03); byte(0x01); word(0); byte(0); // loop forever
+    return {
+      addFrame: function (idx, delayCs) {
+        byte(0x21); byte(0xF9); byte(0x04); byte(0x00); word(delayCs); byte(0); byte(0); // graphic control
+        byte(0x2C); word(0); word(0); word(w); word(h); byte(0); // image descriptor
+        var MIN = 8; byte(MIN); // LZW min code size (8-bit palette)
+        var buf = lzwEncode(MIN, idx);
+        for (var p = 0; p < buf.length;) { var n = Math.min(255, buf.length - p); byte(n); for (var q = 0; q < n; q++) byte(buf[p + q]); p += n; }
+        byte(0); // block terminator
+      },
+      finish: function () { byte(0x3B); return new Uint8Array(out); }
+    };
+  }
+
+  function recordGif() {
+    if (recording) return;
+    recording = true; gifBtn.disabled = true; codeBtn.disabled = true;
+    var scale = Math.min(1, 480 / W);
+    var gw = Math.max(2, Math.round(W * scale)), gh = Math.max(2, Math.round(H * scale));
+    gw -= gw % 2; gh -= gh % 2;
+    var off = document.createElement("canvas"); off.width = gw; off.height = gh;
+    var octx = off.getContext("2d");
+    var frames = [], TOTAL = 40, DELAY = 6; // ~16fps, ~2.4s
+    scatterAll();
+    var f = 0;
+    var iv = setInterval(function () {
+      octx.drawImage(canvas, 0, 0, gw, gh);
+      frames.push(octx.getImageData(0, 0, gw, gh).data.slice());
+      if (++f >= TOTAL) { clearInterval(iv); encode(); }
+      else toast("Recording GIF… " + Math.round(f / TOTAL * 100) + "%", 900);
+    }, DELAY * 10);
+
+    function encode() {
+      toast("Encoding GIF…", 6000);
+      setTimeout(function () {
+        var cols = [];
+        for (var fi = 0; fi < frames.length; fi += 3) {
+          var d = frames[fi];
+          for (var i = 0; i < d.length; i += 28) cols.push((d[i] << 16) | (d[i + 1] << 8) | d[i + 2]);
+        }
+        var pal = buildPalette(cols);
+        var cache = new Int16Array(262144); for (var i = 0; i < cache.length; i++) cache[i] = -1;
+        var gif = GifWriter(gw, gh, pal);
+        for (var fi2 = 0; fi2 < frames.length; fi2++) {
+          var d2 = frames[fi2], idx = new Uint8Array(d2.length / 4);
+          for (var i2 = 0, j = 0; i2 < d2.length; i2 += 4, j++) idx[j] = nearest(pal, d2[i2], d2[i2 + 1], d2[i2 + 2], cache);
+          gif.addFrame(idx, DELAY);
+        }
+        var bytes = gif.finish();
+        var blob = new Blob([bytes], { type: "image/gif" });
+        var url = URL.createObjectURL(blob);
+        var a = document.createElement("a");
+        a.href = url;
+        a.download = "starling-" + ((input.value || "particles").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30) || "particles") + ".gif";
+        document.body.appendChild(a); a.click(); a.remove();
+        setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
+        recording = false; gifBtn.disabled = false; codeBtn.disabled = false;
+        toast("GIF saved · " + ((bytes.length / 1024) | 0) + " KB", 2400);
+      }, 60);
+    }
+  }
+
+  function copyCode() {
+    function done() { toast("Source code copied to clipboard"); }
+    function fallback(src) {
+      try {
+        var ta = document.createElement("textarea");
+        ta.value = src; ta.style.position = "fixed"; ta.style.opacity = "0";
+        document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); done();
+      } catch (e) { toast("Copy failed — see console"); console.log(src); }
+    }
+    function put(src) {
+      if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(src).then(done, function () { fallback(src); });
+      else fallback(src);
+    }
+    // Prefer the on-disk source; fall back to the live DOM (works on file://).
+    fetch(location.href).then(function (r) { return r.text(); })
+      .then(put)
+      .catch(function () { put("<!DOCTYPE html>\n" + document.documentElement.outerHTML); });
+  }
+
+  gifBtn.addEventListener("click", recordGif);
+  codeBtn.addEventListener("click", copyCode);
+
   // fade the hint after a bit
   setTimeout(function () { hintEl.style.opacity = "0.35"; }, 6000);
 

← 4e03b87 test: verify post-commit auto-sync hook  ·  back to Particle Text  ·  Starling: save motion graphic as animated GIF (dependency-fr 76daa36 →