← back to Particle Text

index.html

628 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>Particle Text</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2305060a'/%3E%3Ccircle cx='16' cy='16' r='6.5' fill='%238fd3ff'/%3E%3Ccircle cx='16' cy='16' r='11' fill='none' stroke='%23b7a6ff' stroke-width='1.2' opacity='.6'/%3E%3C/svg%3E">
<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2305060a'/%3E%3Ccircle cx='16' cy='16' r='7' fill='%238fd3ff'/%3E%3C/svg%3E">
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { height: 100%; overflow: hidden; background: #05060a; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    color: #e8ecf4;
    -webkit-user-select: none; user-select: none;
    touch-action: none;
  }
  #stage { position: fixed; inset: 0; }
  canvas { display: block; width: 100%; height: 100%; }

  .ui {
    position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%);
    display: flex; gap: 8px; align-items: center;
    padding: 9px 10px;
    background: rgba(18, 22, 34, 0.55);
    border: 1px solid rgba(255,255,255,0.10);
    border-radius: 16px;
    backdrop-filter: blur(14px);
    -webkit-backdrop-filter: blur(14px);
    box-shadow: 0 12px 40px rgba(0,0,0,0.45);
    z-index: 10;
    max-width: calc(100vw - 20px);
    flex-wrap: wrap; justify-content: center;
  }
  .ui input[type="text"] {
    width: min(46vw, 300px);
    padding: 11px 14px;
    font-size: 15px;
    color: #fff;
    background: rgba(255,255,255,0.06);
    border: 1px solid rgba(255,255,255,0.12);
    border-radius: 11px;
    outline: none;
    transition: border-color .2s, background .2s;
  }
  .ui input[type="text"]:focus {
    border-color: rgba(120,180,255,0.6);
    background: rgba(255,255,255,0.10);
  }
  .ui input::placeholder { color: rgba(255,255,255,0.35); }

  .btn {
    padding: 11px 16px;
    font-size: 14px; font-weight: 600;
    color: #071018;
    background: linear-gradient(135deg, #8fd3ff, #b7a6ff);
    border: none; border-radius: 11px;
    cursor: pointer;
    transition: transform .12s, filter .2s;
    white-space: nowrap;
  }
  .btn:hover { filter: brightness(1.08); }
  .btn:active { transform: scale(0.95); }
  .btn.ghost {
    color: #cdd6e6;
    background: rgba(255,255,255,0.07);
    border: 1px solid rgba(255,255,255,0.12);
    font-weight: 500;
  }
  .btn.ghost:hover { background: rgba(255,255,255,0.13); }

  .hint {
    position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
    font-size: 12.5px; letter-spacing: .3px;
    color: rgba(255,255,255,0.42);
    z-index: 10; text-align: center; pointer-events: none;
    width: 100%; padding: 0 12px;
    transition: opacity .6s;
  }
  .badge {
    position: fixed; top: 18px; right: 16px;
    font-size: 11px; font-variant-numeric: tabular-nums;
    color: rgba(255,255,255,0.30);
    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>
  <div id="stage"><canvas id="c"></canvas></div>
  <div class="hint" id="hint">Type a word &middot; move through the letters to scatter &middot; click for a burst</div>
  <div class="badge" id="badge"></div>
  <div class="ui">
    <input id="word" type="text" value="HELLO" maxlength="24" spellcheck="false" autocomplete="off" placeholder="type a word…">
    <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 () {
  "use strict";

  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d", { alpha: false });
  var input = document.getElementById("word");
  var goBtn = document.getElementById("go");
  var themeBtn = document.getElementById("theme");
  var randBtn = document.getElementById("rand");
  var hintEl = document.getElementById("hint");
  var badgeEl = document.getElementById("badge");

  var reduceMotion = window.matchMedia &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  var DPR = Math.min(window.devicePixelRatio || 1, 2);
  var W = 0, H = 0;
  var particles = [];
  var mouse = { x: -9999, y: -9999, active: false, r: 90 };

  // Offscreen canvas for sampling text pixels
  var sampler = document.createElement("canvas");
  var sctx = sampler.getContext("2d", { willReadFrequently: true });

  // ---- color themes: t is normalized 0..1 across the text width ----
  var themes = [
    { name: "Aurora",  fn: function (t) { return "hsl(" + (188 + t * 96) + ",90%,66%)"; } },
    { name: "Sunset",  fn: function (t) { return "hsl(" + (8 + t * 52) + ",95%,62%)"; } },
    { name: "Neon",    fn: function (t) { return "hsl(" + (t * 320) + ",92%,63%)"; } },
    { name: "Emerald", fn: function (t) { return "hsl(" + (135 + t * 60) + ",80%,58%)"; } },
    { name: "Ice",     fn: function (t) { return "hsl(205,70%," + (62 + t * 26) + "%)"; } }
  ];
  var themeIdx = 0;

  var WORDS = ["HELLO", "PARTICLES", "CANVAS", "MAGIC", "REFORM", "SCATTER",
               "GALAXY", "AURORA", "TYPE ME", "PHYSICS", "SWARM", "GLOW"];

  var FONT = "800 %spx -apple-system, 'Segoe UI', Roboto, Arial, sans-serif";

  function resize() {
    W = window.innerWidth;
    H = window.innerHeight;
    canvas.width = Math.floor(W * DPR);
    canvas.height = Math.floor(H * DPR);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    mouse.r = Math.max(70, Math.min(W, H) * 0.11);
  }

  // Greedy word-wrap at a given font size (font must already be set on sctx)
  function wrapLines(words, maxW) {
    var lines = [];
    var cur = "";
    for (var i = 0; i < words.length; i++) {
      var test = cur ? cur + " " + words[i] : words[i];
      if (cur && sctx.measureText(test).width > maxW) {
        lines.push(cur);
        cur = words[i];
      } else {
        cur = test;
      }
    }
    if (cur) lines.push(cur);
    return lines;
  }

  // Fit text: choose the largest size where wrapped lines fit the box.
  function fitText(text, maxW, maxH) {
    var words = text.split(/\s+/).filter(Boolean);
    if (!words.length) words = [text];
    var size = Math.min(H * 0.30, 340);
    var minSize = 24;
    var best = null;
    while (size >= minSize) {
      sctx.font = FONT.replace("%s", size);
      var lines = wrapLines(words, maxW);
      var widest = 0;
      for (var i = 0; i < lines.length; i++) {
        var w = sctx.measureText(lines[i]).width;
        if (w > widest) widest = w;
      }
      var lineH = size * 1.08;
      var totalH = lines.length * lineH;
      if (widest <= maxW && totalH <= maxH) {
        best = { size: size, lines: lines, lineH: lineH };
        break;
      }
      size -= 6;
    }
    if (!best) {
      size = minSize;
      sctx.font = FONT.replace("%s", size);
      best = { size: size, lines: wrapLines(words, maxW), lineH: size * 1.08 };
    }
    return best;
  }

  // Sample target points from rendered (possibly multi-line) text
  function targetsForText(text) {
    text = (text || "").trim();
    if (!text) return [];

    sampler.width = W;
    sampler.height = H;
    sctx.clearRect(0, 0, W, H);
    sctx.fillStyle = "#fff";
    sctx.textAlign = "center";
    sctx.textBaseline = "middle";

    var fit = fitText(text, W * 0.86, H * 0.60);
    sctx.font = FONT.replace("%s", fit.size);

    var n = fit.lines.length;
    var blockH = n * fit.lineH;
    var startY = H / 2 - blockH / 2 + fit.lineH / 2;
    for (var i = 0; i < n; i++) {
      sctx.fillText(fit.lines[i], W / 2, startY + i * fit.lineH);
    }

    var img = sctx.getImageData(0, 0, W, H).data;
    // gap scales so particle count stays in a pleasing few-thousand range
    var area = W * H;
    var gap = area > 2200000 ? 5 : (area > 1100000 ? 4 : 3);
    var pts = [];
    for (var y = 0; y < H; y += gap) {
      for (var x = 0; x < W; x += gap) {
        if (img[(y * W + x) * 4 + 3] > 128) {
          // slight jitter so the grid never looks mechanical
          pts.push({ x: x + (Math.random() - 0.5) * gap, y: y + (Math.random() - 0.5) * gap });
        }
      }
    }
    return pts;
  }

  function color(t) { return themes[themeIdx].fn(Math.max(0, Math.min(1, t))); }

  function build(text) {
    var pts = targetsForText(text);

    // shuffle for organic fly-in
    for (var i = pts.length - 1; i > 0; i--) {
      var j = (Math.random() * (i + 1)) | 0;
      var tmp = pts[i]; pts[i] = pts[j]; pts[j] = tmp;
    }

    var need = pts.length;
    while (particles.length < need) {
      particles.push({
        x: Math.random() * W, y: Math.random() * H,
        vx: 0, vy: 0, tx: 0, ty: 0,
        size: 1.6, color: "#8fd3ff", twk: Math.random() * 6.283
      });
    }
    if (particles.length > need) particles.length = need;

    for (var k = 0; k < need; k++) {
      var p = particles[k];
      p.tx = pts[k].x;
      p.ty = pts[k].y;
      p.color = color(pts[k].x / W);
      p.size = 1.4 + Math.random() * 1.7;
      // a gentle inward kick so formation reads as a "fly-in"
      p.vx += (Math.random() - 0.5) * 2;
      p.vy += (Math.random() - 0.5) * 2;
    }

    badgeEl.textContent = need.toLocaleString() + " particles · " + themes[themeIdx].name;
  }

  // click / tap = radial burst impulse from the point
  function burst(x, y) {
    var R = Math.min(W, H) * 0.45, R2 = R * R;
    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];
      var dx = p.x - x, dy = p.y - y;
      var d2 = dx * dx + dy * dy;
      if (d2 < R2) {
        var d = Math.sqrt(d2) || 1;
        var f = (1 - d / R) * 22;
        p.vx += (dx / d) * f;
        p.vy += (dy / d) * f;
      }
    }
  }

  function tick() {
    // fade trail
    ctx.globalCompositeOperation = "source-over";
    ctx.fillStyle = "rgba(5, 6, 10, 0.30)";
    ctx.fillRect(0, 0, W, H);

    // additive glow for the particles
    ctx.globalCompositeOperation = "lighter";

    var mr = mouse.r, mr2 = mr * mr;
    var spring = reduceMotion ? 0.10 : 0.020;
    var friction = reduceMotion ? 0.75 : 0.88;

    for (var i = 0; i < particles.length; i++) {
      var p = particles[i];

      var dx = p.tx - p.x, dy = p.ty - p.y;
      p.vx += dx * spring;
      p.vy += dy * spring;

      if (mouse.active) {
        var mx = p.x - mouse.x, my = p.y - mouse.y;
        var d2 = mx * mx + my * my;
        if (d2 < mr2 && d2 > 0.0001) {
          var d = Math.sqrt(d2);
          var force = (mr - d) / mr;
          var f = force * 5.4;
          p.vx += (mx / d) * f;
          p.vy += (my / d) * f;
        }
      }

      p.vx *= friction;
      p.vy *= friction;
      p.x += p.vx;
      p.y += p.vy;

      // subtle twinkle in size when at rest
      var s = p.size;
      if (!reduceMotion) {
        p.twk += 0.05;
        s += Math.sin(p.twk) * 0.25;
      }

      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, s, 0, 6.2832);
      ctx.fill();
    }

    requestAnimationFrame(tick);
  }

  // ---- events ----
  function moveHandler(x, y) { mouse.x = x; mouse.y = y; mouse.active = true; }
  window.addEventListener("mousemove", function (e) { moveHandler(e.clientX, e.clientY); });
  window.addEventListener("mouseout", function () { mouse.active = false; });
  window.addEventListener("touchmove", function (e) {
    if (e.touches[0]) moveHandler(e.touches[0].clientX, e.touches[0].clientY);
  }, { passive: true });
  window.addEventListener("touchend", function () { mouse.active = false; });

  // burst on click (ignore clicks on the UI)
  function isUI(t) { return t && t.closest && t.closest(".ui"); }
  window.addEventListener("pointerdown", function (e) {
    if (isUI(e.target)) return;
    burst(e.clientX, e.clientY);
  });

  var resizeTimer;
  window.addEventListener("resize", function () {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function () { resize(); build(input.value); }, 150);
  });

  // live rebuild while typing (debounced)
  var typeTimer;
  input.addEventListener("input", function () {
    clearTimeout(typeTimer);
    typeTimer = setTimeout(function () { build(input.value); }, 260);
  });
  input.addEventListener("keydown", function (e) {
    if (e.key === "Enter") { clearTimeout(typeTimer); build(input.value); input.blur(); }
  });

  goBtn.addEventListener("click", function () { build(input.value); input.blur(); });
  themeBtn.addEventListener("click", function () {
    themeIdx = (themeIdx + 1) % themes.length;
    build(input.value);
  });
  randBtn.addEventListener("click", function () {
    var w;
    do { w = WORDS[(Math.random() * WORDS.length) | 0]; } while (w === input.value);
    input.value = w;
    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 init() { size = minCode + 1; next = eoi + 1; dict = new Map(); }
    init();
    put(clear, size);
    if (idx.length) {
      var key = idx[0];                          // running code (starts as the first literal)
      for (var i = 1; i < idx.length; i++) {
        var k = idx[i], ck = (key << 8) | k;
        if (dict.has(ck)) { key = dict.get(ck); }
        else {
          put(key, size);
          if (next === 4096) { put(clear, size); init(); }   // dict full → reset
          else { if (next >= (1 << size)) size++; dict.set(ck, next++); } // grow size BEFORE assigning
          key = k;
        }
      }
      put(key, 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 sx = gw / W, sy = gh / H;                 // map particle coords into the GIF frame
    var dotR = Math.max(1, Math.min(gw, gh) / 230); // crisp, visible dot at GIF scale
    var frames = [], TOTAL = 40, DELAY = 6;       // ~16fps, ~2.4s
    octx.fillStyle = "#05060a"; octx.fillRect(0, 0, gw, gh);
    scatterAll();
    var f = 0;
    function endRec(msg) { try { clearInterval(iv); } catch (e) {} recording = false; gifBtn.disabled = false; codeBtn.disabled = false; if (msg) toast(msg); }
    var iv = setInterval(function () {
     try {
      // render particles straight at GIF resolution (preserves brightness + additive glow)
      octx.globalCompositeOperation = "source-over";
      octx.fillStyle = "rgba(5,6,10,0.34)"; octx.fillRect(0, 0, gw, gh); // motion trail
      octx.globalCompositeOperation = "lighter";
      for (var pi = 0; pi < particles.length; pi++) {
        var pp = particles[pi];
        octx.fillStyle = pp.color;
        octx.beginPath();
        octx.arc(pp.x * sx, pp.y * sy, dotR, 0, 6.2832);
        octx.fill();
      }
      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);
     } catch (e) { console.log(e); endRec("GIF capture failed — try again"); }
    }, DELAY * 10);

    function encode() {
      toast("Encoding GIF…", 6000);
      setTimeout(function () {
       try {
        // Sample for the palette: keep EVERY bright particle pixel (they're sparse but are the
        // whole point), and only a thin slice of the dark background — otherwise median-cut spends
        // all 256 slots on near-black and quantizes the glow away.
        var cols = [], dc = 0;
        for (var fi = 0; fi < frames.length; fi += 2) {
          var d = frames[fi];
          for (var i = 0; i < d.length; i += 4) {
            var r = d[i], g = d[i + 1], b = d[i + 2];
            var bright = r > g ? r : g; if (b > bright) bright = b;
            if (bright > 40) cols.push((r << 16) | (g << 8) | b);
            else if ((dc++ & 15) === 0) cols.push((r << 16) | (g << 8) | b);
          }
        }
        if (cols.length < 512) for (var i = 0; i < 512; i++) cols.push(0x05060a);
        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);
       } catch (e) { console.log(e); endRec("GIF encode failed — try again"); }
      }, 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);

  // ---- init ----
  resize();
  ctx.fillStyle = "#05060a";
  ctx.fillRect(0, 0, W, H);
  build(input.value);
  requestAnimationFrame(tick);
})();
</script>
</body>
</html>