Commit 1729689b by PLN (Algolia)

feat(unwrapped): density colour-field behind the scatter (timbral regions)

Low-res IDW of dot colours (72x46 Gaussian-weighted, σ≈3.1), desaturated 50% and
faded by local density, bilinear-upscaled behind the dots → pink kick region,
blue keys region, purple bass / yellow hat pockets emerge as a slow gradient.
Cached offscreen, rebuilt only when colours/axes/filter change (never on hover);
'regions' toggle in the controls; auto-off in vibe-search mode.
parent cec9dec3
......@@ -226,6 +226,7 @@ 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,
field:true /* density colour-field behind the dots */,
vibe:null /* {label, hits:Map(idx→sim), top:idx} */};
const keyOf=s=>`${s.folder}/${s.name}`;
let KEYIDX={}; // "folder/stem" → sample index, for vibe-hit joins
......@@ -289,6 +290,7 @@ function rescale(){
const vs=D.samples.filter(visible);
const base=vs.length?vs:D.samples;
sx=makeScale(base.map(s=>gv(ax,s))); sy=makeScale(base.map(s=>gv(ay,s)));
fieldDirty=true;
}
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);}
......@@ -301,8 +303,12 @@ function drawScatter(){
if(!sx)rescale();
const ax=axis(state.x),ay=axis(state.y);
ctx.clearRect(0,0,plotW,plotH);
/* density colour-field (cached; rebuilt only when colours/positions change) */
if(fieldDirty){buildField();fieldDirty=false;}
if(fieldCv){ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality="high";
ctx.drawImage(fieldCv,PAD.l,PAD.t,plotW-PAD.l-PAD.r,plotH-PAD.t-PAD.b);}
/* gridlines */
ctx.strokeStyle="#ffffff0c";ctx.lineWidth=1;
ctx.strokeStyle=fieldCv?"#ffffff07":"#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);}
......@@ -350,6 +356,41 @@ function nearest(mx,my){
for(const[x,y,i]of pts){const d=(x-mx)**2+(y-my)**2;if(d<bd){bd=d;best=i;}}
return best;
}
/* ── density colour-field: low-res IDW of dot colours, cached, drawn behind ──*/
const _c1=document.createElement("canvas");_c1.width=_c1.height=1;
const _c1x=_c1.getContext("2d",{willReadFrequently:true});
const _rgb={};
function toRGB(col){if(_rgb[col])return _rgb[col];
_c1x.fillStyle="#000";_c1x.fillStyle=col;_c1x.fillRect(0,0,1,1);
const d=_c1x.getImageData(0,0,1,1).data;return _rgb[col]=[d[0],d[1],d[2]];}
let fieldCv=null, fieldDirty=true;
const GW=72, GH=46; // field resolution (bilinear-upscaled)
function buildField(){
fieldCv=null;
if(!state.field||state.vibe||!sx)return;
const ax=axis(state.x),ay=axis(state.y),dots=[];
for(const s of D.samples){ if(!visible(s))continue;
const vx=gv(ax,s),vy=gv(ay,s); if(vx==null||vy==null)continue;
const c=toRGB(dotColor(s));
dots.push([(vx-sx[0])/(sx[1]-sx[0])*GW,(1-(vy-sy[0])/(sy[1]-sy[0]))*GH,c[0],c[1],c[2]]);}
if(!dots.length)return;
const off=document.createElement("canvas");off.width=GW;off.height=GH;
const octx=off.getContext("2d"),img=octx.createImageData(GW,GH);
const sigma=3.1,inv=1/(2*sigma*sigma),cut=56,maxA=0.6,ds=0.5; // ds=desaturation
for(let gy=0;gy<GH;gy++)for(let gx=0;gx<GW;gx++){
let r=0,g=0,b=0,w=0;const cx=gx+0.5,cy=gy+0.5;
for(let k=0;k<dots.length;k++){const d=dots[k];
const dd=(d[0]-cx)**2+(d[1]-cy)**2; if(dd>cut)continue;
const wt=Math.exp(-dd*inv); r+=d[2]*wt;g+=d[3]*wt;b+=d[4]*wt;w+=wt;}
const i=(gy*GW+gx)*4;
if(w<0.02){img.data[i+3]=0;continue;}
r/=w;g/=w;b/=w;const lum=0.3*r+0.59*g+0.11*b;
img.data[i]=r+(lum-r)*ds;img.data[i+1]=g+(lum-g)*ds;img.data[i+2]=b+(lum-b)*ds;
img.data[i+3]=clamp(w/2.4,0,1)*maxA*255; // density → opacity (pockets fade out)
}
octx.putImageData(img,0,0);fieldCv=off;
}
function onMove(e){
const r=cv.getBoundingClientRect();
const i=nearest(e.clientX-r.left,e.clientY-r.top);
......@@ -603,6 +644,7 @@ function render(){
<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 class="ctl"><label>Field</label><div class="seg" id="fld"></div></div>
</div>
<div class="plot">
<canvas id="scatter"></canvas>
......@@ -676,7 +718,7 @@ function render(){
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();
state.lens=b.dataset.l;state.solo.clear();fieldDirty=true;
$("#lens").querySelectorAll("button").forEach(x=>x.classList.toggle("on",x.dataset.l===state.lens));
drawScatter();renderLegend();});
......@@ -684,6 +726,10 @@ function render(){
$("#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();};
// density field toggle
$("#fld").innerHTML=`<button class="${state.field?"on":""}" id="fldb"><span class="g">◍</span>regions</button>`;
$("#fldb").onclick=()=>{state.field=!state.field;$("#fldb").classList.toggle("on",state.field);fieldDirty=true;drawScatter();};
// search
$("#q").oninput=e=>{state.q=e.target.value;rescale();drawScatter();};
......
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