/* A single measured locus, drawn in genomic coordinates. No raster assets. Ranks use descending |LLR| over the full 100 kbp locus, even while zoomed. */ (async function () { const host = document.getElementById('locus-plot'); if (!host) return; const readout = document.getElementById('locus-readout'); const region = document.getElementById('locus-region'); const buttons = ['locus-in', 'locus-out', 'locus-full', 'locus-causal'].map(id => document.getElementById(id)); buttons.forEach(b => { b.disabled = true; }); const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const fmt = n => Math.round(n).toLocaleString('en-US'); try { const response = await fetch('data/abi5-locus.json'); if (!response.ok) throw new Error('Locus data unavailable'); const data = await response.json(); const variants = data.variants; const causalIndex = variants.findIndex(v => v.causal); const causal = variants[causalIndex]; const sorted = variants.map(v => v.abs_llr).sort((a, b) => b - a); const cutoff = sorted[Math.floor(variants.length * .01) - 1]; const ymax = Math.ceil(sorted[0] / 2) * 2; let start = causal.pos - 10000, end = causal.pos + 10000; let selected = causalIndex, inspected = causalIndex, shown = [], W = 960; let L = 96, R = 22; const H = 365, scoreTop = 207, scoreBottom = 318, snpY = 171; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('role', 'group'); svg.setAttribute('tabindex', '0'); svg.setAttribute('aria-label', 'ABI5 genome browser. Gene models, SNP positions and absolute LLR scores.'); svg.setAttribute('aria-describedby', 'locus-help'); host.append(svg); const x = pos => L + (pos - start) / (end - start) * (W - L - R); const y = score => scoreBottom - score / ymax * (scoreBottom - scoreTop); const path = (d, cls) => ``; const text = (px, py, s, cls = '', anchor = 'start') => `${esc(s)}`; const diamond = (px, py, r = 5) => ``; function setView(a, b) { const width = Math.max(1000, Math.min(data.end - data.start, b - a)); start = Math.max(data.start, Math.min(data.end - width, a)); end = start + width; render(); } function zoom(factor) { const centre = variants[selected].pos >= start && variants[selected].pos <= end ? variants[selected].pos : (start + end) / 2; const width = Math.max(1000, Math.min(data.end - data.start, (end - start) * factor)); setView(centre - width / 2, centre + width / 2); } function show(index, announce = false) { inspected = index; const v = variants[index]; const group = svg.querySelector('#locus-focus'); const shade = svg.querySelector('#locus-context'); if (!group || !shade) return; const left = Math.max(L, x(v.pos - data.context_bp / 2)); const right = Math.min(W - R, x(v.pos + data.context_bp / 2)); shade.setAttribute('x', left); shade.setAttribute('width', Math.max(0, right - left)); group.innerHTML = v.pos >= start && v.pos <= end ? path(`M${x(v.pos)} 42V325`, 'focus-line') + `` + (v.causal ? diamond(x(v.pos), snpY, 5) : ``) : ''; readout.setAttribute('aria-live', announce ? 'polite' : 'off'); readout.innerHTML = `${v.causal ? 'Validated causal SNP' : 'Population SNP'}` + `Chr${esc(data.chrom)}:${fmt(v.pos)} ${esc(v.ref)} → ${esc(v.alt)}` + `|LLR| ${v.abs_llr.toFixed(3)}` + `Rank ${fmt(v.rank)}${v.ties > 1 ? '–' + fmt(v.rank + v.ties - 1) : ''} / ${fmt(variants.length)}${v.ties > 1 ? ' (tied)' : ''}`; } function render() { W = host.clientWidth; L = W < 500 ? 72 : 96; R = W < 500 ? 16 : 22; svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.setAttribute('data-start', Math.round(start)); svg.setAttribute('data-end', Math.round(end)); shown = variants.map((v, index) => ({v, index})).filter(({v}) => v.pos >= start && v.pos <= end); region.textContent = `Chr${data.chrom}:${fmt(start)}–${fmt(end)} · ${((end-start)/1000).toLocaleString('en-US',{maximumFractionDigits:1})} kbp · ${fmt(shown.length)} SNPs in view`; buttons[0].disabled = end - start <= 1000; buttons[1].disabled = end - start >= data.end - data.start; buttons[2].disabled = end - start >= data.end - data.start; buttons[3].disabled = false; let out = `ABI5 locus: ${data.model}, ${fmt(variants.length)} measured SNP scores`; out += ``; out += text(12, 32, 'Chr ' + data.chrom, 'track-name'); out += text(12, 80, 'Genes', 'track-name') + text(12, 97, 'Araport11'); out += text(12, snpY + 4, 'SNPs', 'track-name'); out += text(12, 226, '|LLR|', 'track-name'); out += ``; const targetStep = (end-start) / Math.max(1, Math.floor((W-L-R)/120)); const step = [250,500,1000,2500,5000,10000,20000,50000,100000].find(s => s >= targetStep); for (let pos = Math.ceil(start / step) * step; pos <= end; pos += step) { const anchor = x(pos)W-R-30 ? 'end' : 'middle'; out += path(`M${x(pos)} 37V325`, 'grid') + text(x(pos), 30, fmt(pos), '', anchor); } // Pack canonical transcripts in three lanes; labels are shown only when they fit. const lanes = [L-1, L-1, L-1]; const genes = data.genes.filter(g => g.end >= start && g.start <= end); let geneShapes = ''; for (const g of genes) { const gx = Math.max(L, x(g.start)), ge = Math.min(W-R, x(g.end)); const lane = lanes.indexOf(Math.min(...lanes)); const gy = 75 + lane * 26; const isCausal = g.name === 'ABI5'; lanes[lane] = Math.max(ge + 9, gx + g.name.length * 6 + 8); geneShapes += `${esc(g.name)} · ${esc(g.transcript)} · ${g.strand > 0 ? '+' : '−'} strand`; geneShapes += path(`M${gx} ${gy}H${ge}`, 'gene-line'); for (let px = gx+12; px < ge-4; px += 18) { const direction = g.strand > 0 ? 3 : -3; geneShapes += path(`M${px-direction} ${gy-2.5}l${direction} 2.5l${-direction} 2.5`, 'gene-line'); } for (const [a,b] of g.exons) if (b >= start && a <= end) { const ex = Math.max(L,x(a)), ew = Math.max(1,Math.min(W-R,x(b))-ex); geneShapes += ``; } // Reserve the causal gene's label even in the full-locus overview. const labelWidth = g.name.length*6; const next = genes.find(n => n.start > g.start && x(n.start) < gx + labelWidth + 8); if (isCausal || (!next && gx + labelWidth < W-R)) geneShapes += text(gx,gy-9,g.name,'gene-label' + (isCausal ? ' causal-gene' : '')); geneShapes += ''; } out += `${geneShapes}`; for (let score=0; score<=ymax; score+=4) out += path(`M${L} ${y(score)}H${W-R}`,'grid')+text(L-8,y(score)+3,score,'','end'); out += text(W-R,195,'◆ validated causal SNP','','end'); out += ``; out += path(shown.map(({v})=>`M${x(v.pos).toFixed(2)} ${snpY-5}v10`).join(''),'snp-rug'); out += path(shown.map(({v})=>`M${x(v.pos).toFixed(2)} ${scoreBottom}V${y(v.abs_llr).toFixed(2)}`).join(''),'score-stems'); out += path(`M${L} ${y(cutoff)}H${W-R}`,'threshold'); out += text(W-R-4,y(cutoff)-5,'top 1% cutoff','','end'); if (causal.pos >= start && causal.pos <= end) { out += path(`M${x(causal.pos)} ${scoreBottom}V${y(causal.abs_llr)}`,'causal-stem'); out += diamond(x(causal.pos),y(causal.abs_llr),5) + diamond(x(causal.pos),snpY,5); } out += ''; out += ``; out += text(L+19,350,W<500 ? '4,096 bp context' : 'shaded: 4,096 bp scoring window'); svg.innerHTML = out; show(inspected); } function eventPoint(ev) { const point = svg.createSVGPoint(); point.x = ev.clientX; point.y = ev.clientY; return point.matrixTransform(svg.getScreenCTM().inverse()); } function nearest(ev) { const point = eventPoint(ev); if (point.x < L || point.x > W-R || point.y < 147 || point.y > 325) return null; let best = null, distance = Infinity; for (const {v,index} of shown) { const dx = x(v.pos)-point.x; const dy = point.y > 195 ? (y(v.abs_llr)-point.y) * .4 : 0; const d = dx*dx+dy*dy; if (d < distance) { distance = d; best = index; } } return best; } let drag = null; svg.addEventListener('pointerdown', ev => { if (ev.button !== 0) return; const point = eventPoint(ev); if (point.x < L || point.x > W-R) return; drag = {px:ev.clientX,start,end,moved:false}; }); svg.addEventListener('pointermove', ev => { if (drag) { const delta = ev.clientX-drag.px; if (Math.abs(delta)>5) drag.moved = true; if (drag.moved) { svg.setPointerCapture(ev.pointerId); const shift = -delta / svg.getBoundingClientRect().width * W / (W-L-R) * (drag.end-drag.start); setView(drag.start+shift,drag.end+shift); return; } } const index = nearest(ev); if (index !== null && index !== inspected) show(index); }); svg.addEventListener('pointerup', ev => { if (drag && !drag.moved) { const index=nearest(ev); if(index!==null) { selected=index;show(index,true); } } drag=null; if(svg.hasPointerCapture(ev.pointerId)) svg.releasePointerCapture(ev.pointerId); }); svg.addEventListener('pointercancel', () => {drag=null;show(selected);}); svg.addEventListener('pointerleave', () => { if (!drag) show(selected); }); svg.addEventListener('keydown', ev => { if (ev.key === '+' || ev.key === '=') { ev.preventDefault();zoom(.5);return; } if (ev.key === '-') { ev.preventDefault();zoom(2);return; } if (ev.key === 'Home') { ev.preventDefault();selected=causalIndex;inspected=selected;setView(causal.pos-10000,causal.pos+10000);show(selected,true);return; } if (!['ArrowLeft','ArrowRight'].includes(ev.key)) return; ev.preventDefault();selected=Math.max(0,Math.min(variants.length-1,selected+(ev.key==='ArrowLeft'?-1:1))); inspected=selected; const pos=variants[selected].pos, width=end-start; if (pos < start || pos > end) setView(pos-width/2,pos+width/2); show(selected,true); }); buttons[0].addEventListener('click',()=>zoom(.5)); buttons[1].addEventListener('click',()=>zoom(2)); buttons[2].addEventListener('click',()=>setView(data.start,data.end)); buttons[3].addEventListener('click',()=>{selected=causalIndex;inspected=selected;setView(causal.pos-10000,causal.pos+10000);show(selected,true);}); let resize; new ResizeObserver(()=>{cancelAnimationFrame(resize);resize=requestAnimationFrame(render);}).observe(host); render(); } catch (error) { region.textContent = 'ABI5 locus'; readout.textContent = 'The locus could not be loaded. Reload the page to try again.'; host.innerHTML = '

The recall curves below summarise the full benchmark.

'; } })();