[object Object]

← back to Wallco Ai

color-dots: interactive recolor — click a dot to open an HSV color wheel, drag to pick a new ink, pattern live-recolors that ink (per-pixel delta preserves texture). Applies across all 17 variants + classic PDP

69f306b84c9ea8f0517f899088f59fd7f730bc03 · 2026-05-29 13:24:23 -0700 · Steve

Files touched

Diff

commit 69f306b84c9ea8f0517f899088f59fd7f730bc03
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 29 13:24:23 2026 -0700

    color-dots: interactive recolor — click a dot to open an HSV color wheel, drag to pick a new ink, pattern live-recolors that ink (per-pixel delta preserves texture). Applies across all 17 variants + classic PDP
---
 public/js/color-dots.js | 198 +++++++++++++++++++++++++++++++++---------------
 1 file changed, 137 insertions(+), 61 deletions(-)

diff --git a/public/js/color-dots.js b/public/js/color-dots.js
index ae0da37..3bf5ad4 100644
--- a/public/js/color-dots.js
+++ b/public/js/color-dots.js
@@ -1,77 +1,158 @@
-/* color-dots.js — overlay a vertical strip of the pattern's screen-print ink
- * colors along the LEFT edge of every pattern image, on every PDP theme.
- * Same-origin design images (/designs/img/...) are canvas-readable, so we
- * downscale + quantize to the dominant flat inks and render them as dots.
- * Shared across all theme variants + the classic PDP (one include, no per-
- * theme markup edits). Skips room mockups (/designs/room/) and small thumbs. */
+/* 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;       // up to N ink dots
-  var MIN_W = 150;         // ignore thumbnails smaller than this (rendered px)
+  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 }
 
   function isPattern(img) {
     var s = img.currentSrc || img.src || '';
-    if (!/\/designs\/img\//.test(s)) return false;   // only design patterns
-    if (/\/designs\/room\//.test(s)) return false;    // not room mockups
-    return true;
+    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]; }
 
-  // Downscale to a small canvas, bucket colors (coarse quantize merges AA
-  // fringe), return the top DOT_COUNT inks by coverage, dropping near-dupes.
-  function extractInks(img) {
-    try {
-      var W = 56, H = 56;
-      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 data = ctx.getImageData(0, 0, W, H).data;
-      var buckets = {};
-      for (var i = 0; i < data.length; i += 4) {
-        var a = data[i + 3]; if (a < 128) continue;
-        var r = data[i], g = data[i + 1], b = data[i + 2];
-        var q = (Math.round(r / 24) * 24) + ',' + (Math.round(g / 24) * 24) + ',' + (Math.round(b / 24) * 24);
-        if (!buckets[q]) buckets[q] = { n: 0, r: 0, g: 0, b: 0 };
-        var bk = buckets[q]; bk.n++; bk.r += r; bk.g += g; bk.b += b;
+  // 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; }
       }
-      var arr = Object.keys(buckets).map(function (k) {
-        var bk = buckets[k];
-        return { n: bk.n, r: Math.round(bk.r / bk.n), g: Math.round(bk.g / bk.n), b: Math.round(bk.b / bk.n) };
-      }).sort(function (a, b) { return b.n - a.n; });
-      var out = [];
-      for (var j = 0; j < arr.length && out.length < DOT_COUNT; j++) {
-        var col = arr[j];
-        var dup = out.some(function (o) {
-          return Math.abs(o.r - col.r) + Math.abs(o.g - col.g) + Math.abs(o.b - col.b) < 36;
-        });
-        if (!dup) out.push(col);
+      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;
+    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;
       }
-      return out.map(function (o) {
-        return '#' + [o.r, o.g, o.b].map(function (v) { return ('0' + v.toString(16)).slice(-2); }).join('');
-      });
-    } catch (e) { return []; }
+      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]);
   }
 
   function addDots(img) {
     if (img.dataset.cdDone) return;
-    if ((img.naturalWidth || 0) < 8) return;             // not decoded yet
+    if ((img.naturalWidth || 0) < 8) return;
     if ((img.clientWidth || img.width || 0) < MIN_W) return;
-    img.dataset.cdDone = '1';
-    var colors = extractInks(img);
-    if (!colors.length) 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 screen-print colors');
-    col.style.cssText = 'position:absolute;left:10px;top:50%;transform:translateY(-50%);' +
-      'display:flex;flex-direction:column;gap:9px;z-index:6;pointer-events:none';
-    colors.forEach(function (hex) {
-      var d = document.createElement('span');
-      d.title = hex; d.style.pointerEvents = 'auto';
-      d.style.cssText += 'width:18px;height:18px;border-radius:50%;background:' + hex +
+    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);
     });
+    // tiny "drag a color" hint dot at bottom
     host.appendChild(col);
   }
 
@@ -84,16 +165,11 @@
       else img.addEventListener('load', function (e) { addDots(e.target); }, { once: true });
     }
   }
-
   function boot() {
     scan();
-    // re-scan on DOM changes (theme PDPs render their pattern client-side after fetch)
-    try {
-      new MutationObserver(function () { scan(); }).observe(document.body, { childList: true, subtree: true });
-    } catch (e) {}
+    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();
 })();

← 6f2666d cactus-curator: select-all button + live-animate-out + auto-  ·  back to Wallco Ai  ·  Save-this-colorway: per-user EXCLUSIVE versioned colorways. 58b694b →