← back to Dw Catalog 3d

public/index.html

436 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>DW Catalog · Live 3D Drill-Down</title>
<style>
  :root{ --bg:#0c0f15; --panel:#141925cc; --fg:#E6E8EC; --muted:#8A93A2; --gold:#C8A24B;
         --active:#70AD47; --staged:#E0A33E; --draft:#5B9BD5; --archived:#5A6472; --deleted:#E74C3C; }
  *{box-sizing:border-box} html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:var(--fg)}
  #c{position:fixed;inset:0;display:block}
  .hud{position:fixed;z-index:10}
  #title{top:16px;left:24px;pointer-events:none}
  #title h1{margin:0;font-size:19px;letter-spacing:.5px;font-weight:800}
  #title h1 .g{color:var(--gold)}
  #crumb{margin:6px 0 0;font-size:13px;color:var(--muted);pointer-events:auto}
  #crumb b{color:var(--fg)} #crumb a{color:var(--gold);cursor:pointer;text-decoration:none}
  #crumb a:hover{text-decoration:underline} #crumb .sep{opacity:.5;margin:0 7px}
  #legend{top:16px;right:24px;background:var(--panel);backdrop-filter:blur(8px);border:1px solid #2A2F3A;
    border-radius:14px;padding:13px 15px;min-width:220px}
  #legend h3{margin:0 0 9px;font-size:11px;letter-spacing:1.5px;color:var(--muted);text-transform:uppercase}
  .row{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:9px;cursor:pointer;user-select:none}
  .row:hover{background:#1d2230} .row.off{opacity:.34}
  .sw{width:14px;height:14px;border-radius:4px;flex:0 0 auto;box-shadow:0 0 9px currentColor}
  .row .nm{font-size:13px;font-weight:600;flex:1} .row .ct{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
  #scaleToggle{margin-top:10px;display:flex;gap:6px}
  #scaleToggle button{flex:1;background:#1a2030;color:var(--fg);border:1px solid #2A2F3A;border-radius:8px;padding:6px;font-size:11px;cursor:pointer;font-weight:600}
  #scaleToggle button.sel{background:var(--gold);color:#11141A;border-color:var(--gold)}
  #back{left:24px;bottom:54px;background:var(--panel);border:1px solid var(--gold);color:var(--gold);
    border-radius:10px;padding:8px 14px;font-size:13px;font-weight:700;cursor:pointer;display:none;backdrop-filter:blur(8px)}
  #hint{bottom:16px;left:24px;font-size:11px;color:var(--muted);pointer-events:none}
  #tooltip{position:fixed;z-index:20;pointer-events:none;background:#0e1320f2;border:1px solid var(--gold);
    border-radius:10px;padding:10px 12px;font-size:12px;display:none;max-width:240px;box-shadow:0 8px 30px #000a}
  #tooltip .v{font-weight:800;font-size:13px;margin-bottom:6px;color:var(--gold)}
  #tooltip img{width:100%;height:92px;object-fit:cover;border-radius:7px;margin:2px 0 8px;border:1px solid #2A2F3A;background:#0c0f15}
  #tooltip .l{display:flex;justify-content:space-between;gap:14px;line-height:1.55}
  #tooltip .l b{font-variant-numeric:tabular-nums}
  #tooltip .sub{color:var(--muted);font-size:11px;margin-top:5px}
  .dot{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px;vertical-align:middle}
  #loading{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:15;
    color:var(--gold);font-size:14px;letter-spacing:1px;pointer-events:none}
  /* search box (under breadcrumb) */
  #search{margin:8px 0 0;position:relative;pointer-events:auto;width:260px}
  #search input{width:100%;background:var(--panel);backdrop-filter:blur(8px);border:1px solid #2A2F3A;
    border-radius:9px;padding:7px 11px;color:var(--fg);font-size:13px;outline:none}
  #search input:focus{border-color:var(--gold)}
  #search input::placeholder{color:var(--muted)}
  #sresults{position:absolute;top:36px;left:0;right:0;background:#0e1320f4;border:1px solid #2A2F3A;
    border-radius:9px;max-height:300px;overflow:auto;display:none;z-index:30;box-shadow:0 8px 30px #000a}
  #sresults div{padding:7px 11px;font-size:12.5px;cursor:pointer;border-bottom:1px solid #1d2230;display:flex;justify-content:space-between;gap:10px}
  #sresults div:last-child{border-bottom:none} #sresults div:hover,#sresults div.sel{background:#1d2230}
  #sresults div .vn{color:var(--fg);font-weight:600} #sresults div .vc{color:var(--muted);font-variant-numeric:tabular-nums}
  /* L2 status filter chips */
  #stchips{position:fixed;z-index:10;left:24px;bottom:96px;display:none;gap:7px}
  #stchips button{background:var(--panel);backdrop-filter:blur(8px);border:1px solid #2A2F3A;color:var(--fg);
    border-radius:20px;padding:6px 13px;font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:7px}
  #stchips button.off{opacity:.4}
  #stchips button .d{width:9px;height:9px;border-radius:3px;box-shadow:0 0 7px currentColor}
  #stchips button .n{font-variant-numeric:tabular-nums;color:var(--muted)}
  /* D2: Charts tab */
  #chartsBtn{position:fixed;z-index:11;right:24px;bottom:54px;background:var(--panel);backdrop-filter:blur(8px);
    border:1px solid var(--gold);color:var(--gold);border-radius:10px;padding:8px 14px;font-size:13px;font-weight:700;cursor:pointer}
  #charts{position:fixed;inset:0;z-index:40;display:none;background:#0a0d13ee;backdrop-filter:blur(6px);overflow:auto;padding:64px 40px 48px}
  #charts.show{display:block}
  #charts .close{position:fixed;top:20px;right:28px;background:var(--panel);border:1px solid var(--gold);color:var(--gold);
    border-radius:10px;padding:8px 16px;font-size:13px;font-weight:700;cursor:pointer;z-index:41}
  #charts h2{margin:0 0 26px;font-size:20px;font-weight:800;letter-spacing:.5px}
  #charts h2 .g{color:var(--gold)}
  #charts .panel{background:var(--panel);border:1px solid #2A2F3A;border-radius:14px;padding:18px 20px;margin:0 auto 22px;max-width:880px}
  #charts .panel h3{margin:0 0 14px;font-size:12px;letter-spacing:1.4px;color:var(--muted);text-transform:uppercase}
  #charts .stack{display:flex;height:34px;border-radius:8px;overflow:hidden;border:1px solid #2A2F3A}
  #charts .stack span{display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#0c0f15;min-width:0;overflow:hidden;white-space:nowrap}
  #charts .stacklegend{display:flex;flex-wrap:wrap;gap:14px;margin-top:12px;font-size:12px;color:var(--muted)}
  #charts .stacklegend span{display:flex;align-items:center;gap:6px}
  #charts .stacklegend .d{width:11px;height:11px;border-radius:3px}
  #charts .hbar{display:flex;align-items:center;gap:12px;margin:7px 0;font-size:12.5px}
  #charts .hbar .nm{width:170px;flex:0 0 auto;color:var(--fg);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  #charts .hbar .track{flex:1;height:18px;background:#161c28;border-radius:5px;overflow:hidden}
  #charts .hbar .fill{height:100%;border-radius:5px}
  #charts .hbar .ct{width:74px;flex:0 0 auto;text-align:right;color:var(--muted);font-variant-numeric:tabular-nums}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="title" class="hud">
  <h1>DESIGNER WALLCOVERINGS · <span class="g">LIVE 3D CATALOG</span></h1>
  <div id="crumb"></div>
  <div id="search">
    <input id="sinput" type="text" placeholder="Search vendors…" autocomplete="off" spellcheck="false"/>
    <div id="sresults"></div>
  </div>
</div>
<div id="stchips" class="hud"></div>
<div id="legend" class="hud">
  <h3 id="legTitle">Status Layers</h3>
  <div id="rows"></div>
  <div id="scaleToggle"><button data-s="sqrt">√ scale</button><button data-s="log" class="sel">log scale</button><button data-s="lin">linear</button></div>
</div>
<button id="back" class="hud">‹ back</button>
<button id="chartsBtn" class="hud" title="Aggregate catalog charts">📊 Charts</button>
<div id="charts">
  <button class="close" id="chartsClose">✕ close</button>
  <h2>DESIGNER WALLCOVERINGS · <span class="g">CATALOG CHARTS</span></h2>
  <div class="panel"><h3 id="chTotal">Status Composition</h3><div id="chStack" class="stack"></div><div id="chStackLeg" class="stacklegend"></div></div>
  <div class="panel"><h3>Top Vendors by Active Products</h3><div id="chTopVendors"></div></div>
  <div class="panel"><h3>Staged Pipeline · Vendors with the Most Staged</h3><div id="chStaged"></div></div>
</div>
<div id="hint" class="hud">click a vendor → drill into collections → products → open on store · drag to orbit · esc to go up</div>
<div id="tooltip"></div>
<div id="loading" class="hud">loading…</div>

<script type="importmap">
{ "imports": {
  "three":"https://unpkg.com/three@0.160.0/build/three.module.js",
  "three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';

const STORE='https://designerwallcoverings.com/products/';
const LAYERS=[
  {key:'active',name:'Active',color:0x70AD47},{key:'staged',name:'Staged',color:0xE0A33E},
  {key:'draft',name:'Draft',color:0x5B9BD5},{key:'archived',name:'Archived',color:0x5A6472},
  {key:'deleted',name:'Deleted',color:0xE74C3C}];
const HEX=k=>'#'+LAYERS.find(l=>l.key===k).color.toString(16).padStart(6,'0');
const COLLCOLORS=[0xC8A24B,0x70AD47,0x5B9BD5,0xE0A33E,0xA569BD,0x1ABC9C,0xE74C3C,0x48A9C5,0xD4AC0D,0xE08E45];
const visible=Object.fromEntries(LAYERS.map(l=>[l.key,true]));
// product-level status filter (L2): active / staged / draft
const ST=[{key:'active',name:'Active',color:'#70AD47'},{key:'staged',name:'Staged',color:'#E0A33E'},{key:'draft',name:'Draft',color:'#5B9BD5'}];
const stVisible={active:true,staged:true,draft:true};
let scaleMode='log', DATA=[], vendorFilter='', state={level:0,vendor:null,vData:null,coll:null};

const scene=new THREE.Scene();
scene.background=new THREE.Color(0x0c0f15);
scene.fog=new THREE.FogExp2(0x0c0f15,0.010);
const camera=new THREE.PerspectiveCamera(52,innerWidth/innerHeight,0.1,3000);
camera.position.set(58,46,74);
const renderer=new THREE.WebGLRenderer({canvas:c,antialias:true});
renderer.setSize(innerWidth,innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio,2));
const controls=new OrbitControls(camera,renderer.domElement);
controls.enableDamping=true; controls.dampingFactor=.08; controls.target.set(0,7,0);
controls.autoRotate=true; controls.autoRotateSpeed=0.4; controls.maxPolarAngle=Math.PI*0.49;

scene.add(new THREE.AmbientLight(0xffffff,0.6));
const key=new THREE.DirectionalLight(0xffffff,0.9); key.position.set(20,40,20); scene.add(key);
const pl=new THREE.PointLight(0xC8A24B,0.7,300); pl.position.set(-20,28,-10); scene.add(pl);
const grid=new THREE.GridHelper(140,46,0xC8A24B,0x232a36);
grid.material.transparent=true; grid.material.opacity=0.20; scene.add(grid);

const world=new THREE.Group(); scene.add(world);
let picks=[];               // raycastable meshes
const texLoader=new THREE.TextureLoader(); texLoader.setCrossOrigin('anonymous');

// ---- camera fly ----
let fly=null;
function flyTo(pos,tgt){ fly={p0:camera.position.clone(),p1:new THREE.Vector3(...pos),
  t0:controls.target.clone(),t1:new THREE.Vector3(...tgt),k:0}; }
function stepFly(){ if(!fly)return; fly.k=Math.min(1,fly.k+0.045); const e=1-Math.pow(1-fly.k,3);
  camera.position.lerpVectors(fly.p0,fly.p1,e); controls.target.lerpVectors(fly.t0,fly.t1,e);
  if(fly.k>=1) fly=null; }

function scaleFn(v){ if(v<=0)return 0;
  if(scaleMode==='sqrt')return Math.sqrt(v)*0.42;
  if(scaleMode==='log')return Math.log1p(v)*1.7; return v*0.0016; }

function label(text,scale=[9,2.25,1],col='#E6E8EC'){
  const cv=document.createElement('canvas'),s=2; cv.width=256*s; cv.height=64*s;
  const x=cv.getContext('2d'); x.scale(s,s); x.font='600 22px -apple-system,Helvetica,Arial';
  x.fillStyle=col; x.textAlign='center'; x.textBaseline='middle';
  x.fillText(text.length>20?text.slice(0,19)+'…':text,128,32);
  const t=new THREE.CanvasTexture(cv); t.anisotropy=4;
  const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:t,transparent:true,depthWrite:false}));
  sp.scale.set(...scale); return sp;
}
function clearWorld(){ world.clear(); picks=[]; }

// ===================== LEVEL 0 : VENDORS =====================
function renderVendors(){
  state={level:0,vendor:null,vData:null,coll:null}; clearWorld();
  controls.autoRotate=true; legendStatus(); crumb(); hideStChips();
  document.getElementById('back').style.display='none';
  const N=DATA.length,cols=Math.ceil(Math.sqrt(N)),gap=11;
  const fq=vendorFilter;
  DATA.forEach((d,i)=>{
    const cx=(i%cols-(cols-1)/2)*gap, cz=(Math.floor(i/cols)-(Math.ceil(N/cols)-1)/2)*gap;
    const match=!fq||d.vendor.toLowerCase().includes(fq);
    const op=match?.88:.10, eop=match?.5:.10, emi=match?.32:.05;
    let y=0;
    LAYERS.forEach(L=>{ const val=d[L.key]||0; if(val<=0||!visible[L.key])return;
      const h=Math.max(scaleFn(val),0.12);
      const m=new THREE.Mesh(new THREE.BoxGeometry(5.4,h,5.4),
        new THREE.MeshStandardMaterial({color:L.color,emissive:L.color,emissiveIntensity:emi,metalness:.3,roughness:.45,transparent:true,opacity:op}));
      m.position.set(cx,y+h/2,cz); m.userData={kind:'vendor',d}; world.add(m); if(match)picks.push(m);
      const eg=new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry),new THREE.LineBasicMaterial({color:L.color,transparent:true,opacity:eop}));
      eg.position.copy(m.position); world.add(eg); y+=h+0.04; });
    if(match){ const lab=label(d.vendor); lab.position.set(cx,y+1.8,cz); lab.userData={kind:'vendor',d}; world.add(lab); }
  });
  flyTo([58,46,74],[0,7,0]); syncHash();
}

// ===================== LEVEL 1 : COLLECTIONS =====================
async function drillVendor(d){
  if(!d.slug){return;}
  loading(true);
  let v; try{ v=await fetch(`v/${d.slug}.json?_=${Date.now()}`).then(r=>r.json()); }
  catch(e){ loading(false); return; }
  loading(false);
  state={level:1,vendor:d,vData:v,coll:null}; clearWorld();
  controls.autoRotate=false; legendCollections(v); crumb(); hideStChips();
  document.getElementById('back').style.display='block';
  const cs=v.collections, N=cs.length, R=Math.max(12,N*1.6);
  const maxc=Math.max(...cs.map(c=>c.count));
  cs.forEach((cobj,i)=>{
    const ang=(i/N)*Math.PI*2, cx=Math.cos(ang)*R, cz=Math.sin(ang)*R;
    const h=Math.max(scaleFn(cobj.count)*1.4,1.2), col=COLLCOLORS[i%COLLCOLORS.length];
    const m=new THREE.Mesh(new THREE.CylinderGeometry(2.6,3.0,h,6),
      new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:.34,metalness:.35,roughness:.4,transparent:true,opacity:.9}));
    m.position.set(cx,h/2,cz); m.userData={kind:'collection',cobj,col}; world.add(m); picks.push(m);
    const eg=new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry),new THREE.LineBasicMaterial({color:col,transparent:true,opacity:.55}));
    eg.position.copy(m.position); world.add(eg);
    const lab=label(`${cobj.name}  (${cobj.count})`,[11,2.6,1]); lab.position.set(cx,h+2.2,cz);
    lab.userData={kind:'collection',cobj,col}; world.add(lab);
  });
  // center pedestal with vendor name
  const ped=new THREE.Mesh(new THREE.CylinderGeometry(3.6,4.2,1.4,24),
    new THREE.MeshStandardMaterial({color:0xC8A24B,emissive:0xC8A24B,emissiveIntensity:.25,metalness:.6,roughness:.3}));
  ped.position.y=0.7; world.add(ped);
  const vlab=label(d.vendor,[16,3.6,1],'#C8A24B'); vlab.position.set(0,3.4,0); world.add(vlab);
  flyTo([0,R*1.5,R*1.9],[0,4,0]); syncHash();
}

// ===================== LEVEL 2 : PRODUCTS =====================
function drillCollection(cobj){
  state.level=2; state.coll=cobj; clearWorld();
  controls.autoRotate=false; legendCollections(state.vData,cobj); crumb(); renderStChips();
  // filter products by the enabled status chips
  const prods=cobj.products.filter(p=>stVisible[(p.st||'active')]), N=prods.length, cols=Math.max(1,Math.ceil(Math.sqrt(N))), gap=4.4;
  const fallback=new THREE.MeshStandardMaterial({color:0x222a38,emissive:0x141a26,roughness:.7});
  prods.forEach((p,i)=>{
    const cx=(i%cols-(cols-1)/2)*gap, cy=(Math.floor(i/cols))*gap*-1+ (Math.ceil(N/cols))*gap/2;
    const geo=new THREE.PlaneGeometry(3.7,3.7);
    const mat=new THREE.MeshStandardMaterial({color:0xffffff,emissive:0x000000,roughness:.6,side:THREE.DoubleSide,transparent:true});
    const m=new THREE.Mesh(geo,mat); m.position.set(cx,cy+8,0); m.userData={kind:'product',p};
    world.add(m); picks.push(m);
    const frame=new THREE.LineSegments(new THREE.EdgesGeometry(geo),new THREE.LineBasicMaterial({color:0x2A2F3A}));
    frame.position.copy(m.position); world.add(frame);
    if(p.img){ texLoader.load(p.img,t=>{t.colorSpace=THREE.SRGBColorSpace;mat.map=t;mat.needsUpdate=true;},
      undefined,()=>{m.material=fallback.clone();}); } else m.material=fallback.clone();
  });
  const r=Math.max(20,cols*gap*1.1);
  flyTo([0,8,r],[0,8,0]); syncHash();
}

// ---- legends / breadcrumb ----
function legendStatus(){
  document.getElementById('legTitle').textContent='Status Layers';
  document.getElementById('scaleToggle').style.display='flex';
  const rows=document.getElementById('rows'); rows.innerHTML='';
  LAYERS.forEach(L=>{ const tot=DATA.reduce((s,d)=>s+(d[L.key]||0),0);
    const el=document.createElement('div'); el.className='row'+(visible[L.key]?'':' off');
    el.innerHTML=`<span class="sw" style="background:${HEX(L.key)};color:${HEX(L.key)}"></span><span class="nm">${L.name}</span><span class="ct">${tot.toLocaleString()}</span>`;
    el.onclick=()=>{visible[L.key]=!visible[L.key]; if(state.level===0)renderVendors();};
    rows.appendChild(el); });
}
function legendCollections(v,active){
  document.getElementById('legTitle').textContent=`${v.vendor} · Collections`;
  document.getElementById('scaleToggle').style.display='none';
  const rows=document.getElementById('rows'); rows.innerHTML='';
  v.collections.slice(0,14).forEach((c,i)=>{ const col='#'+COLLCOLORS[i%COLLCOLORS.length].toString(16).padStart(6,'0');
    const el=document.createElement('div'); el.className='row'+(active&&active!==c?' off':'');
    el.innerHTML=`<span class="sw" style="background:${col};color:${col}"></span><span class="nm">${c.name}</span><span class="ct">${c.count}</span>`;
    el.onclick=()=>drillCollection(c); rows.appendChild(el); });
}
// L2 product status filter chips
function renderStChips(){
  const bar=document.getElementById('stchips');
  if(state.level!==2||!state.coll){ bar.style.display='none'; return; }
  bar.style.display='flex';
  const prods=state.coll.products||[];
  bar.innerHTML=ST.map(s=>{
    const n=prods.filter(p=>(p.st||'active')===s.key).length;
    return `<button data-st="${s.key}" class="${stVisible[s.key]?'':'off'}">`+
      `<span class="d" style="background:${s.color};color:${s.color}"></span>${s.name} <span class="n">${n}</span></button>`;
  }).join('');
  bar.querySelectorAll('button').forEach(b=>b.onclick=()=>{
    const k=b.dataset.st; stVisible[k]=!stVisible[k];
    // never let all three be off — re-enable if user turned the last one off
    if(!ST.some(s=>stVisible[s.key])) stVisible[k]=true;
    drillCollection(state.coll);
  });
}
function hideStChips(){ document.getElementById('stchips').style.display='none'; }
function crumb(){
  const c=document.getElementById('crumb'); const s='<span class="sep">›</span>';
  let h=`<a onclick="window.__home()">All Vendors</a>`;
  if(state.vendor) h+=s+(state.level===1?`<b>${state.vendor.vendor}</b>`:`<a onclick="window.__vendor()">${state.vendor.vendor}</a>`);
  if(state.coll) h+=s+`<b>${state.coll.name}</b>`;
  c.innerHTML=h;
}
window.__home=()=>renderVendors();
window.__vendor=()=>{ if(state.vData) drillVendor(state.vendor); };
window.__drill=(slug)=>drillVendor(DATA.find(d=>d.slug===slug)||DATA.find(d=>d.slug));
window.__coll=(i)=>{ if(state.vData) drillCollection(state.vData.collections[i||0]); };

// ---- D3: deep-link hash routing (#v=<slug> | #v=<slug>&c=<i>) ----
function syncHash(){
  let h='';
  if(state.vendor&&state.vendor.slug){ h='v='+encodeURIComponent(state.vendor.slug);
    if(state.level===2&&state.coll&&state.vData){ const i=state.vData.collections.indexOf(state.coll);
      if(i>=0) h+='&c='+i; } }
  const nh=h?'#'+h:location.pathname+location.search; // bare path clears the hash
  if(location.hash.replace(/^#/,'')!==h) history.replaceState(null,'',nh);
}
async function restoreFromHash(rawHash){
  const m=new URLSearchParams((rawHash||location.hash).replace(/^#/,''));
  const slug=m.get('v'); if(!slug) return; // nothing to restore → stay at L0
  const d=DATA.find(x=>x.slug===slug); if(!d) return;
  await drillVendor(d);                       // L1 (awaits the v/<slug>.json fetch)
  const ci=m.get('c');
  if(ci!=null&&state.vData&&state.vData.collections[+ci]) drillCollection(state.vData.collections[+ci]); // L2
}

// ---- interaction ----
const ray=new THREE.Raycaster(), mouse=new THREE.Vector2(), tip=document.getElementById('tooltip');
let downXY=null;
addEventListener('pointerdown',e=>downXY=[e.clientX,e.clientY]);
addEventListener('pointerup',e=>{
  if(!downXY)return; const moved=Math.hypot(e.clientX-downXY[0],e.clientY-downXY[1]); downXY=null;
  if(moved>6)return; // was a drag, not a click
  mouse.x=(e.clientX/innerWidth)*2-1; mouse.y=-(e.clientY/innerHeight)*2+1;
  ray.setFromCamera(mouse,camera); const hit=ray.intersectObjects(picks,false)[0]; if(!hit)return;
  const u=hit.object.userData;
  if(u.kind==='vendor') drillVendor(u.d);
  else if(u.kind==='collection') drillCollection(u.cobj);
  else if(u.kind==='product' && u.p.h) window.open(STORE+u.p.h,'_blank');
});
addEventListener('mousemove',e=>{
  mouse.x=(e.clientX/innerWidth)*2-1; mouse.y=-(e.clientY/innerHeight)*2+1;
  ray.setFromCamera(mouse,camera); const hit=ray.intersectObjects(picks,false)[0];
  if(!hit){tip.style.display='none';renderer.domElement.style.cursor='grab';return;}
  const u=hit.object.userData; renderer.domElement.style.cursor='pointer';
  tip.style.display='block'; tip.style.left=Math.min(e.clientX+16,innerWidth-256)+'px'; tip.style.top=(e.clientY+14)+'px';
  if(u.kind==='vendor'){ const d=u.d;
    tip.innerHTML=`<div class="v">${d.vendor}</div>`+(d.img?`<img src="${d.img}" onerror="this.style.display='none'"/>`:'')+
      LAYERS.map(L=>`<div class="l"><span><span class="dot" style="background:${HEX(L.key)}"></span>${L.name}</span><b>${(d[L.key]||0).toLocaleString()}</b></div>`).join('')+
      `<div class="sub">click → drill into collections</div>`;
  } else if(u.kind==='collection'){ const c=u.cobj;
    tip.innerHTML=`<div class="v">${c.name}</div><div class="l"><span>Products</span><b>${c.count}</b></div><div class="sub">click → view products</div>`;
  } else if(u.kind==='product'){ const p=u.p;
    tip.innerHTML=`<div class="v">${p.t}</div>`+(p.img?`<img src="${p.img}" onerror="this.style.display='none'"/>`:'')+
      (p.sku?`<div class="l"><span>SKU</span><b>${p.sku}</b></div>`:'')+(p.p?`<div class="l"><span>Price</span><b>${p.p}</b></div>`:'')+
      `<div class="sub">click → open on designerwallcoverings.com</div>`;
  }
});
document.getElementById('back').onclick=()=>{ if(state.level===2) drillVendor(state.vendor); else renderVendors(); };
addEventListener('keydown',e=>{ if(e.key==='Escape'){ if(document.getElementById('charts').classList.contains('show'))return; if(state.level===2)drillVendor(state.vendor); else if(state.level===1)renderVendors(); }});
document.querySelectorAll('#scaleToggle button').forEach(b=>b.onclick=()=>{
  document.querySelectorAll('#scaleToggle button').forEach(x=>x.classList.remove('sel'));
  b.classList.add('sel'); scaleMode=b.dataset.s; if(state.level===0)renderVendors(); });
function loading(on){document.getElementById('loading').style.display=on?'flex':'none';}

// ---- search: filter L0 towers + jump to a vendor by name ----
const sinput=document.getElementById('sinput'), sresults=document.getElementById('sresults');
function searchMatches(q){ q=q.trim().toLowerCase(); if(!q)return[];
  return DATA.filter(d=>d.vendor.toLowerCase().includes(q)).slice(0,20); }
function showResults(list){
  if(!list.length){ sresults.style.display='none'; sresults.innerHTML=''; return; }
  sresults.innerHTML=list.map((d,i)=>`<div data-slug="${d.slug}" class="${i===0?'sel':''}"><span class="vn">${d.vendor}</span><span class="vc">${(d.active||0).toLocaleString()}</span></div>`).join('');
  sresults.style.display='block';
  sresults.querySelectorAll('div').forEach(el=>el.onclick=()=>jumpTo(el.dataset.slug));
}
function jumpTo(slug){ const d=DATA.find(x=>x.slug===slug); if(!d)return;
  sinput.value=d.vendor; sresults.style.display='none'; vendorFilter=''; if(state.level===0)renderVendors();
  drillVendor(d); }
sinput.addEventListener('input',()=>{ const q=sinput.value;
  vendorFilter=q.trim().toLowerCase();
  showResults(searchMatches(q));
  if(state.level===0) renderVendors();   // filter towers live
});
sinput.addEventListener('keydown',e=>{
  const items=[...sresults.querySelectorAll('div')]; let i=items.findIndex(x=>x.classList.contains('sel'));
  if(e.key==='ArrowDown'){ e.preventDefault(); if(items[i])items[i].classList.remove('sel'); i=Math.min(items.length-1,i+1); if(items[i])items[i].classList.add('sel'); }
  else if(e.key==='ArrowUp'){ e.preventDefault(); if(items[i])items[i].classList.remove('sel'); i=Math.max(0,i-1); if(items[i])items[i].classList.add('sel'); }
  else if(e.key==='Enter'){ const sel=sresults.querySelector('div.sel')||items[0]; if(sel)jumpTo(sel.dataset.slug); }
  else if(e.key==='Escape'){ sinput.value=''; vendorFilter=''; sresults.style.display='none'; if(state.level===0)renderVendors(); sinput.blur(); }
});
document.addEventListener('click',e=>{ if(!document.getElementById('search').contains(e.target)) sresults.style.display='none'; });

// ---- D2: client-side aggregate charts (pure DOM, from already-loaded DATA) ----
function renderCharts(){
  if(!DATA.length) return;
  // 1) status composition — stacked bar of catalog-wide totals
  const tot=Object.fromEntries(LAYERS.map(L=>[L.key,DATA.reduce((s,d)=>s+(d[L.key]||0),0)]));
  const grand=LAYERS.reduce((s,L)=>s+tot[L.key],0)||1;
  document.getElementById('chTotal').textContent=`Status Composition · ${grand.toLocaleString()} total products`;
  document.getElementById('chStack').innerHTML=LAYERS.map(L=>{
    const pct=tot[L.key]/grand*100; if(pct<=0)return'';
    return `<span style="background:${HEX(L.key)};flex:${tot[L.key]} ${tot[L.key]} 0" title="${L.name}: ${tot[L.key].toLocaleString()}">${pct>=5?Math.round(pct)+'%':''}</span>`;
  }).join('');
  document.getElementById('chStackLeg').innerHTML=LAYERS.map(L=>
    `<span><span class="d" style="background:${HEX(L.key)}"></span>${L.name} ${tot[L.key].toLocaleString()}</span>`).join('');
  // 2) top vendors by active
  const hbar=(rows,max,col)=>rows.map(r=>{
    const w=max>0?(r.v/max*100):0;
    return `<div class="hbar"><span class="nm" title="${r.n}">${r.n}</span>`+
      `<span class="track"><span class="fill" style="width:${w}%;background:${col}"></span></span>`+
      `<span class="ct">${r.v.toLocaleString()}</span></div>`;
  }).join('');
  const topA=[...DATA].filter(d=>d.active>0).sort((a,b)=>b.active-a.active).slice(0,12).map(d=>({n:d.vendor,v:d.active}));
  document.getElementById('chTopVendors').innerHTML=hbar(topA,topA.length?topA[0].v:0,HEX('active'));
  // 3) staged pipeline split — vendors with the most staged
  const topS=[...DATA].filter(d=>d.staged>0).sort((a,b)=>b.staged-a.staged).slice(0,12).map(d=>({n:d.vendor,v:d.staged}));
  document.getElementById('chStaged').innerHTML=topS.length
    ? hbar(topS,topS[0].v,HEX('staged'))
    : '<div style="color:var(--muted);font-size:13px">No staged products in the catalog right now.</div>';
}
const chartsEl=document.getElementById('charts');
function toggleCharts(on){ const show=on==null?!chartsEl.classList.contains('show'):on;
  if(show){ renderCharts(); chartsEl.classList.add('show'); } else chartsEl.classList.remove('show'); }
document.getElementById('chartsBtn').onclick=()=>toggleCharts();
document.getElementById('chartsClose').onclick=()=>toggleCharts(false);
addEventListener('keydown',e=>{ if(e.key==='Escape'&&chartsEl.classList.contains('show')) toggleCharts(false); });

const __initialHash=location.hash;   // capture before renderVendors() clears it via syncHash
fetch('scene.json?_='+Date.now()).then(r=>r.json()).then(d=>{
  DATA=d.sort((a,b)=>(b.active+b.archived)-(a.active+a.archived)); renderVendors(); restoreFromHash(__initialHash); });

addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});
(function loop(){requestAnimationFrame(loop);stepFly();controls.update();renderer.render(scene,camera);})();
</script>
</body>
</html>