← back to Catalog Crawl Atlas

public/index.html

422 lines

<!DOCTYPE html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crawl Atlas · TK-135 — Color / Style / Motif</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,500&family=Share+Tech+Mono&family=Oswald:wght@300;500;700&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#0c0d10;--panel:#15161b;--edge:#262832;--cream:#e9e4d6;--muted:#8b8f98;
  --amber:#c9a34a;--green:#39d353;--blue:#5aa9e6;--purple:#b48ce6;--rose:#e6798c;--teal:#4ecdc4;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Oswald',sans-serif;color:var(--cream);background:
  radial-gradient(130% 90% at 50% -10%,#1b1c22,#0c0d10 55%,#050506);height:100vh;-webkit-font-smoothing:antialiased;overflow:hidden}
.app{display:grid;grid-template-rows:auto auto 1fr;height:100vh}
header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 26px;
  border-bottom:1px solid var(--edge);background:linear-gradient(180deg,#16171d,#0d0e12)}
.brand{display:flex;align-items:baseline;gap:14px}
.brand h1{font-family:'Cormorant Garamond',serif;font-weight:600;font-size:26px;letter-spacing:.5px}
.brand .tk{font-family:'Share Tech Mono',monospace;font-size:11px;color:var(--amber);letter-spacing:2px;border:1px solid var(--amber);border-radius:20px;padding:3px 10px}
.brand .sub{font-size:11px;color:var(--muted);letter-spacing:3px;text-transform:uppercase}
.built{font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);text-align:right;letter-spacing:1px}
.cover{display:flex;gap:14px;padding:14px 26px;border-bottom:1px solid var(--edge);overflow-x:auto}
.gauge{flex:1;min-width:150px;background:var(--panel);border:1px solid var(--edge);border-radius:14px;padding:12px 16px;position:relative}
.gauge .lbl{font-size:10px;letter-spacing:2.5px;text-transform:uppercase;color:var(--muted)}
.gauge .num{font-family:'Share Tech Mono',monospace;font-size:30px;line-height:1.05;margin-top:4px}
.gauge .den{font-size:11px;color:var(--muted);font-family:'Share Tech Mono',monospace}
.gauge .bar{height:5px;border-radius:4px;background:#23252e;margin-top:9px;overflow:hidden}
.gauge .bar i{display:block;height:100%;border-radius:4px;transition:width 1.2s cubic-bezier(.2,.8,.2,1)}
.g-color .num{color:var(--green)} .g-color .bar i{background:var(--green)}
.g-style .num{color:var(--blue)} .g-style .bar i{background:var(--blue)}
.g-motif .num{color:var(--purple)} .g-motif .bar i{background:var(--purple)}
.g-material .num{color:var(--teal)} .g-material .bar i{background:var(--teal)}
.g-all .num{color:var(--amber)} .g-all .bar i{background:var(--amber)}
.gauge .tick{position:absolute;top:12px;right:14px;font-size:16px}
.main{display:grid;grid-template-columns:230px 1fr;min-height:0}
aside{border-right:1px solid var(--edge);background:linear-gradient(180deg,#131419,#0c0d11);overflow-y:auto;padding:16px 14px}
aside h2{font-size:10px;letter-spacing:2.5px;text-transform:uppercase;color:var(--muted);margin:6px 2px 10px}
.swatches{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:20px}
.sw{display:flex;align-items:center;gap:7px;width:100%;padding:5px 8px;border-radius:8px;border:1px solid var(--edge);background:#101116;cursor:pointer;transition:.14s}
.sw:hover{border-color:var(--amber);transform:translateX(2px)}
.sw.active{border-color:var(--amber);box-shadow:0 0 0 1px var(--amber)}
.sw .dot{width:16px;height:16px;border-radius:50%;border:1px solid rgba(255,255,255,.25);flex:none}
.sw .nm{font-size:13px;flex:1}
.sw .ct{font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted)}
.grpbtns{display:flex;flex-direction:column;gap:5px}
.grpbtns button{text-align:left;font:inherit;font-size:12px;color:var(--cream);background:#101116;border:1px solid var(--edge);border-radius:8px;padding:6px 9px;cursor:pointer}
.grpbtns button:hover{border-color:var(--muted)}

/* right column scrolls: graph on top, product grid below */
.stage{position:relative;min-height:0;overflow-y:auto}
.graphwrap{position:relative;height:56vh;min-height:400px}
#crumbs{position:absolute;top:12px;left:16px;z-index:4;display:flex;gap:6px;align-items:center;font-size:12px;flex-wrap:wrap;max-width:70%}
#crumbs a{color:var(--muted);cursor:pointer;text-decoration:none;border-bottom:1px dotted #3a3c46}
#crumbs a:hover{color:var(--amber);border-color:var(--amber)}
#crumbs .sep{color:#3a3c46}
.legend{position:absolute;top:12px;right:16px;z-index:4;display:flex;gap:12px;font-size:10px;letter-spacing:1px;color:var(--muted);text-transform:uppercase}
.legend b{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:middle}
svg{width:100%;height:100%;display:block}
.edge{stroke:#31333d}
.elabel{fill:var(--muted);font-family:'Share Tech Mono',monospace;font-size:9px;pointer-events:none}
.node{cursor:pointer}
.node .ring{fill:none;transition:.18s}
.node:hover .ring{stroke:#fff !important;stroke-width:4}
.node text{fill:var(--cream);font-family:'Oswald',sans-serif;pointer-events:none;text-anchor:middle}
.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}
.phead{position:sticky;top:0;background:linear-gradient(180deg,#14151a,#0e0f13);border-bottom:1px solid var(--edge);
  padding:12px 22px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;z-index:3}
.phead .lead{font-family:'Cormorant Garamond',serif;font-size:18px;color:var(--muted);margin-right:2px}
.fchip{display:inline-flex;align-items:center;gap:7px;padding:5px 9px 5px 8px;border-radius:22px;border:1px solid var(--edge);background:#101116;font-size:13px}
.fchip .sw{width:14px;height:14px;border-radius:50%;border:1px solid rgba(255,255,255,.25)}
.fchip .ty{font-size:8px;letter-spacing:1px;text-transform:uppercase;color:var(--muted)}
.fchip b{font-weight:500}
.fchip .x{cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px}
.fchip .x:hover{color:var(--rose)}
.fchip.color{border-color:#39d35366}.fchip.style{border-color:#5aa9e666}.fchip.motif{border-color:#b48ce666}.fchip.material{border-color:#4ecdc466}
.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)}
.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)}
/* view toggle */
.vbtn{font-size:11px;letter-spacing:.5px;padding:4px 10px;border-radius:6px;border:1px solid var(--edge);background:#14151a;color:var(--muted);cursor:pointer}
.vbtn.on{background:var(--amber);border-color:var(--amber);color:#0c0d10}
/* fields menu */
.ca-fieldmenu{position:relative}
.ca-fbtn{font-size:11px;letter-spacing:.5px;padding:4px 10px;border-radius:6px;border:1px solid var(--edge);background:#14151a;color:var(--muted);cursor:pointer}
.ca-fbtn:hover{border-color:var(--amber);color:var(--cream)}
.ca-fpop{position:absolute;top:calc(100% + 6px);right:0;background:#14151a;border:1px solid var(--edge);border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.7);padding:8px 4px;z-index:30;min-width:160px}
.ca-fpop[hidden]{display:none}
.ca-fpop label{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:11px;letter-spacing:.4px;color:var(--muted);cursor:pointer;white-space:nowrap}
.ca-fpop label:hover{color:var(--cream)}
.ca-fpop input{accent-color:var(--amber);width:13px;height:13px;margin:0;padding:0}
/* grid view */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card,150px),1fr));gap:12px;padding:16px 22px 40px}
/* list view */
.grid.list{display:flex;flex-direction:column;gap:6px;padding:12px 22px 40px}
.grid.list .card{flex-direction:row;align-items:center;border-radius:8px;min-height:54px}
.grid.list .card img{width:54px;min-width:54px;height:54px;aspect-ratio:auto;object-fit:cover;border-radius:6px 0 0 6px}
.grid.list .card .cap{flex:1;display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:8px 12px}
.grid.list .card .ct{max-height:none;font-size:13px;flex:1;min-width:120px}
.grid.list .card .tags{margin-top:0}
/* field visibility */
body.ca-hf-image .card img{display:none!important}
body.ca-hf-title [data-f="title"]{display:none!important}
body.ca-hf-tags [data-f="tags"]{display:none!important}
.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}
.card .cap{padding:8px 9px}
.card .ct{font-size:12px;line-height:1.2;max-height:2.4em;overflow:hidden}
.card .tags{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}
.card .tag{font-size:8px;letter-spacing:.4px;padding:1px 6px;border-radius:10px;background:#22232a;color:#b9bdc6}
.more{grid-column:1/-1;text-align:center;padding:14px}
.more button{font:inherit;font-size:13px;color:var(--amber);background:#14151a;border:1px solid var(--amber);border-radius:24px;padding:8px 24px;cursor:pointer}
.pempty{padding:40px 22px;text-align:center;color:var(--muted)}
</style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<div class="app">
  <header>
    <div class="brand"><h1>Crawl&nbsp;Atlas</h1><span class="tk">TK-135</span>
      <span class="sub">Color · Style · Motif · Material — click a node to open its products</span></div>
    <div class="built" id="built">—</div>
  </header>
  <div class="cover" id="cover"></div>
  <div class="main">
    <aside>
      <h2>Colors — pick to map</h2>
      <div class="swatches" id="swatches"></div>
      <h2>Or center on a…</h2>
      <div class="grpbtns" id="grpbtns"></div>
    </aside>
    <div class="stage">
      <div class="graphwrap">
        <div id="crumbs"></div>
        <div class="legend">
          <span><b style="background:#39d353"></b>Color</span>
          <span><b style="background:#5aa9e6"></b>Style</span>
          <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>
          <span class="pl" style="margin-left:10px">View</span>
          <button class="vbtn on" id="caVGrid" title="Grid view">⊞ Grid</button>
          <button class="vbtn" id="caVList" title="List view">☰ List</button>
          <div class="ca-fieldmenu">
            <button class="ca-fbtn" id="caFieldsBtn" aria-haspopup="true" aria-expanded="false">Fields ▾</button>
            <div class="ca-fpop" id="caFpop" hidden role="menu"></div>
          </div>
        </div>
        <div class="grid" id="grid"></div>
      </div>
    </div>
  </div>
</div>

<script>
const CATCOLOR={color:'#39d353',style:'#5aa9e6',motif:'#b48ce6',material:'#4ecdc4'};
const TYPEIDX={color:3,style:4,motif:5,material:6};
let G=null, PROD=null, CDN='https://cdn.shopify.com', current=null, trail=[];
let gridState={rows:[],shown:0};
const filter={color:null,style:null,motif:null,material:null};  // accumulated selection (one per facet)

Promise.all([fetch('graph.json',{cache:'no-store'}).then(r=>r.json()),
             fetch('products.json',{cache:'no-store'}).then(r=>r.json())])
 .then(([g,p])=>{ G=g; PROD=p; CDN=g.cdn||CDN; init(); });

function img(short,w){ return CDN+short+'?width='+w; }
function esc(s){return (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function isDark(hex){try{const n=parseInt(hex.slice(1),16);return (0.299*(n>>16&255)+0.587*(n>>8&255)+0.114*(n&255))<140;}catch{return true;}}

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(); 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
}
function renderCover(){
  const c=G.coverage;
  const cards=[['g-color','Color palette',c.hasColor,c.pctColor,'✓'],['g-style','Style',c.hasStyle,c.pctStyle,''],
    ['g-motif','Motif',c.hasMotif,c.pctMotif,''],['g-material','Material',c.hasMaterial,c.pctMaterial,''],
    ['g-all','Color+Style+Motif',c.hasAll3,c.pctAll3,'']];
  document.getElementById('cover').innerHTML=cards.map(([cls,lbl,n,pct,tick])=>`
    <div class="gauge ${cls}">${tick?`<div class="tick">${tick}</div>`:''}
      <div class="lbl">${lbl}</div><div class="num" data-target="${pct}">0.0%</div>
      <div class="den">${n.toLocaleString()} / ${c.total.toLocaleString()} crawled</div>
      <div class="bar"><i style="width:0%" data-w="${pct}"></i></div></div>`).join('');
  requestAnimationFrame(()=>{document.querySelectorAll('.gauge .bar i').forEach(b=>b.style.width=b.dataset.w+'%');
    document.querySelectorAll('.gauge .num').forEach(el=>{const t=+el.dataset.target;let s=0;const iv=setInterval(()=>{s+=t/34;if(s>=t){s=t;clearInterval(iv);}el.textContent=s.toFixed(1)+'%';},22);});});
}
function renderSwatches(){
  const colors=Object.values(G.nodes).filter(n=>n.type==='color').sort((a,b)=>b.count-a.count);
  document.getElementById('swatches').innerHTML=colors.map(n=>`<div class="sw" data-key="${n.key}">
    <span class="dot" style="background:${n.hex}"></span><span class="nm">${n.label}</span><span class="ct">${n.count.toLocaleString()}</span></div>`).join('');
  document.querySelectorAll('.sw').forEach(el=>el.onclick=()=>center(el.dataset.key,true));
}
function renderGroupBtns(){
  const box=document.getElementById('grpbtns');
  box.innerHTML=[['style','Style'],['motif','Motif'],['material','Material']].map(([t,lbl])=>{
    const top=Object.values(G.nodes).filter(n=>n.type===t).sort((a,b)=>b.count-a.count)[0];
    return top?`<button data-key="${top.key}">Top ${lbl}: ${top.label} (${top.count.toLocaleString()})</button>`:'';}).join('');
  box.querySelectorAll('button').forEach(b=>b.onclick=()=>center(b.dataset.key,true));
}
function center(key,reset,scroll){
  if(!G.nodes[key])return; const node=G.nodes[key]; current=key;
  if(reset)trail=[key]; else if(trail[trail.length-1]!==key)trail.push(key);
  // clicking a node ADDS/REPLACES that facet in the accumulating selection (toggle off if same)
  filter[node.type] = (filter[node.type]===node.label) ? null : node.label;
  document.querySelectorAll('.sw').forEach(s=>s.classList.toggle('active',s.dataset.key===key));
  renderCrumbs(); draw(); applyFilter();
  if(scroll!==false){ const ph=document.getElementById('phead'); if(ph) ph.scrollIntoView({behavior:'smooth',block:'start'}); }
}
function removeFacet(type){ filter[type]=null; draw(); applyFilter(); }
function clearFilter(){ Object.keys(filter).forEach(k=>filter[k]=null); applyFilter(); }
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=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));
  const N=neigh.length,R=245,defs=[],edges=[],nds=[];
  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 w=1+5*(x.cnt/maxC),mx=(cx+nx)/2,my=(cy+ny)/2;
    edges.push(`<line class="edge" x1="${cx}" y1="${cy}" x2="${nx.toFixed(1)}" y2="${ny.toFixed(1)}" stroke-width="${w.toFixed(2)}"/>`);
    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,false,x.cnt));});
  const cring=c.type==='color'?(c.hex||'#888'):CATCOLOR[c.type];
  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,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,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">${(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)}">+${(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]||'~')));
}
// ── Grid / List + Fields ──
const CA_FIELDS=[['image','Image'],['title','Title'],['tags','Tags']];
const CA_FIELDS_DEF=Object.fromEntries(CA_FIELDS.map(([k])=>[k,true]));
let caFieldVis=Object.assign({},CA_FIELDS_DEF,JSON.parse(localStorage.getItem('ca_fields')||'{}'));
function applyCaFields(){
  CA_FIELDS.forEach(([k])=>document.body.classList.toggle('ca-hf-'+k,!caFieldVis[k]));
  document.querySelectorAll('#caFpop input[data-field]').forEach(cb=>cb.checked=!!caFieldVis[cb.dataset.field]);
  localStorage.setItem('ca_fields',JSON.stringify(caFieldVis));
}
function buildCaFieldMenu(){
  const fp=document.getElementById('caFpop');
  fp.innerHTML=CA_FIELDS.map(([k,lbl])=>`<label><input type="checkbox" data-field="${k}"${caFieldVis[k]?' checked':''}> ${lbl}</label>`).join('');
  fp.querySelectorAll('input').forEach(cb=>cb.addEventListener('change',()=>{caFieldVis[cb.dataset.field]=cb.checked;localStorage.setItem('ca_fields',JSON.stringify(caFieldVis));applyCaFields();}));
}
function setCaView(v){
  document.getElementById('grid').classList.toggle('list',v==='list');
  document.getElementById('caVGrid').classList.toggle('on',v==='grid');
  document.getElementById('caVList').classList.toggle('on',v==='list');
  localStorage.setItem('ca_view',v);
}
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); };
  // view + fields
  document.getElementById('caVGrid').onclick=()=>setCaView('grid');
  document.getElementById('caVList').onclick=()=>setCaView('list');
  setCaView(localStorage.getItem('ca_view')||'grid');
  buildCaFieldMenu();
  applyCaFields();
  // fields popover open/close
  const fb=document.getElementById('caFieldsBtn'), fp=document.getElementById('caFpop');
  fb.addEventListener('click',e=>{e.stopPropagation();const h=fp.hidden;fp.hidden=!h;fb.setAttribute('aria-expanded',String(h));});
  document.addEventListener('click',()=>{fp.hidden=true;fb.setAttribute('aria-expanded','false');});
  fp.addEventListener('click',e=>e.stopPropagation());
}
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],...]
  // intersection: product must match EVERY selected facet
  const rows = active.length
    ? PROD.rows.filter(r=>active.every(([type,val])=>r[TYPEIDX[type]]===val))
    : [];
  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>`
                              : `<span class="sw" style="background:${CATCOLOR[type]}"></span>`;
    return `<span class="fchip ${type}">${sw}<span class="ty">${type}</span><b>${esc(val)}</b><span class="x" data-rm="${type}">✕</span></span>`;
  }).join('');
  const ph=document.getElementById('phead');
  ph.innerHTML = active.length
    ? `<span class="lead">Selection</span>${chips}
       <span class="result">${rows.length.toLocaleString()} item${rows.length===1?'':'s'} match all</span>
       <span class="clear" id="clearF">clear all</span>`
    : `<span class="lead">Click a node to start selecting — color · style · motif · material stack into one filter</span>`;
  ph.querySelectorAll('.x').forEach(x=>x.onclick=()=>removeFacet(x.dataset.rm));
  const cf=document.getElementById('clearF'); if(cf) cf.onclick=clearFilter;
  const grid=document.getElementById('grid'); grid.innerHTML='';
  if(!active.length){ grid.innerHTML='<div class="pempty">no facets selected yet — pick a color, then drill into a style / motif to narrow</div>'; return; }
  if(!rows.length){ grid.innerHTML='<div class="pempty">no products match all selected facets — try removing one</div>'; return; }
  renderMore();
}
function renderMore(){
  const BATCH=120, grid=document.getElementById('grid');
  document.querySelectorAll('.more').forEach(e=>e.remove());
  const slice=gridState.rows.slice(gridState.shown,gridState.shown+BATCH);
  const html=slice.map(r=>{
    const [g,t,im,c,s,m,mat]=r;
    const tags=[c&&`<span class="tag" style="border:1px solid #39d35355">${esc(c)}</span>`,s&&`<span class="tag">${esc(s)}</span>`,m&&`<span class="tag">${esc(m)}</span>`,mat&&`<span class="tag">${esc(mat)}</span>`].filter(Boolean).join('');
    const admin=`https://admin.shopify.com/store/designer-laboratory-sandbox/products/${g}`;
    return `<a class="card" href="${admin}" target="_blank" rel="noopener">
      <img loading="lazy" src="${img(im,240)}" alt="">
      <div class="cap"><div class="ct" data-f="title">${esc(t)}</div><div class="tags" data-f="tags">${tags}</div></div></a>`;
  }).join('');
  grid.insertAdjacentHTML('beforeend',html);
  gridState.shown+=slice.length;
  if(gridState.shown<gridState.rows.length){
    grid.insertAdjacentHTML('beforeend',`<div class="more"><button id="moreBtn">Load more · ${(gridState.rows.length-gridState.shown).toLocaleString()} remaining</button></div>`);
    document.getElementById('moreBtn').onclick=renderMore;
  }
}
</script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>