[object Object]

← back to Catalog Crawl Atlas

Atlas refine: filter-aware neighbors (spokes show +N within selection), sort+density controls (persisted), loading state, center shows live selection count

69a677b1c63e8002b8a38d51ddb9d9e1fb9e1ac9 · 2026-07-30 12:08:55 -0700 · Steve Abrams

Files touched

Diff

commit 69a677b1c63e8002b8a38d51ddb9d9e1fb9e1ac9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 12:08:55 2026 -0700

    Atlas refine: filter-aware neighbors (spokes show +N within selection), sort+density controls (persisted), loading state, center shows live selection count
---
 public/index.html | 71 +++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 61 insertions(+), 10 deletions(-)

diff --git a/public/index.html b/public/index.html
index 49bc174..48613d3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -68,6 +68,9 @@ svg{width:100%;height:100%;display:block}
 .node .cnt{fill:var(--muted);font-family:'Share Tech Mono',monospace;font-size:9px}
 .center-lab{fill:#fff;font-family:'Cormorant Garamond',serif;text-anchor:middle;paint-order:stroke;stroke:#000;stroke-width:3px}
 .hint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--muted);letter-spacing:2px;z-index:4}
+.loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-family:'Share Tech Mono',monospace;font-size:13px;letter-spacing:2px;z-index:5}
+.loading::before{content:'';position:absolute;width:60px;height:60px;border:2px solid var(--edge);border-top-color:var(--amber);border-radius:50%;animation:spin 1s linear infinite;margin-top:-52px}
+@keyframes spin{to{transform:rotate(360deg)}}
 
 /* product grid */
 .products{border-top:1px solid var(--edge);background:#0a0b0e}
@@ -84,7 +87,13 @@ svg{width:100%;height:100%;display:block}
 .phead .result{margin-left:auto;font-family:'Share Tech Mono',monospace;font-size:12px;color:var(--amber)}
 .phead .clear{cursor:pointer;font-size:11px;color:var(--muted);border:1px solid var(--edge);border-radius:16px;padding:4px 11px}
 .phead .clear:hover{color:var(--cream);border-color:var(--muted)}
-.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;padding:16px 22px 30px}
+.pctl{position:sticky;top:47px;display:flex;align-items:center;gap:9px;padding:8px 22px;background:#0c0d11;border-bottom:1px solid var(--edge);z-index:2}
+.pctl .pl{font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--muted);margin-left:6px}
+.pctl .pl:first-child{margin-left:0}
+.pctl .mono{font-family:'Share Tech Mono',monospace;letter-spacing:0}
+.pctl select{font:inherit;font-size:12px;color:var(--cream);background:#14151a;border:1px solid var(--edge);border-radius:8px;padding:5px 9px}
+.pctl input[type=range]{width:150px;accent-color:var(--amber)}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card,150px),1fr));gap:12px;padding:16px 22px 40px}
 .card{background:#121318;border:1px solid var(--edge);border-radius:12px;overflow:hidden;text-decoration:none;color:var(--cream);transition:.14s;display:flex;flex-direction:column}
 .card:hover{border-color:var(--amber);transform:translateY(-2px);box-shadow:0 8px 22px rgba(0,0,0,.6)}
 .card img{width:100%;aspect-ratio:1;object-fit:cover;background:#1c1d22;display:block}
@@ -121,11 +130,26 @@ svg{width:100%;height:100%;display:block}
           <span><b style="background:#b48ce6"></b>Motif</span>
           <span><b style="background:#4ecdc4"></b>Material</span>
         </div>
+        <div id="loading" class="loading">◍ loading catalog index — 81,052 products…</div>
         <svg id="graph" viewBox="0 0 1000 700" preserveAspectRatio="xMidYMid meet"></svg>
         <div class="hint">drill down: click a color, then a style, then a motif — each click STACKS into one filter · grid below shows items matching ALL selected</div>
       </div>
       <div class="products" id="products">
         <div class="phead" id="phead"></div>
+        <div class="pctl" id="pctl">
+          <span class="pl">Sort</span>
+          <select id="sortSel">
+            <option value="default">Featured</option>
+            <option value="title">Title A→Z</option>
+            <option value="color">Color</option>
+            <option value="style">Style</option>
+            <option value="motif">Motif</option>
+            <option value="material">Material</option>
+          </select>
+          <span class="pl">Density</span>
+          <input type="range" id="densRange" min="100" max="340" step="10">
+          <span class="pl mono" id="densVal"></span>
+        </div>
         <div class="grid" id="grid"></div>
       </div>
     </div>
@@ -149,7 +173,8 @@ function isDark(hex){try{const n=parseInt(hex.slice(1),16);return (0.299*(n>>16&
 
 function init(){
   document.getElementById('built').textContent='built '+new Date(G.builtAt).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})+' · '+G.coverage.total.toLocaleString()+' imaged products';
-  renderCover(); renderSwatches(); renderGroupBtns();
+  renderCover(); renderSwatches(); renderGroupBtns(); initControls();
+  const ld=document.getElementById('loading'); if(ld) ld.remove();
   const colors=Object.values(G.nodes).filter(n=>n.type==='color').sort((a,b)=>b.count-a.count);
   center(colors[0].key,true,false); // no auto-scroll on first paint
 }
@@ -194,9 +219,17 @@ function renderCrumbs(){
   document.getElementById('crumbs').innerHTML=trail.map((k,i)=>`<a data-i="${i}">${esc(G.nodes[k].label)}</a>`+(i<trail.length-1?'<span class="sep">›</span>':'')).join('');
   document.querySelectorAll('#crumbs a').forEach(a=>a.onclick=()=>{const i=+a.dataset.i;trail=trail.slice(0,i+1);center(trail[i],false);});
 }
+// filter-aware neighbors: what facets can you ADD next, counted within the current selection
+function neighborsFor(){
+  const active=Object.entries(filter).filter(([,v])=>v);
+  const rows=active.length?PROD.rows.filter(r=>active.every(([t,v])=>r[TYPEIDX[t]]===v)):PROD.rows;
+  const tally={};
+  for(const r of rows){ for(const type in TYPEIDX){ const v=r[TYPEIDX[type]]; if(!v||filter[type]===v)continue; tally[type+':'+v]=(tally[type+':'+v]||0)+1; } }
+  return Object.entries(tally).map(([k,cnt])=>({node:G.nodes[k],cnt})).filter(x=>x.node).sort((a,b)=>b.cnt-a.cnt).slice(0,16);
+}
 function draw(){
   const svg=document.getElementById('graph'),W=1000,H=700,cx=W/2,cy=H/2,c=G.nodes[current];
-  let neigh=Object.entries(G.adj[current]||{}).map(([k,cnt])=>({node:G.nodes[k],cnt})).filter(x=>x.node).sort((a,b)=>b.cnt-a.cnt).slice(0,16);
+  let neigh=neighborsFor();
   const maxC=Math.max(1,...neigh.map(x=>x.cnt));
   const order={color:0,style:1,motif:2,material:3};
   neigh.sort((a,b)=>(order[a.node.type]-order[b.node.type])||(b.cnt-a.cnt));
@@ -207,29 +240,47 @@ function draw(){
     edges.push(`<text class="elabel" x="${mx.toFixed(0)}" y="${my.toFixed(0)}">${x.cnt.toLocaleString()}</text>`);});
   neigh.forEach((x,i)=>{const ang=(i/N)*2*Math.PI-Math.PI/2,nx=cx+R*Math.cos(ang),ny=cy+R*Math.sin(ang);
     const r=18+22*Math.sqrt(x.cnt/maxC),ring=x.node.type==='color'?(x.node.hex||'#888'):CATCOLOR[x.node.type];
-    nds.push(nodeSVG(x.node,nx,ny,r,ring,defs,13));});
+    nds.push(nodeSVG(x.node,nx,ny,r,ring,defs,13,false,x.cnt));});
   const cring=c.type==='color'?(c.hex||'#888'):CATCOLOR[c.type];
-  nds.push(nodeSVG(c,cx,cy,64,cring,defs,0,true));
+  const active=Object.entries(filter).filter(([,v])=>v);
+  const selCount=active.length?PROD.rows.filter(r=>active.every(([t,v])=>r[TYPEIDX[t]]===v)).length:c.count;
+  nds.push(nodeSVG(c,cx,cy,64,cring,defs,0,true,selCount));
   svg.innerHTML=`<defs>${defs.join('')}</defs>${edges.join('')}${nds.join('')}`;
   svg.querySelectorAll('.node').forEach(g=>g.onclick=()=>center(g.dataset.key,false));
 }
-function nodeSVG(node,x,y,r,ring,defs,fs,isCenter){
+function nodeSVG(node,x,y,r,ring,defs,fs,isCenter,selCount){
   const id='clip'+node.key.replace(/[^a-z0-9]/gi,'');
   let inner;
   if(node.img){ defs.push(`<clipPath id="${id}"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}"/></clipPath>`);
-    inner=`<image href="${img(node.img,160)}" x="${(x-r).toFixed(1)}" y="${(y-r).toFixed(1)}" width="${(2*r).toFixed(1)}" height="${(2*r).toFixed(1)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${id})"/>`;
+    inner=`<image href="${img(node.img,isCenter?220:160)}" x="${(x-r).toFixed(1)}" y="${(y-r).toFixed(1)}" width="${(2*r).toFixed(1)}" height="${(2*r).toFixed(1)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#${id})"/>`;
   } else { inner=`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}" fill="${ring}"/>`; }
+  const nlabels=Object.values(filter).filter(Boolean).length;
   const labels=isCenter
     ? `<text class="center-lab" x="${x}" y="${(y+r+26).toFixed(0)}" font-size="24">${esc(node.label)}</text>
-       <text class="center-lab" x="${x}" y="${(y+r+44).toFixed(0)}" font-size="12">${node.count.toLocaleString()} skus · ${node.type} · click grid ↓</text>`
+       <text class="center-lab" x="${x}" y="${(y+r+44).toFixed(0)}" font-size="12">${(selCount||node.count).toLocaleString()} item${selCount===1?'':'s'}${nlabels>1?' · '+nlabels+' facets':''} · grid ↓</text>`
     : `<text y="${(y+r+15).toFixed(0)}" x="${x}" font-size="13" font-weight="500">${esc(node.label)}</text>
-       <text class="cnt" x="${x}" y="${(y+r+27).toFixed(0)}">${node.count.toLocaleString()} skus</text>`;
+       <text class="cnt" x="${x}" y="${(y+r+27).toFixed(0)}">+${(selCount||node.count).toLocaleString()} · <tspan fill="#c9a34a">add</tspan></text>`;
   return `<g class="node" data-key="${node.key}">${inner}
     <circle class="ring" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}" stroke="${ring}" stroke-width="${isCenter?5:3.5}"/>
     ${labels}</g>`;
 }
 
 /* ---- accumulating multi-facet filter + product grid ---- */
+let sortMode=localStorage.getItem('ca_sort')||'default';
+function sortRows(rows){
+  if(sortMode==='default') return rows;
+  const idx={title:1,color:3,style:4,motif:5,material:6}[sortMode];
+  return rows.slice().sort((a,b)=>String(a[idx]||'~').localeCompare(String(b[idx]||'~')));
+}
+function initControls(){
+  const ss=document.getElementById('sortSel'), dr=document.getElementById('densRange'), dv=document.getElementById('densVal');
+  ss.value=sortMode;
+  ss.onchange=()=>{ sortMode=ss.value; localStorage.setItem('ca_sort',sortMode); gridState.rows=sortRows(gridState.rows); gridState.shown=0; document.getElementById('grid').innerHTML=''; if(gridState.rows.length)renderMore(); };
+  const dens=+(localStorage.getItem('ca_dens')||150);
+  const applyDens=v=>{ document.getElementById('grid').style.setProperty('--card',v+'px'); dv.textContent=v+'px'; };
+  dr.value=dens; applyDens(dens);
+  dr.oninput=()=>{ applyDens(+dr.value); localStorage.setItem('ca_dens',dr.value); };
+}
 function colorHex(v){ const n=G.nodes['color:'+v]; return n?n.hex:'#888'; }
 function applyFilter(){
   const active=Object.entries(filter).filter(([,v])=>v);        // [[type,value],...]
@@ -237,7 +288,7 @@ function applyFilter(){
   const rows = active.length
     ? PROD.rows.filter(r=>active.every(([type,val])=>r[TYPEIDX[type]]===val))
     : [];
-  gridState={rows,shown:0};
+  gridState={rows:sortRows(rows),shown:0};
   // header: filter chips + result count
   const chips = active.map(([type,val])=>{
     const sw = type==='color' ? `<span class="sw" style="background:${colorHex(val)}"></span>`

← bc458ad Atlas: accumulating multi-facet filter — drilling stacks col  ·  back to Catalog Crawl Atlas  ·  viewer standard: add Grid/List toggle + per-field on/off men 8838139 →