← 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
M viewer-local/public/index.htmlM viewer-local/server.jsM viewer-local/server.log
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 →