Spaces:
Running
Running
File size: 10,596 Bytes
fb408b6 d688ac7 fb408b6 d2ea2f5 fb408b6 d688ac7 fb408b6 d688ac7 fb408b6 d688ac7 fb408b6 d688ac7 fb408b6 d688ac7 fb408b6 586e4b7 a2f1a75 586e4b7 d688ac7 fb408b6 586e4b7 fb408b6 586e4b7 fb408b6 586e4b7 fb408b6 586e4b7 fb408b6 fbc4cf6 c3828f6 fb408b6 c3828f6 fbc4cf6 e5675eb fb408b6 fbc4cf6 fb408b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | // 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);
}
|