[object Object]

← 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

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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,'&quot;')}"><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 →