← back to Japan Enrich
japan viewer: add distributor (buy-from channel) chip + facet + sort
b1fd6b03b5dd8738f378b61a5c0a4af6bb9ef330 · 2026-07-06 13:21:15 -0700 · Steve
Sangetsu is the mill but its SKUs are only orderable through the Goodrich
distributor (sangetsu-goodrich.co.th) — surface that channel separately from
the brand. Derived from source_url host in distInfo(); ~84% of japan-scope
SKUs (13,358) are Goodrich-distributed. Direct-mill lines (Lilycolor/Greenland)
render a muted 'direct' chip. Added: card chip, left-panel facet, Distributor
A→Z sort, info-drawer row, free-text search, and server-side facet/sort/filter parity.
Files touched
M viewer-local/public/index.htmlM viewer-local/server.js
Diff
commit b1fd6b03b5dd8738f378b61a5c0a4af6bb9ef330
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 6 13:21:15 2026 -0700
japan viewer: add distributor (buy-from channel) chip + facet + sort
Sangetsu is the mill but its SKUs are only orderable through the Goodrich
distributor (sangetsu-goodrich.co.th) — surface that channel separately from
the brand. Derived from source_url host in distInfo(); ~84% of japan-scope
SKUs (13,358) are Goodrich-distributed. Direct-mill lines (Lilycolor/Greenland)
render a muted 'direct' chip. Added: card chip, left-panel facet, Distributor
A→Z sort, info-drawer row, free-text search, and server-side facet/sort/filter parity.
---
viewer-local/public/index.html | 15 +++++++++++----
viewer-local/server.js | 30 +++++++++++++++++++++++++++++-
2 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 277820f..078b15c 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -84,6 +84,10 @@ body.imgonly .grid:not(.list) .card .b{display:none}
.chip.cl{border-color:transparent;color:var(--mut);background:transparent;display:inline-flex;align-items:center;gap:5px;padding-left:0}
.chip.cl .dot{width:calc(9px*var(--fs));height:calc(9px*var(--fs));border-radius:50%;border:1px solid #0008;display:inline-block}
.chip.price{color:var(--ink);background:transparent;border-color:var(--line)}
+/* distributor / purchase-channel chip — accent when we must order from a distributor (Goodrich),
+ muted when the mill sells direct (Lilycolor/Greenland) */
+.chip.dist{background:transparent;border-color:var(--acc);color:var(--acc)}
+.chip.dist.direct{border-color:var(--line);color:var(--mut)}
.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
/* colorway sibling dots — the "other colors of this pattern" */
@@ -144,6 +148,7 @@ body.listmode #listhead{display:grid}
<option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option>
<option value="material">Material A→Z</option>
+ <option value="distributor">Distributor A→Z</option>
<option value="width">Width ↑</option>
<option value="price">Price ↑</option>
<option value="images">Most images</option>
@@ -188,7 +193,7 @@ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&',
// ── every data field is a left-panel table: collapsed on load, click value = filter ──
// 'functions' is an array field (multi-valued) — counted/filtered element-wise.
const FIELDS=[
- ['src_label','Source'],['collection','Collections'],['color_family','Colors'],['style','Styles'],
+ ['src_label','Source'],['distributor','Distributor'],['collection','Collections'],['color_family','Colors'],['style','Styles'],
['material','Material'],['width','Width'],['repeat','Repeat'],['fire_class','Fire class'],
['functions','Performance'],['price_band','Price ¥'],['image_state','Image'],['enr','Enriched'],
['prefix','SKU prefix'],['color_name','Color name'],
@@ -250,6 +255,7 @@ function card(p){
const cwDots=(p.siblings&&p.siblings.length)
?`<span class="cws" title="${p.colorway_count} colorways in this pattern">${p.siblings.map(s=>`<i class="${s.hex?'':'pend'}" style="${s.hex?('background:'+esc(s.hex)):''}" title="${esc(s.sku)}${s.family?(' · '+esc(s.family)):''}"></i>`).join('')}<span class="cwn">${p.colorway_count} cw</span></span>`:'';
const price=p.price_yen?`<span class="chip price" title="Japan LIST ¥${p.price_yen.toLocaleString()}/m — not net cost">${usd(p.price_yen)||('¥'+p.price_yen.toLocaleString())}<span class="muted">/m</span></span>`:'';
+ const dist=p.distributor?`<span class="chip dist${p.dist_direct?' direct':''}" title="${p.dist_direct?'Sold direct by the mill':'Purchase channel — this SKU can only be ordered from this distributor'}">${p.dist_direct?'':'🏬 '}${esc(p.distributor)}</span>`:'';
return `<div class="card"${p.url?` data-href="${esc(p.url)}"`:''} data-sku="${esc(p.sku)}">
${img}<div class="b">
<span class="vend">${src}${p.collection?(' · '+esc(p.collection)):''}</span>
@@ -258,7 +264,7 @@ function card(p){
<span class="lc" title="Collection">${esc(p.collection||'')}</span>
<div class="chips">
${p.color_family?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_family)}</span>`:''}
- ${cwDots}${price}
+ ${dist}${cwDots}${price}
<span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
</div>
<span class="lc" title="Color family">${esc(p.color_family||'')}</span>
@@ -269,7 +275,7 @@ function card(p){
<span class="lc" title="Fire class">${esc(p.fire_class||'')}</span>
</div>
<div class="info" onclick="event.stopPropagation()">
- ${kv('SKU',p.sku)}${kv('Source',p.src_label)}${kv('Collection',p.collection)}${kv('Pattern',p.title)}
+ ${kv('SKU',p.sku)}${kv('Source',p.src_label)}${kv('Distributor',p.distributor)}${kv('Collection',p.collection)}${kv('Pattern',p.title)}
${kv('Color name',p.color_name)}${kv('Color family',p.color_family)}${kv('Style',p.style)}${kv('Material',p.material)}
${kv('Width',p.width)}${kv('Repeat',p.repeat)}${kv('Fire',p.fire)}
${kv('Performance',(p.functions||[]).join(', '))}
@@ -306,7 +312,7 @@ function applyFilters(){
VIEW=ALL.filter(p=>{
for(const [k] of FIELDS){ if(FIL[k]&&!matchField(p,k,FIL[k]))return false; }
if(pat&&!String(p.title||'').toLowerCase().includes(pat))return false;
- if(q&&![p.title,p.sku,p.collection,p.style,p.material,p.color_family,p.color_name].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
+ if(q&&![p.title,p.sku,p.collection,p.style,p.material,p.color_family,p.color_name,p.distributor].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
return true;
});
const famIdx=x=>{const i=FAMILY_ORDER.indexOf(x.color_family);return i<0?99:i;};
@@ -318,6 +324,7 @@ function applyFilters(){
sku:(a,b)=>String(a.sku||'').localeCompare(String(b.sku||''),undefined,{numeric:true}),
title:(a,b)=>String(a.title||'').localeCompare(String(b.title||'')),
material:(a,b)=>String(a.material||'').localeCompare(String(b.material||''))||String(a.sku).localeCompare(String(b.sku)),
+ distributor:(a,b)=>String(a.distributor||'').localeCompare(String(b.distributor||''))||String(a.sku).localeCompare(String(b.sku)),
width:(a,b)=>(parseFloat(a.width)||1e9)-(parseFloat(b.width)||1e9),
price:(a,b)=>(a.price_yen||1e15)-(b.price_yen||1e15),
images:(a,b)=>(b.image_count||0)-(a.image_count||0),
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 1a3a71a..7b3916e 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -107,6 +107,27 @@ const fireClass = (s) => {
};
const collectionName = (c) => (c === 'ImportSelection' ? 'Import Selection' : c) || null;
+// ── distributor / purchase channel ──────────────────────────────────────────────
+// The MILL (source: Sangetsu / Lilycolor / Greenland) is NOT always who we order from.
+// Sangetsu is a mill whose catalog is only buyable through its regional DISTRIBUTOR,
+// Goodrich — the site we actually scraped (sangetsu-goodrich.co.th). So each row also
+// carries a buy-from channel, derived from source + source_url host, separate from the
+// brand badge. Lilycolor & Greenland sell direct (channel == mill) → dist_direct:true.
+const DIST_BY_HOST = [
+ [/sangetsu-goodrich\.co\.th$/i, 'Goodrich (Thailand)'],
+ [/sangetsu-goodrich\.vn$/i, 'Goodrich (Vietnam)'],
+ [/goodrichglobal\.com$/i, 'Goodrich (Singapore)'],
+];
+function distInfo(source, url) {
+ let host = '';
+ try { host = new URL(url).hostname.replace(/^www\./, ''); } catch {}
+ for (const [re, label] of DIST_BY_HOST) if (re.test(host)) return { distributor: label, dist_direct: false };
+ if (source === 'sangetsu') return { distributor: 'Goodrich', dist_direct: false }; // sangetsu w/o a recognized host is still via Goodrich
+ if (source === 'lilycolor') return { distributor: 'Lilycolor (direct)', dist_direct: true };
+ if (source === 'greenland') return { distributor: 'Greenland (direct)', dist_direct: true };
+ return { distributor: null, dist_direct: false };
+}
+
// Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
function buildRows() {
const rows = [];
@@ -150,6 +171,7 @@ function buildRows() {
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,
+ ...distInfo('lilycolor', null), // facet/chip: buy-from channel (Lilycolor sells direct)
is_sample: false,
// colorway family — this card + its sibling colorways (vendor-style "other colors of this pattern")
colorway_count: group.length,
@@ -189,6 +211,7 @@ function buildRows() {
image: sImg,
image_state: sImg ? 'Has swatch' : 'No image', // facet: swatch availability
url: r.source_url || null,
+ ...distInfo('sangetsu', r.source_url), // facet/chip: buy-from channel (Sangetsu → Goodrich distributor)
is_sample: false,
colorway_count: cw.length,
siblings,
@@ -232,6 +255,7 @@ function buildRows() {
image: (greenSwatches.has(normSku(sku)) ? `/img/greenland/${sku}` : ((isCw ? imgs[sku] : r.cover_img) || r.cover_img)) || null,
image_state: ((greenSwatches.has(normSku(sku))) || (isCw ? imgs[sku] : r.cover_img)) ? 'Has swatch' : 'No image',
url: r.source_url || null,
+ ...distInfo('greenland', r.source_url), // facet/chip: buy-from channel (Greenland direct)
is_sample: false,
colorway_count: isCw ? skus.length : 1,
siblings,
@@ -273,6 +297,7 @@ const sorters = {
color: (a, b) => { const r = (x) => { const i = FAMILY_ORDER.indexOf(x.color_family); return i < 0 ? 99 : i; }; return r(a) - r(b) || String(a.sku).localeCompare(String(b.sku)); },
style: (a, b) => String(a.style || '~~').localeCompare(String(b.style || '~~')) || String(a.sku).localeCompare(String(b.sku)),
material: (a, b) => String(a.material || '~~').localeCompare(String(b.material || '~~')) || String(a.sku).localeCompare(String(b.sku)),
+ distributor: (a, b) => String(a.distributor || '~~').localeCompare(String(b.distributor || '~~')) || String(a.sku).localeCompare(String(b.sku)),
};
// shared filter — opts.skip names a facet dimension to EXCLUDE (for drill-down facet counts)
@@ -283,7 +308,7 @@ function parseFilters(u) {
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'),
+ funcs: csv('funcs'), prices: csv('prices'), images: csv('images'), distributors: csv('distributors'),
enriched: u.searchParams.get('enriched') || 'all',
};
}
@@ -300,6 +325,7 @@ function applyFilters(rows, f, opts = {}) {
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 !== 'distributor' && f.distributors.size) out = out.filter((r) => r.distributor && f.distributors.has(r.distributor));
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;
@@ -368,6 +394,7 @@ const server = http.createServer((req, res) => {
fire: r.fire, fire_class: r.fire_class, price_yen: r.price_yen, price_band: r.price_band,
image: r.image, image_state: r.image_state, image_count: r.image_count || 0,
url: r.url, colorway_count: r.colorway_count, siblings: (r.siblings || []).slice(0, SIB_MAX),
+ distributor: r.distributor, dist_direct: !!r.dist_direct,
enriched: !!r.enriched, color_family: r.color_family, style: r.style, material: r.material,
hex: r.hex, palette: r.palette || [], color_name: r.color_name || null,
}));
@@ -418,6 +445,7 @@ const server = http.createServer((req, res) => {
functions: tallyArr(applyFilters(POOL, f, { skip: 'funcs' }), 'functions'),
price_band: tally(applyFilters(POOL, f, { skip: 'price' }), 'price_band'),
image_state: tally(applyFilters(POOL, f, { skip: 'image' }), 'image_state'),
+ distributor: tally(applyFilters(POOL, f, { skip: 'distributor' }), 'distributor'),
price_order: PRICE_ORDER,
enriched: enrRows.filter((r) => r.enriched).length,
unenriched: enrRows.filter((r) => !r.enriched).length,
← d13399a Retrofit japan/china staging viewer to internal-line-viewer
·
back to Japan Enrich
·
japan: persist distributor field into staging + dw_unified o 993b675 →