carbon-a-release / layers.js
lvwerra's picture
lvwerra HF Staff
Reduce icon outline width for a subtler contour
a2f1a75 verified
Raw History Blame Contribute Delete
10.6 kB
// Measured annotation counts aligned to the same assembly ordering as the tree.
let locusMode='5',locusBins=[],locusMax=1,iconImages=new Map(),themedIcons=new Map(),iconHits=[],hoverBin=null,showIcons=true;
const BAR_GAP=14,BAR_HEIGHT=174;
function shortCount(n){return n>=1e6?(n/1e6).toLocaleString('en-US',{maximumFractionDigits:2})+'M':n>=1000?(n/1000).toLocaleString('en-US',{maximumFractionDigits:1})+'k':fmt(n)}
function niceCeiling(n){if(!n)return 1;let p=10**Math.floor(Math.log10(n)),f=n/p;return (f<=1?1:f<=2?2:f<=5?5:10)*p}
function makeLocusBins(){
const size=locusMode==='raw'?1:Number(locusMode)||5;locusBins=[];
for(let lo=0;lo<leaves.length;lo+=size){let hi=Math.min(leaves.length,lo+size),known=0,value=0;
for(let i=lo;i<hi;i++){let v=leaves[i].loci;if(v!==null){known++;value+=v}}
locusBins.push({lo,hi,known,value,mid:(lo+hi-1)/2});
}
locusMax=niceCeiling(Math.max(...locusBins.map(b=>b.value)));hoverBin=null;
$('#locus-readout').textContent=shortCount(D.loci.summary.sum_available_loci)+' loci 路 '+fmt(D.loci.summary.available_assemblies)+' assemblies with counts';
$('#locus-explain').textContent=locusMode==='off'?'Locus bars hidden.':locusMode==='raw'?'One bar per assembly. Length = predicted coding loci. Grey ticks mark missing counts.':'Each bar sums available loci across up to '+size+' neighbouring assembly strands. Grey base marks show the fraction with missing counts.';
if(window.POC)Object.assign(POC,{locusBins,locusMax,locusMode,locusTotal:D.loci.summary.sum_available_loci,locusAvailable:D.loci.summary.available_assemblies});
schedule();
}
function barHeight(v){return BAR_HEIGHT*v/locusMax}
// Tiny Canvas arcs can be tessellated differently at their two radii, creating
// dashed slivers. Explicit vertices keep both radial edges continuous. Larger
// sectors share angular subdivisions with <0.1 CSS px chord error at any zoom.
function wedge(a,b,r0,r1,color){
if(r1<=r0||b<=a)return;
const s=baseScale*zoom,steps=Math.max(1,Math.ceil((b-a)/Math.min(.04,Math.sqrt(.8/(r1*s)))));
ctx.beginPath();
for(let j=0;j<=steps;j++){const p=screen(polar(r1,a+(b-a)*j/steps));j?ctx.lineTo(...p):ctx.moveTo(...p)}
for(let j=steps;j>=0;j--)ctx.lineTo(...screen(polar(r0,a+(b-a)*j/steps)));
ctx.closePath();ctx.fillStyle=color;ctx.fill();
}
function drawLoci(){
if(locusMode==='off')return;
const s=baseScale*zoom,c=screen([0,0]),base=R+BAR_GAP;
// A common radial zero and linear scale; missing measurements are distinct marks.
if(zoom<3){ctx.strokeStyle=palette.grid;ctx.lineWidth=.7;ctx.setLineDash([2,5]);for(let v of [0,locusMax/2,locusMax]){ctx.beginPath();ctx.arc(c[0],c[1],(base+barHeight(v))*s,START,START+SPAN);ctx.stroke()}ctx.setLineDash([]);}
for(const b of locusBins){
let a0=START+SPAN*b.lo/leaves.length,a1=START+SPAN*b.hi/leaves.length,ang=(a0+a1)/2,p=screen(polar(base+barHeight(b.value)/2,ang)),h=(barHeight(b.value)+(a1-a0)*(base+barHeight(b.value)))*s;
// Cull only when the entire bar is outside the viewport.
if((p[0]<-h-20||p[0]>W+h+20||p[1]<-h-20||p[1]>H+h+20)&&zoom>2)continue;
if(locusMode==='raw'){
if(b.known){ctx.strokeStyle=b===hoverBin?palette.barHover:palette.bar;ctx.lineWidth=Math.max(.3,Math.min(3,(a1-a0)*base*s*.7));ctx.globalAlpha=Math.min(1,Math.max(.14,(a1-a0)*base*s));ctx.beginPath();ctx.moveTo(...screen(polar(base,ang)));ctx.lineTo(...screen(polar(base+barHeight(b.value),ang)));ctx.stroke();ctx.globalAlpha=1;if(b.value===0){ctx.fillStyle=palette.bar;let q=screen(polar(base,ang));ctx.fillRect(q[0]-.6,q[1]-.6,1.2,1.2)}}
else{ctx.strokeStyle=palette.missing;ctx.lineWidth=Math.max(.35,Math.min(2,(a1-a0)*base*s*.7));ctx.beginPath();ctx.moveTo(...screen(polar(R+4,ang)));ctx.lineTo(...screen(polar(R+4+Math.min(5,3/s),ang)));ctx.stroke()}
}else{
let pad=Math.min((a1-a0)*.16,.0007);
if(b.known)wedge(a0+pad,a1-pad,base,base+barHeight(b.value),b===hoverBin?palette.barHover:palette.bar);
if(b.known<b.hi-b.lo){let fraction=1-b.known/(b.hi-b.lo);wedge(a0,a0+(a1-a0)*fraction,R+4,R+4+Math.min(6,3/s),palette.missing)}
}
}
// A compact key sits in the otherwise empty top seam.
if(zoom<1.6){let [x,y]=screen(polar(base+BAR_HEIGHT,START));ctx.textAlign='center';ctx.font='9px Inter';ctx.fillStyle=palette.muted;ctx.fillText(shortCount(locusMax),c[0],y-7);ctx.fillText('loci / '+(locusMode==='raw'?'assembly':'bin'),c[0],y-20);let p=screen(polar(base,START));ctx.fillText('0',c[0],p[1]-2)}
}
async function loadAtlasIcons(){
const items=Object.entries(D.icons.images);
await Promise.all(items.map(([uid,info])=>new Promise(resolve=>{let img=new Image();img.onload=()=>{iconImages.set(uid,img);resolve()};img.onerror=()=>resolve();img.src='data:image/svg+xml;base64,'+btoa(unescape(encodeURIComponent(info.svg)))})));
const rows=items.map(([uid,v])=>`<tr><td><a href="${esc(v.page)}" target="_blank" rel="noopener">${esc(v.attribution||v.contributor||'PhyloPic contributor')}</a></td><td><a href="${esc(v.license)}" target="_blank" rel="noopener">${esc(v.license.includes('/zero/')?'CC0':v.license.includes('/mark/')?'Public domain':v.license.includes('/by/3.')?'CC BY 3.0':'CC BY 4.0')}</a></td></tr>`).join('');
$('#icon-credits').innerHTML='<table><thead><tr><th>Original image / credit</th><th>Licence</th></tr></thead><tbody>'+rows+'</tbody></table>';
}
function themedIcon(uid,size){
const key=theme+':'+uid+':'+size;if(themedIcons.has(key))return themedIcons.get(key);
const im=iconImages.get(uid),ratio=im.naturalWidth/im.naturalHeight||1;
const iw=ratio>1?size:size*ratio,ih=ratio>1?size/ratio:size,pad=3,dpr=2;
const width=iw+pad*2,height=ih+pad*2,sprite=document.createElement('canvas');
sprite.width=Math.ceil(width*dpr);sprite.height=Math.ceil(height*dpr);
const m=sprite.getContext('2d');m.setTransform(dpr,0,0,dpr,0,0);
// Dilate the original silhouette alpha, then place its fill over the top.
// Padding keeps the contour intact, including antennae and narrow limbs.
for(const [color,outline] of [[palette.iconOutline,true],[palette.icon,false]]){
const mask=document.createElement('canvas');mask.width=sprite.width;mask.height=sprite.height;
const ink=mask.getContext('2d');ink.setTransform(dpr,0,0,dpr,0,0);
ink.drawImage(im,pad,pad,iw,ih);ink.globalCompositeOperation='source-in';
ink.fillStyle=color;ink.fillRect(0,0,width,height);
if(outline){
const radius=size<18?.4:.55;
for(let i=0;i<16;i++){const a=i*Math.PI/8;m.drawImage(mask,Math.cos(a)*radius,Math.sin(a)*radius,mask.width/dpr,mask.height/dpr)}
}else m.drawImage(mask,0,0,mask.width/dpr,mask.height/dpr);
}
const result={image:sprite,width:sprite.width/dpr,height:sprite.height/dpr,iw,ih,pad};
themedIcons.set(key,result);return result;
}
function drawAtlasIcons(){
iconHits=[];if(window.POC)POC.iconsDrawn=[];if(!showIcons)return;
const candidates=[...nodes.values()].filter(n=>n.id!==2759&&n.r&&D.icons.taxa[n.id]&&n.count>=Math.max(2,leaves.length/(zoom*2400))).sort((a,b)=>b.count-a.count);
const viewScale=Math.max(.68,Math.min(1,Math.sqrt(baseScale/.4))),max=W<600?34:zoom<1.5?50:64;
for(const n of candidates){
const uid=D.icons.taxa[n.id],im=iconImages.get(uid);if(!im)continue;
const p=screen(point(n,n.mid)),size=Math.round(Math.max(12,Math.min(32,(11+3.7*Math.log10(n.count)+2*Math.log2(zoom))*viewScale))),x=p[0],y=p[1];
const ratio=im.naturalWidth/im.naturalHeight||D.icons.images[uid].ratio||1,iw=ratio>1?size:size*ratio,ih=ratio>1?size/ratio:size;
if(x-iw/2<6||x+iw/2>W-6||y-ih/2<6||y+ih/2>H-6)continue;
// Actual silhouette bounds make room for smaller branches without overlap.
if(iconHits.some(h=>h.uid===uid||(Math.abs(h.x-x)<(h.w+iw)/2+6&&Math.abs(h.y-y)<(h.h+ih)/2+6)))continue;
const sprite=themedIcon(uid,size);
ctx.drawImage(sprite.image,x-iw/2-sprite.pad,y-ih/2-sprite.pad,sprite.width,sprite.height);
iconHits.push({x,y,w:iw,h:ih,size,r:size/2+3,n,uid});
if(iconHits.length>=max)break;
}
if(window.POC)POC.iconsDrawn=iconHits.map(h=>h.n.id);
}
function hitIcon(x,y){return iconHits.find(h=>Math.abs(h.x-x)<=h.w/2+3&&Math.abs(h.y-y)<=h.h/2+3)}
function hitBar(x,y){if(locusMode==='off')return null;let p=world(x,y),rad=Math.hypot(...p);if(rad<R+2||rad>R+BAR_GAP+BAR_HEIGHT+8)return null;let angle=Math.atan2(p[1],p[0]);while(angle<START)angle+=TAU;if(angle>START+SPAN)return null;let i=Math.floor((angle-START)/SPAN*leaves.length),size=locusMode==='raw'?1:Number(locusMode);return locusBins[Math.floor(i/size)]||null}
function hoverLayers(e){if(sweep.running)return;if(pinnedAssembly){$('#layer-tooltip').hidden=true;canvas.style.cursor='pointer';return;}if(document.querySelector('dialog[open]'))return;let box=stage.getBoundingClientRect(),x=e.clientX-box.left,y=e.clientY-box.top,dial=hitDial(x,y),ic=dial?null:hitIcon(x,y),bin=dial?null:hitBar(x,y),tip=$('#layer-tooltip');
if(!ic&&!bin){tip.hidden=true;if(hoverBin){hoverBin=null;schedule()}const a=hitAssembly(x,y,3);hoverAssembly(a);canvas.style.cursor=a?'pointer':'default';return}
canvas.style.cursor='pointer';
if(!ic&&locusMode==='raw'){tip.hidden=true;if(hoverBin!==bin){hoverBin=bin;schedule()}hoverAssembly(leaves[bin.lo]);return}
hoverAssembly(null);
if(ic){tip.innerHTML=`<b>${esc(ic.n.common||ic.n.name)}</b><span>${esc(ic.n.name)} 路 ${fmt(ic.n.count)} assemblies</span><span>${fmt(mode?ic.n.refMatches:ic.n.existing)} ${mode?'match the RefSeq comparison corpus':'have GenBank annotation records'}</span><small>Assembly counts, not gene counts 路 click to select a strand</small>`}
else{const n=bin.hi-bin.lo,one=n===1,a=leaves[bin.lo];tip.innerHTML=`<b>${one?esc(a.name):shortCount(bin.value)+' predicted coding loci'}</b><span>${one?(bin.known?fmt(bin.value)+' predicted coding loci':'Locus count unavailable'):fmt(bin.known)+' of '+fmt(n)+' assemblies have counts'}</span><small>${one?esc(a.accession):'Sum of available counts 路 click to select an assembly'}</small>`;if(hoverBin!==bin){hoverBin=bin;schedule()}}
tip.hidden=false;const wrap=$('.stage-wrap').getBoundingClientRect();tip.style.left=box.left-wrap.left+Math.max(8,Math.min(W-tip.offsetWidth-8,x+16))+'px';tip.style.top=box.top-wrap.top+Math.max(8,Math.min(H-tip.offsetHeight-8,y+15))+'px';
}
// Icons and bar bins resolve to real strands without changing the camera.
function assemblyAtClick(x,y){
const dial=hitDial(x,y);if(dial)return dial;
const bin=hitBar(x,y);
if(bin){const p=world(x,y);let angle=Math.atan2(p[1],p[0]);while(angle<START)angle+=TAU;
const i=Math.max(bin.lo,Math.min(bin.hi-1,Math.floor((angle-START)/SPAN*leaves.length)));
return leaves[i];
}
const icon=hitIcon(x,y);
return nearest(x,y,7)||(icon?leaves[Math.round(icon.n.mid)]:null);
}