← 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}`));