← back to Dw Marketing Reels
colorwheel/server.js
125 lines
#!/usr/bin/env node
// 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';
import { fileURLToPath } from 'node:url';
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
// ---- 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', () => { 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 { 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;
}
const html = await readFile(join(ROOT, 'index.html'));
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
} catch (e) { res.writeHead(500).end('err: ' + e.message); }
}).listen(PORT, () => console.log(`Shop-by-Color wheel → http://127.0.0.1:${PORT}`));