Commit b7adc89e by PLN (Algolia)

feat(unwrapped): explorable feature-space map of the sample corpus (#81)

ParVagues Unwrapped — a Ship's Bridge dark explorable over unwrapped.json:
- The map: canvas scatter of all 1,485 samples; pick any 2 of the 5 named
  superfeature axes (or raw features); colour by family / audio-cluster /
  folder-kind / folder-agreement; hover to identify, CLICK TO AUDITION the .wav.
- The five axes: PCA loading bars (what composes each superfeature) + RF
  family-discriminator importances (centroid + temporal-centroid on top).
- Family fingerprints: per-family beeswarm on a switchable feature — the
  kick-punchy / bass-sustained split made visible.
- Folders are loose: family x audio-cluster contingency grid (ARI 0.25) — the
  timbre-not-label story, the data behind 'a folder is a loose grouping'.
Zero npm deps; fleet family colours; magenta reserved for the audition pulse.
parent c2d6eedb
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ParVagues Unwrapped — the sample corpus, in feature space</title>
<meta name="description" content="An explorable map of 1,485 ParVagues samples: pick any two of the five superfeature axes the MDA found, colour by family or by what the audio actually clusters into, and click any point to hear it.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ───────────────────────────────────────────────────────────────────────────
ParVagues · Unwrapped — explorable feature space of the sample corpus (#81).
Ship's Bridge language (armada/DESIGN.md): dark instrument surface, Geist +
Geist Mono for data, never hue alone (legend label+glyph+position always),
brand magenta reserved for the *now* (the audition pulse). Fleet sample-family
colours come from the embedded palette (tokens.json → build_unwrapped). Zero
npm deps; reads unwrapped.json (fetch, or inlined block for a static build).
─────────────────────────────────────────────────────────────────────────── */
:root{
--surface:#0a0a0a; --raised:#111113; --overlay:#16161a; --hairline:#ffffff1c;
--ink:#e8e8ea; --mute:#9a9aa2; --faint:#67676e; --faintest:#3a3a40;
--magenta:#d900ff; /* reserved — the audition 'now' only */
--grid:#ffffff0e;
--maxw:1280px;
}
*{box-sizing:border-box}
@media (prefers-reduced-motion: reduce){html{scroll-behavior:auto}}
body{margin:0;background:var(--surface);color:var(--ink);
font:400 15px/1.55 Geist,Inter,system-ui,-apple-system,sans-serif;
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
.mono{font-family:"Geist Mono",ui-monospace,SFMono-Regular,monospace;
font-variant-numeric:tabular-nums}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
h1,h2,h3{margin:0;line-height:1.06;letter-spacing:-0.025em;font-weight:600}
a{color:#7db3ff;text-decoration:none}a:hover{text-decoration:underline}
/* ── masthead ─────────────────────────────────────────────────────────────── */
header{padding:5vh 0 2.4vh;border-bottom:1px solid var(--hairline)}
header h1{font-size:clamp(2rem,5.2vw,3.4rem);font-weight:700;letter-spacing:-0.04em;
text-wrap:balance}
header h1 em{font-style:normal;color:var(--mute)}
.dek{font-size:clamp(1rem,1.5vw,1.18rem);color:var(--mute);max-width:70ch;
text-wrap:pretty;margin:.9rem 0 0}
.dek b{color:var(--ink);font-weight:500}
.stats{display:flex;flex-wrap:wrap;gap:0;margin-top:1.9rem}
.stat{padding:0 1.4rem 0 0;margin-right:1.4rem;border-right:1px solid var(--hairline)}
.stat:last-child{border-right:0}
.stat .v{font:600 1.55rem/1 "Geist Mono",monospace;letter-spacing:-0.02em}
.stat .l{font-size:.74rem;color:var(--faint);margin-top:.3rem;letter-spacing:.01em}
/* ── section frame ────────────────────────────────────────────────────────── */
section{padding:5vh 0 1vh}
.shead{display:flex;align-items:baseline;gap:.8rem;margin-bottom:.3rem}
.shead h2{font-size:clamp(1.4rem,2.6vw,1.95rem)}
.shead .tag{font:500 11px/1 "Geist Mono",monospace;color:var(--faint);
letter-spacing:.05em;text-transform:uppercase}
.sdek{font-size:.98rem;color:var(--mute);max-width:74ch;text-wrap:pretty;margin:0 0 1.4rem}
.sdek b{color:var(--ink);font-weight:500}
.note{font-size:.8rem;color:var(--faint);max-width:80ch;margin-top:1rem;line-height:1.55}
code{font-family:"Geist Mono",ui-monospace,monospace;font-size:.84em;
background:#ffffff0d;border:1px solid var(--hairline);border-radius:5px;
padding:1px 5px;color:#9fd0ff}
/* ── the map: controls ────────────────────────────────────────────────────── */
.map-shell{display:grid;grid-template-columns:1fr;gap:18px}
.controls{display:flex;flex-wrap:wrap;align-items:flex-end;gap:14px 22px;
padding:14px 16px;background:var(--raised);border:1px solid var(--hairline);
border-radius:12px}
.ctl{display:flex;flex-direction:column;gap:6px}
.ctl > label{font:500 10.5px/1 "Geist Mono",monospace;color:var(--faint);
letter-spacing:.06em;text-transform:uppercase}
select,input[type=search]{appearance:none;background:var(--overlay);color:var(--ink);
border:1px solid var(--hairline);border-radius:8px;padding:7px 11px;
font:500 13px/1 Geist,sans-serif;cursor:pointer;min-width:150px}
select:focus,input:focus{outline:2px solid #d900ff55;outline-offset:1px}
input[type=search]{cursor:text;min-width:140px}
.seg{display:inline-flex;background:var(--overlay);border:1px solid var(--hairline);
border-radius:8px;padding:3px;gap:2px}
.seg button{appearance:none;border:0;background:transparent;color:var(--mute);cursor:pointer;
font:500 12.5px/1 Geist,sans-serif;padding:6px 11px;border-radius:6px;
display:flex;align-items:center;gap:6px;transition:.15s ease}
.seg button .g{font-size:.95em;opacity:.6}
.seg button.on{background:#ffffff12;color:var(--ink)}
.seg button.on .g{opacity:1}
.seg button:hover:not(.on){color:var(--ink)}
.flip{appearance:none;border:1px solid var(--hairline);background:var(--overlay);
color:var(--mute);cursor:pointer;border-radius:8px;width:34px;height:34px;
font-size:15px;transition:.15s ease}
.flip:hover{color:var(--ink);background:#ffffff12}
/* ── the map: canvas + overlay ────────────────────────────────────────────── */
.plot{position:relative;background:var(--raised);border:1px solid var(--hairline);
border-radius:12px;overflow:hidden}
#scatter{display:block;width:100%;height:62vh;min-height:440px}
.axlabel{position:absolute;font:500 12px/1.3 "Geist Mono",monospace;color:var(--mute);
pointer-events:none;display:flex;flex-direction:column;gap:2px}
.axlabel .nm{color:var(--ink);font-weight:600}
.axlabel .po{color:var(--faint);font-size:11px}
.axlabel.x{bottom:10px;left:50%;transform:translateX(-50%);text-align:center;align-items:center}
.axlabel.y{top:50%;left:12px;transform:translateY(-50%) rotate(180deg);writing-mode:vertical-rl;
text-align:center;align-items:center}
.axhint{position:absolute;font:500 10px/1 "Geist Mono",monospace;color:var(--faintest);
pointer-events:none}
.axhint.xl{bottom:13px;left:46px}.axhint.xr{bottom:13px;right:14px}
.axhint.yb{top:14px;left:13px}.axhint.yt{bottom:46px;left:13px}
.tip{position:fixed;z-index:60;pointer-events:none;background:#0c0c0ff2;
-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);
border:1px solid var(--hairline);border-radius:9px;padding:9px 11px;
box-shadow:0 8px 30px #000b;max-width:260px;opacity:0;transition:opacity .12s}
.tip.on{opacity:1}
.tip .fam{display:flex;align-items:center;gap:7px;font-weight:600;font-size:13.5px}
.tip .fam .g{font-size:1.05em}
.tip .nm{font:500 12px/1.3 "Geist Mono",monospace;color:var(--mute);margin-top:3px;
word-break:break-all}
.tip .vals{font:500 11.5px/1.5 "Geist Mono",monospace;color:var(--faint);margin-top:6px}
.tip .vals b{color:var(--ink);font-weight:500}
.tip .hear{font-size:11px;color:var(--magenta);margin-top:6px;display:flex;align-items:center;gap:5px}
.plot .count{position:absolute;top:11px;right:13px;font:500 11px/1 "Geist Mono",monospace;
color:var(--faint);background:#0c0c0fcc;border:1px solid var(--hairline);
padding:5px 8px;border-radius:6px}
/* ── legend ───────────────────────────────────────────────────────────────── */
.legend{display:flex;flex-wrap:wrap;gap:6px 8px;margin-top:14px}
.lg{display:inline-flex;align-items:center;gap:7px;font-size:.82rem;color:var(--mute);
background:var(--raised);border:1px solid var(--hairline);border-radius:999px;
padding:5px 11px 5px 9px;cursor:pointer;transition:.14s ease;user-select:none}
.lg:hover{border-color:#ffffff3a;color:var(--ink)}
.lg .sw{width:11px;height:11px;border-radius:3px;flex:none}
.lg .g{font-size:.95em;line-height:1}
.lg b{color:var(--ink);font-weight:500;font-family:"Geist Mono",monospace;font-size:.92em}
.lg.dim{opacity:.32}
.legend .meta{font-size:.76rem;color:var(--faint);align-self:center;margin-left:4px}
/* ── grid of analysis panels ──────────────────────────────────────────────── */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
.panel{border:1px solid var(--hairline);border-radius:12px;padding:18px 20px;
background:linear-gradient(180deg,#ffffff05,transparent)}
.panel h3{font-size:1rem;font-weight:600;margin-bottom:.15rem}
.panel .ph{font-size:.82rem;color:var(--faint);margin-bottom:1rem}
/* superfeature loading bars */
.pcrow{margin-bottom:14px}
.pcrow .hd{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:6px}
.pcrow .hd .nm{font-weight:600;font-size:.95rem}
.pcrow .hd .ev{font:500 11px/1 "Geist Mono",monospace;color:var(--faint)}
.pcrow .poles{display:flex;justify-content:space-between;font:500 10.5px/1 "Geist Mono",monospace;
color:var(--faint);margin-bottom:5px}
.lbars{display:flex;flex-direction:column;gap:3px}
.lbar{display:grid;grid-template-columns:120px 1fr;gap:9px;align-items:center;
font:500 11px/1 "Geist Mono",monospace}
.lbar .fn{color:var(--mute);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lbar .track{position:relative;height:13px;background:var(--grid);border-radius:3px}
.lbar .track .mid{position:absolute;left:50%;top:0;bottom:0;width:1px;background:var(--hairline)}
.lbar .track .fill{position:absolute;top:1px;bottom:1px;border-radius:2px;background:#7db3ff}
.lbar .track .fill.neg{background:#e0a82e}
/* contingency grid */
.cont{overflow-x:auto}
table.cgrid{border-collapse:collapse;font:500 11px/1 "Geist Mono",monospace}
table.cgrid th{color:var(--faint);font-weight:500;padding:4px 5px;text-align:center;font-size:10px}
table.cgrid td.rh{color:var(--ink);text-align:right;padding-right:9px;white-space:nowrap}
table.cgrid td.rh .g{margin-right:5px}
table.cgrid td.c{width:30px;height:26px;text-align:center;border:1px solid var(--surface);
border-radius:3px;color:#0a0a0a;font-size:10px;cursor:default}
/* rf importance bars */
.rf{display:flex;flex-direction:column;gap:5px}
.rfrow{display:grid;grid-template-columns:155px 1fr 44px;gap:10px;align-items:center;
font:500 11.5px/1 "Geist Mono",monospace}
.rfrow .fn{color:var(--mute);text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rfrow.top .fn{color:var(--ink)}
.rfrow .track{height:14px;background:var(--grid);border-radius:3px;overflow:hidden}
.rfrow .track .fill{height:100%;background:#5a8dd6;border-radius:3px}
.rfrow.top .track .fill{background:#36c5f0}
.rfrow .val{color:var(--faint);text-align:right}
/* fingerprint strip */
.fp-ctl{display:flex;align-items:center;gap:10px;margin-bottom:12px}
#fingerprint{display:block;width:100%;height:340px}
footer{border-top:1px solid var(--hairline);margin-top:6vh;padding:3vh 0 6vh;
color:var(--faint);font-size:.82rem}
footer .mono{color:var(--mute)}
.loading{padding:18vh 0;text-align:center;color:var(--mute);font:500 14px/1 "Geist Mono",monospace}
</style>
</head>
<body>
<div id="app"><div class="loading">reading the corpus…</div></div>
<audio id="aud" preload="none"></audio>
<script id="data" type="application/json"></script>
<script>
"use strict";
const $ = (s,r=document)=>r.querySelector(s);
const el = (t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;};
const clamp=(v,a,b)=>v<a?a:v>b?b:v;
const fmt=(v)=>Math.abs(v)>=100?v.toFixed(0):Math.abs(v)>=10?v.toFixed(1):v.toFixed(2);
let D=null, AX=[], state={x:"pc0", y:"pc3", lens:"family", solo:new Set(), q:"", unl:false};
async function load(){
const inl=$("#data");
if(inl&&inl.textContent.trim()){try{D=JSON.parse(inl.textContent)}catch(_){}}
if(!D)D=await fetch("unwrapped.json").then(r=>{if(!r.ok)throw new Error("unwrapped.json "+r.status);return r.json();});
buildAxes(); render();
}
/* axis registry: superfeature PCs first, then the raw music-language features */
function buildAxes(){
AX=[];
D.pc_axes.forEach((a,i)=>AX.push({key:a.key,label:a.label,group:"superfeature",
lo:a.lo,hi:a.hi,sub:`${(a.explained*100).toFixed(1)}% var`,get:s=>s.pc[i]}));
D.raw_axes.forEach(a=>AX.push({key:a.key,label:a.label,group:"feature",
lo:a.desc.split("—")[1]?.trim()?.split("→")[0]?.trim()||"low",
hi:a.desc.split("→")[1]?.trim()||"high",sub:a.unit||a.desc,get:s=>s.feat[a.key]}));
}
const axis=k=>AX.find(a=>a.key===k);
/* ── colour lenses ──────────────────────────────────────────────────────────*/
const KIND_COL={single:"#5bc091",dominant:"#e0a82e",kit:"#36c5f0"};
const KIND_GLYPH={single:"●",dominant:"◑",kit:"⁘"};
function clusterCol(c){return `hsl(${Math.round(c*360/D.n_clusters)} 52% 60%)`;}
function famCol(f){return f&&D.families[f]?D.families[f].color:"#4a4a52";}
function dotColor(s){
if(state.lens==="family")return s.family?famCol(s.family):"#3c3c44";
if(state.lens==="cluster")return clusterCol(s.cluster);
if(state.lens==="kind")return s.kind?KIND_COL[s.kind]:"#3c3c44";
if(state.lens==="agree"){return s.agrees===true?"#5bc091":s.agrees===false?"#ff5252":"#54545c";}
return "#888";
}
/* which legend bucket a sample belongs to, for solo-filtering */
function bucket(s){
if(state.lens==="family")return s.family||"·none";
if(state.lens==="cluster")return "k"+s.cluster;
if(state.lens==="kind")return s.kind||"·none";
if(state.lens==="agree")return s.agrees===true?"agree":s.agrees===false?"lied":"na";
}
function visible(s){
if(!state.unl && !s.family)return false;
if(state.solo.size && !state.solo.has(bucket(s)))return false;
if(state.q){const q=state.q.toLowerCase();
if(!(s.folder.toLowerCase().includes(q)||s.name.toLowerCase().includes(q)||(s.family||"").includes(q)))return false;}
return true;
}
/* ── scatter (canvas) ────────────────────────────────────────────────────────*/
let cv,ctx,DPR=1,plotW=0,plotH=0,pts=[],sx=null,sy=null,hover=-1,playing=-1;
const PAD={l:46,r:14,t:18,b:44};
function makeScale(vals){let mn=Math.min(...vals),mx=Math.max(...vals);
if(mn===mx){mn-=1;mx+=1;}const pad=(mx-mn)*0.04;return [mn-pad,mx+pad];}
function rescale(){
const ax=axis(state.x),ay=axis(state.y);
const vs=D.samples.filter(visible);
const base=vs.length?vs:D.samples;
sx=makeScale(base.map(ax.get)); sy=makeScale(base.map(ay.get));
}
function px(v){return PAD.l+(v-sx[0])/(sx[1]-sx[0])*(plotW-PAD.l-PAD.r);}
function py(v){return plotH-PAD.b-(v-sy[0])/(sy[1]-sy[0])*(plotH-PAD.t-PAD.b);}
function sizeCanvas(){
const r=cv.getBoundingClientRect();DPR=window.devicePixelRatio||1;
plotW=r.width;plotH=r.height;cv.width=plotW*DPR;cv.height=plotH*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
}
function drawScatter(){
if(!sx)rescale();
const ax=axis(state.x),ay=axis(state.y);
ctx.clearRect(0,0,plotW,plotH);
/* gridlines */
ctx.strokeStyle="#ffffff0c";ctx.lineWidth=1;
ctx.beginPath();
for(let i=0;i<=4;i++){const x=PAD.l+i/4*(plotW-PAD.l-PAD.r);ctx.moveTo(x,PAD.t);ctx.lineTo(x,plotH-PAD.b);
const y=PAD.t+i/4*(plotH-PAD.t-PAD.b);ctx.moveTo(PAD.l,y);ctx.lineTo(plotW-PAD.r,y);}
ctx.stroke();
/* axis origin cross at 0 if in range (superfeatures are centred) */
pts=[];
const samples=D.samples;
for(let i=0;i<samples.length;i++){
const s=samples[i]; if(!visible(s))continue;
const x=px(ax.get(s)),y=py(ay.get(s));
pts.push([x,y,i]);
}
/* faint pass first (dim) then bright — depth illusion; hovered/playing on top */
ctx.globalAlpha=0.82;
for(const[x,y,i]of pts){
if(i===hover||i===playing)continue;
ctx.fillStyle=dotColor(samples[i]);
ctx.beginPath();ctx.arc(x,y,3.1,0,7);ctx.fill();
}
ctx.globalAlpha=1;
if(hover>=0){const s=samples[hover];const x=px(ax.get(s)),y=py(ay.get(s));
ctx.fillStyle=dotColor(s);ctx.beginPath();ctx.arc(x,y,5.4,0,7);ctx.fill();
ctx.strokeStyle="#fff";ctx.lineWidth=1.4;ctx.stroke();}
if(playing>=0){const s=samples[playing];const x=px(ax.get(s)),y=py(ay.get(s));
ctx.strokeStyle="#d900ff";ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,8.5,0,7);ctx.stroke();}
$("#count").textContent=`${pts.length} / ${samples.length} samples`;
/* axis labels */
$("#axx .nm").textContent=ax.label; $("#axx .po").textContent=`${ax.lo} ←→ ${ax.hi}`;
$("#axy .nm").textContent=ay.label; $("#axy .po").textContent=`${ay.lo} ←→ ${ay.hi}`;
}
function nearest(mx,my){
let best=-1,bd=144; // 12px²
for(const[x,y,i]of pts){const d=(x-mx)**2+(y-my)**2;if(d<bd){bd=d;best=i;}}
return best;
}
function onMove(e){
const r=cv.getBoundingClientRect();
const i=nearest(e.clientX-r.left,e.clientY-r.top);
if(i!==hover){hover=i;drawScatter();}
const tip=$("#tip");
if(i>=0){const s=D.samples[i],ax=axis(state.x),ay=axis(state.y);
const fam=s.family?D.families[s.family]:null;
tip.innerHTML=`<div class="fam"><span class="g" style="color:${famCol(s.family)}">${fam?fam.glyph:"○"}</span>${fam?fam.label:"unlabelled"}`
+`<span style="color:var(--faint);font-weight:500;font-size:11px"> · ${s.kind||"?"}</span></div>`
+`<div class="nm">${s.folder} / ${s.name}</div>`
+`<div class="vals">${ax.label}: <b>${fmt(ax.get(s))}</b><br>${ay.label}: <b>${fmt(ay.get(s))}</b></div>`
+(s.wav?`<div class="hear">▶ click to hear</div>`:``);
tip.classList.add("on");
const tw=tip.offsetWidth,th=tip.offsetHeight;
tip.style.left=clamp(e.clientX+14,4,innerWidth-tw-4)+"px";
tip.style.top=clamp(e.clientY+14,4,innerHeight-th-4)+"px";
cv.style.cursor=s.wav?"pointer":"default";
}else{tip.classList.remove("on");cv.style.cursor="crosshair";}
}
function onClick(){
if(hover<0)return;const s=D.samples[hover];if(!s.wav)return;
const a=$("#aud");a.src=s.wav;a.currentTime=0;a.play().catch(()=>{});
a.onended=()=>{playing=-1;drawScatter();};
playing=hover;drawScatter();
}
/* ── legend ──────────────────────────────────────────────────────────────────*/
function legendItems(){
if(state.lens==="family")
return D.family_order.map(f=>({key:f,label:D.families[f].label,col:D.families[f].color,
glyph:D.families[f].glyph,n:D.samples.filter(s=>s.family===f).length}));
if(state.lens==="cluster")
return Array.from({length:D.n_clusters},(_,c)=>({key:"k"+c,label:"cluster "+c,col:clusterCol(c),
glyph:"◍",n:D.cluster_sizes[c]||0}));
if(state.lens==="kind")
return ["single","dominant","kit"].map(k=>({key:k,label:k,col:KIND_COL[k],glyph:KIND_GLYPH[k],
n:D.samples.filter(s=>s.kind===k).length}));
if(state.lens==="agree")
return [{key:"agree",label:"folder agrees",col:"#5bc091",glyph:"✓"},
{key:"lied",label:"folder name ≠ sound",col:"#ff5252",glyph:"⚠"},
{key:"na",label:"kit (n/a)",col:"#54545c",glyph:"⁘"}]
.map(o=>({...o,n:D.samples.filter(s=>bucket(s)===o.key).length}));
}
function renderLegend(){
const box=$("#legend");box.innerHTML="";
for(const it of legendItems()){
const on=state.solo.size===0||state.solo.has(it.key);
const lg=el("div","lg"+(on?"":" dim"));
lg.innerHTML=`<span class="sw" style="background:${it.col}"></span>`
+`<span class="g" style="color:${it.col}">${it.glyph}</span>${it.label} <b>${it.n}</b>`;
lg.onclick=()=>{ if(state.solo.has(it.key))state.solo.delete(it.key);
else state.solo.add(it.key); rescale();drawScatter();renderLegend(); };
box.appendChild(lg);
}
const meta=el("span","meta",state.solo.size?`soloing ${state.solo.size} — click to clear`:`click to solo`);
if(state.solo.size){meta.style.cursor="pointer";meta.onclick=()=>{state.solo.clear();rescale();drawScatter();renderLegend();};}
box.appendChild(meta);
}
/* ── superfeature loading bars ───────────────────────────────────────────────*/
function renderAxesPanel(){
const box=$("#pcpanel");box.innerHTML="";
const maxw=Math.max(...D.pc_axes.flatMap(a=>a.loadings.map(l=>Math.abs(l.w))));
for(const a of D.pc_axes){
const row=el("div","pcrow");
row.appendChild(el("div","hd",`<span class="nm">${a.label}</span>`
+`<span class="ev">${(a.explained*100).toFixed(1)}% variance</span>`));
row.appendChild(el("div","poles",`<span>${a.lo}</span><span>${a.hi}</span>`));
const bars=el("div","lbars");
for(const l of a.loadings){
const w=Math.abs(l.w)/maxw*50; // half-width %
const neg=l.w<0;
bars.appendChild(el("div","lbar",`<span class="fn">${l.f}</span>`
+`<div class="track"><div class="mid"></div>`
+`<div class="fill${neg?" neg":""}" style="${neg?`right:50%;width:${w}%`:`left:50%;width:${w}%`}"></div></div>`));
}
row.appendChild(bars);box.appendChild(row);
}
}
/* ── family × cluster contingency grid ───────────────────────────────────────*/
function renderContingency(){
const box=$("#contgrid");box.innerHTML="";
const k=D.n_clusters, fams=D.family_order;
const tbl=el("table","cgrid");
const head=el("tr");head.appendChild(el("th","",""));
for(let c=0;c<k;c++)head.appendChild(el("th","","k"+c));
tbl.appendChild(head);
for(const f of fams){
const row=D.contingency[f]||{};
const tot=Object.values(row).reduce((a,b)=>a+b,0)||1;
const tr=el("tr");
tr.appendChild(el("td","rh",`<span class="g" style="color:${D.families[f].color}">${D.families[f].glyph}</span>${D.families[f].label}`));
for(let c=0;c<k;c++){
const n=row[c]||0,frac=n/tot;
const td=el("td","c",n||"");
td.style.background=n?`color-mix(in oklab, ${D.families[f].color} ${Math.round(18+frac*82)}%, #0a0a0a)`:"#0d0d10";
td.style.color=frac>0.4?"#0a0a0a":"#6a6a72";
td.title=`${D.families[f].label} → cluster ${c}: ${n} (${(frac*100).toFixed(0)}% of family)`;
tr.appendChild(td);
}
tbl.appendChild(tr);
}
box.appendChild(tbl);
}
/* ── RF importance ───────────────────────────────────────────────────────────*/
function renderRF(){
const box=$("#rfpanel");box.innerHTML="";
const imp=D.rf_importance.slice(0,15);
const mx=Math.max(...imp.map(x=>x.importance));
imp.forEach((x,i)=>{
const row=el("div","rfrow"+(i<2?" top":""));
row.innerHTML=`<span class="fn">${x.f}</span>`
+`<div class="track"><div class="fill" style="width:${x.importance/mx*100}%"></div></div>`
+`<span class="val">${x.importance.toFixed(3)}</span>`;
box.appendChild(row);
});
}
/* ── fingerprint strip (per-family beeswarm on one feature) ──────────────────*/
let fcv,fctx,fpW=0,fpH=0,fpFeat="temporal_centroid";
function renderFingerprint(){
const ax=axis(fpFeat);
const fams=D.family_order;
const r=fcv.getBoundingClientRect();fpW=r.width;fpH=r.height;
const dpr=window.devicePixelRatio||1;fcv.width=fpW*dpr;fcv.height=fpH*dpr;
fctx.setTransform(dpr,0,0,dpr,0,0);fctx.clearRect(0,0,fpW,fpH);
const L=92,R=14,T=8,B=22;
const vals=D.samples.map(ax.get).filter(v=>v!=null&&!isNaN(v));
let mn=Math.min(...vals),mx=Math.max(...vals);if(mn===mx){mn-=1;mx+=1;}
const X=v=>L+(v-mn)/(mx-mn)*(fpW-L-R);
const laneH=(fpH-T-B)/fams.length;
fctx.font='500 11px "Geist Mono",monospace';
fams.forEach((f,li)=>{
const cy=T+laneH*(li+0.5);
fctx.strokeStyle="#ffffff09";fctx.beginPath();fctx.moveTo(L,cy);fctx.lineTo(fpW-R,cy);fctx.stroke();
fctx.fillStyle=D.families[f].color;fctx.textAlign="right";fctx.textBaseline="middle";
fctx.fillText(D.families[f].glyph+" "+D.families[f].label,L-8,cy);
const col=D.families[f].color;
for(const s of D.samples){if(s.family!==f)continue;const v=ax.get(s);if(v==null||isNaN(v))continue;
const jit=(((s.name.charCodeAt(0)||0)*7+(s.name.length*13))%100/100-0.5)*laneH*0.62;
fctx.fillStyle=col;fctx.globalAlpha=0.55;
fctx.beginPath();fctx.arc(X(v),cy+jit,2.3,0,7);fctx.fill();}
fctx.globalAlpha=1;
});
fctx.fillStyle="#67676e";fctx.textAlign="left";fctx.textBaseline="alphabetic";
fctx.fillText(fmt(mn),L,fpH-7);fctx.textAlign="right";fctx.fillText(fmt(mx),fpW-R,fpH-7);
fctx.textAlign="center";fctx.fillStyle="#9a9aa2";
fctx.fillText(ax.label+(ax.sub&&ax.group==="feature"?" · "+(D.raw_axes.find(a=>a.key===fpFeat)?.unit||""):""),(L+fpW-R)/2,fpH-7);
}
/* ── full render / wiring ────────────────────────────────────────────────────*/
function render(){
const h=D.headline;
$("#app").innerHTML=`
<header><div class="wrap">
<h1>ParVagues <em>Unwrapped</em></h1>
<p class="dek">Every one-shot in the live palette, placed by how it actually <b>sounds</b> —
${h.n_features} measured features per sample, distilled by PCA into the
<b>five superfeature axes</b> below. Pick any two for the map, recolour by the family the
filename claims or by what the audio clusters into, and <b>click a point to hear it</b>.</p>
<div class="stats">
<div class="stat"><div class="v mono">${h.n_files.toLocaleString()}</div><div class="l">samples mapped</div></div>
<div class="stat"><div class="v mono">${h.n_features}</div><div class="l">features each</div></div>
<div class="stat"><div class="v mono">${h.n_families}</div><div class="l">families</div></div>
<div class="stat"><div class="v mono">${h.intrinsic_dim_90}</div><div class="l">intrinsic dims (90%)</div></div>
<div class="stat"><div class="v mono">${(h.ari_vs_resolver??0).toFixed(2)}</div><div class="l">ARI audio↔family</div></div>
</div>
</div></header>
<section><div class="wrap">
<div class="shead"><h2>The map</h2><span class="tag">explore</span></div>
<p class="sdek">Each dot is one sample. Two axes, your choice the <b>superfeatures</b> are the
composed PCA directions; the <b>raw features</b> are single measured quantities. Hover to
identify, click to audition.</p>
<div class="map-shell">
<div class="controls">
<div class="ctl"><label>X axis</label><select id="selx"></select></div>
<button class="flip" id="flip" title="swap axes"></button>
<div class="ctl"><label>Y axis</label><select id="sely"></select></div>
<div class="ctl"><label>Colour by</label><div class="seg" id="lens"></div></div>
<div class="ctl"><label>Find</label><input type="search" id="q" placeholder="folder / sample / family"></div>
<div class="ctl"><label>Unlabelled</label><div class="seg" id="unl"></div></div>
</div>
<div class="plot">
<canvas id="scatter"></canvas>
<div class="count" id="count"></div>
<div class="axlabel x" id="axx"><span class="nm"></span><span class="po"></span></div>
<div class="axlabel y" id="axy"><span class="nm"></span><span class="po"></span></div>
</div>
<div class="legend" id="legend"></div>
</div>
</div></section>
<section><div class="wrap">
<div class="shead"><h2>The five axes</h2><span class="tag">superfeatures</span></div>
<p class="sdek">PCA on the ${h.n_features} features finds the directions of greatest variation. The
first five — explaining <b>${(h.explained_5pc*100).toFixed(0)}%</b> between them — read as musical
qualities. Bars show which raw features pull each axis; blue pulls toward the high pole, amber the low.</p>
<div class="grid2">
<div class="panel"><h3>Loadings</h3><div class="ph">what composes each superfeature</div><div id="pcpanel"></div></div>
<div class="panel"><h3>What separates the families</h3>
<div class="ph">random-forest importance — which features predict the grounded family</div>
<div class="rf" id="rfpanel"></div>
<p class="note">Brightness (spectral centroid) and the attack/sustain envelope
(temporal centroid) are the top two — the levers behind the kick↔bass split.</p>
</div>
</div>
</div></section>
<section><div class="wrap">
<div class="shead"><h2>Family fingerprints</h2><span class="tag">distributions</span></div>
<p class="sdek">One feature, every family laid out as a strip. Switch the feature to watch the families
slide past each other — on <b>attack ↔ sustain</b>, kicks pile up punchy-left while bass and pads
stretch sustained-right.</p>
<div class="panel">
<div class="fp-ctl"><label class="mono" style="font-size:11px;color:var(--faint)">FEATURE</label>
<select id="fpsel"></select></div>
<canvas id="fingerprint"></canvas>
</div>
</div></section>
<section><div class="wrap">
<div class="shead"><h2>Folders are loose</h2><span class="tag">timbre ≠ label</span></div>
<p class="sdek">If timbre and family were the same thing, each family would land in its own cluster and
this grid would be diagonal. It isn't (ARI <b>${(h.ari_vs_resolver??0).toFixed(2)}</b>,
NMI <b>${(h.nmi_vs_resolver??0).toFixed(2)}</b>): the ${D.n_clusters} <b>audio</b> clusters cut across
the grounded families — a kit's samples scatter, and timbrally-close sounds from different families
share a cluster. Each row is a family; cell brightness = share of that family in the cluster.</p>
<div class="panel cont"><div id="contgrid"></div>
<p class="note">This is the data behind “a folder is a loose grouping”: the sample is the unit of
truth, the folder name only a hint. Switch the map's colour to <code>audio-cluster</code> to see it.</p>
</div>
</div></section>
<footer><div class="wrap">
<span class="mono">${h.n_files.toLocaleString()} samples · ${h.n_features} features · ${D.correlation.n_kept||""} non-redundant · intrinsic dim ${h.intrinsic_dim_90}/${h.intrinsic_dim_95}</span><br>
Generated by <code>build_unwrapped.py</code> from <code>sample_features.json</code> × <code>sample_families.json</code> × the MDA.
Fleet colours from the design tokens. ${D.provenance?.as_of||""}
</div></footer>`;
// populate axis selects (grouped)
const opts=g=>AX.filter(a=>a.group===g).map(a=>`<option value="${a.key}">${a.label}</option>`).join("");
const grouped=`<optgroup label="Superfeatures">${opts("superfeature")}</optgroup>`
+`<optgroup label="Raw features">${opts("feature")}</optgroup>`;
$("#selx").innerHTML=grouped;$("#sely").innerHTML=grouped;
$("#selx").value=state.x;$("#sely").value=state.y;
$("#selx").onchange=e=>{state.x=e.target.value;rescale();drawScatter();};
$("#sely").onchange=e=>{state.y=e.target.value;rescale();drawScatter();};
$("#flip").onclick=()=>{[state.x,state.y]=[state.y,state.x];$("#selx").value=state.x;$("#sely").value=state.y;rescale();drawScatter();};
// lens segmented control
const lensDefs=[["family","families",""],["cluster","audio-cluster",""],["kind","folder kind",""],["agree","folder agrees?",""]];
$("#lens").innerHTML=lensDefs.map(([k,l,g])=>`<button data-l="${k}" class="${k===state.lens?"on":""}"><span class="g">${g}</span>${l}</button>`).join("");
$("#lens").querySelectorAll("button").forEach(b=>b.onclick=()=>{
state.lens=b.dataset.l;state.solo.clear();
$("#lens").querySelectorAll("button").forEach(x=>x.classList.toggle("on",x.dataset.l===state.lens));
drawScatter();renderLegend();});
// unlabelled toggle
$("#unl").innerHTML=`<button class="${state.unl?"on":""}" id="unlb"><span class="g">○</span>show</button>`;
$("#unlb").onclick=()=>{state.unl=!state.unl;$("#unlb").classList.toggle("on",state.unl);rescale();drawScatter();renderLegend();};
// search
$("#q").oninput=e=>{state.q=e.target.value;rescale();drawScatter();};
// canvas
cv=$("#scatter");ctx=cv.getContext("2d");
cv.addEventListener("mousemove",onMove);
cv.addEventListener("mouseleave",()=>{hover=-1;$("#tip").classList.remove("on");drawScatter();});
cv.addEventListener("click",onClick);
const tip=el("div","tip");tip.id="tip";document.body.appendChild(tip);
// fingerprint select
$("#fpsel").innerHTML=D.raw_axes.map(a=>`<option value="${a.key}">${a.label}</option>`).join("");
$("#fpsel").value=fpFeat;$("#fpsel").onchange=e=>{fpFeat=e.target.value;renderFingerprint();};
fcv=$("#fingerprint");fctx=fcv.getContext("2d");
renderLegend();renderAxesPanel();renderRF();renderContingency();
requestAnimationFrame(()=>{sizeCanvas();rescale();drawScatter();renderFingerprint();});
let rt;addEventListener("resize",()=>{clearTimeout(rt);rt=setTimeout(()=>{sizeCanvas();drawScatter();renderFingerprint();},120);});
}
load().catch(e=>{$("#app").innerHTML=`<div class="loading">couldn't load unwrapped.json — ${e.message}<br><br>serve this dir (python3 ../serve.py) or open via file://</div>`;});
</script>
</body></html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment