File size: 20,948 Bytes
23078ef
d688ac7
23078ef
 
 
8e683b6
23078ef
 
 
 
 
 
 
 
 
 
fb408b6
23078ef
d2ea2f5
23078ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d688ac7
23078ef
 
 
 
 
 
d2ea2f5
23078ef
 
d2ea2f5
23078ef
 
 
 
 
8e683b6
23078ef
 
d688ac7
fb408b6
23078ef
d688ac7
fb408b6
8e683b6
 
fbc4cf6
23078ef
 
 
fbc4cf6
8e683b6
cba18a0
fbc4cf6
cba18a0
 
 
 
 
 
 
c5eee7b
cba18a0
d2ea2f5
 
23078ef
 
 
 
 
 
 
e5675eb
fbc4cf6
e5675eb
8e683b6
c3828f6
 
 
 
 
 
 
d688ac7
c3828f6
23078ef
d2ea2f5
 
fbc4cf6
62181a5
fbc4cf6
e5675eb
d688ac7
23078ef
8e683b6
23078ef
8e683b6
fbc4cf6
d688ac7
fbc4cf6
 
 
 
 
 
8e683b6
d2ea2f5
fbc4cf6
d2ea2f5
c3828f6
d2ea2f5
fbc4cf6
 
 
 
c3828f6
 
fbc4cf6
 
c3828f6
fbc4cf6
 
 
 
 
 
 
c3828f6
d2ea2f5
c5eee7b
 
d688ac7
e5675eb
fb408b6
fbc4cf6
 
e5675eb
 
 
23078ef
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;i<leaves.length;i++){
  const a=leaves[i];a.i=i;let n=nodes.get(a.taxid),lineage=[];
  while(n){lineage.unshift(n);n=nodes.get(n.parent)}a.lineage=lineage;
  const keep=lineage.filter(n=>ranks[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<a.points.length;j++){
  let p=a.points[j-1],q=a.points[j],[c,d]=controls(p,q),prev=p,steps=stepsFor(p,q);
  for(let t=1;t<=steps;t++){let next=bez(p,c,d,q,t/steps);hitOwner[k/8]=a.i;for(let v of [prev,next]){data[k++]=v[0];data[k++]=v[1];data[k++]=.008+.054*Math.pow(Math.min(1,Math.hypot(...v)/R),3);data[k++]=a.flag;}prev=next;}
 }a.geometryEnd=k;}
 hitData=data;vertexCount=k/4;const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,data,gl.STATIC_DRAW);const loc=gl.getAttribLocation(program,'a');gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,4,gl.FLOAT,false,0,0);
 window.POC={assemblies:leaves.length,vertices:vertexCount,prior:D.meta.classification_counts['1'],refseq:D.meta.refseq_matches,data:D,leaves,nodes,select:selectAssembly,focus:focusAssembly};
}
function hash(x){x=((x>>>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.count<leaves.length/zoom*.6);
 let placed=locusMode!=='off'&&zoom<1.6?[{x:W/2+pan.x-36,y:screen(polar(R+BAR_GAP+BAR_HEIGHT,START))[1]-14,w:72}]:[];for(const n of labels){let angle=START+SPAN*(n.mid+.5)/leaves.length;let [x,y]=screen(polar(R+BAR_HEIGHT+42,angle));if(x<35||x>W-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=>bx<b.x+b.w+8&&bx+tw>b.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-SPAN<TAU-angle?leaves.at(-1):leaves[0];
 return leaves[Math.min(leaves.length-1,Math.floor(angle/SPAN*leaves.length))];
}
function insideTree(x,y){return Math.hypot(...world(x,y))<=R+(locusMode==='off'?0:BAR_GAP+BAR_HEIGHT)+8}
function hitAssembly(x,y,radius=7){return hitDial(x,y)||nearest(x,y,radius)}
function nearest(x,y,radius=7){
 const [wx,wy]=world(x,y),pad=radius/(baseScale*zoom);let best=pad*pad,found=null;
 for(let k=0;k<hitData.length;k+=8){let ax=hitData[k],ay=hitData[k+1],bx=hitData[k+4],by=hitData[k+5];
  if(wx<Math.min(ax,bx)-pad||wx>Math.max(ax,bx)+pad||wy<Math.min(ay,by)-pad||wy>Math.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(d<best){best=d;found=leaves[hitOwner[k/8]];}
 }return found;
}
function closeViewOptions(){if($('#view-dialog').open)$('#view-dialog').close()}
function chooseAssembly(a){closeViewOptions();stopSweep(false);selectAssembly(a)}
function focusAssembly(a,z=70){closeViewOptions();stopSweep(false);selected=a;zoom=z;let s=baseScale*zoom;pan={x:-a.tip[0]*s,y:-a.tip[1]*s};selectAssembly(a);schedule()}
function clearSelection(){selected=pinnedAssembly=hoveredAssembly=null;$('#selection').hidden=true;scheduleInteraction()}
function hoverAssembly(a){
 if(sweep.running||pinnedAssembly)return;
 if(a===hoveredAssembly)return;
 hoveredAssembly=a;
 if(a)selectAssembly(a,false);else clearSelection();
}
function selectAssembly(a,pin=true,redraw=true){if(!a)return;if(pin){pinnedAssembly=a;hoveredAssembly=null}selected=a;
 $('#selection').dataset.pinned=String(a===pinnedAssembly);
 $('#sel-state').textContent=sweep.running?'Scanning assemblies':pinnedAssembly?'Selected · click anywhere to clear':'Preview · click strand to keep';let r=a.row,species=a.lineage.find(n=>n.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?`<i class="dot ${has?'blue':'green'}"></i>${has?'In the RefSeq comparison corpus':'Outside the RefSeq comparison corpus'}`:r[4]===1?`<i class="dot blue"></i>GenBank annotation record<br><small>${esc(r[7])} · ${esc(r[9])}</small>`:'<i class="dot green"></i>No annotation listed in this GenBank snapshot';
 $('#sel-data').innerHTML=(species?`<span>${esc(species.common||species.name)} · assembly comparison</span><b>${fmt(mode?species.refMatches:species.existing)} / ${fmt(species.count)} ${mode?'in RefSeq corpus':'with GenBank records'}</b>`:'')+`<span>Predicted coding loci</span><b>${a.loci===null?'Count unavailable':fmt(a.loci)}</b>`+(r[3]?`<span>Assembly length</span><b>${(r[3]/1e6).toLocaleString('en-US',{maximumFractionDigits:2})} Mb</b>`:'')+(r[6]?`<span>RefSeq counterpart</span><b>${esc(r[6])}</b>`:'')+(mode&&r[4]===1?`<span>GenBank annotation provider</span><b>${esc(r[7])}</b>`:'');
 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=`<strong>${esc(a.name)}</strong><span>${esc(a.accession)}</span>`;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(`<line x1="${x(p)}" y1="38" x2="${x(p)}" y2="198" stroke="${palette.grid}"/><text x="${x(p)}" y="24" text-anchor="middle">${p/1e6} Mb</text>`)}
 for(let gene of g.genes){if(gene.start>end)continue;let y=gene.strand==='+'?92:158;parts.push(`<g><title>${esc(gene.id)} · ${fmt(gene.start)}–${fmt(gene.end)} bp · ${gene.strand}</title><line x1="${x(gene.start)}" x2="${x(Math.min(end,gene.end))}" y1="${y}" y2="${y}" stroke="${palette.bar}"/>`);for(let [s,e] of gene.cds)if(s<=end)parts.push(`<rect x="${x(s)}" y="${y-7}" width="${Math.max(.6,x(Math.min(end,e))-x(s))}" height="14" fill="${palette.green}"/>`);parts.push('</g>')};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();