Commit aa0de028 by PLN (Algolia)

triangle #60: surface pattern registry — kin + phrases in drawer, Clusters view

- drawer 'patterns & kin' section: a track's nearest tracks (shared n-gram
  similarity, clickable to jump) + notable phrase chips ( shared idiom /
  ⟳ repeated section), click a phrase to filter the table to who else plays it
- Clusters view toggle: connected components of the neighbor graph rendered as
  family cards (members + the shared phrases that bind them) + a distinctive/solo
  roster. 8 families + 54 solos on the real corpus
- pattern_registry.json loaded best-effort alongside catalog_view.json
parent dc8af085
......@@ -109,6 +109,22 @@ a.lnk{color:var(--melodic);text-decoration:none}a.lnk:hover{text-decoration:unde
.ing .ty{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint)}
audio{width:100%;height:30px;margin-top:5px}
.gigrow{display:flex;justify-content:space-between;border:1px solid var(--hairline);border-radius:6px;padding:5px 8px;margin-bottom:4px;font-size:12px;align-items:center}
/* view toggle + patterns/kin */
.views{display:flex;gap:4px;margin-left:6px}
.vb{background:transparent;border:1px solid var(--hairline);color:var(--mute);border-radius:7px;padding:4px 9px;cursor:pointer;font:inherit;font-size:12px}
.vb:hover{color:var(--ink)}.vb.on{color:var(--ink);border-color:var(--melodic);background:#36c5f015}
.kins,.phrs{display:flex;flex-wrap:wrap;gap:5px}
.kin{border:1px solid var(--hairline);border-radius:6px;padding:2px 8px;font-size:12px;cursor:pointer;white-space:nowrap}
.kin:hover{border-color:var(--melodic);color:var(--ink)}.kin b{color:var(--melodic);font-variant-numeric:tabular-nums}
.phr{border:1px solid var(--hairline);border-radius:6px;padding:2px 7px;font-size:11px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
.phr:hover{border-color:var(--magenta)}.phr code{color:#7fe3b0;font-family:"Geist Mono",monospace}
.phr b{color:var(--mute)}.pscope{color:var(--magenta);font-weight:700}
.mute2{color:var(--faint);font-size:11px}
.fam{border:1px solid var(--hairline);border-radius:10px;padding:11px 13px;margin-bottom:10px;background:var(--raised)}
.famh{font-size:12px;letter-spacing:.05em;color:var(--mute);margin-bottom:7px;font-weight:600}
.famh b{color:var(--melodic)}
.pfilter{color:var(--magenta);border:1px solid var(--magenta);border-radius:99px;padding:1px 9px;cursor:pointer;font-size:11px;margin-right:8px}
.solofam{color:var(--faint);font-size:12px;margin-top:12px;border-top:1px solid var(--hairline);padding-top:10px}
</style></head><body>
<div class="app">
<header>
......@@ -119,10 +135,11 @@ audio{width:100%;height:30px;margin-top:5px}
<div class="bar">
<span class="search"><input id="q" placeholder="search track, sound, sample, gig…" oninput="render()"></span>
<span id="filters"></span>
<span class="views" id="views"></span>
<span class="count" id="count"></span>
</div>
<div class="wrap">
<table>
<table id="tableView">
<colgroup>
<col class="c-track"><col class="c-score"><col class="c-meta">
<col class="c-ac"><col class="c-rec"><col class="c-gigs">
......@@ -133,6 +150,7 @@ audio{width:100%;height:30px;margin-top:5px}
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div id="clusterView" style="display:none;padding:14px 20px"></div>
</div>
</div>
<div class="drawer" id="drawer"></div>
......@@ -142,10 +160,16 @@ const C={agree:'--agree',partial:'--partial',conflict:'--conflict',divergent:'--
'no-claim':'--idle',unparsed:'--conflict',empty:'--idle'};
const css=v=>getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const ROLE={percs:'--percs',bass:'--bass',melodic:'--melodic',tops:'--tops',atmos:'--atmos',vox:'--vox'};
let DATA=null, FILTER='all', SEL=null;
let DATA=null, PAT=null, FILTER='all', SEL=null, VIEW='table', PFILTER=null;
let PSIG={}, PMAP={}; // track→pattern-sig, pid→pattern entry (lookup)
const FILTERS=[['all','All'],['agree','Agree'],['partial','Partial'],['conflict','Conflict'],
['divergent','Divergent'],['no-claim','No-claim'],['unrecorded','Unrecorded'],['eda','EDA-grounded']];
// pattern registry is best-effort — the triangle still works without it
fetch('pattern_registry.json').then(r=>r.ok?r.json():null).then(p=>{
if(p){PAT=p; p.tracks.forEach(t=>PSIG[t.track]=t); p.patterns.forEach(e=>PMAP[e.id]=e);}
}).catch(()=>{});
fetch('catalog_view.json').then(r=>{if(!r.ok)throw new Error(r.status);return r.json()})
.then(d=>{DATA=d;boot()})
.catch(e=>{
......@@ -185,10 +209,16 @@ function boot(){
return `<button class="f${k===FILTER?' on':''}" data-k="${k}"
style="${k===FILTER&&c?`background:${c};border-color:${c}`:''}">${dot}${l}</button>`}).join('');
document.querySelectorAll('.f').forEach(b=>b.onclick=()=>{FILTER=b.dataset.k;boot()});
// view toggle (table / clusters) — clusters needs the pattern registry
document.getElementById('views').innerHTML=
[['table','▤ Tracks'],['clusters','🧬 Clusters']].map(([k,l])=>
`<button class="vb${k===VIEW?' on':''}" data-v="${k}">${l}</button>`).join('');
document.querySelectorAll('.vb').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;boot()});
render();
}
function match(r){
if(PFILTER){const e=PMAP[PFILTER]; if(!e||!e.occurrences.some(o=>o.track===r.track))return false}
const q=document.getElementById('q').value.toLowerCase().trim();
if(q){const ing=(r.ingredients||[]).map(g=>g.code+' '+g.description).join(' ');
const hay=(r.name+' '+r.track+' '+r.score_sounds.join(' ')+' '+r.claimed_sounds.join(' ')+' '+r.gigs.join(' ')+' '+ing).toLowerCase();
......@@ -200,8 +230,16 @@ function match(r){
}
function render(){
const tbl=VIEW==='table';
document.getElementById('tableView').style.display=tbl?'':'none';
document.getElementById('clusterView').style.display=tbl?'none':'';
tbl ? renderTable() : renderClusters();
}
function renderTable(){
const rows=DATA.tracks.filter(match);
document.getElementById('count').textContent=`${rows.length} / ${DATA.tracks.length} tracks`;
const pf=PFILTER&&PMAP[PFILTER]?`<span class="pfilter" onclick="clearPhrase()" title="click to clear">🧬 ${esc(PMAP[PFILTER].norm.slice(0,30))} ✕</span>`:'';
document.getElementById('count').innerHTML=pf+`${rows.length} / ${DATA.tracks.length} tracks`;
document.getElementById('rows').innerHTML=rows.map((r,i)=>{
const lv=r.ac.level, c=css(C[lv]);
const idx=DATA.tracks.indexOf(r);
......@@ -246,6 +284,77 @@ function toggleSrc(i){const el=document.getElementById('src-'+i);if(!el)return;
}}
const gigURL=slug=>'https://me.nech.pl/parvagues/live/'+slug.split('/').pop();
// ── patterns & kin (#60) ──────────────────────────────────────────────────
function nameOf(track){return (PSIG[track]&&PSIG[track].name)||track.split('/').pop().replace('.tidal','');}
function trackPhrases(track){
if(!PAT)return [];
const out=[];
for(const p of PAT.patterns){const o=p.occurrences.find(o=>o.track===track);if(o)out.push({p,o});}
return out;
}
function openByTrack(track){const i=DATA.tracks.findIndex(t=>t.track===track);if(i>=0){VIEW='table';open(i);}}
function filterByPhrase(pid){PFILTER=pid;VIEW='table';SEL=null;
document.getElementById('drawer').classList.remove('open');boot();}
function clearPhrase(){PFILTER=null;boot();}
function patternBlock(r){
if(!PAT)return '';
const sig=PSIG[r.track]||{neighbors:[],n_shared:0};
const phr=trackPhrases(r.track);
const shared=phr.filter(x=>x.p.scope==='shared').sort((a,b)=>b.p.n_tracks-a.p.n_tracks);
const repeated=phr.filter(x=>x.p.scope==='repeated');
const nb=(sig.neighbors||[]).map(n=>`<span class="kin" onclick="openByTrack('${esc(n.track)}')"
title="${n.shared_patterns.length} shared phrase(s)">${esc(n.name)} <b>${n.similarity.toFixed(2)}</b></span>`)
.join('')||'<span class="empty">no close kin — a distinctive track</span>';
const chip=({p,o})=>`<span class="phr" onclick="filterByPhrase('${p.id}')"
title="${p.scope} · in ${p.n_tracks} track(s) · click to see who else plays it">
<span class="pscope">${p.scope==='shared'?'↔':'⟳'}</span>
<code>${esc(p.norm.slice(0,40))}${p.norm.length>40?'…':''}</code>
<b>×${p.scope==='shared'?p.n_tracks:o.count}</b></span>`;
const things=shared.concat(repeated).slice(0,16).map(chip).join('')
||'<span class="empty">no shared or repeated phrases</span>';
return `<div class="sec"><h3>🧬 patterns &amp; kin — ${phr.length} phrases${sig.n_shared?` · ${sig.n_shared} shared`:''}</h3>
<div class="mute2" style="margin-bottom:5px">nearest tracks · shared n-gram similarity</div>
<div class="kins">${nb}</div>
<div class="mute2" style="margin:10px 0 5px">notable phrases · ↔ shared idiom · ⟳ repeated section</div>
<div class="phrs">${things}</div></div>`;
}
function renderClusters(){
const el=document.getElementById('clusterView');
if(!PAT){el.innerHTML='<div class="empty">pattern registry not loaded — run <code>python3 tide.py build patterns</code></div>';
document.getElementById('count').textContent='';return;}
// undirected adjacency from the (top-K, sim-floored) neighbor graph
const adj={};
PAT.tracks.forEach(t=>{adj[t.track]=adj[t.track]||new Set();
t.neighbors.forEach(n=>{adj[t.track].add(n.track);(adj[n.track]=adj[n.track]||new Set()).add(t.track);});});
const seen=new Set(),comps=[];
PAT.tracks.forEach(t=>{if(seen.has(t.track))return;
const stack=[t.track],comp=[];seen.add(t.track);
while(stack.length){const x=stack.pop();comp.push(x);(adj[x]||[]).forEach(y=>{if(!seen.has(y)){seen.add(y);stack.push(y);}});}
comps.push(comp);});
const fams=comps.filter(c=>c.length>=2).sort((a,b)=>b.length-a.length);
const solo=comps.filter(c=>c.length===1).map(c=>c[0]);
document.getElementById('count').textContent=`${fams.length} families · ${solo.length} distinctive (solo)`;
const cards=fams.map((c,i)=>{
const cnt={};
c.forEach(tr=>(PSIG[tr]?PSIG[tr].neighbors:[]).forEach(n=>{
if(c.includes(n.track))n.shared_patterns.forEach(p=>cnt[p]=(cnt[p]||0)+1);}));
const top=Object.keys(cnt).sort((a,b)=>cnt[b]-cnt[a]).slice(0,6);
const phrs=top.map(pid=>PMAP[pid]?`<span class="phr" onclick="filterByPhrase('${pid}')">
<code>${esc(PMAP[pid].norm.slice(0,34))}${PMAP[pid].norm.length>34?'…':''}</code></span>`:'').join('');
const members=c.sort((a,b)=>nameOf(a).localeCompare(nameOf(b)))
.map(tr=>`<span class="kin" onclick="openByTrack('${esc(tr)}')">${esc(nameOf(tr))}</span>`).join('');
return `<div class="fam"><div class="famh">family <b>${i+1}</b> · ${c.length} tracks</div>
<div class="kins">${members}</div>
${phrs?`<div class="mute2" style="margin:8px 0 4px">bound by shared phrases</div><div class="phrs">${phrs}</div>`:''}</div>`;
}).join('');
const soloHtml=solo.length?`<div class="solofam">distinctive (no close kin): ${
solo.sort((a,b)=>nameOf(a).localeCompare(nameOf(b)))
.map(tr=>`<span class="kin" onclick="openByTrack('${esc(tr)}')">${esc(nameOf(tr))}</span>`).join(' ')}</div>`:'';
el.innerHTML=cards+soloHtml;
}
function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css(C[lv]);
const orb=Object.entries(r.score).map(([d,snd])=>`<div class="o mono">${d}</div><div class="mono">${esc(snd)}</div>`).join('');
const diff=g=>(r.ac[g]||[]).map(s=>`<span class="d-${g==='metadata_only'?'meta':g==='score_only'?'score':'shared'}">${esc(s)}</span>`).join('')||'<span class="empty">none</span>';
......@@ -286,7 +395,8 @@ function open(i){SEL=i;render();const r=DATA.tracks[i];const lv=r.ac.level,c=css
<div class="sec"><h3><span class="corner cB">B</span>recordings — ${r.n_takes} candidate${r.n_takes!==1?'s':''}</h3>${takes}</div>
<div class="sec"><h3><span class="corner cC">C</span>gigs — ${r.gigs.length}</h3>${gigs}</div>
<div class="sec"><h3>ingredients (site's claim · ${(r.ingredients||[]).length})</h3>${ings}</div>`;
<div class="sec"><h3>ingredients (site's claim · ${(r.ingredients||[]).length})</h3>${ings}</div>
${patternBlock(r)}`;
document.getElementById('drawer').classList.add('open');
}
function close_(){document.getElementById('drawer').classList.remove('open');SEL=null;render()}
......
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