(function () {
const esc = value => String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
const signed = value => (value > 0 ? '+' : value < 0 ? '−' : '') + Math.abs(value).toFixed(2);
const mean = values => values.reduce((sum, value) => sum + value, 0) / values.length;
// Add selection controls to the data pipeline's existing overlay coordinates.
const labels = {
'pipeline-fig': ['Genome selection', 'Window construction', 'Window filtering', 'Genome weighting']
};
function enhanceFigure(id) {
const box = document.getElementById(id);
const figure = box.closest('figure');
const rects = Array.from(box.querySelectorAll('.hot'));
if (!rects.length || typeof d3 === 'undefined') return;
const description = figure.querySelector('.figure-detail');
const defaultDescription = description.innerHTML;
const choices = figure.querySelector('.figure-choices');
const regions = rects.map(rect => d3.select(rect).datum());
let selected = -1;
const buttons = [];
const dim = box.querySelector('.dims');
const applySelection = () => {
figure.classList.toggle('has-selection', selected >= 0);
rects.forEach((rect, i) => {
rect.classList.toggle('selected', i === selected);
rect.setAttribute('aria-pressed', String(i === selected));
});
buttons.forEach((button, i) => button.setAttribute('aria-pressed', String(i - 1 === selected)));
if (dim) {
const otherRegions = selected < 0 ? [] : regions.filter((region, i) => i !== selected && !region.inner && !(regions[selected].inner && region.x <= regions[selected].x && region.x + region.w >= regions[selected].x + regions[selected].w));
d3.select(dim).selectAll('rect').data(otherRegions).join('rect').attr('class', 'dim').attr('x', d => d.x).attr('y', d => d.y).attr('width', d => d.w).attr('height', d => d.h);
}
};
const select = index => {
selected = index;
description.innerHTML = index < 0 ? defaultDescription : '
' + esc(labels[id][index]) + '. ' + esc(regions[index].say) + '
';
document.getElementById('tip').style.display = 'none';
applySelection();
};
['Show all', ...labels[id]].forEach((label, i) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.setAttribute('aria-pressed', String(i === 0));
button.addEventListener('click', () => select(i - 1));
buttons.push(button);
choices.appendChild(button);
});
rects.forEach((rect, i) => {
rect.setAttribute('role', 'button');
rect.setAttribute('tabindex', '0');
rect.setAttribute('aria-label', labels[id][i]);
rect.setAttribute('aria-pressed', 'false');
d3.select(rect)
.on('click.factory', () => select(i === selected ? -1 : i))
.on('keydown.factory', event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(i === selected ? -1 : i); }
if (event.key === 'Escape') { event.preventDefault(); select(-1); }
})
.on('mouseleave.factory', applySelection);
});
const zoom = figure.querySelector('.figure-zoom');
zoom.addEventListener('click', () => {
const enlarged = figure.classList.toggle('enlarged');
zoom.setAttribute('aria-pressed', String(enlarged));
zoom.textContent = enlarged ? 'Fit figure' : 'Enlarge figure';
if (!enlarged) { const viewport = figure.querySelector('.figure-scroll'); viewport.scrollLeft = 0; viewport.scrollTop = 0; }
});
figure.querySelector('.figure-tools').hidden = false;
}
enhanceFigure('pipeline-fig');
// Supplied screenshots are available in a stable hover preview and a dialog.
const imageRegions = document.querySelectorAll('#s1-fig .hot.has-screenshot');
if (imageRegions.length) {
const tip = document.getElementById('tip');
const indices = new WeakMap();
const imagesFor = data => data.images || [data.image];
// Keep every slideshow image downloaded and decoded before its arrow is used.
const preloadedImages = new Map();
const preloadImages = (data, priority = 'low') => {
imagesFor(data).forEach(image => {
let img = preloadedImages.get(image.src);
if (!img) {
img = new Image();
img.fetchPriority = priority;
img.decoding = 'async';
preloadedImages.set(image.src, img);
img.src = image.src;
img.decode().catch(() => {});
} else if (priority === 'high') img.fetchPriority = priority;
});
};
const controls = '
';
const dialog = document.createElement('dialog');
dialog.id = 'factory-screenshot-dialog';
dialog.className = 'factory-screenshot-dialog';
dialog.setAttribute('aria-labelledby', 'factory-screenshot-title');
dialog.innerHTML = '
' + controls + 'Open original image ↗';
document.body.appendChild(dialog);
let activeRegion = null, closeTimer;
const cancelClose = () => clearTimeout(closeTimer);
const hidePreview = () => {
cancelClose();
if (tip.classList.contains('screenshot-preview')) tip.style.display = 'none';
};
const scheduleClose = () => {
cancelClose();
closeTimer = setTimeout(() => {
if (!tip.matches(':hover') && !tip.contains(document.activeElement) && !activeRegion?.matches(':hover') && document.activeElement !== activeRegion) hidePreview();
}, 220);
};
const updateImage = host => {
const data = d3.select(activeRegion).datum();
const images = imagesFor(data), index = indices.get(activeRegion) || 0, current = images[index];
const img = host.querySelector('img');
img.src = current.src; img.alt = current.alt; img.width = current.width; img.height = current.height;
host.querySelector('.screenshot-controls').hidden = images.length < 2;
host.querySelector('.screenshot-position').textContent = `${index + 1} / ${images.length} · ${current.title}`;
const original = host.querySelector('.screenshot-original');
if (original) original.href = current.src;
};
const positionPreview = () => {
const anchor = activeRegion.getBoundingClientRect(), box = tip.getBoundingClientRect();
let x = anchor.right + 12, y = anchor.top;
if (x + box.width > innerWidth - 8) {
x = anchor.left - box.width - 12;
if (x < 8) {
x = 8;
y = anchor.bottom + box.height + 12 <= innerHeight - 8 ? anchor.bottom + 12 : anchor.top - box.height - 12;
}
}
tip.style.left = Math.max(8, Math.min(x, innerWidth - box.width - 8)) + 'px';
tip.style.top = Math.max(8, Math.min(y, innerHeight - box.height - 8)) + 'px';
};
const showPreview = region => {
if (dialog.open) return;
cancelClose(); activeRegion = region;
const data = d3.select(region).datum();
preloadImages(data, 'high');
tip.classList.add('has-screenshot', 'screenshot-preview');
tip.setAttribute('role', 'dialog'); tip.setAttribute('aria-label', `${data.title} screenshots`);
tip.innerHTML = `${esc(data.title)}
${esc(data.say)}${controls}Click the image to enlarge`;
updateImage(tip); tip.style.display = 'block'; positionPreview();
};
const open = () => {
const data = d3.select(activeRegion).datum();
dialog.querySelector('h2').textContent = data.title;
dialog.querySelector('.screenshot-caption').textContent = data.say;
updateImage(dialog); hidePreview(); dialog.showModal();
};
const step = amount => {
const images = imagesFor(d3.select(activeRegion).datum());
indices.set(activeRegion, ((indices.get(activeRegion) || 0) + amount + images.length) % images.length);
if (dialog.open) updateImage(dialog);
else if (tip.classList.contains('screenshot-preview')) updateImage(tip);
};
const arrowKey = event => {
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1);
}
};
imageRegions.forEach(region => {
const data = d3.select(region).datum();
if (imagesFor(data).length > 1) preloadImages(data);
// Replace the following text tooltip with the anchored screenshot preview.
d3.select(region).on('mousemove', null).on('mouseleave', null).on('focus', null).on('blur', null);
region.addEventListener('mouseenter', () => showPreview(region));
region.addEventListener('mouseleave', scheduleClose);
region.addEventListener('focus', () => showPreview(region));
region.addEventListener('blur', scheduleClose);
region.addEventListener('click', () => { activeRegion = region; open(); });
region.addEventListener('keydown', event => {
activeRegion = region;
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); }
if (event.key === 'Escape') hidePreview();
arrowKey(event);
});
});
tip.addEventListener('mouseenter', cancelClose);
tip.addEventListener('mouseleave', scheduleClose);
tip.addEventListener('focusin', cancelClose);
tip.addEventListener('focusout', scheduleClose);
tip.addEventListener('click', event => {
if (!tip.classList.contains('screenshot-preview')) return;
const button = event.target.closest('button');
if (button?.dataset.step) step(Number(button.dataset.step));
if (button?.classList.contains('screenshot-enlarge')) open();
});
tip.addEventListener('keydown', event => {
if (!tip.classList.contains('screenshot-preview')) return;
arrowKey(event);
if (event.key === 'Escape') { activeRegion.focus({ preventScroll: true }); hidePreview(); }
});
dialog.addEventListener('click', event => {
const button = event.target.closest('button[data-step]');
if (button) step(Number(button.dataset.step));
const r = dialog.getBoundingClientRect();
if (event.target === dialog && (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom)) dialog.close();
});
dialog.addEventListener('keydown', arrowKey);
dialog.addEventListener('close', () => { activeRegion.focus({ preventScroll: true }); hidePreview(); });
addEventListener('resize', () => { if (tip.classList.contains('screenshot-preview') && tip.style.display !== 'none') positionPreview(); });
}
// Each choice maps to one measured ablation, never to a sum of independent effects.
const decisions = {
sampling: { label: 'Func-balanced vs uniform sampling', stage: 'Window sampling', title: 'Functional-balanced vs uniform sampling', description: 'Compare a 1:1 mixture of functional and other windows with uniform genome sampling.', reading: 'Functional-balanced sampling improves the score in both seeds of every measured comparison. The 1:1 mixture also retains more unique windows than a stronger functional bias.' },
species: { label: '320 species vs 48 crop', stage: 'Genome selection', title: 'Land plants vs 48 crop species', description: 'Broaden the species pool while holding the window-sampling rule and phylogenetic weighting fixed.', reading: 'The direction and size of the difference depend on the sampling rule. The broader species pool does not show a consistent performance penalty on the crop-focused evaluation suite.' },
redundancy: { label: 'Redundancy filter 0.3 vs 0.9', stage: 'Window filtering', title: 'MinHash threshold 0.3 vs 0.9', description: 'Compare stronger removal of similar windows with a threshold that retains almost all windows.', reading: 'The two seeds differ in direction at this comparison. The final mixture uses 0.3; the report also tests 0.2, which leaves only 4.3% of the initial windows.' },
length: { label: 'sqrt-length vs uniform weighting', stage: 'Window sampling', title: 'Genome-length vs uniform weighting', description: 'Compare genome-length correction with uniform weighting, under two window-sampling rules.', reading: 'Genome-length correction improves the score in both seeds under each of the two measured sampling rules.' },
phylogeny: { label: 'GSC weighting on vs off', stage: 'Genome selection', title: 'Phylogenetic weighting on vs off', description: 'Use Gerstein-Sonnhammer-Chothia (GSC) weights derived from genome distances to change how species are sampled.', reading: 'The effect varies across settings and seeds. GSC weighting is disabled in the final mixture.' },
ambiguous: { label: 'N-content filter 0.20 vs 0', stage: 'Window filtering', title: 'Allow up to 20% N vs no N bases', description: 'Compare windows allowing up to 20% ambiguous bases with windows containing none.', reading: 'The two seeds give changes in opposite directions. This comparison does not establish a consistent benefit from either threshold.' }
};
const contextNames = { '48_crop': '48 crop species', '48_phylo': '48 phylogenetically sampled species', '330': 'Land plant pool', '1700': 'Expanded species pool', gscoff: 'GSC off', gscon: 'GSC on', uniform: 'Uniform windows', functional_balanced: '1:1 functional sampling', functional_heavy: '4:1 functional sampling' };
const contextLabel = value => value.split(',').map(part => contextNames[part.trim()] || part.trim()).join(' · ');
const controls = document.querySelector('.decision-options');
const selector = document.getElementById('comparison-context');
const status = document.getElementById('ablation-status');
const result = document.getElementById('ablation-result');
let data, decisionKey = 'sampling';
let axisMin = -1, axisMax = 2.5;
const position = value => 100 * (value - axisMin) / (axisMax - axisMin);
function renderComparison() {
const decision = decisions[decisionKey];
const effect = data.effects.find(row => row.label === decision.label);
const rows = selector.value === 'all' ? effect.contexts : [effect.contexts[Number(selector.value)]];
const average = selector.value === 'all' ? effect.mean : mean(rows[0].delta);
document.getElementById('effect-mean').textContent = `${signed(average)} pp`;
document.getElementById('effect-scope').textContent = rows.length > 1 ? `Mean across ${rows.length} comparisons` : 'Mean of two seeds';
const chart = document.getElementById('effect-chart');
chart.style.setProperty('--zero', position(0) + '%');
chart.innerHTML = rows.map(row => {
const label = contextLabel(row.context);
const [a, b] = row.delta;
const m = mean(row.delta);
const description = `${label}: seed 42 ${signed(a)}, seed 43 ${signed(b)}, mean ${signed(m)} percentage points.`;
return `${esc(label)}${[a,b].map(value => ``).join('')}
${signed(m)} `;
}).join('');
const ticks = [];
for (let value = Math.ceil(axisMin); value <= axisMax; value++) ticks.push(`${value > 0 ? '+' : ''}${value}`);
chart.insertAdjacentHTML('beforeend', ``);
document.querySelector('#comparison-table tbody').innerHTML = rows.map(row => `| ${esc(contextLabel(row.context))} | ${[...row.delta, mean(row.delta)].map(value => `${signed(value)} | `).join('')}
`).join('');
}
function chooseDecision(key) {
decisionKey = key;
const decision = decisions[key];
document.querySelector(`input[name="decision"][value="${key}"]`).checked = true;
document.querySelectorAll('#ablation-effects .row').forEach(row => {
const selected = typeof d3 !== 'undefined' && d3.select(row).datum().label === decision.label;
row.classList.toggle('selected', selected);
row.setAttribute('aria-expanded', String(selected && document.getElementById('comparison-details').open));
});
const effect = data.effects.find(row => row.label === decision.label);
document.getElementById('decision-stage').textContent = decision.stage;
document.getElementById('decision-title').textContent = decision.title;
document.getElementById('decision-description').textContent = decision.description;
document.getElementById('decision-reading').textContent = decision.reading;
selector.replaceChildren(new Option(`All ${effect.contexts.length} measured comparison${effect.contexts.length === 1 ? '' : 's'}`, 'all'), ...effect.contexts.map((row, index) => new Option(contextLabel(row.context), index)));
selector.disabled = effect.contexts.length === 1;
renderComparison();
}
controls.addEventListener('change', event => { if (event.target.name === 'decision') chooseDecision(event.target.value); });
selector.addEventListener('change', renderComparison);
function enhanceAblationOverview() {
const chart = document.getElementById('ablation-effects');
const bind = () => {
const rows = Array.from(chart.querySelectorAll('.row'));
if (!rows.length || typeof d3 === 'undefined') return false;
rows.forEach(row => {
const effect = d3.select(row).datum();
const key = Object.keys(decisions).find(key => decisions[key].label === effect.label);
row.setAttribute('role', 'button');
row.setAttribute('tabindex', '0');
row.setAttribute('aria-label', 'Inspect ' + decisions[key].title);
row.setAttribute('aria-controls', 'comparison-details');
row.setAttribute('aria-expanded', 'false');
d3.select(row).insert('rect', ':first-child').attr('class', 'row-hit').attr('x', 0).attr('y', -19).attr('width', 900).attr('height', 38);
const inspect = () => {
document.getElementById('comparison-details').open = true;
chooseDecision(key);
document.getElementById('tip').style.display = 'none';
};
row.addEventListener('click', inspect);
row.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); inspect(); }
});
});
return true;
};
if (!bind()) {
const observer = new MutationObserver(() => { if (bind()) observer.disconnect(); });
observer.observe(chart, { childList: true, subtree: true });
}
}
document.getElementById('comparison-details').addEventListener('toggle', event => {
document.querySelectorAll('#ablation-effects .row').forEach(row => row.setAttribute('aria-expanded', String(event.target.open && row.classList.contains('selected'))));
});
async function loadComparisons() {
try {
const response = await fetch('data/ablation.json');
if (!response.ok) throw new Error('Data request failed');
data = await response.json();
for (const decision of Object.values(decisions)) {
const effect = data.effects.find(row => row.label === decision.label);
if (!effect || !Number.isFinite(effect.mean) || !effect.contexts.length || effect.contexts.some(row => row.delta.length !== 2 || !row.delta.every(Number.isFinite))) throw new Error('Incomplete ablation data');
}
const values = data.effects.flatMap(effect => effect.contexts.flatMap(row => row.delta));
axisMin = Math.min(-0.5, Math.floor(Math.min(...values) * 2) / 2);
axisMax = Math.max(0.5, Math.ceil(Math.max(...values) * 2) / 2);
chooseDecision('sampling');
controls.disabled = false;
result.hidden = false;
status.hidden = true;
enhanceAblationOverview();
} catch (error) {
status.innerHTML = 'The comparison data could not be loaded. Read the data ablations in the report.';
} finally {
document.getElementById('ablation-explorer').setAttribute('aria-busy', 'false');
}
}
loadComparisons();
})();