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);
}