← back to Japan Enrich
japan: contrarian fixes to visual-match — disambiguate by distributor (sku collision bug), palette-weighted color distance (top-3, not single hex), stop-words + capped name bonus, real Google Lens URL when public image
a0a32aa49bca6bcf9f067970ef70c9506de9885c · 2026-07-06 19:24:35 -0700 · Steve
Files touched
M viewer-local/public/index.htmlM viewer-local/server.js
Diff
commit a0a32aa49bca6bcf9f067970ef70c9506de9885c
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 6 19:24:35 2026 -0700
japan: contrarian fixes to visual-match — disambiguate by distributor (sku collision bug), palette-weighted color distance (top-3, not single hex), stop-words + capped name bonus, real Google Lens URL when public image
---
viewer-local/public/index.html | 8 ++++----
viewer-local/server.js | 33 ++++++++++++++++++++++++---------
2 files changed, 28 insertions(+), 13 deletions(-)
diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 240ed9d..752bb5e 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -332,7 +332,7 @@ function card(p){
?`<a class="chip dist${p.dist_direct?' direct':''}" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="Open on ${esc(p.distributor)} — import this SKU from here ↗">${distLbl} ↗</a>`
:`<span class="chip dist${p.dist_direct?' direct':''}" title="${p.dist_direct?'Sold direct by the mill':'Purchase channel — order from this distributor'}">${distLbl}</span>`)
:'';
- return `<div class="card"${p.url?` data-href="${esc(p.url)}"`:''} data-sku="${esc(p.sku)}">
+ return `<div class="card"${p.url?` data-href="${esc(p.url)}"`:''} data-sku="${esc(p.sku)}" data-dist="${esc(p.distributor||'')}">
${img}<div class="b">
<span class="vend">${src}${p.collection?(' · '+esc(p.collection)):''}</span>
<div class="ttl" title="${esc(p.title||'')}">${esc(p.title||p.sku||'(untitled)')}</div>
@@ -370,13 +370,13 @@ function card(p){
grid.addEventListener('click',e=>{
if(e.target.closest('.chip.toggle,.chip.imp,.chip.dist,.info,a,button'))return;
const c=e.target.closest('.card[data-sku]');
- if(c&&c.dataset.sku)openMatch(c.dataset.sku);
+ if(c&&c.dataset.sku)openMatch(c.dataset.sku,c.dataset.dist||'');
});
-async function openMatch(sku){
+async function openMatch(sku,dist){
const mv=$('#matchview'),L=$('#mv-left'),R=$('#mv-right');
L.innerHTML='<div class="mv-meta">Loading…</div>';R.innerHTML='';
mv.classList.add('open');document.body.style.overflow='hidden';
- let d;try{d=await fetch(location.origin+'/api/match?sku='+encodeURIComponent(sku)).then(r=>r.json());}
+ let d;try{d=await fetch(location.origin+'/api/match?sku='+encodeURIComponent(sku)+(dist?'&dist='+encodeURIComponent(dist):'')).then(r=>r.json());}
catch(e){R.innerHTML='<div id="mv-empty">match request failed</div>';return;}
if(d.error){L.innerHTML='<div class="mv-meta">'+esc(d.error)+'</div>';return;}
const it=d.item, meth=d.methods||{};
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 9b6ccdd..2ccc083 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -98,8 +98,18 @@ const loadDistColors = () => {
};
loadDistColors();
const hexRGB = (h) => { h = String(h || '').replace('#', ''); return [0, 2, 4].map((i) => parseInt(h.substr(i, 2), 16) || 0); };
-const colorDist = (a, b) => { const x = hexRGB(a), y = hexRGB(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
-const wordset = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter((w) => w.length > 2));
+const rgbDist = (a, b) => { const x = hexRGB(a), y = hexRGB(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
+// palette-weighted distance (dominant 50% / 2nd 30% / 3rd 20%) — a single ground color isn't
+// enough for patterned wallcovering, so weight the top-3 swatches (contrarian fix).
+const WT = [0.5, 0.3, 0.2];
+const paletteDist = (pa, pb) => {
+ pa = pa && pa.length ? pa : []; pb = pb && pb.length ? pb : [];
+ if (!pa.length || !pb.length) return 999;
+ let d = 0; for (let i = 0; i < 3; i++) d += WT[i] * rgbDist(pa[Math.min(i, pa.length - 1)], pb[Math.min(i, pb.length - 1)]);
+ return d;
+};
+const STOP = new Set(['the', 'and', 'for', 'red', 'tan', 'new', 'old', 'big', 'wall', 'wallpaper', 'wallcovering', 'collection', 'vol', 'grey', 'gray', 'blue']);
+const wordset = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter((w) => w.length > 3 && !STOP.has(w)));
// distributor → subdomain slug map, so <slug>.designerwallcoverings.com is a per-distributor
// microsite (e.g. koroseal. / eijffinger. / j-josephson.). Built from the scraped brand map.
let DIST_SLUGS = {};
@@ -517,30 +527,35 @@ const server = http.createServer(async (req, res) => {
// ── visual match: click a Sangetsu item → color-ranked candidates from its distributor's DW catalog ──
if (u.pathname === '/api/match') {
const sku = u.searchParams.get('sku') || '';
- const item = POOL.find((r) => String(r.sku) === sku);
+ const dist = u.searchParams.get('dist') || '';
+ // disambiguate by distributor first (SKUs repeat across patterns → wrong-vendor bug); fall back to sku-only
+ const item = POOL.find((r) => String(r.sku) === sku && (!dist || r.distributor === dist)) || POOL.find((r) => String(r.sku) === sku);
if (!item) return sendJson(req, res, { error: 'not found' });
const vendor = DIST_VENDOR[item.distributor] || null;
const cands = vendor ? (DIST_COLORS[vendor] || []) : [];
const iw = wordset(item.title);
+ const ip = (item.palette && item.palette.length) ? item.palette : (item.hex ? [item.hex] : []);
let ranked = [];
- if (item.hex && cands.length) {
+ if (ip.length && cands.length) {
ranked = cands.map((c) => {
- const nameOverlap = [...wordset(c.name)].filter((w) => iw.has(w)).length;
- return { c, d: colorDist(item.hex, c.hex) - nameOverlap * 40 }; // color first; name overlap pulls closer
+ const overlap = Math.min([...wordset(c.name)].filter((w) => iw.has(w)).length, 2); // capped
+ return { c, d: paletteDist(ip, (c.palette && c.palette.length) ? c.palette : [c.hex]) - overlap * 30 };
}).sort((a, b) => a.d - b.d).slice(0, 12);
} else if (cands.length) {
ranked = cands.map((c) => ({ c, d: [...wordset(c.name)].filter((w) => iw.has(w)).length ? 0 : 1 }))
.sort((a, b) => a.d - b.d).slice(0, 12);
}
+ const pubimg = (item.image && /^https?:/.test(item.image)) ? item.image : null;
return sendJson(req, res, {
- item: { sku: item.sku, title: item.title, image: item.image, hex: item.hex || null,
+ item: { sku: item.sku, title: item.title, image: item.image, hex: item.hex || null, palette: ip,
color_family: item.color_family || null, distributor: item.distributor || null, url: item.url || null },
vendor, catalog_size: cands.length,
matches: ranked.map(({ c, d }) => ({ sku: c.sku, name: c.name, color_name: c.color_name, image: c.image_url,
- product_url: c.product_url, hex: c.hex, family: c.family, score: Math.round(d) })),
+ product_url: c.product_url, hex: c.hex, palette: c.palette, family: c.family, score: Math.round(d) })),
methods: {
dw_unified: !!vendor,
- reverse_image: `https://www.google.com/search?tbm=isch&q=${encodeURIComponent((item.distributor || '') + ' ' + item.title)}`,
+ reverse_image: pubimg ? `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(pubimg)}`
+ : `https://www.google.com/search?tbm=isch&q=${encodeURIComponent((item.distributor || '') + ' ' + item.title)}`,
source: item.url || null,
},
});
← 54e6e3f japan: visual-match viewer — click item → our panel left + c
·
back to Japan Enrich
·
japan: expand visual-match to ALL sub-vendors — +Morris&Co/M b6d69f0 →