← 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 →