← back to Quadrille Showroom
scripts/enrich.js
67 lines
#!/usr/bin/env node
/**
* enrich.js — RESEARCH.md data wins for the version mechanics. Produces
* data/enrichment.json: sku -> { patternBase, family colorways, room, hue, color_bucket }.
* - patternBase + family: split pattern_name on " on " (instant, powers pairs-well-with).
* - room: surface the existing room image (~41% have one).
* - hue + color_bucket: dominant colour per swatch via sharp (powers Color River / Fan).
* Writes patternBase/room/family immediately, then fills hue in the background pass.
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const DATA = path.join(__dirname, '..', 'data', 'showroom-products.json');
const OUT = path.join(__dirname, '..', 'data', 'enrichment.json');
const products = JSON.parse(fs.readFileSync(DATA, 'utf8')).products;
// ---- 1+2: patternBase / family / room (instant) ----
// patternBase = the human-readable pre-"on" pattern name ("Aga Apple").
// family = the FIRST-WORD family used to group colorways for pairs-well-with
// ("Aga"). The old grouping keyed familySkus on patternBase, which over-split
// into 778 near-singleton families ("Aga Apple" ≠ "Aga Jungle"); grouping on
// the first word collapses every colorway of a design into one family — the
// same logic V7's mood-board pairs-well-with already uses client-side. (2026-06-28)
const baseOf = (name) => (name || '').split(/\s+on\s+/i)[0].replace(/\s+screen\s*print.*$/i, '').trim() || (name || 'Pattern');
const familyOf = (name) => baseOf(name).split(/\s+/)[0].trim().toLowerCase() || 'pattern';
const enrich = {};
const families = {};
products.forEach(p => {
const nm = p.pattern_name || p.name;
const base = baseOf(nm);
const fam = familyOf(nm);
enrich[p.sku] = { patternBase: base, family: fam, room: p.room || null, hue: null, color_bucket: null };
(families[fam] = families[fam] || []).push(p.sku);
});
// familySkus = OTHER colorways in the same first-word family (powers pairs-well-with).
products.forEach(p => { enrich[p.sku].familySkus = families[enrich[p.sku].family].filter(s => s !== p.sku).slice(0, 12); });
fs.writeFileSync(OUT, JSON.stringify({ built_at: new Date().toISOString(), hue_done: false, enrich }, null, 0));
console.log(`[enrich] patternBase/family/room written for ${products.length} skus; ${Object.keys(families).length} first-word families (was 778 on patternBase)`);
// ---- 3: dominant hue via sharp (background) ----
let sharp; try { sharp = require('sharp'); } catch { console.log('[enrich] sharp not installed — run: npm i sharp (hue skipped)'); process.exit(0); }
const BUCKETS = [[15,'red'],[45,'orange'],[70,'yellow'],[170,'green'],[200,'teal'],[255,'blue'],[290,'purple'],[330,'magenta'],[360,'red']];
const rgb2hue = (r,g,b)=>{r/=255;g/=255;b/=255;const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn;let h=0;if(d){if(mx===r)h=((g-b)/d)%6;else if(mx===g)h=(b-r)/d+2;else h=(r-g)/d+4;h*=60;if(h<0)h+=360;}const s=mx===0?0:d/mx;return {h:Math.round(h),s,v:mx};};
const fetchBuf = (url)=>new Promise((res,rej)=>{https.get(url,r=>{if(r.statusCode>=400){r.resume();return rej(new Error(r.statusCode));}const c=[];r.on('data',d=>c.push(d));r.on('end',()=>res(Buffer.concat(c)));}).on('error',rej);});
(async()=>{
let done=0, fail=0;
for (const p of products) {
const url = p.image; if (!url || !/^https/.test(url)) { fail++; continue; }
try {
const buf = await fetchBuf(url);
const { data } = await sharp(buf).resize(16,16,{fit:'fill'}).removeAlpha().raw().toBuffer({resolveWithObject:true});
// average only reasonably-saturated pixels so the hue reflects the design, not the ground
let r=0,g=0,b=0,n=0;
for(let i=0;i<data.length;i+=3){const hsv=rgb2hue(data[i],data[i+1],data[i+2]);if(hsv.s>0.18){r+=data[i];g+=data[i+1];b+=data[i+2];n++;}}
if(!n){for(let i=0;i<data.length;i+=3){r+=data[i];g+=data[i+1];b+=data[i+2];n++;}}
const {h}=rgb2hue(r/n,g/n,b/n);
enrich[p.sku].hue=h; enrich[p.sku].color_bucket=BUCKETS.find(x=>h<=x[0])[1];
done++;
if(done%100===0){fs.writeFileSync(OUT,JSON.stringify({built_at:new Date().toISOString(),hue_done:false,enrich},null,0));console.log(`[enrich] hue ${done}/${products.length}`);}
} catch(e){ fail++; }
}
fs.writeFileSync(OUT,JSON.stringify({built_at:new Date().toISOString(),hue_done:true,hue_filled:done,hue_failed:fail,enrich},null,0));
console.log(`[enrich] DONE hue: ${done} filled, ${fail} failed -> ${OUT}`);
})();