← back to Wallco Ai

public/js/color-dots.js

436 lines

/* color-dots.js — left-edge strip of a pattern's screen-print ink colors, now
 * INTERACTIVE: click a dot to open an HSV color wheel, drag to pick a new ink,
 * and the pattern live-recolors that ink (per-pixel delta preserves texture).
 * Same-origin design images (/designs/img/...) are canvas-readable. Shared
 * across all PDP themes + the classic PDP. Skips room mockups + small thumbs. */
(function () {
  'use strict';
  var DOT_COUNT = 6, MIN_W = 150, MAXW = 720;   // cap working res for fast recolor
  var state = new WeakMap();                      // img -> { base, idx, inks, cur, w, h }
  var lastTouched = null;                         // most-recently recolored img — Save targets THIS,
                                                  // not the toolbar's statically-bound img (the PDP has
                                                  // several /designs/img/ instances, each with its own
                                                  // strip; the user may recolor a different one than the
                                                  // toolbar bound to → "Recolor a dot first" false-negative).

  function isPattern(img) {
    var s = img.currentSrc || img.src || '';
    return /\/designs\/img\//.test(s) && !/\/designs\/room\//.test(s);
  }
  function hex(r, g, b) { return '#' + [r, g, b].map(function (v) { return ('0' + Math.max(0, Math.min(255, v | 0)).toString(16)).slice(-2); }).join(''); }
  function hex2rgb(h) { var n = parseInt(h.slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; }

  // HSV(0-360,0-1,0-1) -> [r,g,b]
  function hsv2rgb(h, s, v) {
    var c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c, r = 0, g = 0, b = 0;
    if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; }
    else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; }
    return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
  }

  function buildState(img) {
    var nw = img.naturalWidth, nh = img.naturalHeight;
    var scale = Math.min(1, MAXW / Math.max(nw, nh));
    var w = Math.max(1, Math.round(nw * scale)), h = Math.max(1, Math.round(nh * scale));
    var c = document.createElement('canvas'); c.width = w; c.height = h;
    var ctx = c.getContext('2d', { willReadFrequently: true });
    ctx.drawImage(img, 0, 0, w, h);
    var base = ctx.getImageData(0, 0, w, h);
    var d = base.data;
    // quantize to dominant inks
    var buckets = {};
    for (var i = 0; i < d.length; i += 4) {
      if (d[i + 3] < 128) continue;
      var q = (d[i] >> 5) + ',' + (d[i + 1] >> 5) + ',' + (d[i + 2] >> 5);
      if (!buckets[q]) buckets[q] = { n: 0, r: 0, g: 0, b: 0 };
      var bk = buckets[q]; bk.n++; bk.r += d[i]; bk.g += d[i + 1]; bk.b += d[i + 2];
    }
    var arr = Object.keys(buckets).map(function (k) { var bk = buckets[k]; return [bk.r / bk.n, bk.g / bk.n, bk.b / bk.n, bk.n]; })
      .sort(function (a, b) { return b[3] - a[3]; });
    var inks = [];
    for (var j = 0; j < arr.length && inks.length < DOT_COUNT; j++) {
      var col = arr[j];
      var dup = inks.some(function (o) { return Math.abs(o[0] - col[0]) + Math.abs(o[1] - col[1]) + Math.abs(o[2] - col[2]) < 36; });
      if (!dup) inks.push([col[0], col[1], col[2]]);
    }
    if (!inks.length) return null;
    // nearest-ink index per pixel
    var idx = new Uint8Array(w * h);
    for (var p = 0, q2 = 0; p < d.length; p += 4, q2++) {
      var br = d[p], bg = d[p + 1], bb = d[p + 2], best = 0, bd = 1e9;
      for (var k = 0; k < inks.length; k++) {
        var dr = br - inks[k][0], dg = bg - inks[k][1], db = bb - inks[k][2], dist = dr * dr + dg * dg + db * db;
        if (dist < bd) { bd = dist; best = k; }
      }
      idx[q2] = best;
    }
    return { base: base, idx: idx, inks: inks, cur: inks.map(function (c) { return c.slice(); }), w: w, h: h };
  }

  function recolor(img) {
    var st = state.get(img); if (!st) return;
    lastTouched = img;
    var out = new ImageData(new Uint8ClampedArray(st.base.data), st.w, st.h);
    var bd = st.base.data, od = out.data, idx = st.idx, inks = st.inks, cur = st.cur;
    for (var p = 0, q = 0; p < bd.length; p += 4, q++) {
      var k = idx[q];
      od[p] = bd[p] + (cur[k][0] - inks[k][0]);
      od[p + 1] = bd[p + 1] + (cur[k][1] - inks[k][1]);
      od[p + 2] = bd[p + 2] + (cur[k][2] - inks[k][2]);
    }
    var c = document.createElement('canvas'); c.width = st.w; c.height = st.h;
    c.getContext('2d').putImageData(out, 0, 0);
    img.src = c.toDataURL('image/png');
  }

  // ── color wheel popup ──────────────────────────────────────────────────
  var wheelEl = null;
  function closeWheel() { if (wheelEl) { wheelEl.remove(); wheelEl = null; } }
  document.addEventListener('click', function (e) { if (wheelEl && !wheelEl.contains(e.target) && !e.target.classList.contains('cd-dot')) closeWheel(); });

  function openWheel(dot, img, inkIndex) {
    closeWheel();
    var SZ = 180;
    wheelEl = document.createElement('div');
    wheelEl.className = 'cd-wheel';
    wheelEl.style.cssText = 'position:fixed;z-index:9999;background:#fff;border-radius:14px;padding:12px;box-shadow:0 18px 60px rgba(0,0,0,.32);width:' + (SZ + 24) + 'px';
    var r = dot.getBoundingClientRect();
    wheelEl.style.left = Math.min(window.innerWidth - SZ - 40, r.right + 12) + 'px';
    wheelEl.style.top = Math.max(10, Math.min(window.innerHeight - SZ - 90, r.top - 40)) + 'px';
    var cv = document.createElement('canvas'); cv.width = SZ; cv.height = SZ;
    cv.style.cssText = 'cursor:crosshair;border-radius:50%;display:block;touch-action:none';
    var vslab = document.createElement('input'); vslab.type = 'range'; vslab.min = 0; vslab.max = 100; vslab.value = 100;
    vslab.style.cssText = 'width:100%;margin-top:10px';
    var sw = document.createElement('div'); sw.style.cssText = 'height:22px;border-radius:6px;margin-top:8px;border:1px solid #ddd';
    wheelEl.appendChild(cv); wheelEl.appendChild(vslab); wheelEl.appendChild(sw);
    document.body.appendChild(wheelEl);

    var V = 1, curRGB = state.get(img).cur[inkIndex];
    function drawWheel() {
      var ctx = cv.getContext('2d'), R = SZ / 2, im = ctx.createImageData(SZ, SZ), dd = im.data;
      for (var y = 0; y < SZ; y++) for (var x = 0; x < SZ; x++) {
        var dx = x - R, dy = y - R, dist = Math.sqrt(dx * dx + dy * dy), o = (y * SZ + x) * 4;
        if (dist > R) { dd[o + 3] = 0; continue; }
        var hh = (Math.atan2(dy, dx) * 180 / Math.PI + 360) % 360, ss = dist / R, rgb = hsv2rgb(hh, ss, V);
        dd[o] = rgb[0]; dd[o + 1] = rgb[1]; dd[o + 2] = rgb[2]; dd[o + 3] = 255;
      }
      ctx.putImageData(im, 0, 0);
    }
    function apply(rgb) {
      state.get(img).cur[inkIndex] = [rgb[0], rgb[1], rgb[2]];
      dot.style.background = hex(rgb[0], rgb[1], rgb[2]);
      sw.style.background = hex(rgb[0], rgb[1], rgb[2]);
      clearTimeout(window._cdReco); window._cdReco = setTimeout(function () { recolor(img); }, 60);
    }
    function pick(ev) {
      var b = cv.getBoundingClientRect(), R = SZ / 2;
      var x = (ev.touches ? ev.touches[0].clientX : ev.clientX) - b.left - R;
      var y = (ev.touches ? ev.touches[0].clientY : ev.clientY) - b.top - R;
      var dist = Math.min(R, Math.sqrt(x * x + y * y));
      var hh = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360, ss = dist / R;
      apply(hsv2rgb(hh, ss, V));
    }
    var dragging = false;
    cv.addEventListener('pointerdown', function (e) { dragging = true; cv.setPointerCapture(e.pointerId); pick(e); });
    cv.addEventListener('pointermove', function (e) { if (dragging) pick(e); });
    cv.addEventListener('pointerup', function () { dragging = false; });
    vslab.addEventListener('input', function () { V = vslab.value / 100; drawWheel(); });
    drawWheel();
    sw.style.background = hex(curRGB[0], curRGB[1], curRGB[2]);
  }

  // Visible = laid out, on-screen-sized, not inside a display:none / hidden /
  // opacity:0 modal. clientWidth alone missed the edit-modal preview img and the
  // colorway-fullscreen img, so the 💾 Save toolbar could bind to a HIDDEN host
  // and render invisibly. offsetParent===null catches a display:none ancestor.
  function isVisible(el) {
    if (!el) return false;
    var cs = getComputedStyle(el);
    if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity || '1') < 0.05) return false;
    if (el.offsetParent === null && cs.position !== 'fixed') return false;
    var r = el.getBoundingClientRect();
    return r.width >= MIN_W && r.height >= 8;
  }
  function addDots(img) {
    if (img.dataset.cdDone) return;
    if ((img.naturalWidth || 0) < 8) return;
    if (!isVisible(img)) return;
    var st = buildState(img); if (!st) return;
    img.dataset.cdDone = '1'; state.set(img, st);
    var host = img.parentElement; if (!host) return;
    if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
    var col = document.createElement('div'); col.className = 'cd-strip';
    col.setAttribute('aria-label', 'pattern colors — click a dot to recolor');
    col.style.cssText = 'position:absolute;left:10px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:9px;z-index:6';
    st.inks.forEach(function (rgb, k) {
      var d = document.createElement('button'); d.className = 'cd-dot'; d.type = 'button';
      d.title = 'Click to recolor this ink';
      d.style.cssText = 'width:20px;height:20px;border-radius:50%;border:0;padding:0;cursor:pointer;background:' + hex(rgb[0], rgb[1], rgb[2]) +
        ';box-shadow:0 0 0 2px rgba(255,255,255,.95),0 1px 5px rgba(0,0,0,.35)';
      d.addEventListener('click', function (e) { e.stopPropagation(); openWheel(d, img, k); });
      col.appendChild(d);
    });
    host.appendChild(col);
    // attach the save/version toolbar once, to the main pattern (needs a design id)
    if (!window._cdBarDone && pageDesignId()) { window._cdBarDone = true; buildToolbar(img, host); }
  }

  // ── save / version colorways (per-user exclusive) ────────────────────────
  function pageDesignId() {
    var m = location.pathname.match(/\/design\/(\d+)/); if (m) return parseInt(m[1], 10);
    var q = new URLSearchParams(location.search).get('id'); if (q && /^\d+$/.test(q)) return parseInt(q, 10);
    return null;
  }
  function currentInkMap(img) {
    var st = state.get(img); if (!st) return [];
    return st.inks.map(function (ink, k) {
      return { from: hex(ink[0], ink[1], ink[2]), to: hex(st.cur[k][0], st.cur[k][1], st.cur[k][2]) };
    });
  }
  function applyColorway(img, inkMap) {
    var st = state.get(img); if (!st || !Array.isArray(inkMap)) return;
    inkMap.forEach(function (m) {
      var fr = hex2rgb(m.from), to = hex2rgb(m.to), best = 0, bd = 1e9;
      for (var k = 0; k < st.inks.length; k++) {
        var dr = st.inks[k][0] - fr[0], dg = st.inks[k][1] - fr[1], db = st.inks[k][2] - fr[2], d = dr * dr + dg * dg + db * db;
        if (d < bd) { bd = d; best = k; }
      }
      st.cur[best] = [to[0], to[1], to[2]];
    });
    var strip = img.parentElement && img.parentElement.querySelector('.cd-strip');
    if (strip) [].forEach.call(strip.children, function (dot, k) { if (st.cur[k]) dot.style.background = hex(st.cur[k][0], st.cur[k][1], st.cur[k][2]); });
    recolor(img);
  }
  function toast(host, msg) {
    var t = document.createElement('div'); t.textContent = msg;
    t.style.cssText = 'position:absolute;left:10px;bottom:54px;z-index:8;background:#16203c;color:#fff;font:600 12px sans-serif;padding:6px 12px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.3)';
    host.appendChild(t); setTimeout(function () { t.remove(); }, 1700);
  }
  function buildToolbar(img, host) {
    var did = pageDesignId(); if (!did) return;
    var bar = document.createElement('div'); bar.className = 'cd-bar';
    bar.style.cssText = 'position:absolute;left:10px;bottom:10px;z-index:7;display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:rgba(255,255,255,.94);border-radius:999px;padding:6px 8px;box-shadow:0 2px 10px rgba(0,0,0,.18);max-width:calc(100% - 20px)';
    var save = document.createElement('button'); save.type = 'button'; save.textContent = '💾 Save colorway';
    save.style.cssText = 'border:0;border-radius:999px;background:#16203c;color:#fff;font:600 12px sans-serif;padding:7px 12px;cursor:pointer';
    var vers = document.createElement('span'); vers.className = 'cd-versions'; vers.style.cssText = 'display:flex;gap:6px;align-items:center';
    var reset = document.createElement('button'); reset.type = 'button'; reset.textContent = '↺'; reset.title = 'Reset to original';
    reset.style.cssText = 'border:0;border-radius:50%;width:28px;height:28px;background:#eee;cursor:pointer;font-size:13px';
    bar.appendChild(save); bar.appendChild(vers); bar.appendChild(reset);
    host.appendChild(bar);

    function chip(cw) {
      // Real <a> link so the chip is a navigable product URL (right-click →
      // "Open in new tab" works, can be shared/bookmarked, browser shows the
      // href on hover). Steve 2026-05-29: "click into it as a new product".
      // Plain click stays in-page (instant preview + URL replace, no reload).
      // Modifier/middle clicks fall through to browser-native open-in-tab.
      // Wrapped in a position:relative <span> so the admin Publish badge can
      // absolutely-position itself at the chip's top-right corner without
      // breaking the existing 32x22 link layout.
      var wrap = document.createElement('span');
      wrap.style.cssText = 'position:relative;display:inline-block;line-height:0';
      var b = document.createElement('a');
      // PREFERRED — promoted colorway has its own spoon_all_designs row
      // (server baked the PNG at save time). Clean product URL.
      // FALLBACK — older colorways with no promote: ?cw= overlay on src.
      b.href = cw.new_design_id
        ? ('/design/' + cw.new_design_id)
        : ('/design/' + did + '?cw=' + cw.id);
      b.title = (cw.name || ('Version ' + cw.version)) + ' · ' + new Date(cw.created_at).toLocaleString() +
        (cw.new_design_id ? ' · open product #' + cw.new_design_id : ' · open as product');
      var stops = (cw.ink_map || []).map(function (m) { return m.to; });
      // Match the original button's exact visual: 32×22 chip with the v-label
      // floated as a block element inside (which is how the button rendered it
      // before this anchor swap). inline-block + zero padding/margin neutralizes
      // browser-default <a> spacing.
      b.style.cssText = 'display:inline-block;box-sizing:border-box;' +
        'border:1px solid #ccc;border-radius:6px;width:32px;height:22px;' +
        'padding:0;margin:0;cursor:pointer;text-decoration:none;line-height:0;' +
        (stops.length ? 'background:linear-gradient(90deg,' + stops.join(',') + ')' : 'background:#eee');
      var lab = document.createElement('span'); lab.textContent = 'v' + cw.version;
      b.appendChild(lab); lab.style.cssText = 'display:block;font:700 8px sans-serif;line-height:1;color:#fff;text-shadow:0 0 2px #000;padding:2px 0 0 3px';
      b.addEventListener('click', function (ev) {
        if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.button === 1) return;
        ev.preventDefault();
        applyColorway(img, cw.ink_map);
        try {
          var u = new URL(location.href);
          u.searchParams.set('cw', cw.id);
          history.pushState({ cw: cw.id }, '', u);
        } catch (e) {}
      });
      // Steve 2026-05-29: "doubleclick on colorway to launch into the product
      // page for new item". Single-click stays in-page (applies the colorway
      // via history.pushState — preview without page-load). Double-click
      // navigates to the chip's href so the colorway loads as its own product
      // page. For promoted colorways b.href points at /design/{new_design_id}
      // (a real spoon_all_designs row); for un-promoted overlays it falls back
      // to /design/{did}?cw={cw.id}. Lives in color-dots.js so it ships across
      // all 17 PDP theme variants automatically.
      b.addEventListener('dblclick', function (ev) {
        ev.preventDefault();
        ev.stopPropagation();
        location.href = b.href;
      });
      wrap.appendChild(b);
      // Admin Publish badge — only renders when (1) the caller is an admin
      // (window.__COLORWAYS_IS_ADMIN, set by load() from /api/colorways.isAdmin)
      // AND (2) the colorway has a promoted spoon_all_designs row
      // (cw.new_design_id is set — ink_map-only saves have no row to publish).
      // Eye icon (visible: green = published, gray = unpublished). Click flips
      // is_published via POST /api/colorways/<id>/(un)publish (admin-gated
      // backend, /dtd verdict 2026-05-29 — only explicit admin click publishes
      // a colorway to the public catalog).
      if (window.__COLORWAYS_IS_ADMIN && cw.new_design_id) {
        var badge = document.createElement('button');
        badge.type = 'button';
        var pubState = !!cw.new_design_published;
        function paint() {
          badge.textContent = '👁';
          badge.title = pubState
            ? ('Published — click to unpublish #' + cw.new_design_id)
            : ('Not published — click to publish #' + cw.new_design_id);
          badge.style.cssText = 'position:absolute;top:-6px;right:-6px;z-index:2;' +
            'width:16px;height:16px;border-radius:50%;border:1.5px solid #fff;' +
            'padding:0;margin:0;cursor:pointer;font-size:9px;line-height:1;' +
            'display:flex;align-items:center;justify-content:center;' +
            'box-shadow:0 1px 3px rgba(0,0,0,.4);' +
            (pubState ? 'background:#22a06b;color:#fff;' : 'background:#9aa0a6;color:#fff;');
        }
        paint();
        badge.addEventListener('click', function (ev) {
          // Prevent the click from bubbling to the chip (which would apply the
          // colorway) and from triggering native navigation on the anchor.
          ev.preventDefault();
          ev.stopPropagation();
          if (badge.disabled) return;
          badge.disabled = true;
          var action = pubState ? 'unpublish' : 'publish';
          fetch('/api/colorways/' + cw.id + '/' + action, {
            method: 'POST', credentials: 'same-origin'
          })
            .then(function (r) { return r.json(); })
            .then(function (j) {
              if (j && j.ok) {
                pubState = !!j.is_published;
                cw.new_design_published = pubState;
                paint();
                toast(host, pubState ? 'Published v' + cw.version : 'Unpublished v' + cw.version);
              } else {
                toast(host, (j && j.error) || (action + ' failed'));
              }
            })
            .catch(function () { toast(host, action + ' failed'); })
            .then(function () { badge.disabled = false; });
        });
        // Stop dblclick from bubbling so it doesn't navigate.
        badge.addEventListener('dblclick', function (ev) {
          ev.preventDefault(); ev.stopPropagation();
        });
        wrap.appendChild(badge);
      }
      return wrap;
    }
    // Auto-apply ?cw=<id> from URL on load — makes /design/<id>?cw=<cwid>
    // behave like a distinct "new product" page that opens in that colorway.
    function applyFromUrl(cws) {
      try {
        var cwid = parseInt(new URLSearchParams(location.search).get('cw'), 10);
        if (!cwid) return;
        var hit = (cws || []).filter(function (c) { return c.id === cwid; })[0];
        if (hit && Array.isArray(hit.ink_map)) applyColorway(img, hit.ink_map);
      } catch (e) {}
    }
    function load() {
      fetch('/api/colorways?design_id=' + did, { credentials: 'same-origin' })
        .then(function (r) { return r.json(); })
        .then(function (j) {
          // /api/colorways now returns isAdmin (loopback OR dw_auth JWT OR
          // ?admin=<token>). Stash on window so chip() can gate the Publish
          // badge without re-probing auth. Defaults to false on non-admins so
          // customer sessions never see the badge.
          window.__COLORWAYS_IS_ADMIN = !!(j && j.isAdmin);
          vers.innerHTML = '';
          (j.colorways || []).forEach(function (cw) { vers.appendChild(chip(cw)); });
          applyFromUrl(j.colorways);
        })
        .catch(function () {});
    }
    // Capture the FULL recolored tile as a PNG data URL for the promote step.
    // recolor() always writes the result to img.src as a data URL, but that
    // image may have been downscaled to MAXW for live preview. Re-render here
    // at the same dimensions used for preview — that's what the user actually
    // recolored and saw. Returns null if state is missing.
    function bakeCurrentPng(bImg) {
      var st = state.get(bImg); if (!st) return null;
      var c = document.createElement('canvas'); c.width = st.w; c.height = st.h;
      var out = new ImageData(new Uint8ClampedArray(st.base.data), st.w, st.h);
      var bd = st.base.data, od = out.data, idx = st.idx, inks = st.inks, cur = st.cur;
      for (var p = 0, q = 0; p < bd.length; p += 4, q++) {
        var k = idx[q];
        od[p]     = bd[p]     + (cur[k][0] - inks[k][0]);
        od[p + 1] = bd[p + 1] + (cur[k][1] - inks[k][1]);
        od[p + 2] = bd[p + 2] + (cur[k][2] - inks[k][2]);
      }
      c.getContext('2d').putImageData(out, 0, 0);
      try { return c.toDataURL('image/png'); } catch (e) { return null; }
    }
    save.addEventListener('click', function () {
      // Target the image the user actually recolored (lastTouched), falling back
      // to the toolbar's bound img. Fixes the false "Recolor a dot first" when the
      // recolored strip belongs to a different /designs/img/ instance than `img`.
      var tImg = (lastTouched && state.get(lastTouched)) ? lastTouched : img;
      var st = state.get(tImg); if (!st) return;
      var changed = currentInkMap(tImg).filter(function (m) { return m.from !== m.to; });
      if (!changed.length) { toast(host, 'Recolor a dot first'); return; }
      save.disabled = true; save.textContent = 'Saving…';
      var dataUrl = bakeCurrentPng(tImg);   // omit on failure; server will skip promote
      var body = { design_id: did, ink_map: currentInkMap(tImg) };
      if (dataUrl) body.data_url = dataUrl;
      fetch('/api/colorways/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
        body: JSON.stringify(body) })
        .then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })
        .then(function (o) {
          save.disabled = false; save.textContent = '💾 Save colorway';
          if (o.s === 401) { toast(host, 'Sign in to save your colorway'); return; }
          if (o.j && o.j.ok) {
            if (o.j.new_design_id) {
              toast(host, 'Saved as v' + o.j.version + ' → opening as new product…');
              setTimeout(function () { location.href = '/design/' + o.j.new_design_id; }, 700);
            } else {
              toast(host, 'Saved as v' + o.j.version);
              load();
            }
          }
          else { toast(host, (o.j && o.j.error) || 'Save failed'); }
        })
        .catch(function () { save.disabled = false; save.textContent = '💾 Save colorway'; toast(host, 'Save failed'); });
    });
    reset.addEventListener('click', function () {
      var st = state.get(img); if (!st) return;
      st.cur = st.inks.map(function (c) { return c.slice(); });
      var strip = host.querySelector('.cd-strip');
      if (strip) [].forEach.call(strip.children, function (dot, k) { dot.style.background = hex(st.inks[k][0], st.inks[k][1], st.inks[k][2]); });
      recolor(img);
    });
    load();
  }

  function scan() {
    var imgs = document.images;
    for (var i = 0; i < imgs.length; i++) {
      var img = imgs[i];
      if (img.dataset.cdDone || !isPattern(img)) continue;
      if (img.complete && img.naturalWidth) addDots(img);
      else img.addEventListener('load', function (e) { addDots(e.target); }, { once: true });
    }
  }
  function boot() {
    scan();
    try { new MutationObserver(function () { scan(); }).observe(document.body, { childList: true, subtree: true }); } catch (e) {}
    window.addEventListener('resize', function () { clearTimeout(window._cdRt); window._cdRt = setTimeout(scan, 250); });
  }
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  else boot();
})();