/* 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.
';
}
})();