← 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
M public/js/color-dots.js
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 →