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