const $=s=>document.querySelector(s), fmt=n=>n.toLocaleString('en-US'); let pinnedAssembly=null,hoveredAssembly=null; let hitData,hitOwner; let D,nodes,leaves,groups,selected=null,zoom=1,pan={x:0,y:0},baseScale=1,W=1,H=1,gl,program,vertexCount=0,mode=0,view='all',pending=false; const canvas=$('#tree'),overlay=$('#overlay'),ctx=overlay.getContext('2d'),stage=$('#stage'); const interaction=$('#interaction'),fx=interaction.getContext('2d',{willReadFrequently:true}); const R=740,TAU=Math.PI*2,START=-Math.PI/2+.04,SPAN=TAU-.08; const ranks={kingdom:175,phylum:272,class:355,order:437,family:520,genus:598,species:665,subspecies:696}; const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); function polar(r,a){return [r*Math.cos(a),r*Math.sin(a)]} function point(n,i){let a=START+SPAN*(n.mid+.5)/leaves.length,off=(i-n.mid)*.012;let [x,y]=polar(n.r,a);return [x-Math.sin(a)*off,y+Math.cos(a)*off]} function controls(p,q){let rp=Math.hypot(...p),rq=Math.hypot(...q),delta=(rq-rp)*.46;return [[p[0]*(1+delta/rp),p[1]*(1+delta/rp)],[q[0]*(1-delta/rq),q[1]*(1-delta/rq)]]} function bez(p,c,d,q,t){let s=1-t;return [s*s*s*p[0]+3*s*s*t*c[0]+3*s*t*t*d[0]+t*t*t*q[0],s*s*s*p[1]+3*s*s*t*c[1]+3*s*t*t*d[1]+t*t*t*q[1]]} function prepare(){ nodes=new Map(D.nodes.map(r=>[r[0],{id:r[0],parent:r[1],name:r[2],rank:r[3],common:r[4],children:[],own:[],count:0}])); for(const n of nodes.values())if(n.parent)nodes.get(n.parent)?.children.push(n); D.assemblies.forEach((r,index)=>{nodes.get(r[1]).own.push({row:r,index,accession:r[0],taxid:r[1],name:r[2],loci:D.loci.counts[r[0]]??null,flag:(r[4]===1?1:0)+(r[5]?2:0)});}); const root=nodes.get(2759); function count(n){n.count=n.own.length;n.existing=n.own.filter(a=>a.flag&1).length;n.refMatches=n.own.filter(a=>a.flag&2).length;for(let c of n.children){n.count+=count(c);n.existing+=c.existing;n.refMatches+=c.refMatches}n.children.sort((a,b)=>b.count-a.count||a.id-b.id);return n.count}count(root); leaves=[]; function order(n){n.lo=leaves.length;for(let c of n.children)order(c);n.own.sort((a,b)=>a.accession.localeCompare(b.accession));leaves.push(...n.own);n.hi=leaves.length;n.mid=(n.lo+n.hi-1)/2;for(let a of n.own)a.i=leaves.indexOf(a,n.lo);} order(root); for(let i=0;iranks[n.rank]);let max=92;a.points=[]; // A circular root keeps every assembly's lane distinct at the centre as well. a.points.push(polar(92,START+SPAN*(i+.5)/leaves.length)); for(n of keep){let r=ranks[n.rank];if(r<=max||r>700)continue;n.r=r;max=r;a.points.push(point(n,i));} a.angle=START+SPAN*(i+.5)/leaves.length;a.tip=polar(R,a.angle);a.points.push(a.tip); } groups=[33208,4751,33090,33630,554915,2763,554296,5761].map(id=>nodes.get(id)).filter(n=>n&&n.count>40); return root; } function shader(type,source){const s=gl.createShader(type);gl.shaderSource(s,source);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw Error(gl.getShaderInfoLog(s));return s} function stepsFor(p,q){let rp=Math.hypot(...p),rq=Math.hypot(...q),a=Math.acos(Math.max(-1,Math.min(1,(p[0]*q[0]+p[1]*q[1])/(rp*rq))));return Math.min(32,Math.max(4,Math.ceil(a*24+Math.abs(rq-rp)/50)))} function setupGL(){ gl=canvas.getContext('webgl',{alpha:false,antialias:true,preserveDrawingBuffer:true});if(!gl)throw Error('WebGL is unavailable in this browser.'); const vs=shader(gl.VERTEX_SHADER,`attribute vec4 a;uniform vec2 size;uniform vec2 pan;uniform float scale;uniform float dpr;uniform float mode;uniform float only;uniform float exposure;uniform vec3 greenInk;uniform vec3 blueInk;varying vec4 color;void main(){vec2 p=a.xy*scale+size*.5+pan;gl_Position=vec4(p.x/size.x*2.-1.,1.-p.y/size.y*2.,0.,1.);float blue=mode<.5?mod(a.w,2.):step(1.5,a.w);vec3 ink=mix(greenInk,blueInk,blue);float opacity=clamp(a.z*scale*dpr*exposure,0.,.95);if(only>.5){if(blue<.5)opacity*=.06;else opacity=min(.95,max(.14,opacity*4.));}color=vec4(ink,opacity);}`); const fs=shader(gl.FRAGMENT_SHADER,`precision mediump float;varying vec4 color;void main(){gl_FragColor=color;}`); program=gl.createProgram();gl.attachShader(program,vs);gl.attachShader(program,fs);gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw Error(gl.getProgramInfoLog(program)); gl.useProgram(program);gl.enable(gl.BLEND);gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); // Every real assembly has its own complete path. No generated placeholder leaves. let len=leaves.reduce((n,a)=>n+a.points.slice(1).reduce((m,p,j)=>m+stepsFor(a.points[j],p)*2*4,0),0),data=new Float32Array(len),k=0;hitOwner=new Uint32Array(len/8); const shuffled=leaves.slice().sort((a,b)=>hash(a.i)-hash(b.i)); for(const a of shuffled){a.geometryStart=k;for(let j=1;j>>16)^x)*0x45d9f3b;x=((x>>>16)^x)*0x45d9f3b;return ((x>>>16)^x)>>>0} function schedule(){if(!pending){pending=true;requestAnimationFrame(()=>{pending=false;draw()})}} function resize(){const b=stage.getBoundingClientRect();W=b.width;H=b.height;let dpr=Math.min(devicePixelRatio,2);canvas.width=overlay.width=interaction.width=Math.round(W*dpr);canvas.height=overlay.height=interaction.height=Math.round(H*dpr);baseScale=Math.min(W/((R+BAR_HEIGHT)*2+180),H/((R+BAR_HEIGHT)*2+180));schedule()} function screen(p){let s=baseScale*zoom;return [W/2+pan.x+p[0]*s,H/2+pan.y+p[1]*s]} function world(x,y){let s=baseScale*zoom;return [(x-W/2-pan.x)/s,(y-H/2-pan.y)/s]} function draw(){if(!gl)return;let dpr=canvas.width/W;gl.viewport(0,0,canvas.width,canvas.height);gl.clearColor(...rgb(palette.background),1);gl.clear(gl.COLOR_BUFFER_BIT);gl.useProgram(program);gl.uniform2f(gl.getUniformLocation(program,'size'),W,H);gl.uniform2f(gl.getUniformLocation(program,'pan'),pan.x,pan.y);gl.uniform1f(gl.getUniformLocation(program,'scale'),baseScale*zoom);gl.uniform1f(gl.getUniformLocation(program,'dpr'),dpr);gl.uniform1f(gl.getUniformLocation(program,'mode'),mode);gl.uniform1f(gl.getUniformLocation(program,'only'),view==='existing'?1:0);gl.uniform1f(gl.getUniformLocation(program,'exposure'),theme==='dark'?2:1);gl.uniform3fv(gl.getUniformLocation(program,'greenInk'),rgb(palette.green));gl.uniform3fv(gl.getUniformLocation(program,'blueInk'),rgb(palette.blue));gl.drawArrays(gl.LINES,0,vertexCount); ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);drawLoci(); const labels=zoom<2.5?groups:[...nodes.values()].filter(n=>['family','order','genus'].includes(n.rank)&&n.count>10&&n.countW-35||y<25||y>H-25)continue;let name=n.common||n.name;if(name.length>27)name=n.name;ctx.font=(zoom<2.5?'500 13px':'500 12px')+' Inter';ctx.save();ctx.font='10px Inter';let cw=ctx.measureText(fmt(n.count)+' assemblies').width;ctx.restore();let tw=Math.max(ctx.measureText(name).width,cw),anchor=Math.cos(angle)>.25?'left':Math.cos(angle)<-.25?'right':'center';let bx=anchor==='left'?x:anchor==='right'?x-tw:x-tw/2;if(bx<8){x+=8-bx;bx=8}if(bx+tw>W-8){x-=bx+tw-W+8;bx=W-8-tw}if(placed.some(b=>bxb.x-8&&Math.abs(y-b.y)<32))continue;placed.push({x:bx,y,w:tw});ctx.textAlign=anchor;ctx.fillStyle=palette.ink;ctx.strokeStyle=palette.background;ctx.lineWidth=4;ctx.strokeText(name,x,y);ctx.fillText(name,x,y);ctx.font='10px Inter';ctx.fillStyle=palette.muted;ctx.strokeText(fmt(n.count)+' assemblies',x,y+15);ctx.fillText(fmt(n.count)+' assemblies',x,y+15);} drawAtlasIcons(); if(sweep.running)cacheSweepTree(); drawInteraction(); const away=zoom!==1||Math.abs(pan.x)>1||Math.abs(pan.y)>1;$('#home').hidden=!away;$('.stage-wrap').dataset.zoomed=String(away); $('#zoom-readout').textContent=zoom.toFixed(1)+'×';$('#zoomout').disabled=zoom<=1;$('#zoomin').disabled=zoom>=320; window.POC&&(window.POC.zoom=zoom); } function zoomAt(factor,x=W/2,y=H/2){stopSweep();$('#layer-tooltip').hidden=true;hoverBin=null;let old=zoom,n=Math.max(1,Math.min(320,zoom*factor));let ratio=n/old;pan.x=(pan.x+W/2-x)*ratio+x-W/2;pan.y=(pan.y+H/2-y)*ratio+y-H/2;zoom=n;if(zoom===1)pan={x:0,y:0};schedule()} function reset(){stopSweep(false);$('#layer-tooltip').hidden=true;hoverBin=null;zoom=1;pan={x:0,y:0};clearSelection();schedule()} // Select the strand in the pointer's direction anywhere inside the root opening. // A small neutral hub avoids unstable angles right at the pivot. function hitDial(x,y,dragging=false){ const [wx,wy]=world(x,y),r=Math.hypot(wx,wy); if((!dragging&&r>92)||r*baseScale*zoom<3)return null; const angle=((Math.atan2(wy,wx)-START)%TAU+TAU)%TAU; if(angle>SPAN)return angle-SPANMath.max(ax,bx)+pad||wyMath.max(ay,by)+pad)continue; let dx=bx-ax,dy=by-ay,l=dx*dx+dy*dy,t=l?Math.max(0,Math.min(1,((wx-ax)*dx+(wy-ay)*dy)/l)):0; let d=(wx-ax-t*dx)**2+(wy-ay-t*dy)**2; if(dn.rank==='species');$('#selection').hidden=false;$('#sel-name').textContent=a.name;$('#sel-id').textContent=a.accession;$('#sel-id').href='https://www.ncbi.nlm.nih.gov/datasets/genome/'+a.accession+'/';$('#sel-lineage').textContent=a.lineage.filter(n=>['kingdom','phylum','class','order','family','genus'].includes(n.rank)).map(n=>n.common||n.name).join(' › '); const has=mode?r[5]:r[4]===1; $('#sel-status').innerHTML=mode?`${has?'In the RefSeq comparison corpus':'Outside the RefSeq comparison corpus'}`:r[4]===1?`GenBank annotation record
${esc(r[7])} · ${esc(r[9])}`:'No annotation listed in this GenBank snapshot'; $('#sel-data').innerHTML=(species?`${esc(species.common||species.name)} · assembly comparison${fmt(mode?species.refMatches:species.existing)} / ${fmt(species.count)} ${mode?'in RefSeq corpus':'with GenBank records'}`:'')+`Predicted coding loci${a.loci===null?'Count unavailable':fmt(a.loci)}`+(r[3]?`Assembly length${(r[3]/1e6).toLocaleString('en-US',{maximumFractionDigits:2})} Mb`:'')+(r[6]?`RefSeq counterpart${esc(r[6])}`:'')+(mode&&r[4]===1?`GenBank annotation provider${esc(r[7])}`:''); updateDataAccess(a);$('#sel-loci').hidden=a.accession!=='GCA_000001405.29';if(redraw)scheduleInteraction()} function updateLegend(){let n=mode?D.meta.refseq_matches:D.meta.classification_counts['1'];$('#blue-count').textContent=fmt(n);$('#green-count').textContent=fmt(leaves.length-n);$('#blue-label').textContent=mode?'In RefSeq comparison corpus':'GenBank annotation record';$('#green-label').textContent=mode?'Outside that comparison corpus':'No GenBank record listed';$('#scope-note').textContent=mode?'Counts are assemblies, not genes. Blue marks exact paired-accession matches to the project’s 2,055-assembly RefSeq corpus, not all RefSeq annotations. Green does not imply new genes.':'Counts are assemblies, not genes. This view checks GenBank submission metadata; it does not include annotations stored only on paired RefSeq records. Missing metadata is not proof of no prior annotation.';$('#strand-green-label').textContent=mode?'Outside RefSeq comparison corpus':'No GenBank annotation listed';$('#strand-blue-label').textContent=mode?'In RefSeq comparison corpus':'GenBank annotation listed';$('.strand-legend').title=$('#scope-note').textContent;if(selected&&!sweep.running)selectAssembly(selected,false);schedule()} function search(q){q=q.trim().toLowerCase();let out=[];if(q.length>1){out=leaves.filter(a=>a.accession.toLowerCase().includes(q)||a.name.toLowerCase().includes(q)||(nodes.get(a.taxid).common||'').toLowerCase().includes(q)).slice(0,7);}$('#results').replaceChildren(...out.map(a=>{let b=document.createElement('button');b.innerHTML=`${esc(a.name)}${esc(a.accession)}`;b.onclick=()=>{chooseAssembly(a);$('#results').replaceChildren();$('#search').value=''};return b}));if(q.length>1&&!out.length)$('#results').textContent='No match in this PoC subset.';} function geneModal(){closeViewOptions();stopSweep();const g=D.example;$('#gene-dialog').showModal();$('#gene-summary').textContent=fmt(g.gene_count)+' candidate coding loci in this assembly';const svg=$('#gene-svg');let start=0,end=1000000,width=1100,left=30,right=1080;let x=p=>left+(p-start)/(end-start)*(right-left);let parts=[];for(let p=0;p<=end;p+=100000){parts.push(`${p/1e6} Mb`)} for(let gene of g.genes){if(gene.start>end)continue;let y=gene.strand==='+'?92:158;parts.push(`${esc(gene.id)} · ${fmt(gene.start)}–${fmt(gene.end)} bp · ${gene.strand}`);for(let [s,e] of gene.cds)if(s<=end)parts.push(``);parts.push('')};svg.innerHTML=parts.join('');$('#gene-count-window').textContent=g.genes.filter(g=>g.start<=end).length+' candidate loci in this 1 Mb window. Bars show predicted coding segments; connecting lines span intervening sequence.'; } async function init(){try{const raw=Uint8Array.from(atob($('#dataset').textContent.trim()),c=>c.charCodeAt(0));D=JSON.parse(await new Response(new Blob([raw]).stream().pipeThrough(new DecompressionStream('gzip'))).text());prepare();await Promise.all([document.fonts.ready,loadAtlasIcons()]);setupGL();prepareSweep();setTheme(theme);makeLocusBins();resize();updateLegend();$('#loading').remove();$('#shown').textContent=fmt(leaves.length);$('#taxa-shown').textContent=fmt(D.meta.taxa);$('#search').disabled=false; new ResizeObserver(resize).observe(stage); stage.addEventListener('wheel',e=>{e.preventDefault();let r=canvas.getBoundingClientRect();zoomAt(Math.exp(-e.deltaY*.0017),e.clientX-r.left,e.clientY-r.top)},{passive:false}); let down=null,moved=false,suppressChartClick=false,clickPoint=null,hoverPending=false,hoverEvent=null; const queueHover=e=>{hoverEvent=e;if(hoverPending)return;hoverPending=true;requestAnimationFrame(()=>{hoverPending=false;const event=hoverEvent;hoverEvent=null;if(!down&&event)hoverLayers(event)})}; stage.addEventListener('pointerdown',e=>{ if(e.button!==0)return; hoverEvent=null;suppressChartClick=false;clickPoint=null; down={x:e.clientX,y:e.clientY};moved=false; stage.setPointerCapture(e.pointerId); }); stage.addEventListener('pointermove',e=>{ if(!down){queueHover(e);return} const dx=e.clientX-down.x,dy=e.clientY-down.y; if(!moved&&Math.hypot(dx,dy)<4)return; if(!moved){stopSweep(false);hoverAssembly(null)} moved=true;canvas.style.cursor='grabbing';$('#layer-tooltip').hidden=true; pan.x+=dx;pan.y+=dy;down={x:e.clientX,y:e.clientY};schedule(); }); stage.addEventListener('pointerup',e=>{if(!down)return;clickPoint={x:e.clientX,y:e.clientY};suppressChartClick=moved;down=null;canvas.style.cursor='default'}); stage.addEventListener('pointercancel',()=>{down=null;suppressChartClick=true;canvas.style.cursor='default'}); stage.addEventListener('pointerleave',e=>{hoverEvent=null;$('#layer-tooltip').hidden=true;if(hoverBin){hoverBin=null;schedule()}if(!e.relatedTarget?.closest?.('#selection'))hoverAssembly(null);if(!down)canvas.style.cursor='default'}); $('#selection').addEventListener('pointerleave',()=>hoverAssembly(null)); // Snapshot before target handlers run so actions inside the info box can use // the selected assembly before this same click clears it. document.addEventListener('click',e=>{e.hadAssemblySelection=!!pinnedAssembly||sweep.running;e.hadAssemblyPreview=!!hoveredAssembly},true); document.addEventListener('click',e=>{ if(e.target.closest('#stage')){ if(suppressChartClick){suppressChartClick=false;return} stopSweep(false);$('#layer-tooltip').hidden=true; if(e.hadAssemblySelection){clearSelection();return} const box=stage.getBoundingClientRect(),p=clickPoint||{x:e.clientX,y:e.clientY},x=p.x-box.left,y=p.y-box.top;clickPoint=null; const a=assemblyAtClick(x,y);if(a)selectAssembly(a);else clearSelection(); }else if(e.hadAssemblySelection||e.hadAssemblyPreview){stopSweep(false);clearSelection()} }); document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!document.querySelector('dialog[open]'))clearSelection()}); $('#sel-download').onclick=()=>showDataAccess(selected);$('#data-close').onclick=()=>$('#data-dialog').close();$('#data-copy').onclick=copyDataCode; $('#theme-toggle').onclick=()=>setTheme(theme==='dark'?'light':'dark'); $('#view-options').onclick=()=>{stopSweep(false);$('#view-dialog').showModal()};$('#view-close').onclick=closeViewOptions; $('#locus-mode').onchange=e=>{locusMode=e.target.value;makeLocusBins()};$('#show-icons').onchange=e=>{showIcons=e.target.checked;schedule()};$('#credits-open').onclick=()=>$('#credits-dialog').showModal();$('#credits-close').onclick=()=>$('#credits-dialog').close(); $('#home').onclick=reset; $('#zoomin').onclick=()=>zoomAt(2);$('#zoomout').onclick=()=>zoomAt(.5);$('#reset').onclick=reset;$('#search').addEventListener('input',e=>search(e.target.value));$('#search').addEventListener('keydown',e=>{if(e.key==='Escape')$('#results').replaceChildren();if(e.key==='Enter')$('#results button')?.click()});$('#scope').onchange=e=>{mode=Number(e.target.value);updateLegend()};$('#existing-only').onchange=e=>{view=e.target.checked?'existing':'all';schedule()};$('#human').onclick=()=>chooseAssembly(leaves.find(a=>a.accession==='GCA_000001405.29'));$('#detail').onclick=()=>focusAssembly(leaves.find(a=>a.accession==='GCA_000001405.29'),95);$('#sel-loci').onclick=geneModal;$('#gene-open').onclick=geneModal;$('#gene-close').onclick=()=>$('#gene-dialog').close();$('#sel-close').onclick=()=>{clearSelection();schedule()};$('#sel-focus').onclick=()=>focusAssembly(selected,100);$('#about').onclick=()=>{stopSweep();$('#about-dialog').showModal()};$('#about-close').onclick=()=>$('#about-dialog').close(); const params=new URLSearchParams(location.search); if(params.get('animate')==='1')setTimeout(startSweep,1000); }catch(e){if($('#loading'))$('#loading').textContent='Unable to load the tree: '+e.message;console.error(e)}} init();