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