← back to Rigo Line Viewer
rigo-line-viewer: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
43c92f124bccb9ddec11f1e2a03a9b7340502395 · 2026-07-31 12:16:17 -0700 · Steve Abrams
Files touched
A public/drill.jsM public/index.html
Diff
commit 43c92f124bccb9ddec11f1e2a03a9b7340502395
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 31 12:16:17 2026 -0700
rigo-line-viewer: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
---
public/drill.js | 36 ++++++++++++++++++++++++++++++++++++
public/index.html | 54 ++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 78 insertions(+), 12 deletions(-)
diff --git a/public/drill.js b/public/drill.js
new file mode 100644
index 0000000..e63cdd3
--- /dev/null
+++ b/public/drill.js
@@ -0,0 +1,36 @@
+// ── href-to-deeper-data primitives — SINGLE SOURCE OF TRUTH (HARD rule, 2026-07-31) ──
+// Every displayed data point must be an href to its deeper (filtered) data. These are the
+// reusable atoms; a viewer's index.html loads this file, then defines FIELDS, FIL, $, esc,
+// and applyFilters (which these reference at call time via the shared classic-script global
+// scope). Canonical copy: ~/Projects/_shared/web/drill.js → each viewer's public/drill.js.
+// Memory: all-data-points-href-to-deeper-data. Reference viewers: astek-landing, schumacher-internal.
+
+// Build the URL for a given filter set — any count/atom becomes a real, shareable link.
+function qstr(fil, q, pat) {
+ const u = new URLSearchParams();
+ for (const [k] of FIELDS) { if (fil[k]) u.set(k, fil[k]); }
+ if (q) u.set('q', q); if (pat) u.set('pat', pat);
+ const s = u.toString(); return s ? ('?' + s) : location.pathname;
+}
+// Read the URL's filter state back into FIL + the search inputs (deep-link / back-button).
+function readURL() {
+ const u = new URLSearchParams(location.search);
+ for (const [k] of FIELDS) FIL[k] = u.get(k) || '';
+ $('#q').value = u.get('q') || ''; $('#pattern').value = u.get('pat') || '';
+}
+// Reflect the current filter state into the URL (push = new history entry, else replace).
+function writeURL(push) {
+ const url = qstr(FIL, $('#q').value.trim(), $('#pattern').value.trim());
+ history[push ? 'pushState' : 'replaceState']({}, '', url);
+}
+// drill(field,value,label,cls) → an <a> whose href IS the filtered view for that value.
+// cmd/ctrl/middle-click opens it in a new tab; plain click filters in place (delegated listener).
+function drill(field, val, label, cls) {
+ if (val == null || val === '') return '';
+ const href = qstr({ ...FIL, [field]: String(val) }, $('#q').value.trim(), $('#pattern').value.trim());
+ return `<a class="drill ${cls || ''}" href="${href}" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${label != null ? esc(label) : esc(val)}</a>`;
+}
+// Atom drill target: SET the filter to this value (drill in), vs pickF which TOGGLES.
+function setF(k, v) { if (FIL[k] === String(v)) return; FIL[k] = String(v); applyFilters('push'); }
+
+window.qstr = qstr; window.readURL = readURL; window.writeURL = writeURL; window.drill = drill; window.setF = setF;
diff --git a/public/index.html b/public/index.html
index a98bfaa..4dae6c0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -32,6 +32,9 @@ select { background:var(--card); color:var(--fg); border:1px solid var(--line);
body.imgonly .meta { display:none; }
.count { color:var(--dim); font-size:12px; }
label.tog { font-size:12px; color:var(--dim); display:flex; gap:5px; align-items:center; cursor:pointer; }
+/* ── drill atom: every data point is an href to its filtered view (deeper data) ── */
+.drill { color:inherit; text-decoration:none; cursor:pointer; border-bottom:1px dotted transparent; }
+.drill:hover { color:var(--teal); border-bottom-color:var(--teal); }
</style>
</head>
<body>
@@ -58,6 +61,9 @@ label.tog { font-size:12px; color:var(--dim); display:flex; gap:5px; align-items
</aside>
<div id="grid"></div>
</main>
+<!-- href-to-deeper-data primitives (qstr / readURL / writeURL / drill / setF) — shared single source of truth -->
+<input type="hidden" id="pattern">
+<script src="/drill.js"></script>
<script>
let ALL = [], filters = {};
const FACET_FIELDS = [
@@ -66,6 +72,12 @@ const FACET_FIELDS = [
['color_name','Color'], ['status','Status'],
];
const $ = (s) => document.querySelector(s);
+// ── Adapter: drill.js reads globals named FIELDS/FIL/esc/applyFilters; this viewer's real
+// names are FACET_FIELDS/filters/render. Alias them so the shared primitives resolve. ──
+const FIELDS = FACET_FIELDS; // [field, label] pairs — same shape drill.js expects
+const FIL = filters; // SAME object reference — writes via drill/setF hit filters
+const esc = (s) => String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+function applyFilters(nav) { buildSelected(); render(); if (nav !== 'none') writeURL(nav === 'push'); }
const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
async function boot() {
@@ -73,6 +85,7 @@ async function boot() {
const j = await r.json();
ALL = j.products;
$('#count').textContent = j.count + ' SKUs · loaded ' + fmtDate(j.loadedAt);
+ readURL(); // deep-link: seed FIL (=filters) + #q from the URL before first render
buildFacets(); render();
}
function buildFacets() {
@@ -81,14 +94,19 @@ function buildFacets() {
const counts = {};
for (const p of ALL) { const v = p[field] || '(none)'; counts[v] = (counts[v]||0)+1; }
const div = document.createElement('div'); div.className = 'facet';
- div.innerHTML = `<h3>${label}</h3><table>` +
- Object.entries(counts).sort((a,b)=>b[1]-a[1]).map(([v,n]) =>
- `<tr data-f="${field}" data-v="${v.replace(/"/g,'"')}"><td>${v}</td><td class="n">${n}</td></tr>`).join('') + '</table>';
+ div.innerHTML = `<h3>${esc(label)}</h3><table>` +
+ Object.entries(counts).sort((a,b)=>b[1]-a[1]).map(([v,n]) => {
+ // facet option = a REAL href to its filtered view (toggles this value on/off)
+ const href = qstr({ ...FIL, [field]: (FIL[field]===String(v) ? '' : String(v)) }, $('#q').value.trim(), '');
+ return `<tr data-f="${esc(field)}" data-v="${esc(v)}"><td><a class="drill" href="${href}" data-f="${esc(field)}" data-v="${esc(v)}">${esc(v)}</a></td><td class="n">${n}</td></tr>`;
+ }).join('') + '</table>';
div.querySelector('h3').onclick = () => div.classList.toggle('open');
- div.querySelectorAll('tr').forEach(tr => tr.onclick = () => {
+ div.querySelectorAll('tr').forEach(tr => tr.onclick = (e) => {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; // let real href open new tab
+ e.preventDefault();
const f = tr.dataset.f, v = tr.dataset.v;
if (filters[f] === v) delete filters[f]; else filters[f] = v;
- buildSelected(); render();
+ applyFilters('push');
});
box.appendChild(div);
}
@@ -100,7 +118,8 @@ function buildSelected() {
}
function render() {
const q = $('#q').value.toLowerCase();
- let rows = ALL.filter(p => Object.entries(filters).every(([f,v]) => (p[f]||'(none)') === v));
+ // empty-string filter values (set by drill.js when a filter is toggled off) count as inactive
+ let rows = ALL.filter(p => Object.entries(filters).every(([f,v]) => !v || (p[f]||'(none)') === v));
if (q) rows = rows.filter(p => [p.pattern_name,p.pr_pattern_name,p.color_name,p.mfr_sku,p.dw_sku].join(' ').toLowerCase().includes(q));
const s = $('#sort').value;
rows.sort((a,b) =>
@@ -110,22 +129,33 @@ function render() {
s==='sku' ? (a.mfr_sku||'').localeCompare(b.mfr_sku||'') :
(a.color_name||'').localeCompare(b.color_name||''));
$('#grid').innerHTML = rows.map(p => `
- <div class="card" title="${p.mfr_sku} · created ${p.created_at||''}" onclick="window.open('${p.product_url}','_blank')">
- ${p.img ? `<img loading="lazy" src="${p.img}">` : '<div style="aspect-ratio:1;background:#222"></div>'}
+ <div class="card" title="${esc(p.mfr_sku)} · created ${p.created_at||''}">
+ ${p.img ? `<img loading="lazy" src="${esc(p.img)}" onclick="window.open('${esc(p.product_url)}','_blank')" style="cursor:pointer">` : '<div style="aspect-ratio:1;background:#222"></div>'}
<div class="meta">
- <div class="pr">${p.pr_pattern_name || '<i>unnamed</i>'} · ${p.color_name||''}</div>
- <div class="internal">${p.pattern_name} · ${p.mfr_sku} · ${p.collection}</div>
- <div class="internal">${p.dw_sku||'no DW SKU'} · ${p.pr_collection_name||''} · ${p.weight_type||''}</div>
+ <div class="pr">${p.pr_pattern_name ? esc(p.pr_pattern_name) : '<i>unnamed</i>'} · ${drill('color_name',p.color_name,p.color_name)}</div>
+ <div class="internal">${esc(p.pattern_name)} · ${esc(p.mfr_sku)} · ${drill('collection',p.collection,p.collection)}</div>
+ <div class="internal">${p.dw_sku?esc(p.dw_sku):'no DW SKU'} · ${drill('pr_collection_name',p.pr_collection_name,p.pr_collection_name)} · ${drill('weight_type',p.weight_type,p.weight_type)}</div>
+ <div class="internal">${drill('backing',p.backing,p.backing)}${p.match_type?' · '+drill('match_type',p.match_type,p.match_type):''}${p.status?' · '+drill('status',p.status,p.status):''}</div>
<div class="when">🕓 ${fmtDate(p.created_at)}</div>
</div>
</div>`).join('');
$('#count').textContent = rows.length + ' / ' + ALL.length + ' SKUs';
}
-$('#q').oninput = render; $('#sort').onchange = () => { render(); localStorage.setItem('rigo.sort', $('#sort').value); };
+// ── delegated drill: plain click on a card atom filters in place; cmd/ctrl/shift/middle keeps the real href ──
+$('#grid').addEventListener('click', (e) => {
+ const d = e.target.closest('a.drill'); if (!d) return;
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; // real href → new tab
+ e.preventDefault();
+ setF(d.dataset.filter, d.dataset.v); // setF (drill.js) sets FIL[k]=v then calls applyFilters('push')
+});
+let qt;
+$('#q').oninput = () => { clearTimeout(qt); qt = setTimeout(() => applyFilters('push'), 300); };
+$('#sort').onchange = () => { render(); localStorage.setItem('rigo.sort', $('#sort').value); };
$('#density').oninput = (e) => { document.documentElement.style.setProperty('--cols', e.target.value); localStorage.setItem('rigo.cols', e.target.value); };
$('#imgonly').onchange = (e) => document.body.classList.toggle('imgonly', e.target.checked);
if (localStorage.getItem('rigo.cols')) { $('#density').value = localStorage.getItem('rigo.cols'); document.documentElement.style.setProperty('--cols', $('#density').value); }
if (localStorage.getItem('rigo.sort')) $('#sort').value = localStorage.getItem('rigo.sort');
+addEventListener('popstate', () => { readURL(); buildSelected(); render(); }); // back/forward → re-read URL
boot();
</script>
</body>
← 4e5a9f4 chore: lint, refactor, v1.0.1 (session close)
·
back to Rigo Line Viewer
·
viewer standard: add Grid/List toggle + per-field on/off men 4721c6d →