[object Object]

← back to Japan Enrich

japan/china viewer: add 6 DW facets (collection, width, fire, performance, price band, image) to the enriched nav

a06707a7cc94a943b20f14496473a3988e8b8c33 · 2026-06-30 13:26:24 -0700 · Steve

- server: derived collection/fire_class/functions/price_band/image_state per row, both vendors degrade gracefully
- /api/facets: drill-down counts for all 6 new dims (functions multi-valued); fixed PRICE_BANDS comma-collision (≤¥1,000 → ≤¥1k) that broke CSV multi-select
- index.html: 6 new chip rows, state+localStorage persistence, active-filter chips, Clear all, price band fixed order

Files touched

Diff

commit a06707a7cc94a943b20f14496473a3988e8b8c33
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 30 13:26:24 2026 -0700

    japan/china viewer: add 6 DW facets (collection, width, fire, performance, price band, image) to the enriched nav
    
    - server: derived collection/fire_class/functions/price_band/image_state per row, both vendors degrade gracefully
    - /api/facets: drill-down counts for all 6 new dims (functions multi-valued); fixed PRICE_BANDS comma-collision (≤¥1,000 → ≤¥1k) that broke CSV multi-select
    - index.html: 6 new chip rows, state+localStorage persistence, active-filter chips, Clear all, price band fixed order
---
 viewer-local/public/index.html | 54 ++++++++++++++++++++++++++++++++----
 viewer-local/server.js         | 62 ++++++++++++++++++++++++++++++++++++++----
 viewer-local/server.log        |  2 +-
 3 files changed, 106 insertions(+), 12 deletions(-)

diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 00b51eb..a6fabb8 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -109,6 +109,12 @@
       <div class="frow"><span class="flabel">Color</span><div class="frow" id="colorRow"></div></div>
       <div class="frow"><span class="flabel">Style</span><div class="frow" id="styleRow"></div></div>
       <div class="frow"><span class="flabel">Material</span><div class="frow" id="matRow"></div></div>
+      <div class="frow"><span class="flabel">Collection</span><div class="frow" id="collRow"></div></div>
+      <div class="frow"><span class="flabel">Width</span><div class="frow" id="widthRow"></div></div>
+      <div class="frow"><span class="flabel">Fire</span><div class="frow" id="fireRow"></div></div>
+      <div class="frow"><span class="flabel">Performance</span><div class="frow" id="funcRow"></div></div>
+      <div class="frow"><span class="flabel">Price</span><div class="frow" id="priceRow"></div></div>
+      <div class="frow"><span class="flabel">Image</span><div class="frow" id="imgRow"></div></div>
       <div class="frow"><span class="flabel">Enriched</span>
         <div class="seg" id="enrSeg">
           <button data-enr="all" class="on">All</button>
@@ -166,6 +172,12 @@ let state = {
   colors: loadSet('dw_colors'),
   styles: loadSet('dw_styles'),
   materials: loadSet('dw_materials'),
+  collections: loadSet('dw_collections'),
+  widths: loadSet('dw_widths'),
+  fires: loadSet('dw_fires'),
+  funcs: loadSet('dw_funcs'),
+  prices: loadSet('dw_prices'),
+  images: loadSet('dw_images'),
   enriched: localStorage.getItem('dw_enr') || 'all',
   offset: 0, total: 0, loading: false, done: false,
 };
@@ -190,13 +202,23 @@ function persistFacets() {
   localStorage.setItem('dw_colors', JSON.stringify([...state.colors]));
   localStorage.setItem('dw_styles', JSON.stringify([...state.styles]));
   localStorage.setItem('dw_materials', JSON.stringify([...state.materials]));
+  localStorage.setItem('dw_collections', JSON.stringify([...state.collections]));
+  localStorage.setItem('dw_widths', JSON.stringify([...state.widths]));
+  localStorage.setItem('dw_fires', JSON.stringify([...state.fires]));
+  localStorage.setItem('dw_funcs', JSON.stringify([...state.funcs]));
+  localStorage.setItem('dw_prices', JSON.stringify([...state.prices]));
+  localStorage.setItem('dw_images', JSON.stringify([...state.images]));
   localStorage.setItem('dw_enr', state.enriched);
 }
 function facetParams(extra = {}) {
   return new URLSearchParams({
     source: state.source, sort: state.sort, q: state.q,
     colors: [...state.colors].join(','), styles: [...state.styles].join(','),
-    materials: [...state.materials].join(','), enriched: state.enriched, ...extra,
+    materials: [...state.materials].join(','),
+    collections: [...state.collections].join(','), widths: [...state.widths].join(','),
+    fires: [...state.fires].join(','), funcs: [...state.funcs].join(','),
+    prices: [...state.prices].join(','), images: [...state.images].join(','),
+    enriched: state.enriched, ...extra,
   });
 }
 
@@ -247,11 +269,14 @@ function renderFacets(f) {
     el.onclick = () => toggleSet(state.colors, fam);
     colorRow.appendChild(el);
   });
-  // style + material chips (sorted by count desc)
-  const chipRow = (rowId, counts, set) => {
+  // generic count-chip row (sorted by count desc, or by a fixed `order` array when given)
+  const chipRow = (rowId, counts, set, order) => {
     const row = document.getElementById(rowId); row.innerHTML = '';
     row.appendChild(allChip(set));
-    const entries = Object.entries(counts || {}).sort((a, b) => b[1] - a[1]);
+    let entries = Object.entries(counts || {});
+    entries = order
+      ? entries.sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
+      : entries.sort((a, b) => b[1] - a[1]);
     if (!entries.length) { const s = document.createElement('span'); s.className = 'un'; s.textContent = 'none yet'; row.appendChild(s); return; }
     entries.forEach(([val, ct]) => {
       const on = set.has(val);
@@ -264,6 +289,12 @@ function renderFacets(f) {
   };
   chipRow('styleRow', f.style, state.styles);
   chipRow('matRow', f.material, state.materials);
+  chipRow('collRow', f.collection, state.collections);
+  chipRow('widthRow', f.width, state.widths);
+  chipRow('fireRow', f.fire_class, state.fires);
+  chipRow('funcRow', f.functions, state.funcs);
+  chipRow('priceRow', f.price_band, state.prices, f.price_order);
+  chipRow('imgRow', f.image_state, state.images);
   // enriched seg counts
   document.querySelector('#enrSeg [data-enr=enriched]').textContent = `Enriched (${(f.enriched || 0).toLocaleString()})`;
   document.querySelector('#enrSeg [data-enr=unenriched]').textContent = `Unenriched (${(f.unenriched || 0).toLocaleString()})`;
@@ -281,18 +312,29 @@ function renderActive() {
   state.colors.forEach(v => add('Color', v, state.colors));
   state.styles.forEach(v => add('Style', v, state.styles));
   state.materials.forEach(v => add('Material', v, state.materials));
+  state.collections.forEach(v => add('Collection', v, state.collections));
+  state.widths.forEach(v => add('Width', v, state.widths));
+  state.fires.forEach(v => add('Fire', v, state.fires));
+  state.funcs.forEach(v => add('Performance', v, state.funcs));
+  state.prices.forEach(v => add('Price', v, state.prices));
+  state.images.forEach(v => add('Image', v, state.images));
   if (state.enriched !== 'all') {
     const el = document.createElement('span'); el.className = 'afilter';
     el.innerHTML = `<b>Enriched:</b> ${state.enriched} ✕`;
     el.onclick = () => { state.enriched = 'all'; document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === 'all')); applyFilterChange(); };
     row.appendChild(el);
   }
-  const n = state.colors.size + state.styles.size + state.materials.size + (state.enriched !== 'all' ? 1 : 0);
+  const n = state.colors.size + state.styles.size + state.materials.size
+    + state.collections.size + state.widths.size + state.fires.size
+    + state.funcs.size + state.prices.size + state.images.size
+    + (state.enriched !== 'all' ? 1 : 0);
   document.getElementById('facetCount').textContent = n ? `· ${n} active` : '';
   if (n) {
     const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
     c.onclick = () => {
-      state.colors.clear(); state.styles.clear(); state.materials.clear(); state.enriched = 'all';
+      [state.colors, state.styles, state.materials, state.collections, state.widths,
+       state.fires, state.funcs, state.prices, state.images].forEach(s => s.clear());
+      state.enriched = 'all';
       document.querySelectorAll('#enrSeg button').forEach(b => b.classList.toggle('on', b.dataset.enr === 'all'));
       applyFilterChange();
     };
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 2260c9c..4910fe6 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -59,6 +59,29 @@ let ENRICH = loadEnrich();
 // 12 color families (fixed order for the Color sort + swatch nav), with a representative dot hex.
 const FAMILY_ORDER = ['Red', 'Orange/Brown', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Neutral/Beige', 'Black', 'Grey', 'White'];
 
+// ── derived facet helpers (the "and more" dimensions) ───────────────────────────
+// Price bands on the Japan LIST price (¥/m) — fixed order so the chips read low→high.
+// NB: labels MUST stay comma-free — facet multi-select is CSV-encoded in the query string.
+const PRICE_BANDS = [
+  { key: '≤¥1k', test: (y) => y <= 1000 },
+  { key: '¥1k–2k', test: (y) => y > 1000 && y <= 2000 },
+  { key: '¥2k–4k', test: (y) => y > 2000 && y <= 4000 },
+  { key: '¥4k–8k', test: (y) => y > 4000 && y <= 8000 },
+  { key: '¥8k+', test: (y) => y > 8000 },
+];
+const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
+const priceBand = (yen) => { if (!yen) return null; const b = PRICE_BANDS.find((x) => x.test(yen)); return b ? b.key : null; };
+// Fire/flammability class — bucket the Japanese code (不燃 / 準不燃 / 防火) or Sangetsu Type II/III.
+const fireClass = (s) => {
+  if (!s) return null;
+  if (/^不燃/.test(s)) return 'Non-combustible 不燃';
+  if (/^準不燃/.test(s)) return 'Semi-noncomb 準不燃';
+  if (/^防火/.test(s)) return 'Fire-retardant 防火';
+  if (/^type/i.test(s)) return s.replace(/^type/i, 'Type').trim();
+  return s.split(/\s+/)[0];
+};
+const collectionName = (c) => (c === 'ImportSelection' ? 'Import Selection' : c) || null;
+
 // Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
 function buildRows() {
   const rows = [];
@@ -80,19 +103,25 @@ function buildRows() {
     const key = (r.sku_range_raw || r.mfr_sku || '').trim();
     const group = patterns.get(key) || [r.mfr_sku];
     const siblings = group.filter((s) => s !== r.mfr_sku).map(siblingChip);
+    // prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
+    const imgVal = ((allcIndex[normSku(r.mfr_sku)] || fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`))) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null;
     rows.push({
       source: 'lilycolor',
       sku: r.mfr_sku,
       prefix: r.mfr_prefix || null,
       title: r.title_ja || r.mfr_sku,
       catalog: r.source_catalog || null,                  // WILL / LIGHT / V-wall / MATERIALS / Import Selection
-      width: r.width || null,
+      collection: collectionName(r.source_catalog),       // facet: vendor catalog/book
+      width: r.width || null,                             // facet: roll width
       composition: (r.functions && r.functions.length) ? r.functions.join(', ') : null,
+      functions: r.functions || [],                       // facet (multi): performance functions
       fire: (r.fire_ratings && r.fire_ratings[0]) || null,
+      fire_class: fireClass((r.fire_ratings && r.fire_ratings[0]) || null), // facet: fire class
       price_yen: r.list_price_yen || null,                 // Japan LIST price (¥), not confirmed net cost
+      price_band: priceBand(r.list_price_yen || null),     // facet: ¥ price band
       repeat: (r.repeat_tate_cm && r.repeat_yoko_cm) ? `${r.repeat_tate_cm}×${r.repeat_yoko_cm}cm` : null,
-      // prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
-      image: ((allcIndex[normSku(r.mfr_sku)] || fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`))) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null,
+      image: imgVal,
+      image_state: imgVal ? 'Has swatch' : ((r.image_count || 0) ? 'Archived only' : 'No image'), // facet: swatch availability
       image_count: r.image_count || 0,
       image_kinds: r.image_kinds || [],
       url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
@@ -117,15 +146,22 @@ function buildRows() {
       // per-colorway enrichment (hex/family per colorway; style/material fanned from the pattern-level vision pass)
       const e = ENRICH[normSku(sku)] || null;
       const siblings = cw.filter((s) => s !== sku).map(siblingChip);
+      const sImg = imgs[sku] || null;
       rows.push({
         source: 'sangetsu',
         sku,
         prefix: r.mfr_prefix || null,
         title: r.pattern || sku,
-        width: (r.spec && r.spec.width) || null,
+        collection: null,                                 // Sangetsu has no vendor-book facet yet
+        width: (r.spec && r.spec.width) || null,          // facet: roll width (sparse for Sangetsu)
         composition: (r.spec && r.spec.is_grasscloth) ? 'Grasscloth' : null,
+        functions: (r.spec && r.spec.is_metallic) ? ['metallic'] : [], // facet (multi): scant for Sangetsu
         fire: (r.spec && r.spec.type) || null,
-        image: imgs[sku] || null,
+        fire_class: fireClass((r.spec && r.spec.type) || null), // facet: fire class (Type II/III)
+        price_yen: null,
+        price_band: null,                                 // no confirmed Sangetsu price yet
+        image: sImg,
+        image_state: sImg ? 'Has swatch' : 'No image',    // facet: swatch availability
         url: r.source_url || null,
         is_sample: false,
         colorway_count: cw.length,
@@ -174,6 +210,8 @@ function parseFilters(u) {
     source: u.searchParams.get('source') || 'all',
     q: (u.searchParams.get('q') || '').toLowerCase(),
     colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
+    collections: csv('collections'), widths: csv('widths'), fires: csv('fires'),
+    funcs: csv('funcs'), prices: csv('prices'), images: csv('images'),
     enriched: u.searchParams.get('enriched') || 'all',
   };
 }
@@ -184,6 +222,12 @@ function applyFilters(rows, f, opts = {}) {
   if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => r.color_family && f.colors.has(r.color_family));
   if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => r.style && f.styles.has(r.style));
   if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => r.material && f.materials.has(r.material));
+  if (opts.skip !== 'collection' && f.collections.size) out = out.filter((r) => r.collection && f.collections.has(r.collection));
+  if (opts.skip !== 'width' && f.widths.size) out = out.filter((r) => r.width && f.widths.has(r.width));
+  if (opts.skip !== 'fire' && f.fires.size) out = out.filter((r) => r.fire_class && f.fires.has(r.fire_class));
+  if (opts.skip !== 'funcs' && f.funcs.size) out = out.filter((r) => r.functions && r.functions.some((x) => f.funcs.has(x)));
+  if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => r.price_band && f.prices.has(r.price_band));
+  if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => r.image_state && f.images.has(r.image_state));
   if (opts.skip !== 'enriched' && f.enriched === 'enriched') out = out.filter((r) => r.enriched);
   if (opts.skip !== 'enriched' && f.enriched === 'unenriched') out = out.filter((r) => !r.enriched);
   return out;
@@ -208,12 +252,20 @@ const server = http.createServer((req, res) => {
   if (u.pathname === '/api/facets') {
     const f = parseFilters(u);
     const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
+    const tallyArr = (rows, key) => { const m = {}; for (const r of rows) { for (const v of (r[key] || [])) m[v] = (m[v] || 0) + 1; } return m; };
     const enrRows = applyFilters(ROWS, f, { skip: 'enriched' });
     res.writeHead(200, { 'Content-Type': 'application/json' });
     res.end(JSON.stringify({
       color_family: tally(applyFilters(ROWS, f, { skip: 'color' }), 'color_family'),
       style: tally(applyFilters(ROWS, f, { skip: 'style' }), 'style'),
       material: tally(applyFilters(ROWS, f, { skip: 'material' }), 'material'),
+      collection: tally(applyFilters(ROWS, f, { skip: 'collection' }), 'collection'),
+      width: tally(applyFilters(ROWS, f, { skip: 'width' }), 'width'),
+      fire_class: tally(applyFilters(ROWS, f, { skip: 'fire' }), 'fire_class'),
+      functions: tallyArr(applyFilters(ROWS, f, { skip: 'funcs' }), 'functions'),
+      price_band: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
+      image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+      price_order: PRICE_ORDER,
       enriched: enrRows.filter((r) => r.enriched).length,
       unenriched: enrRows.filter((r) => !r.enriched).length,
       total: applyFilters(ROWS, f).length,
diff --git a/viewer-local/server.log b/viewer-local/server.log
index d4a9e75..524093d 100644
--- a/viewer-local/server.log
+++ b/viewer-local/server.log
@@ -1 +1 @@
-Staging viewer → http://localhost:9931  (lilycolor 2543 / sangetsu 4230 SKUs)
+Staging viewer → http://localhost:9931  (lilycolor 2543 / sangetsu 10532 SKUs)

← 5e316b7 chore: lint (load() try/finally guard), refactor (siblingChi  ·  back to Japan Enrich  ·  viewer: env-gated admin Basic Auth (REQUIRE_AUTH) for deploy 1826109 →