← back to Dw Photo Capture

scripts/build-color-dots.cjs

180 lines

#!/usr/bin/env node
/**
 * build-color-dots.cjs — generate the data-driven "color dots" for the
 * designerwallcoverings.com /pages/shop-by-color WHEEL (the Palette tab).
 *
 * Steve's rule (2026-07-19): the wheel must show ONLY colors that actually
 * exist in the hex_unified product-colors DB, every dot must back >=20 real
 * patterns, dots are ordered by most-used quantity, AND each dot must be a
 * UNIQUE, visually-distinct color (no run of near-identical soft-whites).
 *
 * Method (DTD verdict A, 5/5, 2026-07-19): perceptual CIELAB ΔE76 clustering.
 *   1. Gate to LIVE truth exactly like build-color-index.cjs (active +
 *      online_store_published + has_product_variant), ONE row per product.
 *   2. Aggregate to exact hexes with a count + mean LAB.
 *   3. Greedy-merge hexes within ΔE76 <= DOT_MERGE_DE into the MOST-USED hex of
 *      the cluster (seeds are visited most-used-first, so the representative is
 *      always the biggest real hex). Sum the cluster's pattern counts.
 *   4. Keep clusters whose SUMMED count >= DOT_MIN_PATTERNS. Order by count desc.
 *   5. Name each dot by nearest (ΔE) luxury/Pantone lexicon entry, and bucket it
 *      into a color family (for the wheel's family filter tabs).
 *
 * Output: data/color-dots.json → { generated_at, merge_de, min_patterns,
 *   total_products, covered, count, dots:[ { x:"#rrggbb", n:count, name, family,
 *   l,a,b, members } ] }   (served read-only by /apps/color-dots)
 *
 * Run where pg resolves (dwphoto's node_modules):
 *   cd ~/Projects/dw-photo-capture && node scripts/build-color-dots.cjs
 * $0 (local PG). Idempotent; safe to schedule alongside build-color-index.cjs.
 */
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');

const pool = new Pool(
  process.env.DATABASE_URL
    ? { connectionString: process.env.DATABASE_URL }
    : { host: '/tmp', database: 'dw_unified' }
);

const OUT = path.join(__dirname, '..', 'data', 'color-dots.json');
const DOT_MERGE_DE = Number(process.env.DOT_MERGE_DE || 10);    // ΔE76 merge radius
const DOT_MIN_PATTERNS = Number(process.env.DOT_MIN_PATTERNS || 20); // Steve's floor

// Compact luxury/Pantone lexicon (name + hex) → each dot gets the nearest name.
// Mirrors the interior-designer colorway vocabulary used elsewhere in DW.
const LEXICON = [
  ['Soft White', '#f2f2f2'], ['Pure White', '#ffffff'], ['Ivory Cream', '#f5f0e0'],
  ['Beige', '#f5f5dc'], ['Chamois', '#d9c9a5'], ['Greige', '#d9d2c4'], ['Almond Buff', '#d2b48c'],
  ['Sand Dollar', '#d5c4a1'], ['Warm Taupe', '#af9483'], ['Oatmeal', '#c1b39a'],
  ['Dove Grey', '#b5b5b5'], ['Silver Lining', '#c0c0c0'], ['Ultimate Gray', '#939597'],
  ['Ash Grey', '#a9a9a9'], ['Pewter', '#848482'], ['Charcoal', '#4a4a4a'], ['Anthracite', '#383838'],
  ['Jet Black', '#2c2c2c'], ['Obsidian', '#1c1c1c'], ['Slate', '#36454f'], ['Gunmetal', '#576a70'],
  ['Midnight Navy', '#1b2a4a'], ['Classic Blue', '#0f4c81'], ['Indigo', '#273752'],
  ['Provence', '#658dc6'], ['Serenity', '#92a8d1'], ['Powder Blue', '#b0c4de'],
  ['Cerulean', '#9bb7d4'], ['Slate Blue', '#708090'], ['Steel Blue', '#466987'],
  ['Airy Blue', '#92b6d5'], ['Tidewater', '#2a9d8f'], ['Mineral Blue', '#4e8b98'],
  ['Deep Lake', '#186a62'], ['Aqua Haze', '#6ecfbd'], ['Teal', '#008080'], ['Eucalyptus', '#5f8575'],
  ['Sage', '#9caf88'], ['Willow Bough', '#5a7247'], ['Moss', '#8a9a5b'], ['Olive Branch', '#6b6b47'],
  ['Hunter Green', '#335648'], ['Forest', '#228b22'], ['Celadon', '#ace1af'], ['Fern', '#98a788'],
  ['Ochre', '#c5a55a'], ['Honey Gold', '#d4a937'], ['Antique Gold', '#b8943e'], ['Old Gold', '#9f8050'],
  ['Champagne', '#e8d5b5'], ['Buttercup', '#fad02e'], ['Mimosa', '#e8c840'], ['Lemon', '#f0e68c'],
  ['Amberglow', '#d4763b'], ['Terracotta', '#c4633a'], ['Burnt Sienna', '#a0522d'],
  ['Camel', '#c19a6b'], ['Cognac', '#a38266'], ['Chestnut', '#805841'],
  ['Cocoa Brown', '#8b6c5c'], ['Espresso', '#483c32'], ['Mocha', '#64473a'], ['Bark', '#736755'],
  ['Living Coral', '#ff6f61'], ['Apricot', '#f5b895'], ['Blush', '#de9da5'], ['Rose', '#bb9e9a'],
  ['Dusty Cedar', '#ad655f'], ['Racing Red', '#b22234'], ['Cranberry', '#9b2335'],
  ['Marsala', '#964f4c'], ['Ultra Violet', '#6a4c93'], ['Lavender', '#9b8bb4'], ['Plum', '#6c3461'],
];

function hexToRgb(hex) { hex = hex.replace('#',''); return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)]; }
function rgbToLab(r, g, b) {
  let R=r/255, G=g/255, B=b/255;
  R = R>0.04045 ? Math.pow((R+0.055)/1.055,2.4) : R/12.92;
  G = G>0.04045 ? Math.pow((G+0.055)/1.055,2.4) : G/12.92;
  B = B>0.04045 ? Math.pow((B+0.055)/1.055,2.4) : B/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;
  const f=t=> t>0.008856 ? Math.cbrt(t) : (7.787*t + 16/116);
  X=f(X); Y=f(Y); Z=f(Z);
  return { l:116*Y-16, a:500*(X-Y), b:200*(Y-Z) };
}
const LEX = LEXICON.map(([name, hex]) => { const [r,g,b]=hexToRgb(hex); return { name, ...rgbToLab(r,g,b) }; });
function nearestName(lab) {
  let best='Neutral', bd=Infinity;
  for (const e of LEX) { const dl=e.l-lab.l, da=e.a-lab.a, db=e.b-lab.b, d=dl*dl+da*da+db*db; if (d<bd){bd=d; best=e.name;} }
  return best;
}
// Family bucket for the wheel filter tabs. Neutrals are gated by PERCEPTUAL
// CIELAB chroma (√(a²+b²)) — HSL saturation is inflated for light colors, so a
// pale beige/cream reads as "yellow" under HSL but as a near-neutral under LAB.
// Chromatic hue buckets fall back to HSL hue (fine once neutrals are removed).
function familyOf(hex, lab) {
  const [r,g,b]=hexToRgb(hex); const R=r/255,G=g/255,B=b/255;
  const mx=Math.max(R,G,B), mn=Math.min(R,G,B); let h=0, d=mx-mn;
  if (d!==0){ if (mx===R) h=((G-B)/d+(G<B?6:0)); else if (mx===G) h=((B-R)/d+2); else h=((R-G)/d+4); h*=60; }
  const C = Math.sqrt(lab.a*lab.a + lab.b*lab.b);   // LAB chroma
  const L = lab.l;
  if (L > 90 && C < 12) return 'white';
  if (L < 16) return 'black';
  if (C < 8) return 'grey';                          // truly achromatic
  // Pale near-neutrals are creams/beiges/taupes — they lean WARM (LAB b>0). A
  // pale COOL tint (powder blue, b<<0) keeps its hue instead of collapsing.
  if (L > 90 && C < 30 && lab.b > -4) return 'neutral';
  if (C < 20 && L >= 55 && lab.b > -4) return 'neutral';
  if (C < 26 && h >= 15 && h <= 45 && L < 55) return 'brown';
  // hue ranges (HSL degrees): red 0-15/345-360, orange 15-40, gold 40-55,
  // yellow 55-70, green 70-165, teal 165-190, blue 190-255, purple 255-320, coral 320-345
  if (h<15||h>=345) return 'red';
  if (h<40) return 'orange';
  if (h<55) return 'gold';
  if (h<70) return 'yellow';
  if (h<165) return 'green';
  if (h<190) return 'teal';
  if (h<255) return 'blue';
  if (h<320) return 'purple';
  return 'coral';
}

(async () => {
  const t0 = Date.now();
  const q = `
    SELECT DISTINCT ON (pc.handle)
      lower('#' || replace(pc.hex,'#','')) AS hex, pc.lab_l AS l, pc.lab_a AS a, pc.lab_b AS b
    FROM product_colors pc
    JOIN shopify_products sp ON sp.handle = pc.handle
    WHERE lower(sp.status) = 'active'
      AND sp.online_store_published = true
      AND sp.has_product_variant IS TRUE
      AND pc.lab_l IS NOT NULL AND pc.lab_a IS NOT NULL AND pc.lab_b IS NOT NULL
      AND pc.handle IS NOT NULL AND pc.handle <> ''
      AND pc.hex ~* '^#?[0-9a-f]{6}$'
    ORDER BY pc.handle`;
  const { rows } = await pool.query(q);
  await pool.end();

  // exact-hex aggregate: count + mean LAB
  const m = new Map();
  for (const r of rows) {
    let e = m.get(r.hex);
    if (!e) { e = { hex:r.hex, n:0, l:0,a:0,b:0 }; m.set(r.hex,e); }
    e.n++; e.l+=r.l; e.a+=r.a; e.b+=r.b;
  }
  let hexes = [...m.values()].map(e => ({ hex:e.hex, n:e.n, l:e.l/e.n, a:e.a/e.n, b:e.b/e.n }));
  hexes.sort((x,y)=> y.n - x.n);   // most-used first → reps are the biggest real hexes

  // greedy ΔE76 clustering
  const clusters = [];
  for (const h of hexes) {
    let best=null, bd=Infinity;
    for (const c of clusters) {
      const dl=c.l-h.l, da=c.a-h.a, db=c.b-h.b, d=Math.sqrt(dl*dl+da*da+db*db);
      if (d<=DOT_MERGE_DE && d<bd) { bd=d; best=c; }
    }
    if (best) { best.n += h.n; best.members.push(h.hex); }
    else clusters.push({ hex:h.hex, l:h.l,a:h.a,b:h.b, n:h.n, members:[h.hex] });
  }

  const dots = clusters
    .filter(c => c.n >= DOT_MIN_PATTERNS)
    .sort((x,y)=> y.n - x.n)
    .map(c => ({
      x: c.hex, n: c.n,
      name: nearestName({ l:c.l, a:c.a, b:c.b }),
      family: familyOf(c.hex, { l:c.l, a:c.a, b:c.b }),
      l: Math.round(c.l*10)/10, a: Math.round(c.a*10)/10, b: Math.round(c.b*10)/10,
      members: c.members.length
    }));

  const payload = {
    generated_at: new Date().toISOString(),
    merge_de: DOT_MERGE_DE, min_patterns: DOT_MIN_PATTERNS,
    total_products: rows.length,
    covered: dots.reduce((s,d)=>s+d.n,0),
    count: dots.length,
    dots
  };
  fs.writeFileSync(OUT, JSON.stringify(payload));
  const kb = Math.round(fs.statSync(OUT).size / 1024);
  console.log(`color-dots: ${dots.length} unique dots (ΔE${DOT_MERGE_DE}, >=${DOT_MIN_PATTERNS}) covering ${payload.covered}/${rows.length} → ${OUT} (${kb} KB) in ${Date.now()-t0}ms`);
})().catch(e => { console.error('build-color-dots FAILED:', e.message); process.exit(1); });