Commit 8a61a415 by PLN (Algolia)

feat(unwrapped): de-mud the field + zoom/pan to dive in

Field: per-cell DOMINANT family colour (was a mean of all overlapping hues →
brown mud); mixed/overlap zones now fade by 'purity' instead of muddying.
Tuned colourful-but-subtle behind the dots (desaturate 0.40 + slight lift to
white, maxα 0.44) — dots stay the stars.
Zoom/pan: wheel to zoom at cursor, drag to pan when zoomed, +/−/reset controls
(top-left) + double-click reset; dots grow with zoom; field & hit-testing follow
the view transform, clipped to the plot rect.
parent d69e0aa0
...@@ -146,6 +146,13 @@ input[type=search]{cursor:text;min-width:140px} ...@@ -146,6 +146,13 @@ input[type=search]{cursor:text;min-width:140px}
.plot .count{position:absolute;top:11px;right:13px;font:500 11px/1 "Geist Mono",monospace; .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); color:var(--faint);background:#0c0c0fcc;border:1px solid var(--hairline);
padding:5px 8px;border-radius:6px} padding:5px 8px;border-radius:6px}
.plot .zoom{position:absolute;left:11px;top:11px;display:flex;align-items:center;gap:4px;
background:#0c0c0fcc;border:1px solid var(--hairline);border-radius:8px;padding:3px}
.plot .zoom button{appearance:none;border:0;background:transparent;color:var(--mute);
cursor:pointer;width:24px;height:24px;border-radius:5px;font:500 15px/1 Geist,sans-serif;
display:flex;align-items:center;justify-content:center;transition:.13s}
.plot .zoom button:hover{background:#ffffff14;color:var(--ink)}
.plot .zoom .zl{font-size:10.5px;color:var(--faint);min-width:24px;text-align:center;padding:0 2px}
/* ── legend ───────────────────────────────────────────────────────────────── */ /* ── legend ───────────────────────────────────────────────────────────────── */
.legend{display:flex;flex-wrap:wrap;gap:6px 8px;margin-top:14px} .legend{display:flex;flex-wrap:wrap;gap:6px 8px;margin-top:14px}
...@@ -294,6 +301,22 @@ function rescale(){ ...@@ -294,6 +301,22 @@ function rescale(){
} }
function px(v){return PAD.l+(v-sx[0])/(sx[1]-sx[0])*(plotW-PAD.l-PAD.r);} 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 py(v){return plotH-PAD.b-(v-sy[0])/(sy[1]-sy[0])*(plotH-PAD.t-PAD.b);}
/* view transform (zoom/pan), applied to base pixels at draw time */
let view={k:1,tx:0,ty:0};
const VX=bx=>bx*view.k+view.tx, VY=by=>by*view.k+view.ty;
function clampPan(){
if(view.k<=1){view.k=1;view.tx=0;view.ty=0;return;}
const L=PAD.l,R=plotW-PAD.r,T=PAD.t,B=plotH-PAD.b;
view.tx=clamp(view.tx, R*(1-view.k), L*(1-view.k));
view.ty=clamp(view.ty, B*(1-view.k), T*(1-view.k));
}
function zoomAt(mx,my,f){
const nk=clamp(view.k*f,1,14),rf=nk/view.k;
view.tx=mx-(mx-view.tx)*rf; view.ty=my-(my-view.ty)*rf; view.k=nk;
clampPan(); drawScatter(); updateZoomUI();
}
function resetZoom(){view={k:1,tx:0,ty:0};drawScatter();updateZoomUI();}
function updateZoomUI(){const z=$("#zlvl");if(z)z.textContent=view.k>1.02?view.k.toFixed(1)+"×":"fit";}
function sizeCanvas(){ function sizeCanvas(){
const r=cv.getBoundingClientRect();DPR=window.devicePixelRatio||1; const r=cv.getBoundingClientRect();DPR=window.devicePixelRatio||1;
plotW=r.width;plotH=r.height;cv.width=plotW*DPR;cv.height=plotH*DPR; plotW=r.width;plotH=r.height;cv.width=plotW*DPR;cv.height=plotH*DPR;
...@@ -303,49 +326,53 @@ function drawScatter(){ ...@@ -303,49 +326,53 @@ function drawScatter(){
if(!sx)rescale(); if(!sx)rescale();
const ax=axis(state.x),ay=axis(state.y); const ax=axis(state.x),ay=axis(state.y);
ctx.clearRect(0,0,plotW,plotH); ctx.clearRect(0,0,plotW,plotH);
/* density colour-field (cached; rebuilt only when colours/positions change) */
if(fieldDirty){buildField();fieldDirty=false;} if(fieldDirty){buildField();fieldDirty=false;}
/* clip everything zoomable to the plot rect (so pan/zoom doesn't bleed) */
ctx.save();
ctx.beginPath();ctx.rect(PAD.l,PAD.t,plotW-PAD.l-PAD.r,plotH-PAD.t-PAD.b);ctx.clip();
/* density colour-field — drawn through the view transform */
if(fieldCv){ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality="high"; 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);} ctx.drawImage(fieldCv,VX(PAD.l),VY(PAD.t),(plotW-PAD.l-PAD.r)*view.k,(plotH-PAD.t-PAD.b)*view.k);}
/* gridlines */ /* gridlines (static frame) */
ctx.strokeStyle=fieldCv?"#ffffff07":"#ffffff0c";ctx.lineWidth=1; ctx.strokeStyle=fieldCv?"#ffffff07":"#ffffff0c";ctx.lineWidth=1;
ctx.beginPath(); 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); 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);} const y=PAD.t+i/4*(plotH-PAD.t-PAD.b);ctx.moveTo(PAD.l,y);ctx.lineTo(plotW-PAD.r,y);}
ctx.stroke(); ctx.stroke();
/* axis origin cross at 0 if in range (superfeatures are centred) */
pts=[]; pts=[];
const samples=D.samples; const samples=D.samples;
for(let i=0;i<samples.length;i++){ for(let i=0;i<samples.length;i++){
const s=samples[i]; if(!visible(s))continue; const s=samples[i]; if(!visible(s))continue;
const vx=gv(ax,s),vy=gv(ay,s); if(vx==null||vy==null)continue; const vx=gv(ax,s),vy=gv(ay,s); if(vx==null||vy==null)continue;
pts.push([px(vx),py(vy),i]); pts.push([VX(px(vx)),VY(py(vy)),i]);
} }
const R=2.6+(view.k-1)*0.5; // dots grow a touch with zoom for diving in
const vibe=state.vibe; const vibe=state.vibe;
/* in vibe mode draw misses faintly first, hits on top sized by similarity */ /* in vibe mode draw misses faintly first, hits on top sized by similarity */
for(const[x,y,i]of pts){ for(const[x,y,i]of pts){
if(i===hover||i===playing)continue; if(i===hover||i===playing)continue;
if(vibe){const sim=vibe.hits.get(i); if(vibe){const sim=vibe.hits.get(i);
if(sim==null){ctx.globalAlpha=0.14;ctx.fillStyle="#5a5a62"; if(sim==null){ctx.globalAlpha=0.14;ctx.fillStyle="#5a5a62";
ctx.beginPath();ctx.arc(x,y,2,0,7);ctx.fill();continue;} ctx.beginPath();ctx.arc(x,y,R*0.7,0,7);ctx.fill();continue;}
ctx.globalAlpha=0.92;ctx.fillStyle=vibeRamp(sim); ctx.globalAlpha=0.92;ctx.fillStyle=vibeRamp(sim);
ctx.beginPath();ctx.arc(x,y,3+vibe.norm(sim)*5.5,0,7);ctx.fill();continue;} ctx.beginPath();ctx.arc(x,y,R+vibe.norm(sim)*5.5,0,7);ctx.fill();continue;}
ctx.globalAlpha=0.82;ctx.fillStyle=dotColor(samples[i]); ctx.globalAlpha=0.82;ctx.fillStyle=dotColor(samples[i]);
ctx.beginPath();ctx.arc(x,y,3.1,0,7);ctx.fill(); ctx.beginPath();ctx.arc(x,y,R,0,7);ctx.fill();
} }
ctx.globalAlpha=1; ctx.globalAlpha=1;
if(state.vibe&&state.vibe.top!=null){const s=samples[state.vibe.top]; if(state.vibe&&state.vibe.top!=null){const s=samples[state.vibe.top];
const vx=gv(ax,s),vy=gv(ay,s); const vx=gv(ax,s),vy=gv(ay,s);
if(vx!=null&&vy!=null){ctx.strokeStyle="#d900ff";ctx.lineWidth=2; if(vx!=null&&vy!=null){ctx.strokeStyle="#d900ff";ctx.lineWidth=2;
ctx.beginPath();ctx.arc(px(vx),py(vy),9,0,7);ctx.stroke();}} ctx.beginPath();ctx.arc(VX(px(vx)),VY(py(vy)),R+6,0,7);ctx.stroke();}}
if(hover>=0){const s=samples[hover];const vx=gv(ax,s),vy=gv(ay,s); if(hover>=0){const s=samples[hover];const vx=gv(ax,s),vy=gv(ay,s);
if(vx!=null&&vy!=null){const x=px(vx),y=py(vy); if(vx!=null&&vy!=null){const x=VX(px(vx)),y=VY(py(vy));
ctx.fillStyle=state.vibe?vibeRamp(state.vibe.hits.get(hover)??0.3):dotColor(s); ctx.fillStyle=state.vibe?vibeRamp(state.vibe.hits.get(hover)??0.3):dotColor(s);
ctx.beginPath();ctx.arc(x,y,5.4,0,7);ctx.fill(); ctx.beginPath();ctx.arc(x,y,R+2.3,0,7);ctx.fill();
ctx.strokeStyle="#fff";ctx.lineWidth=1.4;ctx.stroke();}} ctx.strokeStyle="#fff";ctx.lineWidth=1.4;ctx.stroke();}}
if(playing>=0){const s=samples[playing];const vx=gv(ax,s),vy=gv(ay,s); if(playing>=0){const s=samples[playing];const vx=gv(ax,s),vy=gv(ay,s);
if(vx!=null&&vy!=null){ctx.strokeStyle="#d900ff";ctx.lineWidth=2; if(vx!=null&&vy!=null){ctx.strokeStyle="#d900ff";ctx.lineWidth=2;
ctx.beginPath();ctx.arc(px(vx),py(vy),8.5,0,7);ctx.stroke();}} ctx.beginPath();ctx.arc(VX(px(vx)),VY(py(vy)),R+5,0,7);ctx.stroke();}}
ctx.restore(); // end plot-rect clip
$("#count").textContent=`${pts.length} / ${samples.length} samples`; $("#count").textContent=`${pts.length} / ${samples.length} samples`;
/* axis labels */ /* axis labels */
$("#axx .nm").textContent=ax.label; $("#axx .po").textContent=`${ax.lo} ←→ ${ax.hi}`; $("#axx .nm").textContent=ax.label; $("#axx .po").textContent=`${ax.lo} ←→ ${ax.hi}`;
...@@ -368,30 +395,54 @@ const GW=72, GH=46; // field resolution (biline ...@@ -368,30 +395,54 @@ const GW=72, GH=46; // field resolution (biline
function buildField(){ function buildField(){
fieldCv=null; fieldCv=null;
if(!state.field||state.vibe||!sx)return; if(!state.field||state.vibe||!sx)return;
const ax=axis(state.x),ay=axis(state.y),dots=[]; const ax=axis(state.x),ay=axis(state.y);
// index each dot by its (lens) colour → small palette, so a cell can pick its
// DOMINANT family rather than averaging a dozen hues into mud.
const palIdx={},palRGB=[],dots=[];
for(const s of D.samples){ if(!visible(s))continue; 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 vx=gv(ax,s),vy=gv(ay,s); if(vx==null||vy==null)continue;
const c=toRGB(dotColor(s)); const col=dotColor(s); let pi=palIdx[col];
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(pi==null){pi=palRGB.length;palIdx[col]=pi;palRGB.push(toRGB(col));}
dots.push([(vx-sx[0])/(sx[1]-sx[0])*GW,(1-(vy-sy[0])/(sy[1]-sy[0]))*GH,pi]);}
if(!dots.length)return; if(!dots.length)return;
const K=palRGB.length,acc=new Float32Array(K);
const off=document.createElement("canvas");off.width=GW;off.height=GH; const off=document.createElement("canvas");off.width=GW;off.height=GH;
const octx=off.getContext("2d"),img=octx.createImageData(GW,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 const sigma=2.9,inv=1/(2*sigma*sigma),cut=64;
const ds=0.40 /*desaturate (keep some colour)*/, lift=0.20 /*airy*/, maxA=0.44;
for(let gy=0;gy<GH;gy++)for(let gx=0;gx<GW;gx++){ 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; acc.fill(0);let total=0;const cx=gx+0.5,cy=gy+0.5;
for(let k=0;k<dots.length;k++){const d=dots[k]; 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 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 wt=Math.exp(-dd*inv); acc[d[2]]+=wt; total+=wt;}
const i=(gy*GW+gx)*4; const i=(gy*GW+gx)*4;
if(w<0.02){img.data[i+3]=0;continue;} if(total<0.05){img.data[i+3]=0;continue;}
r/=w;g/=w;b/=w;const lum=0.3*r+0.59*g+0.11*b; let mi=0,mv=acc[0]; for(let q=1;q<K;q++)if(acc[q]>mv){mv=acc[q];mi=q;}
img.data[i]=r+(lum-r)*ds;img.data[i+1]=g+(lum-g)*ds;img.data[i+2]=b+(lum-b)*ds; const frac=mv/total; // purity of the dominant family
img.data[i+3]=clamp(w/2.4,0,1)*maxA*255; // density → opacity (pockets fade out) let r=palRGB[mi][0],g=palRGB[mi][1],b=palRGB[mi][2];
const lum=0.3*r+0.59*g+0.11*b;
r=r+(lum-r)*ds; g=g+(lum-g)*ds; b=b+(lum-b)*ds; // desaturate toward grey
r=r+(255-r)*lift; g=g+(255-g)*lift; b=b+(255-b)*lift; // then lift toward white
img.data[i]=r;img.data[i+1]=g;img.data[i+2]=b;
// alpha by density AND purity → mixed/overlap zones fade out instead of muddying
img.data[i+3]=clamp(total/2.0,0,1)*maxA*(0.3+0.7*frac)*255;
} }
octx.putImageData(img,0,0);fieldCv=off; octx.putImageData(img,0,0);fieldCv=off;
} }
let drag=null; // {x0,y0,tx0,ty0,moved}
function onDown(e){
if(e.button!==0||view.k<=1)return; // pan only when zoomed in
drag={x0:e.clientX,y0:e.clientY,tx0:view.tx,ty0:view.ty,moved:false};
cv.style.cursor="grabbing";$("#tip").classList.remove("on");
}
function onUp(){if(drag){if(drag.moved)_supClick=true;drag=null;cv.style.cursor=view.k>1?"grab":"crosshair";}}
function onWheel(e){e.preventDefault();const r=cv.getBoundingClientRect();
zoomAt(e.clientX-r.left,e.clientY-r.top, e.deltaY<0?1.18:1/1.18);}
function onMove(e){ function onMove(e){
if(drag){const dx=e.clientX-drag.x0,dy=e.clientY-drag.y0;
if(Math.abs(dx)+Math.abs(dy)>3)drag.moved=true;
view.tx=drag.tx0+dx;view.ty=drag.ty0+dy;clampPan();drawScatter();return;}
const r=cv.getBoundingClientRect(); const r=cv.getBoundingClientRect();
const i=nearest(e.clientX-r.left,e.clientY-r.top); const i=nearest(e.clientX-r.left,e.clientY-r.top);
if(i!==hover){hover=i;drawScatter();} if(i!==hover){hover=i;drawScatter();}
...@@ -409,12 +460,14 @@ function onMove(e){ ...@@ -409,12 +460,14 @@ function onMove(e){
tip.style.left=clamp(e.clientX+14,4,innerWidth-tw-4)+"px"; tip.style.left=clamp(e.clientX+14,4,innerWidth-tw-4)+"px";
tip.style.top=clamp(e.clientY+14,4,innerHeight-th-4)+"px"; tip.style.top=clamp(e.clientY+14,4,innerHeight-th-4)+"px";
cv.style.cursor=s.wav?"pointer":"default"; cv.style.cursor=s.wav?"pointer":"default";
}else{tip.classList.remove("on");cv.style.cursor="crosshair";} }else{tip.classList.remove("on");cv.style.cursor=view.k>1?"grab":"crosshair";}
} }
function playIdx(i){const s=D.samples[i];if(!s||!s.wav)return; function playIdx(i){const s=D.samples[i];if(!s||!s.wav)return;
const a=$("#aud");a.src=s.wav;a.currentTime=0;a.play().catch(()=>{}); const a=$("#aud");a.src=s.wav;a.currentTime=0;a.play().catch(()=>{});
a.onended=()=>{playing=-1;drawScatter();};playing=i;drawScatter();} a.onended=()=>{playing=-1;drawScatter();};playing=i;drawScatter();}
let _supClick=false;
function onClick(e){ function onClick(e){
if(_supClick){_supClick=false;return;} // was a pan-drag, not a click
if(hover<0)return; if(hover<0)return;
if(e&&e.shiftKey){findSimilar(hover);return;} // shift-click → find similar if(e&&e.shiftKey){findSimilar(hover);return;} // shift-click → find similar
playIdx(hover); playIdx(hover);
...@@ -649,6 +702,12 @@ function render(){ ...@@ -649,6 +702,12 @@ function render(){
<div class="plot"> <div class="plot">
<canvas id="scatter"></canvas> <canvas id="scatter"></canvas>
<div class="count" id="count"></div> <div class="count" id="count"></div>
<div class="zoom" id="zoom">
<button id="zin" title="zoom in">+</button>
<button id="zout" title="zoom out">−</button>
<span class="zl mono" id="zlvl">fit</span>
<button id="zrst" title="reset view">⊡</button>
</div>
<div class="axlabel x" id="axx"><span class="nm"></span><span class="po"></span></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 class="axlabel y" id="axy"><span class="nm"></span><span class="po"></span></div>
</div> </div>
...@@ -745,6 +804,13 @@ function render(){ ...@@ -745,6 +804,13 @@ function render(){
cv.addEventListener("mousemove",onMove); cv.addEventListener("mousemove",onMove);
cv.addEventListener("mouseleave",()=>{hover=-1;$("#tip").classList.remove("on");drawScatter();}); cv.addEventListener("mouseleave",()=>{hover=-1;$("#tip").classList.remove("on");drawScatter();});
cv.addEventListener("click",onClick); cv.addEventListener("click",onClick);
cv.addEventListener("mousedown",onDown);
addEventListener("mouseup",onUp);
cv.addEventListener("wheel",onWheel,{passive:false});
cv.addEventListener("dblclick",resetZoom);
$("#zin").onclick=()=>zoomAt(plotW/2,plotH/2,1.4);
$("#zout").onclick=()=>zoomAt(plotW/2,plotH/2,1/1.4);
$("#zrst").onclick=resetZoom;
const tip=el("div","tip");tip.id="tip";document.body.appendChild(tip); const tip=el("div","tip");tip.id="tip";document.body.appendChild(tip);
// fingerprint select // fingerprint select
......
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