← back to Dw Photo Capture
shop-by-color: data-driven ΔE-collapsed color dots (real DB hexes, ≥20 patterns, qty-ordered)
e0020541019a94680e6bde1e27dd509d54045f1a · 2026-07-19 08:22:33 -0700 · Steve Abrams
- scripts/build-color-dots.cjs: greedy CIELAB ΔE76 clustering (DTD verdict A) of
product_colors (live-truth gated) into visually-unique dots; keep clusters
≥20 patterns, order by summed count desc; nearest-Pantone naming + LAB-chroma
family bucketing. 146 dots covering 41,907/43,284 products.
- server.js: public GET /apps/color-dots serving data/color-dots.json (hot-reload
on mtime, same CORS posture as /apps/color-index).
- refresh-color-index.sh: build + rsync color-dots.json alongside the index.
Files touched
A data/color-dots.jsonA scripts/build-color-dots.cjsM scripts/refresh-color-index.shM server.js
Diff
commit e0020541019a94680e6bde1e27dd509d54045f1a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 19 08:22:33 2026 -0700
shop-by-color: data-driven ΔE-collapsed color dots (real DB hexes, ≥20 patterns, qty-ordered)
- scripts/build-color-dots.cjs: greedy CIELAB ΔE76 clustering (DTD verdict A) of
product_colors (live-truth gated) into visually-unique dots; keep clusters
≥20 patterns, order by summed count desc; nearest-Pantone naming + LAB-chroma
family bucketing. 146 dots covering 41,907/43,284 products.
- server.js: public GET /apps/color-dots serving data/color-dots.json (hot-reload
on mtime, same CORS posture as /apps/color-index).
- refresh-color-index.sh: build + rsync color-dots.json alongside the index.
---
data/color-dots.json | 1 +
scripts/build-color-dots.cjs | 177 +++++++++++++++++++++++++++++++++++++++++
scripts/refresh-color-index.sh | 9 ++-
server.js | 46 ++++++++++-
4 files changed, 230 insertions(+), 3 deletions(-)
diff --git a/data/color-dots.json b/data/color-dots.json
new file mode 100644
index 0000000..4a37a20
--- /dev/null
+++ b/data/color-dots.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-19T15:18:03.892Z","merge_de":10,"min_patterns":20,"total_products":43284,"covered":41907,"count":146,"dots":[{"x":"#f2f2f2","n":18280,"name":"Soft White","family":"white","l":95.5,"a":0,"b":0,"members":1358},{"x":"#d9d2c4","n":5100,"name":"Greige","family":"grey","l":84.4,"a":0,"b":7.8,"members":2489},{"x":"#a9a9a9","n":1523,"name":"Ash Grey","family":"grey","l":69.2,"a":0,"b":0,"members":1238},{"x":"#f5f5dc","n":1237,"name":"Beige","family":"neutral","l":95.9,"a":-4.2,"b":12,"members":473},{"x":"#c1b39a","n":1158,"name":"Oatmeal","family":"neutral","l":73.5,"a":0.9,"b":14.6,"members":982},{"x":"#c7ced4","n":882,"name":"Silver Lining","family":"grey","l":82.4,"a":-1.2,"b":-3.8,"members":790},{"x":"#808080","n":834,"name":"Pewter","family":"grey","l":53.6,"a":0,"b":0,"members":605},{"x":"#a3957d","n":757,"name":"Warm Taupe","family":"neutral","l":62.3,"a":1.2,"b":14.6,"members":640},{"x":"#282828","n":569,"name":"Jet Black","family":"grey","l":16.1,"a":0,"b":0,"members":407},{"x":"#36454f","n":553,"name":"Slate","family":"blue","l":28.4,"a":-3.3,"b":-8,"members":291},{"x":"#736755","n":455,"name":"Bark","family":"brown","l":44.2,"a":1.6,"b":11.9,"members":438},{"x":"#4f4f4f","n":447,"name":"Charcoal","family":"grey","l":33.6,"a":0,"b":0,"members":414},{"x":"#d2b48c","n":419,"name":"Almond Buff","family":"orange","l":75,"a":5,"b":24.4,"members":254},{"x":"#ddd1ac","n":392,"name":"Chamois","family":"neutral","l":83.9,"a":-1.7,"b":19.9,"members":392},{"x":"#483c32","n":380,"name":"Espresso","family":"brown","l":26.3,"a":3.4,"b":8.1,"members":327},{"x":"#fffdd0","n":320,"name":"Beige","family":"neutral","l":98.5,"a":-6.5,"b":21.8,"members":9},{"x":"#000000","n":272,"name":"Obsidian","family":"black","l":0,"a":0,"b":0,"members":71},{"x":"#b0beaf","n":257,"name":"Dove Grey","family":"neutral","l":75.5,"a":-7.6,"b":5.9,"members":257},{"x":"#a38266","n":238,"name":"Cognac","family":"orange","l":56.8,"a":8.7,"b":20,"members":205},{"x":"#273752","n":200,"name":"Indigo","family":"blue","l":22.9,"a":2.1,"b":-18.4,"members":180},{"x":"#576a70","n":186,"name":"Gunmetal","family":"blue","l":43.5,"a":-5.6,"b":-5.7,"members":178},{"x":"#708090","n":185,"name":"Slate Blue","family":"blue","l":52.8,"a":-2.1,"b":-10.6,"members":172},{"x":"#add8e6","n":165,"name":"Powder Blue","family":"blue","l":83.8,"a":-10.9,"b":-11.5,"members":57},{"x":"#c19a6b","n":163,"name":"Camel","family":"orange","l":66.1,"a":8.4,"b":30.2,"members":153},{"x":"#97b5b5","n":163,"name":"Dove Grey","family":"neutral","l":71.5,"a":-10.2,"b":-3.4,"members":153},{"x":"#7a959a","n":157,"name":"Ultimate Gray","family":"teal","l":59.9,"a":-8.3,"b":-5.8,"members":157},{"x":"#808973","n":155,"name":"Eucalyptus","family":"neutral","l":55.8,"a":-7.5,"b":10.7,"members":155},{"x":"#bcb88a","n":153,"name":"Sand Dollar","family":"yellow","l":74.1,"a":-6,"b":23.9,"members":136},{"x":"#192335","n":151,"name":"Midnight Navy","family":"black","l":13.6,"a":1.6,"b":-13.1,"members":125},{"x":"#98a788","n":139,"name":"Fern","family":"neutral","l":66.6,"a":-11,"b":14.3,"members":128},{"x":"#b0c4de","n":134,"name":"Powder Blue","family":"blue","l":78.5,"a":-1.3,"b":-15.2,"members":68},{"x":"#98a1b2","n":127,"name":"Ultimate Gray","family":"blue","l":66,"a":0.4,"b":-9.9,"members":123},{"x":"#bb9e9a","n":126,"name":"Rose","family":"neutral","l":67.5,"a":10,"b":6.1,"members":125},{"x":"#64473a","n":126,"name":"Mocha","family":"brown","l":33,"a":10.5,"b":12.7,"members":126},{"x":"#fedfc3","n":118,"name":"Champagne","family":"neutral","l":90.7,"a":6.5,"b":17.7,"members":86},{"x":"#53645a","n":116,"name":"Gunmetal","family":"green","l":40.7,"a":-8.7,"b":3.5,"members":103},{"x":"#51576a","n":111,"name":"Slate","family":"blue","l":37.1,"a":2.3,"b":-11.7,"members":111},{"x":"#466987","n":110,"name":"Steel Blue","family":"blue","l":43,"a":-3.8,"b":-20.5,"members":101},{"x":"#805841","n":107,"name":"Chestnut","family":"brown","l":41.1,"a":13.6,"b":19.9,"members":99},{"x":"#947c79","n":107,"name":"Pewter","family":"red","l":54.1,"a":8.7,"b":5.1,"members":107},{"x":"#806b46","n":104,"name":"Olive Branch","family":"brown","l":46.4,"a":2.9,"b":23.8,"members":104},{"x":"#cee8e4","n":102,"name":"Soft White","family":"white","l":90.1,"a":-9.3,"b":-1,"members":102},{"x":"#91936b","n":100,"name":"Fern","family":"yellow","l":59.8,"a":-7.6,"b":21,"members":100},{"x":"#9f8050","n":93,"name":"Old Gold","family":"orange","l":55.5,"a":5.6,"b":30.5,"members":93},{"x":"#4169e1","n":92,"name":"Ultra Violet","family":"blue","l":47.8,"a":26.3,"b":-65.3,"members":1},{"x":"#84a294","n":92,"name":"Fern","family":"neutral","l":64.1,"a":-13.3,"b":3.8,"members":90},{"x":"#e2c487","n":91,"name":"Almond Buff","family":"gold","l":80.4,"a":2.3,"b":34.5,"members":90},{"x":"#e8bdb6","n":80,"name":"Rose","family":"neutral","l":80.1,"a":14.3,"b":9.3,"members":79},{"x":"#228b22","n":79,"name":"Forest","family":"green","l":50.6,"a":-49.6,"b":45,"members":5},{"x":"#4a543a","n":78,"name":"Hunter Green","family":"green","l":34.2,"a":-9.2,"b":13.9,"members":75},{"x":"#2a493d","n":77,"name":"Hunter Green","family":"green","l":28.4,"a":-14.7,"b":3.6,"members":75},{"x":"#527c7d","n":77,"name":"Mineral Blue","family":"teal","l":49.1,"a":-14.2,"b":-5.2,"members":77},{"x":"#a87840","n":75,"name":"Old Gold","family":"orange","l":54.2,"a":12.7,"b":37.7,"members":73},{"x":"#b74b3d","n":70,"name":"Terracotta","family":"red","l":46.2,"a":42.8,"b":30.4,"members":68},{"x":"#c48f45","n":67,"name":"Antique Gold","family":"orange","l":63.2,"a":12.4,"b":46.3,"members":64},{"x":"#877f4b","n":67,"name":"Old Gold","family":"gold","l":52.8,"a":-4.8,"b":29.3,"members":62},{"x":"#ad6155","n":67,"name":"Dusty Cedar","family":"red","l":49.6,"a":29.5,"b":20.3,"members":66},{"x":"#e2c16f","n":64,"name":"Ochre","family":"gold","l":79.3,"a":1.5,"b":45.3,"members":60},{"x":"#b39b5b","n":64,"name":"Ochre","family":"gold","l":64.8,"a":0.3,"b":36.6,"members":64},{"x":"#613528","n":63,"name":"Mocha","family":"red","l":27.4,"a":18.3,"b":16.7,"members":57},{"x":"#345a60","n":63,"name":"Gunmetal","family":"teal","l":35.8,"a":-11.7,"b":-7.7,"members":63},{"x":"#383b26","n":62,"name":"Espresso","family":"yellow","l":24,"a":-5.5,"b":12.6,"members":62},{"x":"#468499","n":59,"name":"Mineral Blue","family":"blue","l":52,"a":-13.8,"b":-17.4,"members":53},{"x":"#00466b","n":59,"name":"Classic Blue","family":"blue","l":28,"a":-4.1,"b":-26.8,"members":54},{"x":"#823333","n":59,"name":"Marsala","family":"red","l":32.6,"a":34.1,"b":17.5,"members":59},{"x":"#993333","n":58,"name":"Cranberry","family":"red","l":36.7,"a":42.5,"b":23.5,"members":56},{"x":"#4a2f31","n":58,"name":"Espresso","family":"red","l":22.7,"a":12.7,"b":3.8,"members":58},{"x":"#1f170e","n":55,"name":"Obsidian","family":"black","l":8.4,"a":2.4,"b":6.8,"members":55},{"x":"#008080","n":54,"name":"Teal","family":"teal","l":48.3,"a":-28.8,"b":-8.5,"members":16},{"x":"#b17958","n":54,"name":"Cognac","family":"orange","l":55.8,"a":18,"b":26.9,"members":54},{"x":"#6d6667","n":54,"name":"Gunmetal","family":"grey","l":43.8,"a":2.9,"b":0.4,"members":54},{"x":"#acc7a8","n":53,"name":"Sage","family":"neutral","l":77.5,"a":-15,"b":12.5,"members":45},{"x":"#607e6b","n":53,"name":"Eucalyptus","family":"green","l":50.1,"a":-14.9,"b":6.9,"members":51},{"x":"#926238","n":53,"name":"Chestnut","family":"orange","l":45.8,"a":14.9,"b":31.5,"members":53},{"x":"#a7d1c9","n":51,"name":"Silver Lining","family":"neutral","l":80.8,"a":-15.4,"b":-0.6,"members":48},{"x":"#9aa76f","n":51,"name":"Moss","family":"green","l":66.3,"a":-14.1,"b":27.5,"members":51},{"x":"#ffd700","n":49,"name":"Buttercup","family":"gold","l":86.9,"a":-1.9,"b":87.1,"members":1},{"x":"#696f45","n":49,"name":"Olive Branch","family":"yellow","l":45.4,"a":-9.7,"b":22.7,"members":49},{"x":"#f7e7b0","n":48,"name":"Champagne","family":"neutral","l":91.7,"a":-2.7,"b":28.8,"members":36},{"x":"#c9937b","n":47,"name":"Cognac","family":"orange","l":65.5,"a":17.2,"b":20.7,"members":46},{"x":"#83381c","n":46,"name":"Burnt Sienna","family":"orange","l":33.4,"a":30.5,"b":32.3,"members":44},{"x":"#d2b8c7","n":46,"name":"Silver Lining","family":"coral","l":77.3,"a":11.8,"b":-4.1,"members":44},{"x":"#7f4637","n":46,"name":"Marsala","family":"red","l":36.3,"a":22.8,"b":19.7,"members":46},{"x":"#003366","n":45,"name":"Classic Blue","family":"blue","l":21.3,"a":6.8,"b":-34.2,"members":37},{"x":"#415280","n":45,"name":"Classic Blue","family":"blue","l":35.4,"a":7,"b":-28,"members":44},{"x":"#d4a74a","n":44,"name":"Honey Gold","family":"gold","l":71,"a":6.6,"b":53,"members":40},{"x":"#5f9ea0","n":43,"name":"Mineral Blue","family":"teal","l":61.2,"a":-19.7,"b":-7.4,"members":40},{"x":"#fddedb","n":43,"name":"Ivory Cream","family":"white","l":90.9,"a":10.2,"b":5.4,"members":43},{"x":"#a47262","n":43,"name":"Cocoa Brown","family":"red","l":52.7,"a":17.7,"b":16.8,"members":43},{"x":"#5d815b","n":42,"name":"Willow Bough","family":"green","l":50.4,"a":-20.7,"b":16.6,"members":34},{"x":"#78b0cc","n":42,"name":"Airy Blue","family":"blue","l":69,"a":-11,"b":-20,"members":42},{"x":"#8b4513","n":41,"name":"Burnt Sienna","family":"orange","l":37.5,"a":26.4,"b":41,"members":14},{"x":"#8cb387","n":41,"name":"Sage","family":"green","l":69.2,"a":-21.8,"b":18.3,"members":27},{"x":"#7797b0","n":41,"name":"Slate Blue","family":"blue","l":60.9,"a":-5,"b":-16.6,"members":41},{"x":"#675234","n":41,"name":"Chestnut","family":"brown","l":36.3,"a":4.4,"b":20.9,"members":41},{"x":"#4682b4","n":40,"name":"Provence","family":"blue","l":52.5,"a":-4.1,"b":-32.2,"members":17},{"x":"#85605c","n":40,"name":"Cocoa Brown","family":"red","l":44.4,"a":14.5,"b":8.2,"members":39},{"x":"#6d535a","n":40,"name":"Mocha","family":"coral","l":38.2,"a":12,"b":0.1,"members":40},{"x":"#591818","n":39,"name":"Marsala","family":"red","l":19.4,"a":29.8,"b":16.9,"members":35},{"x":"#a66133","n":39,"name":"Burnt Sienna","family":"orange","l":48.1,"a":23.9,"b":37.5,"members":38},{"x":"#bdb76b","n":38,"name":"Ochre","family":"yellow","l":73.4,"a":-8.8,"b":39.3,"members":37},{"x":"#426244","n":38,"name":"Willow Bough","family":"green","l":38.4,"a":-18.5,"b":13.5,"members":38},{"x":"#ffbf00","n":36,"name":"Buttercup","family":"gold","l":81,"a":10.4,"b":83,"members":2},{"x":"#800020","n":35,"name":"Cranberry","family":"red","l":25.8,"a":48.9,"b":21.3,"members":28},{"x":"#b2993f","n":35,"name":"Antique Gold","family":"gold","l":63.8,"a":-1.3,"b":49.3,"members":34},{"x":"#9e813d","n":35,"name":"Old Gold","family":"gold","l":55.4,"a":2.9,"b":40.3,"members":34},{"x":"#de5d83","n":34,"name":"Cranberry","family":"coral","l":57.1,"a":53.8,"b":4.7,"members":4},{"x":"#b92a22","n":34,"name":"Racing Red","family":"red","l":41.4,"a":55.6,"b":40.3,"members":33},{"x":"#b3592c","n":34,"name":"Terracotta","family":"orange","l":48.2,"a":33.3,"b":41.7,"members":33},{"x":"#ff7f50","n":33,"name":"Living Coral","family":"orange","l":67.3,"a":45.4,"b":47.5,"members":5},{"x":"#779ecb","n":33,"name":"Serenity","family":"blue","l":63.9,"a":-1.7,"b":-27.3,"members":29},{"x":"#594659","n":33,"name":"Charcoal","family":"purple","l":32.3,"a":11.9,"b":-8.2,"members":30},{"x":"#0a3138","n":33,"name":"Slate","family":"teal","l":18.1,"a":-10.5,"b":-8.4,"members":33},{"x":"#19478f","n":32,"name":"Classic Blue","family":"blue","l":31.1,"a":12.5,"b":-44.2,"members":29},{"x":"#5e8b52","n":32,"name":"Willow Bough","family":"green","l":53.4,"a":-26.9,"b":25.7,"members":29},{"x":"#c67e53","n":32,"name":"Terracotta","family":"orange","l":59.5,"a":23.6,"b":34.8,"members":31},{"x":"#e07a5f","n":31,"name":"Terracotta","family":"red","l":62.2,"a":37,"b":32.3,"members":26},{"x":"#5e2e32","n":31,"name":"Mocha","family":"red","l":25.5,"a":22.2,"b":7.4,"members":31},{"x":"#ffb6c1","n":29,"name":"Blush","family":"red","l":81.1,"a":28,"b":5,"members":3},{"x":"#4a411f","n":29,"name":"Espresso","family":"gold","l":27.7,"a":-1.4,"b":22,"members":28},{"x":"#c7817d","n":29,"name":"Dusty Cedar","family":"red","l":61.1,"a":26.6,"b":13.4,"members":29},{"x":"#dc143c","n":28,"name":"Racing Red","family":"red","l":47,"a":70.9,"b":33.6,"members":5},{"x":"#04686b","n":28,"name":"Deep Lake","family":"teal","l":39.6,"a":-23.6,"b":-8.9,"members":26},{"x":"#a04f55","n":28,"name":"Marsala","family":"red","l":43.8,"a":34.1,"b":12.2,"members":27},{"x":"#918940","n":28,"name":"Moss","family":"gold","l":56.4,"a":-7.1,"b":39.6,"members":26},{"x":"#6b7ea2","n":28,"name":"Steel Blue","family":"blue","l":52.6,"a":2.1,"b":-21.5,"members":28},{"x":"#ba7335","n":26,"name":"Amberglow","family":"orange","l":55,"a":22.6,"b":44.8,"members":25},{"x":"#40171b","n":26,"name":"Espresso","family":"black","l":14.3,"a":20.4,"b":7.2,"members":26},{"x":"#d6a4ac","n":26,"name":"Blush","family":"neutral","l":72.1,"a":19.6,"b":3,"members":26},{"x":"#3b6a35","n":26,"name":"Willow Bough","family":"green","l":40.4,"a":-27.7,"b":24.7,"members":26},{"x":"#a493a7","n":25,"name":"Lavender","family":"purple","l":63,"a":10,"b":-8.2,"members":25},{"x":"#4f8a7d","n":25,"name":"Eucalyptus","family":"teal","l":53.3,"a":-22.5,"b":0.9,"members":25},{"x":"#004650","n":24,"name":"Slate","family":"teal","l":26.6,"a":-15.2,"b":-11.5,"members":20},{"x":"#e6b373","n":24,"name":"Ochre","family":"orange","l":76.3,"a":10.9,"b":39.6,"members":23},{"x":"#d0ddbf","n":24,"name":"Beige","family":"neutral","l":86.4,"a":-9.7,"b":13.3,"members":24},{"x":"#bfb751","n":23,"name":"Ochre","family":"yellow","l":73.2,"a":-10.3,"b":51.8,"members":23},{"x":"#e2b531","n":22,"name":"Honey Gold","family":"gold","l":75.7,"a":4,"b":68.2,"members":21},{"x":"#6b3b13","n":22,"name":"Chestnut","family":"orange","l":30.2,"a":17.8,"b":32.5,"members":22},{"x":"#8d261a","n":22,"name":"Cranberry","family":"red","l":32.1,"a":42.6,"b":32.7,"members":22},{"x":"#e7b19a","n":22,"name":"Apricot","family":"orange","l":76.5,"a":16.5,"b":19.4,"members":22},{"x":"#935235","n":22,"name":"Burnt Sienna","family":"orange","l":42,"a":24.3,"b":28.6,"members":22},{"x":"#556b2f","n":21,"name":"Willow Bough","family":"green","l":42.2,"a":-18.8,"b":30.6,"members":16},{"x":"#d35934","n":21,"name":"Terracotta","family":"red","l":53.2,"a":46,"b":44.5,"members":21},{"x":"#ff6347","n":20,"name":"Living Coral","family":"red","l":62.2,"a":57.9,"b":46.4,"members":7},{"x":"#cb6362","n":20,"name":"Dusty Cedar","family":"red","l":54.6,"a":41.1,"b":20.1,"members":20},{"x":"#7a8f5c","n":20,"name":"Moss","family":"green","l":56.6,"a":-16.8,"b":24.7,"members":20}]}
\ No newline at end of file
diff --git a/scripts/build-color-dots.cjs b/scripts/build-color-dots.cjs
new file mode 100644
index 0000000..4de7378
--- /dev/null
+++ b/scripts/build-color-dots.cjs
@@ -0,0 +1,177 @@
+#!/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(h) { h = h.replace('#',''); return [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.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), l=(mx+mn)/2; 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';
+ 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); });
diff --git a/scripts/refresh-color-index.sh b/scripts/refresh-color-index.sh
index cc8846c..bacc3f0 100755
--- a/scripts/refresh-color-index.sh
+++ b/scripts/refresh-color-index.sh
@@ -14,8 +14,13 @@ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
echo "[$(date '+%F %T')] building color-index from shopify_products…"
node scripts/build-color-index.cjs
-echo "[$(date '+%F %T')] shipping color-index.json → Kamatera (hot-reload on mtime)…"
+echo "[$(date '+%F %T')] building color-dots (ΔE-collapsed shop-by-color wheel swatches)…"
+node scripts/build-color-dots.cjs
+
+echo "[$(date '+%F %T')] shipping color-index.json + color-dots.json → Kamatera (hot-reload on mtime)…"
rsync -az data/color-index.json \
root@45.61.58.125:/root/public-projects/dwphoto/data/color-index.json
+rsync -az data/color-dots.json \
+ root@45.61.58.125:/root/public-projects/dwphoto/data/color-dots.json
-echo "[$(date '+%F %T')] color-index refreshed + shipped."
+echo "[$(date '+%F %T')] color-index + color-dots refreshed + shipped."
diff --git a/server.js b/server.js
index 0ea45e2..06839cd 100644
--- a/server.js
+++ b/server.js
@@ -381,6 +381,21 @@ function loadColorIndex() {
_colorIndexMtime = st.mtimeMs;
return _colorIndex;
}
+// ── Color-DOTS (shop-by-color WHEEL swatch set) ────────────────────────────────
+// The wheel's clickable dots must be REAL DB colors only, each backing >=20
+// patterns, ordered by most-used qty, and each visually UNIQUE (ΔE-collapsed).
+// Built by scripts/build-color-dots.cjs → data/color-dots.json. Served read-only,
+// hot-reloaded on mtime like the color index. See dw-shop-by-color-wheel memory.
+const COLOR_DOTS_PATH = require('path').join(__dirname, 'data', 'color-dots.json');
+let _colorDots = null, _colorDotsMtime = 0;
+function loadColorDots() {
+ const fs = require('fs');
+ const st = fs.statSync(COLOR_DOTS_PATH); // throws if missing → handler 503s
+ if (_colorDots && st.mtimeMs === _colorDotsMtime) return _colorDots;
+ _colorDots = JSON.parse(fs.readFileSync(COLOR_DOTS_PATH, 'utf8'));
+ _colorDotsMtime = st.mtimeMs;
+ return _colorDots;
+}
// sRGB hex → CIELAB (D65), matching how product_colors LAB was computed upstream.
function hexToLab(hex) {
hex = hex.replace('#', '');
@@ -901,7 +916,7 @@ function pipeUpstreamImage(src, res, headers) {
const appHandler = (req, res) => {
// public (no-auth) paths: health + the home-screen-install assets iOS fetches without creds
- const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar', '/apps/color-index', '/apps/color-widen'];
+ const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar', '/apps/color-index', '/apps/color-widen', '/apps/color-dots'];
const _p = req.url.split('?')[0];
if (!PUBLIC.includes(_p) && !checkAuth(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Photo Capture"' });
@@ -977,6 +992,35 @@ const appHandler = (req, res) => {
// · Output allowlist: only public catalog fields already visible on the store.
// · CORS: reflect an ALLOWED storefront origin only.
// · 10% tolerance is ONE named constant (COLOR_INDEX_TOLERANCE_PCT) → ΔE ceiling.
+ // ── Shop-by-Color WHEEL dot set (GET) ──
+ // The data-driven color dots for /pages/shop-by-color: real DB colors only,
+ // each >=20 patterns, ΔE-collapsed to be visually unique, ordered by qty.
+ // Read-only GET (no input), public, same CORS posture as the color routes.
+ if (u.pathname === '/apps/color-dots') {
+ const ALLOWED_ORIGINS = new Set([
+ 'https://designerwallcoverings.com',
+ 'https://www.designerwallcoverings.com',
+ 'https://designer-laboratory-sandbox.myshopify.com'
+ ]);
+ const origin = req.headers.origin || '';
+ const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
+ const CORS = {
+ 'Access-Control-Allow-Origin': corsOrigin,
+ 'Vary': 'Origin',
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ 'Cache-Control': 'public, max-age=3600'
+ };
+ if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
+ if (req.method !== 'GET') return send(res, 405, { err: 'GET required' }, CORS);
+ try {
+ const d = loadColorDots();
+ return send(res, 200, d, CORS);
+ } catch (e) {
+ return send(res, 503, { ok: false, err: 'color dots unavailable', dots: [] }, CORS);
+ }
+ }
+
if (u.pathname === '/apps/color-index') {
const ALLOWED_ORIGINS = new Set([
'https://designerwallcoverings.com',
← a9c6a11 chore: version-up v1.5.0 (session close — shop-by-color prod
·
back to Dw Photo Capture
·
auto-save: 2026-07-20T03:59:45 (1 files) — data/color-dots.j 2a2122c →