[object Object]

← back to Dw Marketing Reels

colorwheel: widen to 15% ΔE band (big data set), fix spin/click color mismatch, drop hex+count, center dot = picked color

80983377606cddc38baff57fae10cfc46f9741db · 2026-07-16 10:39:10 -0700 · Steve

Files touched

Diff

commit 80983377606cddc38baff57fae10cfc46f9741db
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 16 10:39:10 2026 -0700

    colorwheel: widen to 15% ΔE band (big data set), fix spin/click color mismatch, drop hex+count, center dot = picked color
---
 colorwheel/index.html |  51 ++++++++++++-----------
 colorwheel/server.js  | 109 ++++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 124 insertions(+), 36 deletions(-)

diff --git a/colorwheel/index.html b/colorwheel/index.html
index 82ae038..b2123a6 100644
--- a/colorwheel/index.html
+++ b/colorwheel/index.html
@@ -15,22 +15,22 @@
   header p{margin:6px 0 0;color:#7a7266;font-size:13px;letter-spacing:.04em}
   .stage{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:22px 16px 8px}
   .wheelwrap{position:relative;width:340px;height:340px;flex:0 0 auto}
+  /* the spin layer carries BOTH the wheel and the selector ring, so the ring
+     rotates WITH the wheel and always sits over its true color */
+  .spin{position:absolute;inset:0;animation:spin 40s linear infinite}
+  .wheelwrap.dragging .spin{animation-play-state:paused}
   #wheel{width:100%;height:100%;border-radius:50%;cursor:crosshair;
-    box-shadow:0 10px 40px rgba(0,0,0,.18), inset 0 0 0 1px rgba(0,0,0,.06);
-    animation:spin 40s linear infinite}
-  .wheelwrap.dragging #wheel{animation-play-state:paused}
+    box-shadow:0 10px 40px rgba(0,0,0,.18), inset 0 0 0 1px rgba(0,0,0,.06)}
   @keyframes spin{to{transform:rotate(360deg)}}
-  /* selector ring sits ABOVE the spinning wheel and does not rotate */
   #pick{position:absolute;width:34px;height:34px;border-radius:50%;border:3px solid #fff;
     box-shadow:0 2px 10px rgba(0,0,0,.35), 0 0 0 1px rgba(0,0,0,.25);transform:translate(-50%,-50%);
     left:50%;top:14%;pointer-events:none;transition:box-shadow .2s}
   .hub{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:118px;height:118px;
     border-radius:50%;background:#fff;box-shadow:0 6px 22px rgba(0,0,0,.16), inset 0 0 0 1px var(--line);
     display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
-  .hub .sw{width:52px;height:52px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12);
-    transition:background .15s;background:#8aa}
-  .hub .hex{font-size:12px;letter-spacing:.08em;color:#6b6357;margin-top:8px;font-variant-numeric:tabular-nums}
-  .hub .cnt{font-size:11px;color:var(--gold);margin-top:2px;min-height:14px}
+  /* the center dot IS the selected color — the wheel's readout */
+  .hub .sw{width:78px;height:78px;border-radius:50%;box-shadow:inset 0 0 0 1px rgba(0,0,0,.12);
+    transition:background .2s;background:#8aa}
   .side{max-width:300px}
   .side h2{font-weight:400;font-size:15px;letter-spacing:.06em;text-transform:uppercase;margin:0 0 8px}
   .side p{color:#7a7266;font-size:13px;line-height:1.6;margin:0 0 14px}
@@ -65,12 +65,12 @@
 
   <div class="stage">
     <div class="wheelwrap" id="ww">
-      <canvas id="wheel" width="680" height="680"></canvas>
-      <div id="pick"></div>
+      <div class="spin" id="spin">
+        <canvas id="wheel" width="680" height="680"></canvas>
+        <div id="pick"></div>
+      </div>
       <div class="hub">
         <div class="sw" id="sw"></div>
-        <div class="hex" id="hex">#5F7060</div>
-        <div class="cnt" id="cnt"></div>
       </div>
     </div>
     <div class="side">
@@ -105,24 +105,31 @@ function hsl2rgb(h,s,l){h/=360;let r,g,b;if(s===0){r=g=b=l;}else{
   r=t(h+1/3);g=t(h);b=t(h-1/3);}return[Math.round(r*255),Math.round(g*255),Math.round(b*255)];}
 function rgbHex(r,g,b){return '#'+[r,g,b].map(x=>x.toString(16).padStart(2,'0')).join('');}
 
-const ww=document.getElementById('ww'), pick=document.getElementById('pick'),
-  sw=document.getElementById('sw'), hexEl=document.getElementById('hex'),
-  cnt=document.getElementById('cnt'), grid=document.getElementById('grid');
+const ww=document.getElementById('ww'), spin=document.getElementById('spin'),
+  pick=document.getElementById('pick'), sw=document.getElementById('sw'),
+  grid=document.getElementById('grid');
 let curHue=140, curSat=0.45;
 
-function place(hue,sat){ // position the (non-rotating) selector ring
-  const ang=(hue)*Math.PI/180, rad=sat*48; // 48% of half-size
+function place(hue,sat){ // ring lives INSIDE the spin layer at the wheel-local angle,
+  const ang=hue*Math.PI/180, rad=sat*48; // so it rides over its own color as the wheel turns
   pick.style.left=(50+Math.cos(ang)*rad)+'%';
   pick.style.top=(50+Math.sin(ang)*rad)+'%';
   const [r,g,b]=hsl2rgb(hue,sat,0.5), hx=rgbHex(r,g,b);
-  pick.style.background=hx; sw.style.background=hx; hexEl.textContent=hx.toUpperCase();
+  pick.style.background=hx; sw.style.background=hx; // the center dot becomes the picked color
   return hx;
 }
+function spinDeg(){ // read the wheel's live rotation from its CSS transform matrix
+  const tr=getComputedStyle(spin).transform;
+  if(!tr||tr==='none') return 0;
+  const m=new DOMMatrixReadOnly(tr);
+  let d=Math.atan2(m.b,m.a)*180/Math.PI; if(d<0)d+=360; return d;
+}
 function fromEvent(e){
   const rect=cv.getBoundingClientRect();
   const cx=e.touches?e.touches[0].clientX:e.clientX, cy=e.touches?e.touches[0].clientY:e.clientY;
   const dx=cx-(rect.left+rect.width/2), dy=cy-(rect.top+rect.height/2);
-  let h=Math.atan2(dy,dx)*180/Math.PI; if(h<0)h+=360;
+  let scr=Math.atan2(dy,dx)*180/Math.PI; if(scr<0)scr+=360;
+  const h=(scr-spinDeg()+360)%360;              // undo the spin → the TRUE hue under the pointer
   const s=Math.min(1,Math.sqrt(dx*dx+dy*dy)/(rect.width/2));
   curHue=h; curSat=Math.max(.12,s); return place(curHue,curSat);
 }
@@ -142,18 +149,16 @@ CHIPS.forEach(([h,s])=>{const c=document.createElement('div');c.className='chip'
 
 async function load(hex){
   grid.innerHTML='<div class="loading">Finding wallcoverings in this color</div>';
-  cnt.textContent='';
   try{
     const r=await fetch('/api/colors',{method:'POST',headers:{'Content-Type':'application/json'},
-      body:JSON.stringify({hex,k:48})});
+      body:JSON.stringify({hex,ceiling:15})});   // widen to a ~15% color-variation band
     const j=await r.json(); const res=(j.results||[]);
-    cnt.textContent=(j.total_in_tolerance!=null? j.total_in_tolerance+' matches':'');
     if(!res.length){grid.innerHTML='<div class="empty">No close matches for this shade yet — try a neighboring hue.</div>';return;}
     grid.innerHTML='';
     res.forEach((p,i)=>{
       if(!p.handle||!p.image)return;
       const a=document.createElement('a');a.className='card';a.href='https://designerwallcoverings.com/products/'+encodeURIComponent(p.handle);
-      a.target='_blank';a.rel='noopener';a.style.animationDelay=(i*18)+'ms';
+      a.target='_blank';a.rel='noopener';a.style.animationDelay=Math.min(i*12,700)+'ms';
       const im=document.createElement('img');im.className='im';im.loading='lazy';im.src=p.image;
       im.onerror=()=>a.remove();
       const dot=document.createElement('span');dot.className='dot';dot.style.background=p.hex||hex;
diff --git a/colorwheel/server.js b/colorwheel/server.js
index 08adeaf..dc7065a 100644
--- a/colorwheel/server.js
+++ b/colorwheel/server.js
@@ -1,7 +1,15 @@
 #!/usr/bin/env node
-// Tiny zero-dep server for the Shop-by-Color motion wheel. Serves index.html and
-// proxies POST /api/colors → the live color-index endpoint (server-side, so the
-// browser never hits a CORS wall).
+// Zero-dep server for the Shop-by-Color motion wheel. Serves index.html and, on
+// POST /api/colors, widens the live color index into a big "up to N% variation"
+// result set.
+//
+// Why the widening lives here: the upstream index
+// (photo.designerwallcoverings.com/apps/color-index) hard-caps each query at
+// ΔE≈10 (~30 matches) and ignores every tolerance param. So for each pick we
+// query a small HSL grid of perturbed hues/lightness/saturation around the base
+// color, union their ΔE-10 neighborhoods, then re-filter with our OWN CIE-Lab
+// ΔE ceiling (default 15 ≈ "15% variation"). One browser request → up to 27
+// upstream queries done in parallel here (cached), never seen by the client.
 import http from 'node:http';
 import https from 'node:https';
 import { readFile } from 'node:fs/promises';
@@ -11,27 +19,102 @@ import { dirname, join } from 'node:path';
 const ROOT = dirname(fileURLToPath(import.meta.url));
 const PORT = Number(process.env.PORT || 9851);
 const ENDPOINT = 'https://photo.designerwallcoverings.com/apps/color-index';
+const DEFAULT_CEILING = 15;   // CIE-Lab ΔE ceiling ≈ "15% color variation"
+const MAX_CARDS = 400;        // safety cap on how many cards we return
 
-function proxy(body) {
-  return new Promise((resolve, reject) => {
+// ---- color math (all local, $0) --------------------------------------------
+const h2r = (h) => { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; };
+const r2h = (r, g, b) => '#' + [r, g, b].map(x => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0')).join('');
+function rgb2hsl(r, g, b) {
+  r /= 255; g /= 255; b /= 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b); let h, s, l = (mx + mn) / 2;
+  if (mx === mn) { h = s = 0; } else {
+    const d = mx - mn; s = l > .5 ? d / (2 - mx - mn) : d / (mx + mn);
+    switch (mx) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; }
+    h /= 6;
+  }
+  return [h * 360, s, l];
+}
+function hsl2rgb(h, s, l) {
+  h /= 360; let r, g, b;
+  if (s === 0) { r = g = b = l; } else {
+    const q = l < .5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
+    const t = (a) => { if (a < 0) a += 1; if (a > 1) a -= 1; if (a < 1 / 6) return p + (q - p) * 6 * a; if (a < 1 / 2) return q; if (a < 2 / 3) return p + (q - p) * (2 / 3 - a) * 6; return p; };
+    r = t(h + 1 / 3); g = t(h); b = t(h - 1 / 3);
+  }
+  return [r * 255, g * 255, b * 255];
+}
+function rgb2lab(r, g, b) {
+  r /= 255; g /= 255; b /= 255;
+  [r, g, b] = [r, g, b].map(v => v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92);
+  let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047,
+      y = (r * 0.2126 + g * 0.7152 + b * 0.0722),
+      z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
+  [x, y, z] = [x, y, z].map(v => v > 0.008856 ? v ** (1 / 3) : 7.787 * v + 16 / 116);
+  return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
+}
+const dE = (a, b) => { const A = rgb2lab(...h2r(a)), B = rgb2lab(...h2r(b)); return Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2]); };
+
+// ---- upstream query ---------------------------------------------------------
+function queryIndex(hex) {
+  const body = JSON.stringify({ hex, k: 80 });
+  return new Promise((resolve) => {
     const u = new URL(ENDPOINT);
     const req = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST',
-      headers: { 'Content-Type': 'application/json', 'Origin': 'https://designerwallcoverings.com',
-        'Content-Length': Buffer.byteLength(body) } },
-      (r) => { let d = ''; r.on('data', c => d += c); r.on('end', () => resolve(d)); });
-    req.on('error', reject); req.write(body); req.end();
+      headers: { 'Content-Type': 'application/json', 'Origin': 'https://designerwallcoverings.com', 'Content-Length': Buffer.byteLength(body) } },
+      (r) => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { resolve([]); } }); });
+    req.on('error', () => resolve([])); req.write(body); req.end();
   });
 }
 
+// widen a single base hex into a big within-ceiling result set
+const DH = [-14, 0, 14], DL = [-0.12, 0, 0.12], DS = [-0.15, 0, 0.15];
+async function widen(baseHex, ceiling) {
+  const [r0, g0, b0] = h2r(baseHex); const [H, S, L] = rgb2hsl(r0, g0, b0);
+  const seeds = new Set();
+  for (const dh of DH) for (const dl of DL) for (const ds of DS) {
+    const [r, g, b] = hsl2rgb((H + dh + 360) % 360, Math.max(0, Math.min(1, S + ds)), Math.max(0.12, Math.min(0.9, L + dl)));
+    seeds.add(r2h(r, g, b));
+  }
+  const byHandle = new Map();
+  await Promise.all([...seeds].map(async (hx) => {
+    for (const p of await queryIndex(hx)) {
+      if (!p.handle || !p.hex) continue;
+      if (!byHandle.has(p.handle)) byHandle.set(p.handle, p);
+    }
+  }));
+  const out = [];
+  for (const p of byHandle.values()) {
+    const d = dE(baseHex, p.hex);
+    if (d <= ceiling) out.push({ handle: p.handle, title: p.title, vendor: p.vendor, hex: p.hex, image: p.image, delta_e: d });
+  }
+  out.sort((a, b) => a.delta_e - b.delta_e);
+  return out.slice(0, MAX_CARDS);
+}
+
+// tiny in-memory cache keyed by quantized hex+ceiling (protects the upstream
+// during wheel drags — nearby picks reuse the same widened set)
+const cache = new Map(); const TTL = 5 * 60 * 1000;
+const quant = (hex) => { const [r, g, b] = h2r(hex); return r2h(r & 0xF0, g & 0xF0, b & 0xF0); };
+
 http.createServer(async (req, res) => {
   try {
     if (req.method === 'POST' && req.url === '/api/colors') {
       let b = ''; req.on('data', c => b += c);
       req.on('end', async () => {
-        try { const out = await proxy(b || '{}');
-          res.writeHead(200, { 'content-type': 'application/json' }).end(out); }
-        catch (e) { res.writeHead(502, { 'content-type': 'application/json' })
-          .end(JSON.stringify({ results: [], error: e.message })); }
+        try {
+          const { hex, ceiling } = JSON.parse(b || '{}');
+          if (!hex) throw new Error('hex required');
+          const ceil = Number(ceiling) || DEFAULT_CEILING;
+          const key = quant(hex) + '@' + ceil;
+          const hit = cache.get(key);
+          let results;
+          if (hit && Date.now() - hit.t < TTL) { results = hit.v; }
+          else { results = await widen(hex, ceil); cache.set(key, { t: Date.now(), v: results }); }
+          res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ hex, ceiling: ceil, results }));
+        } catch (e) {
+          res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ results: [], error: e.message }));
+        }
       });
       return;
     }

← 5ca3fe3 shop-by-color motion wheel (local preview :9851, proxies liv  ·  back to Dw Marketing Reels  ·  colorwheel: add 6-dot palette below wheel, paginated left/ri 5f28b85 →