/* The four message sections of the report page, drawn live from data/*.json:
the model factory (agentic orchestration, data pipeline, data ablations),
zero-shot causal variants, scaling, and fine-tuning. Needs d3. */
(async function () {
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/ (v == null || isNaN(v)) ? '' : Number(v).toFixed(d);
const tip = document.getElementById('tip');
let tipHtml = '';
const showTip = (html, ev, hasImage = false) => {
if (html !== tipHtml || tip.classList.contains('screenshot-preview')) { tip.innerHTML = html; tipHtml = html; }
tip.classList.remove('screenshot-preview');
tip.setAttribute('role', 'tooltip');
tip.removeAttribute('aria-label');
tip.classList.toggle('has-screenshot', hasImage);
tip.style.display = 'block';
const pad = 14, r = tip.getBoundingClientRect();
let x = ev.clientX + pad, y = ev.clientY + pad;
if (x + r.width > innerWidth - 8) x = ev.clientX - r.width - pad;
if (y + r.height > innerHeight - 8) y = ev.clientY - r.height - pad;
tip.style.left = Math.max(8, Math.min(x, innerWidth - r.width - 8)) + 'px';
tip.style.top = Math.max(8, Math.min(y, innerHeight - r.height - 8)) + 'px';
};
const hideTip = () => { tip.style.display = 'none'; };
const onVisible = (el, fn) => { if (reduced || !('IntersectionObserver' in window)) { fn(); return; } const io = new IntersectionObserver((es) => { if (es.some(e => e.isIntersecting)) { io.disconnect(); fn(); } }, { threshold: 0.2 }); io.observe(el); };
const load = (f) => fetch('data/' + f).then(r => r.json());
// ============================================================ authored figures with a hotspot layer
// The figure is the report's own drawing (an
); a transparent SVG in the same
// coordinate system sits on top and carries the hover regions, each of which speaks
// one sentence of the figure's caption.
const stack = (id, W, H, regions, opts = {}) => {
const box = document.getElementById(id); if (!box) return null;
const svg = d3.create('svg').attr('class', 'hotspots').attr('viewBox', `0 0 ${W} ${H}`).attr('preserveAspectRatio', 'none');
const dims = opts.dim ? svg.append('g').attr('class', 'dims') : null;
const hs = svg.append('g');
const rects = hs.selectAll('rect.hot').data(regions).join('rect').attr('class', 'hot').attr('x', d => d.x).attr('y', d => d.y).attr('width', d => d.w).attr('height', d => d.h).attr('rx', 6);
const showRegion = (ev, d) => {
showTip(`${esc(d.title)}
${esc(d.say)}`, ev);
if (dims) { dims.selectAll('rect').data(regions.filter(r => r !== d && !r.inner)).join('rect').attr('class', 'dim').attr('x', r => r.x).attr('y', r => r.y).attr('width', r => r.w).attr('height', r => r.h); }
};
rects.on('mousemove', showRegion).on('mouseleave', () => { hideTip(); if (dims) dims.selectAll('rect').remove(); });
rects.filter(d => d.image || d.images?.length).classed('has-screenshot', true)
.attr('role', 'button').attr('tabindex', '0').attr('aria-label', d => `${d.title}. Open image.`)
.attr('aria-haspopup', 'dialog').attr('aria-controls', 'factory-screenshot-dialog')
.on('focus', (ev, d) => {
const r = ev.currentTarget.getBoundingClientRect();
showRegion({ clientX: r.right, clientY: r.top }, d);
}).on('blur', hideTip);
box.appendChild(svg.node());
return svg;
};
// Verbatim sentences from the Figure S1 caption in sections/supplementary.tex
// (fig:model_factory_orchestration). Keep whole sentences, including when one
// describes two regions. Coordinates use the PDF's 532 x 576 pt space.
const s1 = stack('s1-fig', 532, 576, [
{ x: 10, y: 18, w: 124, h: 32, title: 'Research Team', say: 'The researchers intervene at each stage as needed and receive warnings from both orchestrators.' },
{ x: 6, y: 40, w: 519, h: 51, title: 'Plan Agent', image: { src: 'assets/screenshots/plan-agent-whiteboard.png', alt: 'The research whiteboard supplied as input to the plan agent.', width: 1794, height: 1610, title: 'Plan agent input' }, say: 'A plan agent turns a research plan proposed by researchers into training configurations and potentially code changes and estimates their feasibility in a given timeframe, then hands them to an orchestrator agent on a gateway node, which dispatches jobs to the training-cluster queue, monitors the runs, and edits code when errors occur.' },
{ x: 336, y: 123, w: 192, h: 216, title: 'Gateway · Orchestrator Agent', say: 'A plan agent turns a research plan proposed by researchers into training configurations and potentially code changes and estimates their feasibility in a given timeframe, then hands them to an orchestrator agent on a gateway node, which dispatches jobs to the training-cluster queue, monitors the runs, and edits code when errors occur.' },
{ x: 38, y: 130, w: 166, h: 188, title: 'Training Cluster', image: { src: 'assets/screenshots/training-cluster-timeline.png', alt: 'The training cluster timeline showing jobs distributed across nodes over time, with the job list below.', width: 2918, height: 1572, title: 'Training cluster timeline' }, say: 'Training runs produce checkpoints on disks, which in turn trigger evaluation of those checkpoints on a separate cluster, where a second more lightweight orchestrator agent potentially fixes and relaunches failing evaluations.' },
{ x: 6, y: 379, w: 231, h: 156, title: 'Evaluation Cluster', images: [
{ src: 'assets/screenshots/evaluation-cluster-history.png', alt: 'Recorded Seoul evaluation cluster utilization on 17 August 2026 at 20:37 UTC, with GPU activity over the preceding six hours.', width: 2880, height: 2200, title: 'Historical cluster activity' },
{ src: 'assets/screenshots/evaluation-cluster-checkpoints.png', alt: 'Running and pending checkpoint evaluations on 17 August 2026 at 20:37 UTC, reconstructed from recorded Slurm state changes.', width: 2880, height: 2200, title: 'Checkpoint evaluations' }
], say: 'Training runs produce checkpoints on disks, which in turn trigger evaluation of those checkpoints on a separate cluster, where a second more lightweight orchestrator agent potentially fixes and relaunches failing evaluations.' },
{ x: 379, y: 410, w: 146, h: 96, title: 'Results Server', images: [
{ src: 'assets/screenshots/results-server.png', alt: 'The results server dashboard showing evaluation metrics across training checkpoints.', width: 2048, height: 899, title: 'Training metrics' },
{ src: 'assets/screenshots/results-server-queue.png', alt: 'The evaluation queue showing submitted, queued and running evaluations alongside completed model scores.', width: 3008, height: 1638, title: 'Evaluation queue' }
], say: 'Training and evaluation metrics are written to a results server that the agents query and that researchers analyse in parallel.' },
]);
if (s1 && !reduced) {
// the pulse follows configurations, checkpoints and metrics: plan -> gateway -> training -> evaluation -> results
const segs = ['M 495 91 L 495 166', 'M 352 226 L 206 226', 'M 121 318 L 121 379', 'M 237 458 L 379 458'].map(d => { const e = document.createElementNS('http://www.w3.org/2000/svg', 'path'); e.setAttribute('d', d); return e; });
const dot = s1.append('circle').attr('class', 'pulse').attr('r', 4.5);
let t0 = null; const per = 1700;
const tick = (ts) => { if (!t0) t0 = ts; const t = ((ts - t0) % (per * segs.length)) / per; const i = Math.floor(t); const pth = segs[i]; const q = pth.getPointAtLength((t - i) * pth.getTotalLength()); dot.attr('cx', q.x).attr('cy', q.y); requestAnimationFrame(tick); };
onVisible(document.getElementById('s1-fig'), () => requestAnimationFrame(tick));
}
// Verbatim excerpts from the Figure 2a caption in sections/results.tex
// (fig:data_ablations), with LaTeX typography rendered as plain text.
// Coordinates use the schematic's 648 x 432 pt space.
stack('pipeline-fig', 648, 432, [
{ x: 2, y: 4, w: 196, h: 424, title: 'a · Genome selection · inter-species', say: 'Inter-species: input species catalogues such as Ensembl or NCBI are merged, filtered on assembly quality, and sourmash distances (d = 1 − ANI) clustered by complete linkage, keeping one representative genome per cluster.' },
{ x: 206, y: 4, w: 228, h: 424, title: 'b · Window construction · intra-species', say: 'Intra-species: within a species, windows are sampled according to precise sets of rules relying on genomic region annotations or repeats.' },
{ x: 442, y: 4, w: 204, h: 424, title: 'c · Window filtering · inter-window', say: 'Inter-window: after sampling, some windows are filtered out due to redundancy or quality criteria (presence of N), or downsampled for large species in order to equalise the sampling across species irrespective of their sizes.' },
{ x: 448, y: 254, w: 194, h: 70, inner: true, title: 'Reweight by genome size', say: 'The fourth cell visualises how far that equalisation goes: one point per genome (n = 306), green where the genome-length weight exceeds its floor, dashed line an equal split, solid line the binned median.' },
], { dim: true });
// ============================================================ data pipeline: genome-length weighting + ablations
const pipe = document.getElementById('pipeline-weighting');
if (pipe) {
const corpus = await load('corpus.json');
const sp = corpus.species.filter(s => s.genome_bp);
const total = corpus.totals.windows;
const Dmin = d3.min(sp, s => s.genome_bp);
const weight = (D) => Math.max(0.5, Math.sqrt(Dmin / D));
const W = 560, H = 300, L = 54, R = 16, T = 18, B = 44;
const x = d3.scaleLog().domain([0.1e9, 13e9]).range([L, W - R]);
const y = d3.scaleLog().domain([0.02, 3]).range([H - B, T]);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', 'Share of corpus windows against genome size');
const ax = svg.append('g').attr('class', 'grid axis');
[0.05, 0.1, 0.2, 0.5, 1, 2].forEach(v => { ax.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(v)).attr('y2', y(v)); ax.append('text').attr('x', L - 8).attr('y', y(v) + 4).attr('text-anchor', 'end').text(v + '%'); });
[0.1e9, 0.5e9, 1e9, 2e9, 5e9, 10e9].forEach(v => { ax.append('line').attr('x1', x(v)).attr('x2', x(v)).attr('y1', T).attr('y2', H - B); ax.append('text').attr('x', x(v)).attr('y', H - B + 18).attr('text-anchor', 'middle').text((v / 1e9) + ' Gb'); });
ax.append('text').attr('x', (L + W - R) / 2).attr('y', H - 6).attr('text-anchor', 'middle').text('genome size (log scale)');
svg.append('text').attr('class', 'ann').attr('transform', `translate(14 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text('% of corpus windows');
const eq = 100 / sp.length;
svg.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(eq)).attr('y2', y(eq)).attr('stroke', css('--ink-3')).attr('stroke-dasharray', '3 4');
svg.append('text').attr('class', 'ann').attr('x', W - R).attr('y', y(eq) - 5).attr('text-anchor', 'end').text(`equal split, ${eq.toFixed(2)}%`);
// binned median
const edges = [0.1e9, 0.2e9, 0.4e9, 0.8e9, 1.6e9, 3.2e9, 6.4e9, 13e9];
const med = [];
for (let i = 0; i < edges.length - 1; i++) { const b = sp.filter(s => s.genome_bp >= edges[i] && s.genome_bp < edges[i + 1]); if (b.length >= 3) med.push([Math.sqrt(edges[i] * edges[i + 1]), d3.median(b, s => 100 * s.windows / total)]); }
svg.append('path').attr('d', d3.line().x(d => x(d[0])).y(d => y(d[1]))(med)).attr('fill', 'none').attr('stroke', css('--ink')).attr('stroke-width', 2);
svg.append('text').attr('class', 'ann').attr('x', x(med[med.length - 1][0]) - 8).attr('y', y(med[med.length - 1][1]) - 8).attr('text-anchor', 'end').text('binned median');
const pts = svg.selectAll('circle.sp').data(sp).join('circle').attr('class', 'sp').attr('cx', d => x(d.genome_bp)).attr('cy', d => y(100 * d.windows / total)).attr('r', 3.6)
.attr('fill', d => weight(d.genome_bp) > 0.5 ? css('--ours') : css('--other')).attr('stroke', css('--card')).attr('stroke-width', 1);
pts.on('mousemove', (ev, d) => showTip(`${esc(d.species)} · ${esc(d.order)}
genome ${(d.genome_bp / 1e9).toFixed(2)} Gb · weight ${weight(d.genome_bp).toFixed(2)}
${d.windows.toLocaleString()} windows · ${(100 * d.windows / total).toFixed(2)}% of the corpus`, ev)).on('mouseleave', hideTip);
pipe.appendChild(svg.node());
// the weight
const w2 = document.getElementById('pipeline-weight');
if (w2) {
const W2 = 300, H2 = 300, L2 = 44, R2 = 14;
const x2 = d3.scaleLog().domain([0.1e9, 13e9]).range([L2, W2 - R2]);
const y2 = d3.scaleLinear().domain([0, 1.05]).range([H - B, T]);
const s2 = d3.create('svg').attr('viewBox', `0 0 ${W2} ${H2}`).attr('role', 'img').attr('aria-label', 'The genome-length weight');
const a2 = s2.append('g').attr('class', 'grid axis');
[0.25, 0.5, 0.75, 1].forEach(v => { a2.append('line').attr('x1', L2).attr('x2', W2 - R2).attr('y1', y2(v)).attr('y2', y2(v)); a2.append('text').attr('x', L2 - 8).attr('y', y2(v) + 4).attr('text-anchor', 'end').text(v); });
[0.1e9, 0.5e9, 2e9, 10e9].forEach(v => a2.append('text').attr('x', x2(v)).attr('y', H - B + 18).attr('text-anchor', 'middle').text((v / 1e9) + ' Gb'));
a2.append('text').attr('x', (L2 + W2 - R2) / 2).attr('y', H - 6).attr('text-anchor', 'middle').text('genome size (log scale)');
s2.append('text').attr('class', 'ann').attr('transform', `translate(12 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text('the weight');
const xs = d3.range(0, 1.0001, 0.01).map(t => 0.1e9 * Math.pow(130, t));
s2.append('path').attr('d', d3.line().x(D => x2(D)).y(D => y2(weight(D)))(xs)).attr('fill', 'none').attr('stroke', css('--ours-deep')).attr('stroke-width', 2.2);
const Dfloor = Dmin / 0.25;
s2.append('line').attr('x1', x2(Dfloor)).attr('x2', x2(Dfloor)).attr('y1', y2(0.5)).attr('y2', H - B).attr('stroke', css('--rule-2')).attr('stroke-dasharray', '2 3');
s2.append('text').attr('class', 'ann').attr('x', x2(Dfloor) + 5).attr('y', y2(0.5) + 16).text(`floor 0.5 from ${(Dfloor / 1e9).toFixed(2)} Gb`);
w2.appendChild(s2.node());
}
}
const abl = document.getElementById('ablation-effects');
if (abl) {
const data = await load('ablation.json');
const rows = data.effects;
const W = 900, rowH = 40, L = 270, R = 90, T = 30, H = T + rows.length * rowH + 40;
const all = rows.flatMap(r => r.contexts.flatMap(c => c.delta));
const x = d3.scaleLinear().domain([Math.min(-1, d3.min(all) - 0.1), Math.max(2, d3.max(all) + 0.1)]).range([L, W - R]);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', 'Effect of each data setting on S_bal');
svg.append('rect').attr('x', x(0)).attr('y', T - 10).attr('width', W - R - x(0)).attr('height', rows.length * rowH + 10).attr('fill', css('--lime')).attr('opacity', 0.18);
svg.append('line').attr('x1', x(0)).attr('x2', x(0)).attr('y1', T - 10).attr('y2', T + rows.length * rowH).attr('stroke', css('--ink-3'));
const ax = svg.append('g').attr('class', 'axis');
x.ticks(7).forEach(v => { ax.append('text').attr('x', x(v)).attr('y', T + rows.length * rowH + 18).attr('text-anchor', 'middle').text((v > 0 ? '+' : '') + v.toFixed(1)); });
ax.append('text').attr('x', (x.range()[0] + x.range()[1]) / 2).attr('y', H - 4).attr('text-anchor', 'middle').text('downstream task performance difference, percentage points (first-named setting minus second)');
const rg = svg.selectAll('g.row').data(rows).join('g').attr('class', 'row').attr('transform', (d, i) => `translate(0,${T + i * rowH + rowH / 2})`);
rg.append('line').attr('x1', 16).attr('x2', W - 16).attr('y1', rowH / 2).attr('y2', rowH / 2).attr('stroke', css('--rule'));
rg.append('text').attr('class', 'lbl').attr('x', L - 14).attr('y', 4).attr('text-anchor', 'end').text(d => d.label);
rg.append('text').attr('class', 'ann').attr('x', W - R + 12).attr('y', 4).text(d => `${d.contexts.length} context${d.contexts.length > 1 ? 's' : ''}`);
rg.each(function (d) {
const gr = d3.select(this); const n = d.contexts.length;
const offs = n === 1 ? [0] : d3.range(n).map(k => -12 + 24 * k / (n - 1));
const order = d3.range(n).sort((a, b) => d3.mean(d.contexts[a].delta) - d3.mean(d.contexts[b].delta));
order.forEach((k, slot) => {
const c = d.contexts[k]; const yy = offs[slot]; const m = d3.mean(c.delta);
gr.append('line').attr('x1', x(c.delta[0])).attr('x2', x(c.delta[1])).attr('y1', yy).attr('y2', yy).attr('stroke', css('--ink-3'));
c.delta.forEach(v => gr.append('circle').attr('cx', x(v)).attr('cy', yy).attr('r', 2.6).attr('fill', css('--card')).attr('stroke', css('--ink-3')));
gr.append('circle').attr('cx', x(m)).attr('cy', yy).attr('r', 4.2).attr('fill', css('--ink')).attr('stroke', css('--card')).attr('class', 'ctx')
.on('mousemove', (ev) => showTip(`${esc(d.label)}
context: ${esc(c.context)}
seed 42: ${c.delta[0] > 0 ? '+' : ''}${c.delta[0].toFixed(2)} pp · seed 43: ${c.delta[1] > 0 ? '+' : ''}${c.delta[1].toFixed(2)} pp
mean ${m > 0 ? '+' : ''}${m.toFixed(2)} pp`, ev)).on('mouseleave', hideTip);
});
gr.append('path').attr('d', d3.symbol(d3.symbolDiamond, 90)()).attr('transform', `translate(${x(d.mean)},0)`).attr('fill', css('--ours-deep')).attr('stroke', css('--card')).attr('stroke-width', 1.5);
});
abl.appendChild(svg.node());
}
// ============================================================ scaling: the report's two panels
const sc = document.getElementById('scaling-traces');
if (sc) {
const data = await load('scaling.json');
const order = ['botanic1-S', 'botanic1-M', 'botanic1-L', 'botanic1-XL'];
const names = { 'botanic1-S': 'Botanic1-S (318M)', 'botanic1-M': 'Botanic1-M (688M)', 'botanic1-L': 'Botanic1-L (2.11B)', 'botanic1-XL': 'Botanic1-XL (3.18B)' };
const col = { 'botanic1-S': '--ours-pale', 'botanic1-M': '--ours-light', 'botanic1-L': '--ours', 'botanic1-XL': '--ours-deep' };
const two = document.createElement('div'); two.className = 'two'; sc.appendChild(two);
const panel = (title) => { const d = document.createElement('div'); d.className = 'chart'; two.appendChild(d); return d; };
const bis = d3.bisector(p => p[0]).left;
const crosshair = (svg, series, x, T, Hb, L, Wr, fmt, unit) => {
const xh = svg.append('line').attr('y1', T).attr('y2', Hb).attr('stroke', css('--ink-2')).style('display', 'none');
svg.append('rect').attr('class', 'hit').attr('x', L).attr('y', T).attr('width', Wr - L).attr('height', Hb - T)
.on('mousemove', (ev) => { const p = d3.pointer(ev, svg.node()); const tok = x.invert(p[0]); xh.attr('x1', p[0]).attr('x2', p[0]).style('display', ''); const rows = series.map(s => { const i = Math.min(s.pts.length - 1, Math.max(0, bis(s.pts, tok))); return { s, v: s.pts[i][1] }; }); showTip(`${tok.toFixed(1)}B tokens · ${unit}
` + rows.map(r => `| ${names[r.s.k]} | ${fmt(r.v)} |
`).join('') + '
', ev); })
.on('mouseleave', () => { xh.style('display', 'none'); hideTip(); });
};
// (a) training loss (EMA) against tokens seen, learning-rate schedule on the right axis
{
const W = 560, H = 420, L = 58, R = 64, T = 18, B = 48;
const series = order.map(k => ({ k, pts: data.models[k].loss.filter(p => p[0] >= 0.7) }));
const lr = data.models['botanic1-S'].lr.filter(p => p[0] >= 0.7);
const x = d3.scaleLog().domain([0.7, 400]).range([L, W - R]);
const y = d3.scaleLog().domain([0.6, 1.3]).range([H - B, T]);
const y2 = d3.scaleLinear().domain([0, 4.2e-4]).range([H - B, T]);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', 'Training loss against tokens seen');
const ax = svg.append('g').attr('class', 'grid axis');
[0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3].forEach(v => { ax.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(v)).attr('y2', y(v)); ax.append('text').attr('x', L - 8).attr('y', y(v) + 4).attr('text-anchor', 'end').text(v.toFixed(2)); });
[1, 3, 10, 30, 100, 300].forEach(v => { ax.append('line').attr('x1', x(v)).attr('x2', x(v)).attr('y1', T).attr('y2', H - B); ax.append('text').attr('x', x(v)).attr('y', H - B + 18).attr('text-anchor', 'middle').text(v + 'B'); });
[0, 1e-4, 2e-4, 3e-4, 4e-4].forEach(v => ax.append('text').attr('x', W - R + 8).attr('y', y2(v) + 4).text((v * 1e4).toFixed(1)));
ax.append('text').attr('x', (L + W - R) / 2).attr('y', H - 8).attr('text-anchor', 'middle').text('tokens seen');
svg.append('text').attr('class', 'ann').attr('transform', `translate(14 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text('training loss (EMA)');
svg.append('text').attr('class', 'ann').attr('transform', `translate(${W - 10} ${(T + H - B) / 2}) rotate(90)`).attr('text-anchor', 'middle').text('learning rate (×10⁻⁴)');
svg.append('path').attr('d', d3.line().x(p => x(p[0])).y(p => y2(p[1]))(lr)).attr('fill', 'none').attr('stroke', css('--ours-deep')).attr('stroke-dasharray', '5 4').attr('stroke-width', 1.3);
series.forEach(s => svg.append('path').attr('d', d3.line().x(p => x(p[0])).y(p => y(p[1])).curve(d3.curveMonotoneX)(s.pts)).attr('fill', 'none').attr('stroke', css(col[s.k])).attr('stroke-width', 2.2));
series.forEach(s => { const iso = data.models[s.k].iso; if (iso) svg.append('path').attr('d', d3.symbol(d3.symbolStar, 130)()).attr('transform', `translate(${x(iso.tokens_b)},${y(iso.loss)})`).attr('fill', css(col[s.k])).attr('stroke', css('--ink')).attr('stroke-width', 0.8); });
const leg = svg.append('g').attr('transform', `translate(${W - R - 150},${T + 14})`);
leg.append('line').attr('x1', 0).attr('x2', 22).attr('y1', 0).attr('y2', 0).attr('stroke', css('--ours')).attr('stroke-width', 3); leg.append('text').attr('class', 'lbl-2').attr('x', 28).attr('y', 4).text('training loss');
leg.append('line').attr('x1', 0).attr('x2', 22).attr('y1', 18).attr('y2', 18).attr('stroke', css('--ours-deep')).attr('stroke-dasharray', '5 4'); leg.append('text').attr('class', 'lbl-2').attr('x', 28).attr('y', 22).text('LR schedule');
crosshair(svg, series, x, T, H - B, L, W - R, v => v.toFixed(3), 'training loss');
panel().appendChild(svg.node());
}
// (b) downstream task performance against tokens seen, from 30B
{
const W = 560, H = 420, L = 58, R = 20, T = 18, B = 48;
const series = order.map(k => ({ k, pts: data.models[k].sbal.filter(p => p[0] >= 28) }));
const x = d3.scaleLog().domain([28, 400]).range([L, W - R]);
const y = d3.scaleLinear().domain([0.70, 0.755]).range([H - B, T]);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', 'Downstream task performance against tokens seen');
const ax = svg.append('g').attr('class', 'grid axis');
y.ticks(6).forEach(v => { ax.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(v)).attr('y2', y(v)); ax.append('text').attr('x', L - 8).attr('y', y(v) + 4).attr('text-anchor', 'end').text(v.toFixed(2)); });
[30, 100, 300].forEach(v => { ax.append('line').attr('x1', x(v)).attr('x2', x(v)).attr('y1', T).attr('y2', H - B); ax.append('text').attr('x', x(v)).attr('y', H - B + 18).attr('text-anchor', 'middle').text(v + 'B'); });
ax.append('text').attr('x', (L + W - R) / 2).attr('y', H - 8).attr('text-anchor', 'middle').text('tokens seen');
svg.append('text').attr('class', 'ann').attr('transform', `translate(14 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text('downstream task performance');
series.forEach(s => { svg.append('path').attr('d', d3.line().x(p => x(p[0])).y(p => y(p[1]))(s.pts)).attr('fill', 'none').attr('stroke', css(col[s.k])).attr('stroke-width', 2.2); s.pts.forEach(p => svg.append('circle').attr('cx', x(p[0])).attr('cy', y(p[1])).attr('r', 2.2).attr('fill', css(col[s.k]))); });
series.forEach(s => { const iso = data.models[s.k].iso; if (iso) svg.append('path').attr('d', d3.symbol(d3.symbolStar, 150)()).attr('transform', `translate(${x(iso.tokens_b)},${y(iso.sbal)})`).attr('fill', css(col[s.k])).attr('stroke', css('--ink')).attr('stroke-width', 0.8); });
const leg = svg.append('g').attr('transform', `translate(${W - R - 205},${H - B - 96})`);
leg.append('text').attr('class', 'lbl').attr('x', 0).attr('y', 0).text('model size');
order.forEach((k, i) => { const g = leg.append('g').attr('transform', `translate(0,${16 + i * 17})`); g.append('line').attr('x1', 0).attr('x2', 22).attr('y1', 0).attr('y2', 0).attr('stroke', css(col[k])).attr('stroke-width', 3); g.append('text').attr('class', 'lbl-2').attr('x', 28).attr('y', 4).text(`${names[k]} ${data.models[k].iso ? data.models[k].iso.sbal.toFixed(3) : ''}`); });
const gs = leg.append('g').attr('transform', `translate(0,${16 + 4 * 17})`); gs.append('path').attr('d', d3.symbol(d3.symbolStar, 110)()).attr('transform', 'translate(11,0)').attr('fill', css('--card')).attr('stroke', css('--ink')); gs.append('text').attr('class', 'lbl-2').attr('x', 28).attr('y', 4).text('chosen checkpoint');
crosshair(svg, series, x, T, H - B, L, W - R, v => v.toFixed(3), 'downstream task performance');
panel().appendChild(svg.node());
}
}
// ============================================================ zero-shot: causal variants, compact
const zs = document.getElementById('zeroshot-curve');
if (zs) {
const data = await load('causal.json');
const S = data.studies, MODELS = data.models;
const COLOUR = { XL315: '--ours-deeper', L: '--ours-deep', M315: '--ours', S315: '--ours-light', PlantCAD2: '--plantcad', 'Evo2-1b': '--evo2', PhyloP: '--baseline', PhastCons: '--baseline-2', VEP_consequence: '--baseline' };
const DASH = { PhyloP: '6 4', PhastCons: '2 4', VEP_consequence: '10 4 2 4' };
const sel = ['XL315', 'L', 'M315', 'S315', 'PlantCAD2', 'Evo2-1b', 'PhyloP', 'PhastCons', 'VEP_consequence'].map(k => MODELS.find(m => m.key === k));
const hit = (k, nb, nt) => Math.min(Math.max(k - nb, 0), nt) / nt;
const recall = (m, alpha) => { let s = 0, n = 0; for (let i = 0; i < S.length; i++) { const N = m.n_variants[i]; if (N < 0) continue; s += hit(Math.floor(alpha * N), m.n_better[i], m.n_tie[i]); n++; } return s / n; };
const AL = d3.range(0, 81).map(i => Math.pow(10, -4 + 4 * i / 80));
const W = 960, H = 420, L = 56, R = 240, T = 20, B = 52;
const lx = (a) => L + (Math.log10(a) + 4) / 4 * (W - L - R);
const y = (v) => T + (1 - v) * (H - T - B);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', 'Recall against shortlist fraction');
const ax = svg.append('g').attr('class', 'grid axis');
d3.range(0, 1.01, 0.2).forEach(v => { ax.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(v)).attr('y2', y(v)); ax.append('text').attr('x', L - 8).attr('y', y(v) + 4).attr('text-anchor', 'end').text(v.toFixed(1)); });
[1e-4, 1e-3, 1e-2, 1e-1, 1].forEach(a => { ax.append('line').attr('x1', lx(a)).attr('x2', lx(a)).attr('y1', T).attr('y2', H - B); ax.append('text').attr('x', lx(a)).attr('y', H - B + 18).attr('text-anchor', 'middle').text((a * 100) + '%'); });
ax.append('text').attr('x', (L + W - R) / 2).attr('y', H - 10).attr('text-anchor', 'middle').text("fraction of the locus' SNVs shortlisted");
svg.append('text').attr('class', 'ann').attr('transform', `translate(14 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text('fraction of studies recovering the causal variant');
[[0.001, 'top 0.1%'], [0.01, 'top 1%']].forEach(([a, l]) => { svg.append('line').attr('x1', lx(a)).attr('x2', lx(a)).attr('y1', T).attr('y2', H - B).attr('stroke', css('--rule-2')).attr('stroke-dasharray', '2 3'); svg.append('text').attr('class', 'ann').attr('x', lx(a) + 5).attr('y', T + 12).text(l); });
svg.append('path').attr('d', AL.map((a, i) => (i ? 'L' : 'M') + lx(a) + ' ' + y(a)).join(' ')).attr('fill', 'none').attr('stroke', css('--ink-3')).attr('stroke-dasharray', '2 4');
const series = sel.map(m => ({ m, pts: AL.map(a => recall(m, a)) }));
series.forEach(({ m, pts }) => svg.append('path').attr('d', AL.map((a, i) => (i ? 'L' : 'M') + lx(a).toFixed(1) + ' ' + y(pts[i]).toFixed(1)).join(' ')).attr('fill', 'none').attr('stroke', css(COLOUR[m.key])).attr('stroke-width', 2).attr('stroke-dasharray', DASH[m.key] || null));
// legend at right, ordered by R(0.01)
const leg = svg.append('g').attr('transform', `translate(${W - R + 14},${T + 6})`);
series.slice().sort((a, b) => recall(b.m, 0.01) - recall(a.m, 0.01)).forEach(({ m }, i) => { const g = leg.append('g').attr('transform', `translate(0,${i * 20})`); g.append('line').attr('x1', 0).attr('x2', 22).attr('y1', 0).attr('y2', 0).attr('stroke', css(COLOUR[m.key])).attr('stroke-width', 2).attr('stroke-dasharray', DASH[m.key] || null); g.append('text').attr('class', 'lbl-2').attr('x', 28).attr('y', 4).text(`${m.name} ${recall(m, 0.01).toFixed(3)}`); });
leg.append('text').attr('class', 'ann').attr('x', 0).attr('y', -10).text('R(0.01)');
const xh = svg.append('line').attr('y1', T).attr('y2', H - B).attr('stroke', css('--ink-2')).style('display', 'none');
svg.append('rect').attr('class', 'hit').attr('x', L).attr('y', T).attr('width', W - L - R).attr('height', H - T - B)
.on('mousemove', (ev) => { const p = d3.pointer(ev, svg.node()); const a = Math.pow(10, -4 + (p[0] - L) / (W - L - R) * 4); xh.attr('x1', p[0]).attr('x2', p[0]).style('display', ''); const rows = series.map(({ m }) => ({ m, r: recall(m, a) })).sort((u, v) => v.r - u.r); showTip(`top ${(a * 100).toPrecision(2)}% of the locus` + rows.map(({ m, r }) => `| ${esc(m.name)} | ${r.toFixed(3)} |
`).join('') + '
', ev); })
.on('mouseleave', () => { xh.style('display', 'none'); hideTip(); });
const host = document.createElement('div'); host.className = 'chart'; host.appendChild(svg.node()); zs.appendChild(host);
}
// ============================================================ fine-tuning
const ft = document.getElementById('finetune-chart');
if (ft) {
const data = await load('finetune.json');
const MODELS = ['Botanic1-S', 'AgroNT', 'PlantCAD2-L'];
const MC = { 'Botanic1-S': '--ours', 'AgroNT': '--agront', 'PlantCAD2-L': '--plantcad' };
const METHODS = ['Full fine-tuning', 'LoRA', 'IA3'];
const TASKS = [
{ key: 'terminator', label: 'Terminator strength', metrics: [['pearson_merged', 'Pearson r, merged (tobacco / maize)'], ['r2_merged', 'R², merged']], base: { model: 'gorjifard_densenet', label: 'DenseNet from scratch', pearson_merged: 'pearson', r2_merged: 'r2' } },
{ key: 'promoter', label: 'Promoter strength', metrics: [['pearson_tobacco', 'Pearson r, tobacco'], ['pearson_maize', 'Pearson r, maize'], ['r2_tobacco', 'R², tobacco'], ['r2_maize', 'R², maize']], base: { model: 'jores_cnn', label: 'CNN from scratch' } },
{ key: 'poly_a', label: 'poly(A) site', metrics: [['auroc_mean', 'mean AUROC over six species, global tuning'], ['auroc_mean_per_species', 'mean AUROC, per-species tuning']] },
{ key: 'lncrna', label: 'lncRNA', metrics: [['auroc_mean', 'mean AUROC over six species, global tuning'], ['auroc_mean_per_species', 'mean AUROC, per-species tuning']] },
];
let task = TASKS[0], metric = task.metrics[0][0];
const wrapEl = document.createElement('div');
wrapEl.innerHTML = `Task${TASKS.map((t, i) => ``).join('')}
Metric
`;
ft.appendChild(wrapEl);
const msel = wrapEl.querySelector('#ft-metric');
const cell = (model, method) => data.cells.find(c => c.model === model && c.task === task.key && c.method === method) || {};
const baseline = () => {
if (!task.base) return null;
const sub = metric.includes('tobacco') ? 'Tobacco' : metric.includes('maize') ? 'Maize' : 'merged';
const met = metric.startsWith('pearson') ? 'pearson' : 'r2';
const b = data.baselines.find(b => b.model === task.base.model && b.task === task.key && b.metric === met && b.subtask === sub);
return b ? { v: b.value, label: task.base.label } : null;
};
const draw = () => {
msel.innerHTML = task.metrics.map(([k, l]) => ``).join('');
const W = 960, H = 380, L = 56, R = 24, T = 26, B = 46;
const vals = METHODS.flatMap(me => MODELS.map(mo => cell(mo, me)[metric])).filter(v => v != null);
const b = baseline();
const lo = Math.max(0, Math.floor((d3.min(vals.concat(b ? [b.v] : [])) - 0.08) * 10) / 10);
const x0 = d3.scaleBand().domain(METHODS).range([L, W - R]).paddingInner(0.3).paddingOuter(0.15);
const x1 = d3.scaleBand().domain(MODELS).range([0, x0.bandwidth()]).padding(0.12);
const y = d3.scaleLinear().domain([lo, 1]).range([H - B, T]);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', `${task.label}: ${metric}`);
const ax = svg.append('g').attr('class', 'grid axis');
y.ticks(6).forEach(v => { ax.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(v)).attr('y2', y(v)); ax.append('text').attr('x', L - 8).attr('y', y(v) + 4).attr('text-anchor', 'end').text(v.toFixed(2)); });
METHODS.forEach(me => ax.append('text').attr('x', x0(me) + x0.bandwidth() / 2).attr('y', H - B + 20).attr('text-anchor', 'middle').text(me));
svg.append('text').attr('class', 'ann').attr('transform', `translate(16 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text(task.metrics.find(m => m[0] === metric)[1]);
METHODS.forEach(me => MODELS.forEach(mo => {
const c = cell(mo, me); const v = c[metric]; if (v == null) return;
const g = svg.append('g');
g.append('rect').attr('x', x0(me) + x1(mo)).attr('y', y(v)).attr('width', x1.bandwidth()).attr('height', y(lo) - y(v)).attr('rx', 3).attr('fill', css(MC[mo]));
g.append('text').attr('class', 'value').attr('x', x0(me) + x1(mo) + x1.bandwidth() / 2).attr('y', y(v) - 6).attr('text-anchor', 'middle').text(v.toFixed(3));
const extra = Object.entries(c).filter(([k]) => !['model', 'task', 'method', 'auroc_species'].includes(k)).map(([k, val]) => `| ${k.replace(/_/g, ' ')} | ${typeof val === 'number' ? val.toFixed(3) : val} |
`).join('');
g.on('mousemove', (ev) => showTip(`${mo} · ${me} · ${task.label}`, ev)).on('mouseleave', hideTip);
}));
if (b) { svg.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(b.v)).attr('y2', y(b.v)).attr('stroke', css('--ink')).attr('stroke-dasharray', '5 4'); svg.append('text').attr('class', 'ann').attr('x', W - R).attr('y', y(b.v) - 6).attr('text-anchor', 'end').text(`${b.label} ${b.v.toFixed(2)}`); }
const leg = svg.append('g').attr('transform', `translate(${L},${T - 12})`);
MODELS.forEach((mo, i) => { const g = leg.append('g').attr('transform', `translate(${i * 150},0)`); g.append('rect').attr('width', 12).attr('height', 12).attr('rx', 2).attr('fill', css(MC[mo])).attr('y', -10); g.append('text').attr('class', 'lbl-2').attr('x', 18).attr('y', 0).text(mo); });
const host = wrapEl.querySelector('#ft-svg'); host.innerHTML = ''; host.appendChild(svg.node());
};
wrapEl.querySelector('#ft-task').addEventListener('click', (e) => { const bt = e.target.closest('button'); if (!bt) return; task = TASKS.find(t => t.key === bt.dataset.v); metric = task.metrics[0][0]; [...bt.parentNode.children].forEach(x => x.setAttribute('aria-pressed', x === bt)); draw(); });
msel.addEventListener('change', () => { metric = msel.value; draw(); });
draw();
}
// ============================================================ TF-family binding (bars, as the fine-tuning chart)
const tf = document.getElementById('tf-chart');
if (tf) {
const data = await load('tf.json');
const glms = data.test.filter(d => d.initialization !== 'random');
const scratch = data.test.find(d => d.initialization === 'random');
const MC = (m) => m.startsWith('Botanic1') ? '--ours' : m.startsWith('NTv3') ? '--ntv3' : m.startsWith('AgroNT') ? '--agront' : '--other';
const W = 960, H = 380, L = 56, R = 24, T = 26, B = 46;
const lo = 0.55;
const x = d3.scaleBand().domain(glms.map(d => d.model)).range([L, W - R]).paddingInner(0.35).paddingOuter(0.2);
const y = d3.scaleLinear().domain([lo, 0.76]).range([H - B, T]);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', 'Chromosome 5 macro average precision per model');
const ax = svg.append('g').attr('class', 'grid axis');
y.ticks(5).forEach(v => { ax.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(v)).attr('y2', y(v)); ax.append('text').attr('x', L - 8).attr('y', y(v) + 4).attr('text-anchor', 'end').text(v.toFixed(2)); });
glms.forEach(d => { ax.append('text').attr('x', x(d.model) + x.bandwidth() / 2).attr('y', H - B + 20).attr('text-anchor', 'middle').text(d.model); ax.append('text').attr('x', x(d.model) + x.bandwidth() / 2).attr('y', H - B + 36).attr('text-anchor', 'middle').text(`${d.params_m >= 1000 ? (d.params_m / 1000).toFixed(d.params_m % 1000 ? 1 : 0) + 'B' : Math.round(d.params_m) + 'M'} params · ${d.input_bp.toLocaleString()} bp`); });
svg.append('text').attr('class', 'ann').attr('transform', `translate(16 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text('chromosome 5 macro average precision');
const contrast = (m) => data.contrasts.find(c => c.comparison === m);
glms.forEach(d => {
const g = svg.append('g');
g.append('rect').attr('x', x(d.model)).attr('y', y(d.mean)).attr('width', x.bandwidth()).attr('height', y(lo) - y(d.mean)).attr('rx', 3).attr('fill', css(MC(d.model)));
d.seeds.forEach((v, i) => g.append('circle').attr('cx', x(d.model) + x.bandwidth() * (0.3 + 0.2 * i)).attr('cy', y(v)).attr('r', 3).attr('fill', css('--card')).attr('stroke', css('--ink-2')));
g.append('text').attr('class', 'value').attr('x', x(d.model) + x.bandwidth() / 2).attr('y', y(d.mean) - 8).attr('text-anchor', 'middle').text(d.mean.toFixed(3));
g.append('rect').attr('class', 'hit').attr('x', x(d.model) - 6).attr('y', T).attr('width', x.bandwidth() + 12).attr('height', H - T - B);
g.on('mousemove', (ev) => { const c = contrast(d.model); showTip(`${esc(d.model)} · pre-trained, full fine-tuning
seeds: ${d.seeds.map(v => v.toFixed(3)).join(', ')} · mean ${d.mean.toFixed(3)}` + (c ? `
paired margin of Botanic1-S over ${esc(d.model)}: +${c.margin.toFixed(3)} [${c.lower.toFixed(3)}, ${c.upper.toFixed(3)}] over ${c.blocks} chromosome blocks` : '') + (d.model === 'Botanic1-S' && data.validation.length ? `
chromosome 4: ${data.validation.map(v => `${v.initialization === 'random' ? 'random initialisation' : 'pre-trained'} ${v.mean.toFixed(3)}`).join(' · ')}` : ''), ev); }).on('mouseleave', hideTip);
});
if (scratch) { svg.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(scratch.mean)).attr('y2', y(scratch.mean)).attr('stroke', css('--ink')).attr('stroke-dasharray', '5 4'); const lg = svg.append('g').attr('transform', `translate(${W - R - 230},${T + 8})`); lg.append('line').attr('x1', 0).attr('x2', 24).attr('y1', 0).attr('y2', 0).attr('stroke', css('--ink')).attr('stroke-dasharray', '5 4'); lg.append('text').attr('class', 'lbl-2').attr('x', 30).attr('y', 4).text(`${scratch.model} from scratch ${scratch.mean.toFixed(2)}`); }
const host = document.createElement('div'); host.className = 'chart'; host.appendChild(svg.node()); tf.appendChild(host);
}
// ============================================================ ATAC-seq against ChromBPNet (grouped bars)
const at = document.getElementById('atac-chart');
if (at) {
const data = await load('atac.json');
const METRICS = [['count_r', 'count Pearson r, higher is better'], ['spearman', 'Spearman, higher is better'], ['norm_jsd', 'normalised JSD, higher is better'], ['raw_jsd', 'raw JSD, lower is better']];
let metric = 'count_r';
const SP = ['Arabidopsis thaliana', 'Zea mays'];
const ARMS = [['ChromBPNet', 'dual-head'], ['Botanic1-S pre-trained', 'dual-head'], ['Botanic1-S pre-trained', 'single-head'], ['Botanic1-S from scratch', 'dual-head'], ['Botanic1-S from scratch', 'single-head']];
const armKey = (a) => a.join(' · ');
const AC = { 'ChromBPNet · dual-head': '--other', 'Botanic1-S pre-trained · dual-head': '--ours', 'Botanic1-S pre-trained · single-head': '--ours-deep', 'Botanic1-S from scratch · dual-head': '--ours-pale', 'Botanic1-S from scratch · single-head': '--tree' };
const wrapEl = document.createElement('div');
wrapEl.innerHTML = `Metric
`;
at.appendChild(wrapEl);
const draw = () => {
const W = 960, H = 396, L = 56, R = 24, T = 46, B = 46;
const vals = data.rows.map(r => r[metric]);
const lo = Math.max(0, Math.floor((d3.min(vals) - 0.06) * 20) / 20), hi = Math.ceil((d3.max(vals) + 0.03) * 20) / 20;
const x0 = d3.scaleBand().domain(SP).range([L, W - R]).paddingInner(0.25).paddingOuter(0.1);
const x1 = d3.scaleBand().domain(ARMS.map(armKey)).range([0, x0.bandwidth()]).padding(0.15);
const y = d3.scaleLinear().domain([lo, hi]).range([H - B, T]);
const svg = d3.create('svg').attr('viewBox', `0 0 ${W} ${H}`).attr('role', 'img').attr('aria-label', 'Base-resolution ATAC-seq prediction against ChromBPNet');
const ax = svg.append('g').attr('class', 'grid axis');
y.ticks(6).forEach(v => { ax.append('line').attr('x1', L).attr('x2', W - R).attr('y1', y(v)).attr('y2', y(v)); ax.append('text').attr('x', L - 8).attr('y', y(v) + 4).attr('text-anchor', 'end').text(v.toFixed(2)); });
SP.forEach(sp => ax.append('text').attr('x', x0(sp) + x0.bandwidth() / 2).attr('y', H - B + 20).attr('text-anchor', 'middle').attr('font-style', 'italic').text(sp));
svg.append('text').attr('class', 'ann').attr('transform', `translate(16 ${(T + H - B) / 2}) rotate(-90)`).attr('text-anchor', 'middle').text(METRICS.find(m => m[0] === metric)[1]);
data.rows.forEach(r => {
const k = armKey([r.backbone, r.head]); const v = r[metric]; const ci = r[metric + '_ci'];
const g = svg.append('g'); const bx = x0(r.species) + x1(k);
g.append('rect').attr('x', bx).attr('y', y(v)).attr('width', x1.bandwidth()).attr('height', y(lo) - y(v)).attr('rx', 3).attr('fill', css(AC[k]));
if (ci) g.append('line').attr('x1', bx + x1.bandwidth() / 2).attr('x2', bx + x1.bandwidth() / 2).attr('y1', y(ci[0])).attr('y2', y(ci[1])).attr('stroke', css('--ink')).attr('stroke-width', 1.2);
g.append('text').attr('class', 'value').attr('x', bx + x1.bandwidth() / 2).attr('y', y(ci ? ci[1] : v) - 6).attr('text-anchor', 'middle').text(v.toFixed(3));
g.on('mousemove', (ev) => showTip(`${esc(r.backbone)} · ${esc(r.head)} · ${esc(r.species)}| count r | ${r.count_r.toFixed(3)} |
| Spearman | ${r.spearman.toFixed(3)} |
| normalised JSD | ${r.norm_jsd.toFixed(3)} |
| raw JSD | ${r.raw_jsd.toFixed(3)} |
${ci ? `95% bootstrap interval on this metric: [${ci[0].toFixed(3)}, ${ci[1].toFixed(3)}]` : 'scored by ChromBPNet\'s own pipeline, no interval'}`, ev)).on('mouseleave', hideTip);
});
const leg = svg.append('g').attr('transform', `translate(${L},${T - 30})`);
ARMS.forEach((a, i) => { const row = i < 3 ? 0 : 1, colI = i < 3 ? i : i - 3; const g = leg.append('g').attr('transform', `translate(${colI * 250},${row * 16})`); g.append('rect').attr('width', 12).attr('height', 12).attr('rx', 2).attr('y', -10).attr('fill', css(AC[armKey(a)])); g.append('text').attr('class', 'lbl-2').attr('x', 18).attr('y', 0).text(armKey(a)); });
const host = wrapEl.querySelector('#atac-svg'); host.innerHTML = ''; host.appendChild(svg.node());
};
wrapEl.querySelector('#atac-metric').addEventListener('change', (e) => { metric = e.target.value; draw(); });
draw();
}
})();