← 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
M colorwheel/index.htmlM colorwheel/server.js
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 →