/* Sparse-feature window browser.
Activations are stride-1 measurements read out of one SAE over one window.
Three rules shape the drawing:
- zero means the feature did not fire, so an unfired base is left blank and
the lane is labelled silent rather than given a short bar;
- several bases under one pixel column collapse to their MAXIMUM, never to a
mean, and never by interpolating between measured bases;
- annotations live inside a recessed panel, in pale washes and mid-tone
ticks, so nothing in the annotation register can be read as a measurement.
The dashed anchor outline marks where the window sampler looked. It is an
outline, never a fill, and it is labelled as its own thing so it cannot pass
for an annotated site. Every colour is a CSS custom property; the only value
computed in JS is the ink of a base letter, which is chosen from the luminance
of the cell it sits on. */
(async function () {
const host = document.getElementById('feat-plot');
if (!host) return;
const readout = document.getElementById('feat-readout');
const region = document.getElementById('feat-region');
const picker = document.getElementById('win-picker');
const list = document.getElementById('feat-list');
const title = document.getElementById('feat-title');
const sub = document.getElementById('feat-sub');
const caseEl = document.getElementById('feat-case');
const modeBtns = Array.from(document.querySelectorAll('#feat-mode button'));
const tools = ['feat-in', 'feat-out', 'feat-full'].map(id => document.getElementById(id));
tools.forEach(b => { if (b) b.disabled = true; });
wireHelp(); /* explains the browser, so it works before a window is chosen */
/* Window files carry free-form fields whose shapes differ between kinds, so a
value is only treated as a list when it actually is one. */
const asList = v => Array.isArray(v) ? v.filter(Boolean) : [];
const esc = value => String(value ?? '').replace(/[&<>"']/g, ch =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
const fmt = n => Math.round(n).toLocaleString('en-US');
const num = (n, d = 2) => n.toFixed(d);
const path = (d, cls, extra = '') => ``;
const text = (px, py, s, cls = '', anchor = 'start', extra = '') =>
`${esc(s)}`;
const rect = (x, y, w, h, fill, cls = '', extra = '') =>
``;
const PALETTE = 12; // one hue per lane, so no two lanes share a colour
const GLOBAL_KEYS = 10; // how many of the 8,192 the whole-dictionary strip names
const featColour = slot => slot < 0 ? 'var(--feat-none)' : `var(--feat-${slot % PALETTE + 1})`;
/* ---- vocabulary -------------------------------------------------------- */
const ANCHOR_ORDER = ['tss', 'tis', 'donor', 'acceptor', 'background'];
const ANCHOR_COL = {
tss: 'TSS', tis: 'Start codon', donor: 'Donor', acceptor: 'Acceptor', background: 'Background'
};
const ANCHOR_TITLE = {
tss: 'Transcription-start', tis: 'Translation-start', donor: 'Splice-donor',
acceptor: 'Splice-acceptor', background: 'Unanchored background'
};
const SITE_LABEL = { tss: 'TSS', tis: 'start codon', donor: 'donor', acceptor: 'acceptor', stop: 'stop', tts: 'TTS' };
const SITE_SHORT = { tss: 'TSS', tis: 'start', donor: 'donor', acceptor: 'accept', stop: 'stop', tts: 'TTS' };
const REGION_LABEL = {
intergenic: 'intergenic', cds: 'CDS', utr5: '5′ UTR', utr3: '3′ UTR',
intron: 'intron', ncrna: 'non-coding'
};
const KIND_GROUP = {
site: 'Point sites', range: 'Regions', motif: 'Sequence motifs', pattern: 'Repeat patterns'
};
const KIND_ORDER = ['site', 'range', 'motif', 'pattern'];
/* Why a window earns its place. `agreement` is the quiet case; the other three
are the interesting ones and share one accent, but they keep separate names
because an alternative isoform, an unannotated site and a moved annotation
are three different claims. An unrecognised kind is treated as flagged
rather than quietly filed under agreement. */
const SEL_KIND = {
agreement: { tag: 'Agreement', mark: 'agreement', flag: false },
alternative: { tag: 'Alternative isoform', mark: 'alternative site', flag: true },
unannotated: { tag: 'Unannotated site', mark: 'unannotated site', flag: true },
changed: { tag: 'Annotation moved', mark: 'annotation moved', flag: true }
};
const selKind = sel => SEL_KIND[sel && sel.kind]
|| { tag: String((sel && sel.kind) || 'selected'), mark: 'finding', flag: true };
/* A `selection` says what the annotation carries. A `usage_check` says what
independent registers found, which is a different question and is kept as a
separate verdict: a feature firing where nothing is annotated is not a
discovery until a register says the site is used, and `untested` is not a
weaker `used` — it is the absence of a check. An unrecognised verdict falls
through to `untested` so a new one can never read as confirmation. */
const USAGE_VERDICT = {
used: { tag: 'Used', gloss: 'an independent register records the site in use' },
untestable: { tag: 'Untestable', gloss: 'the registers cover the locus but no measurement is possible there' },
untested: { tag: 'Untested', gloss: 'no usage register was consulted on this assembly' }
};
const usageVerdict = u => USAGE_VERDICT[u && u.verdict] || USAGE_VERDICT.untested;
/* what each register row reported, in the register's own terms */
const USAGE_RESULT = {
used: 'records it', absent: 'does not carry it',
'no data': 'has no data', 'not consulted': 'not consulted'
};
/* Evidence is free-form and differs by kind, so only fields actually present
are read, and `score` is only given a name for `agreement`, where its scale
is documented. */
function selMetric(sel) {
const e = (sel && sel.evidence) || {};
const bits = [];
if (sel.kind === 'agreement') {
if (typeof sel.score === 'number') bits.push(`combined score ${sel.score.toFixed(3)}`);
return bits.join(' · ');
}
if (e.dinuc) bits.push(String(e.dinuc));
const moved = e.old_coord != null || e.new_coord != null;
if (typeof e.shift_bp === 'number') {
bits.push(moved ? `moved ${fmt(e.shift_bp)} bp between releases`
: `${fmt(e.shift_bp)} bp from the canonical site`);
}
if (typeof e.nearest_full_bp === 'number') {
bits.push(`nearest annotated site ${fmt(e.nearest_full_bp)} bp away`);
}
if (typeof e.exons_old === 'number' && e.exons_old === e.exons_new) {
bits.push(`exon count unchanged at ${fmt(e.exons_old)}`);
}
if (typeof e.n_cases_in_window === 'number' && e.n_cases_in_window > 1) {
bits.push(`${fmt(e.n_cases_in_window)} cases in this window`);
}
return bits.join(' · ');
}
/* the base pair a window was chosen for, and for a moved annotation the pair */
function findingMarks(sel) {
if (!sel || sel.position == null) return [];
const e = sel.evidence || {};
const marks = [{ base: sel.position, filled: true, tag: selKind(sel).mark }];
if (typeof e.window_index_new === 'number' && e.window_index_new !== sel.position) {
marks[0].tag = 'was';
marks.push({ base: e.window_index_new, filled: false, tag: 'now' });
}
return marks;
}
const MAX_LANES = 12;
const LIST_UNNAMED = 40;
/* ---- fonts, read out of the tokens so measuring matches the drawing ---- */
const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
let FONTS = {};
const gauge = document.createElement('canvas').getContext('2d');
function readFonts() {
const sans = cssVar('--sans') || 'sans-serif';
const mono = cssVar('--mono') || 'monospace';
FONTS = {
name: `600 11px ${sans}`,
nameId: `600 11px ${mono}`,
meta: `500 10px ${mono}`,
strand: `500 10px ${sans}`,
reg: `500 9px ${mono}`,
site: `9px ${sans}`,
band: `500 8.5px ${sans}`,
tick: `10px ${mono}`
};
}
const measure = (s, font) => { gauge.font = font; return gauge.measureText(String(s)).width; };
/* mono at 9px carries a 0.13em tracking in CSS, which measureText cannot see */
const measureTracked = (s, font, em) => measure(s, font) + String(s).length * em;
/* The lane colours are design tokens, so they are read back out of the
stylesheet once per draw; a base letter is then inked near-black or white by
the luminance of the cell it sits on. */
/* Relative luminance at which white and black ink have equal contrast:
(L + 0.05)^2 = 1.05 * 0.05. Above it, ink black; below it, ink white. The
base glyphs generalise the same test: each base has a light and a dark
variant of its own hue and takes whichever has more contrast on the cell. */
const INK_FLIP = 0.1791;
const BASE_KEYS = ['a', 'c', 'g', 't', 'n'];
const contrastOf = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const luminance = value => {
const m = String(value).trim().match(/^#?([0-9a-f]{6})$/i);
if (!m) return 0.5;
const n = parseInt(m[1], 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(v => {
const f = v / 255;
return f <= 0.03928 ? f / 12.92 : Math.pow((f + 0.055) / 1.055, 2.4);
});
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
};
let inks = new Map(); // slot -> plain contrast ink
let baseInk = new Map(); // `slot:base` -> that base's hue, in the readable variant
function readPalette() {
const cs = getComputedStyle(document.documentElement);
inks = new Map(); baseInk = new Map();
const cellLum = new Map();
for (let i = 1; i <= PALETTE; i++) cellLum.set(i - 1, luminance(cs.getPropertyValue(`--feat-${i}`)));
cellLum.set(-1, luminance(cs.getPropertyValue('--feat-none')));
const variants = new Map();
for (const k of BASE_KEYS) {
const lit = (cs.getPropertyValue(`--base-${k}-lit`) || '').trim() || '#ffffff';
const dim = (cs.getPropertyValue(`--base-${k}-dim`) || '').trim() || '#0c110e';
variants.set(k, [lit, luminance(lit), dim, luminance(dim)]);
}
for (const [slot, cl] of cellLum) {
inks.set(slot, cl > INK_FLIP ? '#0c110e' : '#ffffff');
for (const k of BASE_KEYS) {
const [lit, ll, dim, dl] = variants.get(k);
baseInk.set(`${slot}:${k}`, contrastOf(cl, ll) >= contrastOf(cl, dl) ? lit : dim);
}
}
}
const inkFor = slot => inks.get(slot) || '#0c110e';
const baseInkFor = (slot, letter) => {
const c = String(letter || 'n').toLowerCase();
return baseInk.get(`${slot}:${BASE_KEYS.includes(c) ? c : 'n'}`) || inkFor(slot);
};
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const LUT = new Uint8Array(256);
for (let i = 0; i < B64.length; i++) LUT[B64.charCodeAt(i)] = i;
/* uint8 run -> activation, undoing the 255-level quantisation of the feature's
own peak in this window. */
function expand(runs, peak, n) {
const out = new Float32Array(n);
for (const [start, b64] of runs) {
const clean = b64.replace(/=+$/, '');
let byte = 0, bits = 0, at = start;
for (let i = 0; i < clean.length; i++) {
byte = (byte << 6) | LUT[clean.charCodeAt(i)];
bits += 6;
if (bits >= 8) {
bits -= 8;
out[at++] = ((byte >> bits) & 0xff) / 255 * peak;
}
}
}
return out;
}
/* ---- state ------------------------------------------------------------- */
let index = null, doc = null, cache = new Map();
let shown = []; // feature ids with a lane, in slot order
let start = 0, end = 0; // view, in window bases
let cursor = 0, pinned = null, mode = 'selected';
let W = 0, L = 120, R = 14, H = 400, compact = false;
let G = null; // geometry of the current draw
let svg = null, drag = null, pinch = null, vectors = new Map();
let restOpen = null; // whether the unnamed group is unfolded
const pointers = new Map();
let featureIndex = new Map();
const featureById = id => featureIndex.get(id);
/* This SAE is BatchTopK with k active features, so at any base the features
outside that set read exactly zero, not small. A window therefore only has
to store a stride-1 track for the features that reach a base's top ranks,
and every lane is a complete track. A feature with no stored track never
fires hard enough to reach them anywhere in the window. */
let tracks = new Map();
function trackOf(id) {
if (tracks.has(id)) return tracks.get(id);
const f = featureById(id);
const out = f
? { kind: 'full', v: vectorOf(id), peak: f.peak, label: f.label || null }
: null;
tracks.set(id, out);
return out;
}
/* Reading one base out of every stored track — thousands of them — must not
expand every track, so a single quantised byte is decoded in place. */
const runIdx = new Map();
function runsOf(f) {
if (runIdx.has(f.id)) return runIdx.get(f.id);
const list = asList(f.runs).map(([at, b64]) => {
const clean = String(b64).replace(/=+$/, '');
return { at, b64: clean, len: Math.floor(clean.length * 6 / 8) };
});
runIdx.set(f.id, list);
return list;
}
function byteAt(b64, i) {
const bit = i * 8;
let at = Math.floor(bit / 6);
const skip = bit - at * 6;
let acc = 0, bits = 0;
while (bits < skip + 8 && at < b64.length) {
acc = (acc << 6) | LUT[b64.charCodeAt(at++)];
bits += 6;
}
return (acc >> (bits - skip - 8)) & 0xff;
}
function valueAt(f, base) {
if (vectors.has(f.id)) return vectors.get(f.id)[base];
for (const r of runsOf(f)) {
if (base >= r.at && base < r.at + r.len) return byteAt(r.b64, base - r.at) / 255 * f.peak;
}
return 0;
}
const slotOf = id => shown.indexOf(id);
const vectorOf = id => {
if (!vectors.has(id)) {
const f = featureById(id);
vectors.set(id, f ? expand(f.runs, f.peak, doc.n_bases) : new Float32Array(doc.n_bases));
}
return vectors.get(id);
};
/* Genomic coordinate of a window base. A minus-strand window's seq is already
reverse-complemented, so display order runs against the coordinate. */
/* The window's n_bases occupy the 1-based inclusive interval [start+1, end]
(verified by refetching the region and matching seq_sha256), so `start` is a
half-open left edge and end - start === n_bases. A minus-strand window's seq
is already reverse-complemented, so display order runs against the
coordinate: base 0 sits at `end` and the last base at `start + 1`. */
const coordOf = base => doc.strand < 0 ? doc.end - base : doc.start + 1 + base;
const baseOf = coord => doc.strand < 0 ? doc.end - coord : coord - doc.start - 1;
const x = base => L + (base - start) / (end - start) * (W - L - R);
const nBasesShown = () => end - start;
/* the view's end is exclusive too, so the last base on screen is end - 1 */
const coordMin = () => Math.min(coordOf(start), coordOf(end - 1));
const coordMax = () => Math.max(coordOf(start), coordOf(end - 1));
/* ---- annotation lookups ------------------------------------------------ */
const strandsCovered = () => !doc.annotations ? []
: ['sense', 'antisense'].filter(s => doc.annotations[s] && doc.annotations[s].covered);
const regionAt = (strand, base) => {
const s = doc.annotations && doc.annotations[strand];
if (!s || !s.covered || !s.regions) return null;
for (const r of s.regions) if (base >= r.from && base <= r.to) return r.class;
return null;
};
const siteAt = (strand, base) => {
const s = doc.annotations && doc.annotations[strand];
if (!s || !s.covered || !s.sites) return null;
const hit = s.sites.find(t => t.base === base);
return hit ? hit.class : null;
};
const genesAt = (strand, base) => {
const s = doc.annotations && doc.annotations[strand];
if (!s || !s.covered || !s.genes) return [];
return s.genes.filter(g => base >= g.from && base <= g.to);
};
/* Per-base winner among the lanes on screen, or the whole dictionary's rank-0
feature when the reader asks for it. */
let argmaxLut = null;
function buildArgmaxLut() {
const lut = new Int32Array(doc.n_bases).fill(-1);
for (const [s, len, id] of doc.argmax.runs) {
if (id < 0) continue;
for (let i = s; i < s + len; i++) lut[i] = id;
}
argmaxLut = lut;
}
const topGlobal = base => argmaxLut ? argmaxLut[base] : -1;
function topSelected(base) {
let best = -1, bestV = 0;
for (const id of shown) {
const t = trackOf(id);
if (!t) continue;
const v = t.v[base];
if (v > bestV) { bestV = v; best = id; }
}
return { id: best, value: bestV };
}
function globalSlotMap() {
const ranked = doc.features.slice().sort((a, b) => b.argmax_bases - a.argmax_bases)
.filter(f => f.argmax_bases > 0).slice(0, GLOBAL_KEYS);
const map = new Map(), used = new Set();
for (const f of ranked) {
if (!shown.includes(f.id)) continue;
const slot = slotOf(f.id) % PALETTE;
map.set(f.id, slot); used.add(slot);
}
let next = 0;
for (const f of ranked) {
if (map.has(f.id)) continue;
while (next < PALETTE && used.has(next)) next++;
const slot = Math.min(next, PALETTE - 1);
map.set(f.id, slot); used.add(slot);
}
return map;
}
/* ---- geometry ---------------------------------------------------------- */
/* One pass over the tracks, so every y coordinate the renderer uses is decided
in one place and the layout never shifts while panning. */
function layout() {
W = Math.max(240, host.clientWidth || 900);
readFonts();
/* The gutter has to hold whichever is widest: a lane name, a lane's
id-and-max line, a strand name, or a register key. Below the point where
that leaves too little room for the plot, the labels move onto the plot
and the gutter disappears. */
let need = 0;
for (const id of shown) {
const t = trackOf(id);
if (!t) continue;
const pre = t.label ? `f${id} · ` : '';
need = Math.max(need,
measure(t.label || `f${id}`, t.label ? FONTS.name : FONTS.nameId),
measure(pre + 'max 00.00', FONTS.meta));
}
need = Math.max(need,
measure('top lane here', FONTS.name), measure('top of 8,192', FONTS.name),
measure('max in column', FONTS.meta), measure('most bases in column', FONTS.meta),
measure('antisense', FONTS.strand), measure('gene model', FONTS.strand),
measureTracked('ANNOTATION', FONTS.reg, 1.17));
const gutter = Math.min(212, Math.round(need) + 12);
compact = (W - gutter - 14) < 430;
L = compact ? 3 : gutter;
R = compact ? 3 : 14;
const g = { plotL: L, plotR: W - R, plotW: W - L - R, compact };
let y = 2;
g.anchorBase = y + 9; g.anchorTop = y; y += 13;
g.tickBase = y + 10; g.axisY = y + 15; y += 15;
g.findTop = null; g.findBase = null;
if (doc.selection && doc.selection.position != null) {
g.findTop = y + 1; g.findBase = y + 9; y += 13;
}
if (compact) { g.stripLabBase = y + 10; y += 13; }
g.stripY = y; g.stripH = compact ? 20 : 26; y += g.stripH;
if (mode === 'global') { g.gkeyY = y + 5; y += 19; } else g.gkeyY = null;
y += compact ? 12 : 15;
/* annotation panel */
g.annTop = y;
let py = y + (compact ? 6 : 7);
g.annRegBase = py + 7; py += compact ? 13 : 13;
g.blocks = [];
const blocks = strandsCovered().map(s => ({ kind: 'strand', strand: s }));
if (!blocks.length && doc.gene_report) blocks.push({ kind: 'model' });
for (const b of blocks) {
if (compact) { b.labBase = py + 8; py += 12; }
if (!compact) { b.siteLabBase = py + 8; py += 11; }
b.siteY = py; b.siteH = 8; py += 8;
if (b.kind === 'strand') { b.bandY = py; b.bandH = compact ? 13 : 14; py += b.bandH; }
b.geneTop = py; b.geneH = b.kind === 'model' ? 22 : 15;
b.geneLabBase = py + 8; b.geneLineY = py + (b.kind === 'model' ? 16 : 12);
py += b.geneH;
b.centre = (b.siteY + py) / 2;
py += compact ? 6 : 8;
g.blocks.push(b);
}
py += compact ? 3 : 4;
g.annH = Math.max(20, py - g.annTop);
y = g.annTop + g.annH;
y += compact ? 15 : 20;
/* lanes */
g.laneRegBase = y + 7; g.laneRuleY = y - 3; y += 13;
g.laneTop = y;
g.laneH = compact ? 30 : 44;
g.laneLab = compact ? 13 : 0;
g.laneGap = compact ? 9 : 11;
g.lanePitch = g.laneH + g.laneGap + g.laneLab;
y += Math.max(g.lanePitch, shown.length * g.lanePitch);
g.gridTop = g.annTop;
g.gridBottom = y - g.laneGap;
H = Math.round(y + 6);
G = g;
return g;
}
/* ---- drawing ----------------------------------------------------------- */
function render() {
if (!doc || !svg) return;
readPalette();
layout();
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('aria-label', ariaLabel());
const pxPerBase = G.plotW / nBasesShown();
const cols = Math.max(1, Math.round(G.plotW));
let out = `
${esc(windowTitleText())} — ${esc(doc.chrom)}:${fmt(coordMin())}–${
fmt(coordMax())}, ${shown.length} feature lane${shown.length === 1 ? '' : 's'}`;
out += ``;
out += drawRuler();
out += drawScale(pxPerBase);
out += drawStrip(cols, pxPerBase);
if (mode === 'global') out += drawGlobalKey();
out += drawFinding(pxPerBase);
out += drawAnnotations(pxPerBase);
out += drawLanes(cols, pxPerBase);
out += drawAnchor(pxPerBase);
out += '';
svg.innerHTML = out;
focus(pinned === null ? cursor : pinned, false);
paintRegion(pxPerBase);
}
function ticks() {
const target = nBasesShown() / Math.max(1, Math.floor(G.plotW / (compact ? 84 : 112)));
const step = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000].find(s => s >= target) || 5000;
const first = Math.ceil(coordMin() / step) * step;
const out = [];
for (let c = first; c <= coordMax(); c += step) {
const base = baseOf(c);
if (base < start || base > end) continue;
out.push({ coord: c, base, px: x(base) });
}
return out;
}
/* A conventional scale bar: a bracket exactly one round number of bases wide,
snapped to 1/2/5 x 10^n, relabelled whenever the view changes. It lives in
the top row, right-aligned, clear of the ruler and of the gutter. */
function drawScale(pxPerBase) {
G.scaleLeft = W;
const room = Math.min(compact ? 118 : 176, G.plotW * 0.32);
let step = 1;
for (let e = 0; e <= 6; e++) {
for (const m of [1, 2, 5]) {
const v = m * Math.pow(10, e);
if (v * pxPerBase <= room) step = v;
}
}
const barPx = step * pxPerBase;
if (barPx < 16) return '';
const label = step >= 1000 ? `${step / 1000} kb` : `${step} bp`;
const y = G.anchorTop + 7;
const right = W - R, left = right - barPx;
let out = path(`M${left.toFixed(2)} ${y - 3.5}v7M${left.toFixed(2)} ${y}H${right}M${right} ${y - 3.5}v7`, 'scalebar');
const lw = measure(label, FONTS.reg) + 12;
const px = left - 6 - lw;
if (px > L + 8) {
out += rect(px, y - 6.5, lw, 13, '', 'scale-pill', ' rx="6.5"');
out += text(px + lw / 2, y + 3.2, label, 'scale-lab', 'middle');
G.scaleLeft = px;
} else {
G.scaleLeft = left;
}
return out;
}
function drawRuler() {
let out = path(`M${L} ${G.axisY}H${W - R}`, 'axis');
let grid = '';
for (const t of ticks()) {
const anchor = t.px < L + 30 ? 'start' : t.px > W - R - 30 ? 'end' : 'middle';
out += path(`M${t.px.toFixed(2)} ${G.axisY}V${G.axisY + 4}`, 'axis');
out += text(t.px, G.tickBase, fmt(t.coord), 'ruler-tick', anchor);
grid += `M${t.px.toFixed(2)} ${G.gridTop}V${G.gridBottom}`;
}
out += `${path(grid, 'grid')}`;
return out;
}
/* The headline device: one mark per pixel column, coloured by whichever
feature fires hardest anywhere under that column. Maximum, never a mean. */
function drawStrip(cols, pxPerBase) {
const globalSlots = mode === 'global' ? globalSlotMap() : null;
const colSlot = new Int32Array(cols).fill(-1);
let cells = '';
for (let c = 0; c < cols; c++) {
const from = Math.floor(start + c / pxPerBase);
const to = Math.max(from + 1, Math.floor(start + (c + 1) / pxPerBase));
let winner = -1;
if (mode === 'selected') {
let best = 0;
for (let b = from; b < to && b < doc.n_bases; b++) {
const t = topSelected(b);
if (t.value > best) { best = t.value; winner = t.id; }
}
} else {
/* The stored argmax track carries the rank-0 feature per base, not its
value, so a column can only report which feature holds the most bases
in it. Named as such in the label rather than passed off as a max. */
const tally = new Map();
let best = 0;
for (let b = from; b < to && b < doc.n_bases; b++) {
const id = topGlobal(b);
if (id < 0) continue;
const n = (tally.get(id) || 0) + 1;
tally.set(id, n);
if (n > best) { best = n; winner = id; }
}
}
const slot = winner < 0 ? -1
: (mode === 'selected' ? slotOf(winner) : (globalSlots.get(winner) ?? -1));
colSlot[c] = slot;
cells += rect(L + c, G.stripY, 1.02, G.stripH,
winner < 0 ? 'var(--feat-none)' : featColour(slot), winner < 0 ? 'nofire' : '');
}
let out = `${cells}`;
/* letters once there is room for them, inked by base identity in whichever
variant of that base's hue has more contrast against the cell */
if (pxPerBase >= 7) {
for (let b = Math.floor(start); b < Math.ceil(end) && b < doc.n_bases; b++) {
if (b < 0) continue;
const centre = x(b) + pxPerBase / 2;
if (centre - 4 < L || centre + 4 > W - R) continue;
const slot = colSlot[Math.max(0, Math.min(cols - 1, Math.round(centre - L)))];
/* an inline style, because #feat-plot text { fill } would beat a
presentation attribute and every letter would come out ink-2 */
out += `${doc.seq[b]}`;
}
}
out += '';
out += rect(L, G.stripY, G.plotW, G.stripH, '', 'strip-frame');
const primary = mode === 'selected' ? 'top lane here' : 'top of 8,192';
const secondary = mode === 'selected' ? 'max in column' : 'most bases in column';
if (compact) {
out += text(L + 2, G.stripLabBase, primary, 'lane-name', 'start');
out += text(W - R, G.stripLabBase, secondary, 'lane-max', 'end');
} else {
out += text(L - 11, G.stripY + 12, primary, 'lane-name', 'end');
out += text(L - 11, G.stripY + 24, secondary, 'lane-max', 'end');
}
return out;
}
/* In global mode the strip reads all 8,192 features, so the ten colours mean
something other than the lanes below. Name them. */
function drawGlobalKey() {
const ranked = Array.from(globalSlotMap().entries());
let out = '', cx = L + 1;
out += text(cx, G.gkeyY + 8, 'strip keys:', 'lane-max', 'start');
cx += measure('strip keys:', FONTS.meta) + 9;
for (const [id, slot] of ranked) {
const label = `f${id}`;
const w = measure(label, FONTS.meta);
if (cx + w + 12 > W - R) break;
out += rect(cx, G.gkeyY + 1, 8, 8, featColour(slot), '', ' rx="2"');
out += text(cx + 11, G.gkeyY + 8, label, 'gkey-id', 'start');
cx += w + 20;
}
return out;
}
/* The annotation register. A recessed plate, pale washes, mid-tone ticks: the
point is that no part of this block can be mistaken for a measurement. */
function drawAnnotations(pxPerBase) {
let out = rect(L, G.annTop, G.plotW, G.annH, '', 'ann-panel', ' rx="4"');
out += compact
? text(L + 6, G.annRegBase, 'ANNOTATION', 'reg-lab halo-panel', 'start')
: text(L - 11, G.annRegBase, 'ANNOTATION', 'reg-lab', 'end');
let body = '';
for (const b of G.blocks) {
const name = b.kind === 'model' ? 'gene model' : b.strand;
if (compact) body += text(L + 6, b.labBase, name, 'strand-name halo-panel', 'start');
else out += text(L - 11, b.centre + 3, name, 'strand-name', 'end');
body += b.kind === 'model' ? drawGeneModel(b, pxPerBase) : drawStrand(b, pxPerBase);
}
return out + `${body}`;
}
function drawStrand(b, pxPerBase) {
const s = doc.annotations[b.strand];
let out = '';
/* regions partition every base, so they are drawn as abutting bands */
let bandLabels = '';
for (const r of s.regions) {
if (r.to < start || r.from > end) continue;
const a = x(Math.max(r.from, start)), z = x(Math.min(r.to + 1, end));
out += rect(a, b.bandY, z - a, b.bandH, `var(--ann-${r.class})`, 'ann-band');
/* The washes are pale on purpose, which makes them close in luminance and
useless under greyscale or dichromacy, so each band says what it is
wherever there is room for the word. */
const label = REGION_LABEL[r.class] || r.class;
const lw = measure(label, FONTS.band);
if (z - a > lw + 10) {
bandLabels += text((a + z) / 2, b.bandY + b.bandH / 2 + 3, label, 'ann-bandlab', 'middle');
}
}
out += bandLabels;
out += path(`M${L} ${b.bandY}H${W - R}M${L} ${b.bandY + b.bandH}H${W - R}`, 'ann-band-rule');
/* gene extents, with a strand chevron and end caps */
for (const g of s.genes) {
if (g.to < start || g.from > end) continue;
const a = Math.max(x(g.from), L), z = Math.min(x(g.to + 1), W - R);
out += path(`M${a.toFixed(2)} ${b.geneLineY}H${z.toFixed(2)}` +
`M${a.toFixed(2)} ${b.geneLineY - 3}v6M${z.toFixed(2)} ${b.geneLineY - 3}v6`, 'gene-line');
const label = `${b.strand === 'sense' ? '▶' : '◀'} ${g.gene_name || g.gene_id}`;
if (z - a > measure(label, FONTS.site) + 10) {
out += text(b.strand === 'sense' ? a + 4 : z - 4, b.geneLabBase, label, 'gene-label',
b.strand === 'sense' ? 'start' : 'end');
}
}
/* sites are points, never runs: a tick with a small cap, so the idiom does
not resemble an activation stem rising from a baseline */
const marks = s.sites.filter(t => t.base >= start && t.base <= end);
const tickW = Math.max(1.4, Math.min(pxPerBase, 4));
for (const t of marks) {
const px = x(t.base);
out += rect(px, b.siteY, tickW, b.siteH + (b.bandH || 0), `var(--ann-${t.class})`);
out += rect(px - 2 + tickW / 2, b.siteY, 4, 2.5, `var(--ann-${t.class})`);
}
if (!compact) out += siteLabels(marks.map(t => ({ px: x(t.base), cls: t.class })), b.siteLabBase);
return out;
}
/* Site class names, drawn only where they fit without touching each other. */
function siteLabels(marks, base) {
let out = '', lastRight = -1e9;
for (const m of marks.slice().sort((a, b) => a.px - b.px)) {
const long = m.label || SITE_LABEL[m.cls] || m.cls;
const short = m.label ? m.label.split(' ')[0] : (SITE_SHORT[m.cls] || m.cls);
for (const label of [long, short]) {
const w = measure(label, FONTS.site);
if (m.px - w / 2 < lastRight + 5) continue;
if (m.px - w / 2 < L + 1 || m.px + w / 2 > W - R - 1) continue;
out += text(m.px, base, label, 'ann-sitelab', 'middle');
lastRight = m.px + w / 2;
break;
}
}
return out;
}
/* The featured locus carries the report's own gene model instead of a label
track, plus the two donor positions the annotation releases disagree on. */
function drawGeneModel(b, pxPerBase) {
const g = doc.gene_report;
let out = '';
const a = x(baseOf(g.start)), z = x(baseOf(g.end) + 1);
out += path(`M${a.toFixed(2)} ${b.geneLineY}H${z.toFixed(2)}`, 'gene-line');
for (const [s0, s1] of g.exons) {
const ex = x(baseOf(s0)), ew = x(baseOf(s1) + 1) - ex;
out += rect(ex, b.geneLineY - 5, ew, 10, '', 'gene-exon');
}
out += text(a + 4, b.geneLabBase, `▶ ${g.gene_id}`, 'gene-label', 'start');
const c = doc.contested;
if (c) {
const marks = [];
if (c.araport11_donor != null) marks.push({ base: c.araport11_donor, cls: 'donor', label: 'Araport11 donor' });
if (c.tair12_donor != null) marks.push({ base: c.tair12_donor, cls: 'donor', label: 'TAIR12 donor' });
if (c.acceptor != null) marks.push({ base: c.acceptor, cls: 'acceptor', label: 'acceptor' });
const tickW = Math.max(1.4, Math.min(pxPerBase, 4));
for (const m of marks) {
if (m.base < start || m.base > end) continue;
const px = x(m.base);
out += rect(px, b.siteY, tickW, b.siteH + 5, `var(--ann-${m.cls})`);
out += rect(px - 2 + tickW / 2, b.siteY, 4, 2.5, `var(--ann-${m.cls})`);
}
if (!compact) {
out += siteLabels(marks.filter(m => m.base >= start && m.base <= end)
.map(m => ({ px: x(m.base), cls: m.cls, label: m.label })), b.siteLabBase, true);
}
}
return out;
}
/* One lane per selected feature. A bar per pixel column, height = the maximum
activation measured under that column, scaled to the feature's peak over the
whole window so lanes stay comparable as the reader zooms. Columns with no
measurement above zero get no bar at all. */
/* A lane's published name is a claim the report measured. A lane chosen because it
matches an annotated category best in THIS window is a different kind of statement,
so it is labelled with the category and drawn in the softer style. */
let laneMarks = new Map();
function buildLaneMarks() {
laneMarks = new Map();
for (const r of asList(doc && doc.lane_rationale)) {
if (r && r.id != null) laneMarks.set(Number(r.id), r);
}
}
const marksOf = id => laneMarks.get(id) || null;
function drawLanes(cols, pxPerBase) {
if (!shown.length) {
return text(L + 4, G.laneTop + 20,
'No lanes. Pick a feature below to read it base by base.', 'lane-silent', 'start');
}
let out = path(`M${L} ${G.laneRuleY}H${W - R}`, 'reg-rule');
out += compact
? text(L + 2, G.laneRegBase, 'ACTIVATION', 'reg-lab', 'start')
: text(L - 11, G.laneRegBase, 'ACTIVATION', 'reg-lab', 'end');
let plot = '', gutter = '';
shown.forEach((id, slot) => {
const t = trackOf(id);
if (!t) return;
const top = G.laneTop + slot * G.lanePitch + G.laneLab;
const bottom = top + G.laneH;
const colour = featColour(slot);
/* A bar per pixel column, its height the MAXIMUM measured under that
column, scaled to the feature's peak over the whole window so lanes stay
comparable as the reader zooms. A column with nothing above zero gets no
bar at all: zero means the feature did not fire. */
let localMax = 0;
const stems = [];
for (let c = 0; c < cols; c++) {
const from = Math.floor(start + c / pxPerBase);
const to = Math.max(from + 1, Math.floor(start + (c + 1) / pxPerBase));
let peak = 0;
for (let b = from; b < to && b < doc.n_bases; b++) if (t.v[b] > peak) peak = t.v[b];
if (peak > 0) {
if (peak > localMax) localMax = peak;
const h = Math.max(1, peak / t.peak * (G.laneH - 3));
stems.push(`M${(L + c + 0.5).toFixed(2)} ${bottom}V${(bottom - h).toFixed(2)}`);
}
}
const silent = localMax === 0;
plot += path(`M${L} ${bottom}H${W - R}`, silent ? 'lane-base silent' : 'lane-base');
if (stems.length) {
plot += ``;
}
const mk = marksOf(id);
const inWindow = !t.label && mk && mk.nice && mk.mcc_in_window != null;
const name = t.label || (inWindow ? mk.nice : `f${id}`);
const pre = (t.label || inWindow) ? `f${id} · ` : '';
const meta = pre + (silent ? 'silent here' : `max ${num(localMax)}`);
const fit = inWindow ? `marks ${num(mk.mcc_in_window)} here` : null;
const tip = `f${id}${t.label ? ' ' + t.label : ''}` +
(inWindow ? ` — the feature that marks ${mk.nice} best in this window, Matthews`
+ ` correlation ${num(mk.mcc_in_window)} at its own ${mk.cut_percentile}th percentile.`
+ ' Chosen and scored in this window, so it describes this picture rather than'
+ ' estimating how it behaves elsewhere.' : ' — ') + (silent
? `silent over this view; window peak ${num(t.peak)}.`
: `max in view ${num(localMax)}, window peak ${num(t.peak)}.`);
gutter += `${esc(tip)}`;
const dim = silent ? ' opacity="0.4"' : '';
if (compact) {
gutter += rect(L + 1, top - 11, 8, 8, colour, '', ' rx="2"' + dim);
gutter += text(L + 13, top - 4, name,
'halo ' + (t.label ? 'lane-name' : 'lane-name lane-id-name'), 'start');
gutter += text(W - R - 1, top - 4, meta + (fit ? ` · ${num(mk.mcc_in_window)}` : ''),
(silent ? 'lane-silent' : 'lane-max') + ' halo', 'end');
} else {
gutter += rect(L - 7, top, 3, G.laneH, colour, 'lane-key', ' rx="1.5"' + dim);
gutter += text(L - 11, top + 12, name,
t.label ? 'lane-name' : 'lane-name lane-id-name', 'end');
gutter += text(L - 11, top + 25, meta, silent ? 'lane-silent' : 'lane-max', 'end');
if (fit) gutter += text(L - 11, top + 37, fit, 'lane-fit', 'end');
}
gutter += '';
});
return out + `${plot}` + gutter;
}
/* The base this window was chosen for: a solid triangle, deliberately paired
with the anchor's dashed outline — both say "look here", one from the
sampler and one from the selection, and neither is an annotation. For a
moved annotation both frames are drawn, filled for the position the
selection names and hollow for where the newer release puts it, with the
shift bracketed between them once there is room to label it. */
function drawFinding(pxPerBase) {
if (G.findBase === null) return '';
const marks = findingMarks(doc.selection).filter(m => m.base >= start && m.base <= end);
if (!marks.length) return '';
let out = '';
for (const m of marks) {
const px = x(m.base) + pxPerBase / 2;
out += ``;
}
const xs = marks.map(m => x(m.base) + pxPerBase / 2);
const gap = Math.abs(xs[xs.length - 1] - xs[0]);
const e = (doc.selection.evidence) || {};
const lim = Math.min(W - R, G.scaleLeft - 8);
const wide = txt => measureTracked(txt, FONTS.reg, 0.72);
if (marks.length > 1 && gap > 30 && typeof e.shift_bp === 'number') {
const a = Math.min(...xs), z = Math.max(...xs);
out += path(`M${a.toFixed(2)} ${G.findTop + 3.5}H${z.toFixed(2)}`, 'finding-bracket');
out += text((a + z) / 2, G.findBase - 6, `${fmt(e.shift_bp)} bp`, 'finding-lab', 'middle');
/* the side labels are a nicety; the bracket and the base-pair count carry
the claim, so they are dropped rather than pushed off the plot */
if (a - 4 - wide(marks[0].tag) > L) {
out += text(a - 4, G.findBase, marks[0].tag, 'finding-lab', 'end');
}
const last = marks[marks.length - 1].tag;
if (z + 4 + wide(last) < lim) out += text(z + 4, G.findBase, last, 'finding-lab', 'start');
} else {
const px = xs[0];
const long = marks.length > 1
? `${selKind(doc.selection).mark}, ${fmt(e.shift_bp || 0)} bp` : marks[0].tag;
const short = marks.length > 1 ? `${fmt(e.shift_bp || 0)} bp` : marks[0].tag;
let done = false;
for (const label of [long, short]) {
const lw = wide(label);
if (px + 8 + lw < lim) { out += text(px + 8, G.findBase, label, 'finding-lab', 'start'); done = true; break; }
if (px - 8 - lw > L) { out += text(px - 8, G.findBase, label, 'finding-lab', 'end'); done = true; break; }
}
if (!done) {
const lw = wide(short);
const cx = Math.max(L + lw / 2 + 1, Math.min(lim - lw / 2 - 1, px));
out += text(cx, G.findBase, short, 'finding-lab', 'middle');
}
}
return out;
}
/* The anchor is where the sampler looked. Outline only, and labelled, so it can
never read as an annotated base. */
function drawAnchor(pxPerBase) {
if (!doc.anchor || doc.anchor.offset == null) return '';
const at = doc.anchor.offset;
if (at < start || at > end) return '';
const px = x(at);
const w = Math.max(3, pxPerBase + 3);
let out = ``;
out += path(`M${px.toFixed(2)} ${G.anchorTop + 11}V${(G.stripY - 4).toFixed(2)}`, 'anchor');
const label = `ANCHOR · ${String(doc.anchor.kind || '').toUpperCase()}`;
const lw = measureTracked(label, FONTS.reg, 0.9);
const left = px + 6 + lw < Math.min(W - R, G.scaleLeft - 8);
out += text(left ? px + 6 : px - 6, G.anchorBase, label, 'anchor-lab', left ? 'start' : 'end');
return out;
}
/* ---- coordinate bar ---------------------------------------------------- */
function paintRegion(pxPerBase) {
const span = nBasesShown();
const scale = pxPerBase >= 1
? `${num(pxPerBase, pxPerBase >= 10 ? 0 : 1)} px per base`
: `${num(1 / pxPerBase, 1 / pxPerBase >= 10 ? 0 : 1)} bp per column, max`;
region.innerHTML =
`${esc(doc.chrom)}:${fmt(coordMin())}–${fmt(coordMax())}` +
`asm${esc(doc.assembly)}` +
`strand${doc.strand < 0 ? 'minus' : 'plus'}` +
`shown${fmt(span)} of ${fmt(doc.n_bases)} bp` +
`scale${scale}`;
}
/* ---- cursor and readout ------------------------------------------------ */
function focus(base, polite) {
const g = svg.querySelector('#feat-focus');
if (!g) return;
if (base === null || base < start || base > end) { g.innerHTML = ''; return; }
const px = x(base);
const cls = pinned === base ? 'pin' : 'cursor';
let out = path(`M${px.toFixed(2)} ${G.axisY}V${G.gridBottom}`, cls);
if (pinned === base) {
out += ``;
}
g.innerHTML = out;
describe(base, polite);
}
/* The readout is aria-live. Pointer hover would fire it on every pixel, so
politeness is switched off while the pointer drives it and switched back on
for the keyboard and for pinning, which is where a screen-reader user is. */
function setPoliteness(on) {
const want = on ? 'polite' : 'off';
if (readout.getAttribute('aria-live') !== want) readout.setAttribute('aria-live', want);
}
function describe(base, polite) {
setPoliteness(!!polite);
const letter = (doc.seq[base] || 'N').toUpperCase();
const baseTok = `var(--base-${'acgt'.includes(letter.toLowerCase()) ? letter.toLowerCase() : 'n'})`;
let posRows = `base${fmt(base + 1)} / ${
fmt(doc.n_bases)}` +
`chr${esc(doc.chrom)}${fmt(coordOf(base))}` +
`seq${
esc(letter)}`;
if (doc.anchor && doc.anchor.offset === base) {
posRows += `anchorsampled here`;
}
let annRows = '';
for (const strand of strandsCovered()) {
const reg = regionAt(strand, base), site = siteAt(strand, base);
const genes = genesAt(strand, base).map(g => g.gene_name || g.gene_id).filter(Boolean);
let value = reg
? `${esc(REGION_LABEL[reg] || reg)}`
: 'not annotated';
if (site) {
value += ` ${
esc(SITE_LABEL[site] || site)}`;
}
if (genes.length) value += ` ${esc(genes.join(', '))}`;
annRows += `${esc(strand)}${value}`;
}
/* The featured locus carries the report's gene model instead of a label
track, so the same register is derived from its exons and introns. */
if (!annRows && doc.gene_report) {
const g = doc.gene_report;
const c = coordOf(base);
const inGene = c >= g.start && c <= g.end;
const inExon = (g.exons || []).some(([a, z]) => c >= a && c <= z);
const cls = !inGene ? 'intergenic' : inExon ? 'cds' : 'intron';
const word = !inGene ? 'outside the gene' : inExon ? 'CDS' : 'intron';
annRows = `gene model` +
`${esc(word)}` +
(inGene ? ` ${esc(g.gene_id)}` : '') + ``;
const con = doc.contested;
if (con) {
const which = con.araport11_donor === base
? { cls: 'donor', site: 'donor', who: 'where TAIR10 and Araport11 put it' }
: con.tair12_donor === base
? { cls: 'donor', site: 'donor', who: 'where the 1999 BAC and TAIR12 put it' }
: con.acceptor === base
? { cls: 'acceptor', site: 'acceptor', who: 'of the same intron' } : null;
if (which) {
annRows += `contested` +
`${
esc(which.site)}${esc(which.who)}`;
}
}
}
if (!annRows) {
annRows = 'registerno annotation track for this window';
}
const hits = shown.map(id => {
const t = trackOf(id);
return t ? { id, t, v: t.v[base] } : null;
}).filter(Boolean);
const fired = hits.filter(h => h.v > 0).sort((a, b) => b.v - a.v);
const zero = hits.filter(h => h.v === 0);
let actBody = fired.length
? '
silent here · ${
zero.map(h => 'f' + h.id).join(' ')}
`;
}
const gid = topGlobal(base);
actBody += `
${gid >= 0
? `strongest of all 8,192 here: f${gid}` + (shown.includes(gid) ? '' : ' (no lane)')
: 'no feature in the dictionary fired at this base'}
`;
readout.innerHTML =
`
Position${
pinned === base ? 'pinned' : ''}
` +
`
${posRows}
` +
`
Annotation
` +
`
${annRows}
` +
`
Activationstride 1
${actBody}
`;
paintPanel(base);
}
/* ---- view control ------------------------------------------------------ */
function setView(a, b) {
const width = Math.round(Math.max(40, Math.min(doc.n_bases, b - a)));
start = Math.max(0, Math.min(doc.n_bases - width, Math.round(a)));
end = start + width;
render();
}
/* Panning fires per pointermove; one render per frame is enough. */
let viewRaf = 0, pendingView = null;
function setViewSoon(a, b) {
pendingView = [a, b];
if (viewRaf) return;
viewRaf = requestAnimationFrame(() => {
viewRaf = 0;
const [p, q] = pendingView;
setView(p, q);
});
}
function zoom(factor) {
const centre = (pinned !== null && pinned >= start && pinned <= end) ? pinned : (start + end) / 2;
const width = Math.max(40, Math.min(doc.n_bases, nBasesShown() * factor));
setView(centre - width / 2, centre + width / 2);
}
/* ---- window picker -----------------------------------------------------
The layout is read out of the data, never assumed. When the windows carry a
`selection` field the picker groups by species and labels each window by
what it shows — its gene and whether the features agree with the annotation
or contest it. Without that field it falls back to the species-by-anchor
matrix, so the page never breaks while the window set is being swapped. */
function pickerPlan() {
const featured = index.windows.find(w => w.featured);
const rest = index.windows.filter(w => !w.featured);
const groups = [];
const byKey = new Map();
for (const w of rest) {
if (!byKey.has(w.species)) {
const g = { species: w.species, binomial: w.species_binomial, family: w.family, windows: [], cells: new Map() };
byKey.set(w.species, g); groups.push(g);
}
const g = byKey.get(w.species);
g.windows.push(w);
g.cells.set((w.anchor && w.anchor.kind) || 'other', w);
}
const described = rest.some(w => w.selection && w.selection.kind);
if (described) return { shape: 'selection', featured, groups };
const kinds = [];
for (const w of rest) {
const k = (w.anchor && w.anchor.kind) || 'other';
if (!kinds.includes(k)) kinds.push(k);
}
const cols = ANCHOR_ORDER.filter(k => kinds.includes(k))
.concat(kinds.filter(k => !ANCHOR_ORDER.includes(k)));
return { shape: 'anchor', featured, groups, cols };
}
/* what a window is called in the picker: its gene, else its uid */
const windowGeneLabel = w => {
const genes = asList(w.genes_sense);
return genes.length ? shortList(genes) : w.window_uid;
};
function paintPicker() {
const plan = pickerPlan();
let out = '';
if (plan.featured) {
const f = plan.featured;
out += ``;
}
if (plan.shape === 'selection') {
for (const g of plan.groups) {
out += `
`;
for (const w of g.windows) {
const sel = w.selection || {};
const k = selKind(sel);
const name = windowGeneLabel(w);
const metric = selMetric(sel);
const usage = w.usage_check;
const uv = usage ? usageVerdict(usage) : null;
const aria = [`${name}, ${g.binomial}`, k.tag, sel.headline, sel.why,
uv ? `usage: ${uv.tag}, ${uv.gloss}` : '', w.window_uid]
.filter(Boolean).join(' — ');
out += `';
}
out += '
';
}
} else if (plan.groups.length) {
out += `
`;
out += '';
for (const k of plan.cols) out += `${esc(ANCHOR_COL[k] || k)}`;
for (const g of plan.groups) {
out += `${esc(g.binomial)}${
g.family ? `${esc(g.family)}` : ''}`;
for (const k of plan.cols) {
const w = g.cells.get(k);
if (!w) { out += ''; continue; }
const aria = `${ANCHOR_TITLE[k] || k} window in ${g.binomial}, ${w.window_uid}`;
out += ``;
}
}
out += '
';
}
picker.innerHTML = out;
picker.classList.toggle('win-wide', plan.shape === 'selection');
/* one row per species when the viewport can hold it, however many windows a
species turns out to have */
const widest = plan.groups.reduce((n, g) => Math.max(n, g.windows.length), 1);
picker.style.setProperty('--wl-cols', String(Math.min(8, Math.max(1, widest))));
/* Twenty-one buttons would be twenty-one tab stops. One radiogroup with a
roving tabindex is one stop, and a screen reader announces the position. */
picker.setAttribute('role', 'radiogroup');
const cells = Array.from(picker.querySelectorAll('[data-uid]'));
cells.forEach((b, i) => {
b.setAttribute('role', 'radio');
b.setAttribute('aria-checked', 'false');
b.tabIndex = i === 0 ? 0 : -1;
b.addEventListener('click', () => choose(b.dataset.uid));
b.addEventListener('keydown', ev => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[ev.key];
let next = null;
if (step) next = cells[(i + step + cells.length) % cells.length];
else if (ev.key === 'Home') next = cells[0];
else if (ev.key === 'End') next = cells[cells.length - 1];
if (!next) return;
ev.preventDefault();
next.focus();
choose(next.dataset.uid);
});
});
}
/* ---- feature picker ---------------------------------------------------- */
/* Thirty-eight rows, grouped: the fourteen the report names, by what kind of
thing they detect, then the rest ranked by how many bases they win here. */
function paintList() {
const named = doc.features.filter(f => f.curated);
const ranked = doc.features.filter(f => !f.curated)
.sort((a, b) => b.argmax_bases - a.argmax_bases || b.peak - a.peak);
/* a window can store thousands of tracks; the list offers the ones that win
the most bases, and the panel finds any of the rest at a base */
const rest = ranked.slice(0, LIST_UNNAMED);
const full = shown.length >= MAX_LANES;
const row = f => {
const on = shown.includes(f.id);
const slot = slotOf(f.id);
const silent = !(f.peak > 0);
const cov = doc.n_bases ? Math.min(100, Math.round(f.n_fired / doc.n_bases * 100)) : 0;
const cls = ['fl-row', f.curated ? 'named' : '', on ? 'on' : '', silent ? 'silent' : ''].join(' ');
const detail = silent
? 'silent here'
: `${num(f.peak)}`;
const covTitle = silent ? 'no base fired in this window'
: `${fmt(f.n_fired)} of ${fmt(doc.n_bases)} bases fired (${cov}%)`;
/* the checkbox itself is the lane's colour swatch once it is on */
const tint = on ? ` style="accent-color:${featColour(slot)}"` : '';
const what = f.label
? esc(f.label)
: `wins ${fmt(f.argmax_bases)} bp of the strip`;
return ``;
};
let namedRows = '';
const seen = new Set();
for (const kind of KIND_ORDER.concat(['other'])) {
const bucket = named.filter(f => (KIND_ORDER.includes(f.kind) ? f.kind : 'other') === kind);
if (!bucket.length) continue;
bucket.sort((a, b) => b.peak - a.peak);
namedRows += `
${esc(KIND_GROUP[kind] || 'other')}
`;
namedRows += bucket.map(f => { seen.add(f.id); return row(f); }).join('');
}
/* the fold state is the reader's, not something a repaint gets to reset */
if (restOpen === null) restOpen = false;
const open = restOpen;
list.innerHTML =
`
${shown.length} of ${MAX_LANES} lanes${
full ? ' · full' : ''}` +
`
` +
(namedRows
? `
Named in the report ${named.length}` +
`the report measures what each one detects
` +
`
${namedRows}
`
: '') +
(rest.length
? `Unnamed ${
rest.length}${ranked.length > rest.length ? ' of ' + fmt(ranked.length) : ''}` +
`ranked by bases won in this window` +
`
${rest.map(row).join('')}
`
: '') +
`
Numbers are this window's peak activation for the feature; the bar is the share of
the window's bases where it fired at all. Up to ${fmt(MAX_LANES)} lanes at a time${
ranked.length > rest.length
? `. This window stores tracks for ${fmt(doc.features.length)} features in all — Top features finds any of them at a base`
: ''}.
`;
const details = list.querySelector('details.fl-group');
if (details) details.addEventListener('toggle', () => { restOpen = details.open; });
list.querySelectorAll('input').forEach(box => {
box.addEventListener('change', () => {
const id = Number(box.dataset.id);
if (box.checked) {
if (shown.length < MAX_LANES) shown.push(id); else box.checked = false;
} else shown = shown.filter(f => f !== id);
/* the list is repainted, so hand keyboard focus back to the same row */
const refocus = document.activeElement === box;
paintList(); render();
if (refocus) {
const again = list.querySelector(`input[data-id="${id}"]`);
if (again) again.focus({ preventScroll: true });
}
});
});
const reset = list.querySelector('.fl-reset');
if (reset) reset.addEventListener('click', () => {
shown = asList(doc.default_features).slice(0, MAX_LANES).filter(id => trackOf(id));
paintList(); render();
});
}
/* ---- titles ------------------------------------------------------------ */
/* Windows are named by the gene they cover and what they show. The sampler
anchor is still drawn in the plot, but it is not what a window is called. */
const shortList = names => names.slice(0, 2).join(', ') + (names.length > 2 ? '…' : '');
const docGeneLabel = () => {
const sense = doc.annotations && doc.annotations.sense;
const own = asList(sense && sense.genes).map(g => g.gene_name || g.gene_id).filter(Boolean);
if (own.length) return shortList(own);
const meta = index && index.windows.find(w => w.window_uid === doc.window_uid);
const listed = asList(meta && meta.genes_sense);
if (listed.length) return shortList(listed);
if (doc.gene_report && doc.gene_report.gene_id) return doc.gene_report.gene_id;
return doc.window_uid;
};
const windowTitleText = () => doc.featured && doc.gene_report
? `${doc.gene_report.gene_id}, the contested donor`
: `${docGeneLabel()}${doc.selection ? ' — ' + selKind(doc.selection).tag.toLowerCase() : ''}`;
const ariaLabel = () =>
`${windowTitleText()}. Genome browser: a per-base top-feature strip, an annotation register and ${
shown.length} activation lane${shown.length === 1 ? '' : 's'} over ${fmt(nBasesShown())} of ${
fmt(doc.n_bases)} base pairs.`;
function paintHeading() {
if (doc.featured && doc.gene_report) {
title.innerHTML = `${esc(doc.gene_report.gene_id)}, the contested donor`;
} else {
const k = doc.selection ? selKind(doc.selection) : null;
title.innerHTML = esc(docGeneLabel()) + (k
? ` ${esc(k.tag)}` : '');
}
const bits = [`${esc(doc.window_uid)}`,
`${esc(doc.species_binomial)}`];
if (doc.family) bits.push(esc(doc.family));
bits.push(esc(doc.assembly));
bits.push(`${fmt(doc.n_bases)} bp`);
sub.innerHTML = bits.join('/') +
(doc.split ? `${esc(doc.split)}` : '');
paintCase();
}
/* What this window shows, as one island: the reason it was chosen, and — for the
windows where a feature marks a site the annotation does not carry — the
verdict the independent registers returned. A window with nothing to check
shows only the reason, never an unanswered question. */
function paintCase() {
if (!caseEl) return;
const sel = doc.selection;
const u = doc.usage_check;
if (!sel && !doc.note) { caseEl.hidden = true; caseEl.innerHTML = ''; return; }
const k = sel ? selKind(sel) : null;
/* doc.note repeats the headline as its own first clause, so showing both
would stutter; the headline is the chip and the rest is the description. */
const why = (sel && sel.why) || doc.note || '';
let html = '
Usage is asked of registers independent of the annotation the ' +
'tracks were scored against, and never of the model. ' +
'What each register is.
' +
'
';
}
caseEl.innerHTML = html;
caseEl.hidden = false;
}
/* ---- window loading ---------------------------------------------------- */
let gen = 0;
async function load(uid) {
const mine = ++gen;
if (!cache.has(uid)) {
host.setAttribute('aria-busy', 'true');
region.textContent = `Loading ${uid}…`;
let payload;
try {
const r = await fetch(`data/sae/${uid}.json`);
if (!r.ok) throw new Error(`window ${uid} unavailable`);
payload = await r.json();
} finally {
if (mine === gen) host.removeAttribute('aria-busy');
}
/* a slower earlier click must not overwrite a later one */
if (mine !== gen) return;
cache.set(uid, payload);
}
if (mine !== gen) return;
doc = cache.get(uid);
vectors = new Map();
tracks = new Map();
runIdx.clear();
featureIndex = new Map(asList(doc.features).map(f => [f.id, f]));
buildArgmaxLut();
buildLaneMarks();
shown = asList(doc.default_features).slice(0, MAX_LANES).filter(id => trackOf(id));
start = 0; end = doc.n_bases;
/* the cursor opens on the base the window was chosen for, so the readout
already describes the finding without pinning it and blocking hover */
cursor = doc.selection && doc.selection.position != null ? doc.selection.position
: (doc.anchor && doc.anchor.offset != null ? doc.anchor.offset : Math.floor(doc.n_bases / 2));
pinned = null;
paintHeading();
picker.querySelectorAll('[data-uid]').forEach(b => {
const on = b.dataset.uid === uid;
b.setAttribute('aria-checked', String(on));
b.tabIndex = on ? 0 : -1;
});
if (findBtn) {
const marks = findingMarks(doc.selection);
findBtn.hidden = !marks.length;
findBtn.textContent = marks.length > 1 ? 'Show the shift' : 'Zoom to change locus';
findBtn.title = marks.length
? `Frame ${marks.length > 1 ? 'both positions' : 'base ' + fmt(marks[0].base + 1)}, the reason this window is here`
: '';
}
paintList();
render();
tools.forEach(b => { if (b) b.disabled = false; });
}
/* ---- top-features panel ------------------------------------------------
Orders the features firing at the cursor by reading the window's stored
tracks at that base. Clicking a row gives the feature a lane. */
let panel = null, panelDock = null, panelBase = null, findBtn = null;
let controls = null, controlsDock = null;
const PANEL_ROWS = 8;
/* The window stores a full track for every feature that reaches a base's top
`panel_depth` anywhere in it, so the strongest `panel_depth` at any base are
all present and scanning the stored tracks gives the true ordering there.
The data states the rule; asking for more rows than it guarantees is the one
case where the panel has to qualify what it shows. */
const storedDepth = () => {
const c = doc && doc.coverage;
const d = c && Number(c.panel_depth);
return Number.isFinite(d) && d > 0 ? d : 0;
};
function topAt(base) {
const rows = [];
for (const f of doc.features) {
const v = valueAt(f, base);
if (v > 0) rows.push({ id: f.id, v, f });
}
rows.sort((a, b) => b.v - a.v);
/* The claim holds when the panel asks for no more rows than the store
guarantees; otherwise it says only what it can see. */
const depth = storedDepth();
return { rows: rows.slice(0, PANEL_ROWS), nz: rows.length,
whole: depth > 0 && PANEL_ROWS <= depth };
}
/* ---- floating docks ----------------------------------------------------
Both movable surfaces — the top-features panel and the lanes dock — are
positioned inside .feat-stage rather than the viewport, so they scroll away
with the figure instead of following the reader down the page. A drag is
clamped to the stage, and a remembered position that no longer falls inside
it is discarded rather than restoring a panel where it cannot be reached. */
const DOCK_KEY = 'botanic1.sae.docks';
const docks = [];
const stageEl = () => host.closest('.feat-stage') || host.closest('figure') || host.parentElement;
function readDocks() {
try {
const raw = sessionStorage.getItem(DOCK_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (error) { return {}; }
}
function writeDocks(next) {
try { sessionStorage.setItem(DOCK_KEY, JSON.stringify(next)); } catch (error) { /* private mode */ }
}
function saveDock(d) {
const all = readDocks();
const was = all[d.name] || {};
const entry = { open: !d.el.hidden, left: was.left, top: was.top };
if (d.floating() && !d.el.hidden) {
const box = d.el.getBoundingClientRect(), stage = stageEl().getBoundingClientRect();
entry.left = Math.round(box.left - stage.left);
entry.top = Math.round(box.top - stage.top);
}
all[d.name] = entry;
writeDocks(all);
}
function makeDock(spec) {
const el = spec.el;
const d = {
el, name: spec.name, toggle: spec.toggle, rest: spec.rest,
floating: () => getComputedStyle(el).position === 'absolute'
};
/* place, in stage coordinates, clamped so the whole surface stays reachable */
d.place = (left, top) => {
if (!d.floating()) { el.style.maxHeight = ''; return; }
const stage = stageEl().getBoundingClientRect();
/* let it measure against the whole stage, clamp, then cap to what is left
below wherever it landed, so its body scrolls instead of hanging out */
el.style.maxHeight = `${Math.max(160, Math.round(stage.height - 20))}px`;
const w = el.offsetWidth || 300, h = el.offsetHeight || 200;
const x = Math.max(0, Math.min(Math.max(0, stage.width - w), left));
const y = Math.max(0, Math.min(Math.max(0, stage.height - h), top));
el.style.left = `${Math.round(x)}px`;
el.style.top = `${Math.round(y)}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.maxHeight = `${Math.max(160, Math.round(stage.height - y - 12))}px`;
};
d.inStage = (left, top) => {
const stage = stageEl().getBoundingClientRect();
const w = el.offsetWidth || 300, h = el.offsetHeight || 200;
return left >= -2 && top >= -2 && left <= stage.width - w + 2 && top <= stage.height - h + 2;
};
d.setOpen = open => {
el.hidden = !open;
if (d.toggle) d.toggle.setAttribute('aria-expanded', String(open));
if (open) {
const saved = readDocks()[d.name] || {};
requestAnimationFrame(() => {
if (saved.left != null && saved.top != null && d.inStage(saved.left, saved.top)) {
d.place(saved.left, saved.top);
} else d.settle();
});
}
saveDock(d);
};
d.settle = () => {
if (!d.floating() || el.hidden) return;
const at = d.rest(d);
if (at) d.place(at.left, at.top);
};
const handle = el.querySelector(spec.handle);
if (handle) {
let grab = null;
handle.addEventListener('pointerdown', ev => {
if (ev.target.closest('button')) return;
if (!d.floating()) return;
const box = el.getBoundingClientRect(), stage = stageEl().getBoundingClientRect();
grab = { dx: ev.clientX - box.left, dy: ev.clientY - box.top, sx: stage.left, sy: stage.top };
handle.classList.add('dragging');
handle.setPointerCapture(ev.pointerId);
});
handle.addEventListener('pointermove', ev => {
if (!grab) return;
d.place(ev.clientX - grab.dx - grab.sx, ev.clientY - grab.dy - grab.sy);
});
const drop = ev => {
if (!grab) return;
grab = null;
handle.classList.remove('dragging');
if (handle.hasPointerCapture(ev.pointerId)) handle.releasePointerCapture(ev.pointerId);
saveDock(d);
};
handle.addEventListener('pointerup', drop);
handle.addEventListener('pointercancel', drop);
}
docks.push(d);
return d;
}
/* When there is room the stage grows a right gutter and the lanes dock rests
in it, beside the canvas rather than on top of it. Narrower than that it is
an overlay the reader can drag, and narrower still it drops into the flow. */
const GUTTER_AT = 1081;
function syncGutter() {
const stage = stageEl();
const want = !!(controls && controlsDock && !controls.hidden
&& controlsDock.floating() && window.innerWidth >= GUTTER_AT);
if (stage.classList.contains('stage-docked') !== want) {
stage.classList.toggle('stage-docked', want);
return true;
}
return false;
}
/* a resting place inside the stage that keeps clear of the other open dock */
function restIn(d, corner) {
const stage = stageEl().getBoundingClientRect();
const w = d.el.offsetWidth || 300, h = d.el.offsetHeight || 220;
const pad = 14;
const top = stage.height - h - pad;
let y = corner === 'top' ? 92 : Math.max(92, top);
const other = docks.find(o => o !== d && o.floating() && !o.el.hidden);
let x = Math.max(pad, stage.width - w - pad);
if (other) {
const ob = other.el.getBoundingClientRect();
const oy = ob.top - stage.top, oh = ob.height;
const overlaps = y < oy + oh && y + h > oy;
if (overlaps) {
if (oy + oh + pad + h <= stage.height) y = oy + oh + pad;
else if (oy - pad - h >= 0) y = oy - pad - h;
else x = Math.max(pad, ob.left - stage.left - w - pad);
}
}
return { left: x, top: y };
}
/* the top-features panel: the page ships the shell, the fallback keeps it
working if it ever does not */
function ensurePanel() {
if (panel) return;
const stage = stageEl();
panel = document.getElementById('feat-panel');
if (!panel) { panel = document.createElement('aside'); panel.id = 'feat-panel'; }
panel.className = 'fp feat-float';
panel.setAttribute('role', 'group');
panel.setAttribute('aria-label', 'Top features at the inspected base');
panel.hidden = true;
panel.innerHTML =
'
Top features here
' +
'' +
'
' +
'';
/* absolute positioning has to resolve against the stage, and the figure
clips its own overflow, so the panel lives as the stage's last child;
in static flow that also puts it after the lanes dock */
stage.appendChild(panel);
let toggle = document.getElementById('feat-panel-toggle');
if (!toggle) {
toggle = document.createElement('button');
toggle.type = 'button';
toggle.id = 'feat-panel-toggle';
toggle.setAttribute('aria-controls', 'feat-panel');
toggle.textContent = 'Top features';
(document.querySelector('.feat-tools') || stage).appendChild(toggle);
}
toggle.disabled = false;
panelDock = makeDock({
el: panel, name: 'panel', toggle, handle: '.fp-head',
rest: d => restIn(d, 'bottom')
});
toggle.addEventListener('click', () => {
panelDock.setOpen(panel.hidden);
if (!panel.hidden) paintPanel(panelBase === null ? cursor : panelBase);
});
panel.querySelector('.fp-close').addEventListener('click', () => {
panelDock.setOpen(false);
toggle.focus();
});
panel.addEventListener('keydown', ev => {
if (ev.key === 'Escape') { panelDock.setOpen(false); toggle.focus(); }
});
const saved = readDocks().panel || {};
if (saved.open) { panelDock.setOpen(true); paintPanel(cursor); }
}
/* the lanes dock: colouring mode and the feature checklist, beside the canvas
instead of a screen away from it */
function ensureControls() {
if (controls) return;
controls = document.getElementById('feat-controls');
if (!controls) return;
controls.classList.add('feat-float');
let close = controls.querySelector('.dock-close');
const head = controls.querySelector('.dock-title');
if (head && !close) {
close = document.createElement('button');
close.type = 'button';
close.className = 'dock-close';
close.setAttribute('aria-label', 'Close the lanes panel');
close.innerHTML = '×';
head.appendChild(close);
}
const toggle = document.getElementById('feat-controls-toggle');
if (toggle) toggle.disabled = false;
controlsDock = makeDock({
el: controls, name: 'controls', toggle, handle: '.dock-title',
rest: d => {
if (stageEl().classList.contains('stage-docked')) {
const stage = stageEl().getBoundingClientRect();
return { left: stage.width - (d.el.offsetWidth || 336) - 7, top: 0 };
}
return restIn(d, 'top');
}
});
const wrapped = controlsDock.setOpen;
controlsDock.setOpen = open => {
controls.hidden = !open;
syncGutter();
wrapped(open);
};
if (toggle) toggle.addEventListener('click', () => controlsDock.setOpen(controls.hidden));
if (close) close.addEventListener('click', () => {
controlsDock.setOpen(false);
if (toggle) toggle.focus();
});
const saved = readDocks().controls || {};
controlsDock.setOpen(saved.open !== false);
}
function paintPanel(base) {
panelBase = base;
if (!panel || panel.hidden || base == null || !doc) return;
const { rows, nz, whole } = topAt(base);
panel.querySelector('.fp-at').textContent = `base ${fmt(base + 1)} · ${esc(doc.seq[base] || '')}`;
panel.querySelector('.fp-src').innerHTML = whole
? `The ${fmt(rows.length)} strongest features at this base, out of all 8,192.` +
` This window carries a complete track for every feature that reaches a base's top ${
fmt(storedDepth())} anywhere in it, so these are the true strongest here;` +
` ${fmt(nz)} of the ${fmt(doc.features.length)} it stores fire at this base.`
: `The ${fmt(rows.length)} strongest at this base among the ${
fmt(doc.features.length)} features this window stores.`;
const list = panel.querySelector('.fp-rows');
if (!rows.length) {
list.innerHTML = '
No feature fired at this base.
';
} else {
list.innerHTML = rows.map(r => {
const on = shown.includes(r.id);
const colour = on ? featColour(slotOf(r.id)) : 'var(--rule-2)';
const name = r.f.label ? esc(r.f.label) : 'unnamed';
const tip = on ? 'Remove this lane' : 'Give this feature a lane';
return ``;
}).join('');
list.querySelectorAll('.fp-row').forEach(btn => btn.addEventListener('click', () => {
const id = Number(btn.dataset.id);
if (shown.includes(id)) shown = shown.filter(f => f !== id);
else if (shown.length < MAX_LANES) shown.push(id);
else return;
paintList(); render();
}));
}
const gid = topGlobal(base);
panel.querySelector('.fp-foot').innerHTML =
(gid >= 0 ? `Rank 0 of all 8,192 here: f${gid}` : 'Nothing in the dictionary fired here') +
`Click a row to add or remove its lane, up to ${fmt(MAX_LANES)}.`;
}
/* A window that will not load is reported in the readout. The plot keeps the
window it already has rather than being replaced by a fallback paragraph,
which would detach the svg that render() writes into. */
function choose(uid) {
load(uid).catch(error => {
host.removeAttribute('aria-busy');
if (doc) paintRegion(G ? G.plotW / nBasesShown() : 1);
readout.setAttribute('aria-live', 'polite');
readout.innerHTML = `
Window unavailable
` +
`
${esc(uid)} could not be loaded. The window on screen is unchanged;` +
` pick another window or reload the page.
`;
if (window.console) console.error(error);
});
}
/* ---- boot -------------------------------------------------------------- */
try {
const r = await fetch('data/sae/index.json');
if (!r.ok) throw new Error('feature index unavailable');
index = await r.json();
paintPicker();
ensurePanel();
ensureControls();
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('role', 'group');
svg.setAttribute('tabindex', '0');
svg.setAttribute('aria-describedby', 'feat-help');
svg.setAttribute('aria-label', 'Feature browser, loading');
host.appendChild(svg);
/* the finding is a handful of bases in 8,191; one click frames it */
findBtn = document.createElement('button');
findBtn.type = 'button';
findBtn.id = 'feat-finding';
findBtn.hidden = true;
findBtn.addEventListener('click', () => {
const marks = findingMarks(doc.selection);
if (!marks.length) return;
const bases = marks.map(m => m.base);
const lo = Math.min(...bases), hi = Math.max(...bases);
const span = Math.max(240, (hi - lo) * 4);
const centre = (lo + hi) / 2;
cursor = marks[0].base;
pinned = null;
setView(centre - span / 2, centre + span / 2);
focus(cursor, true);
});
(document.querySelector('.feat-tools') || host.parentElement).appendChild(findBtn);
document.getElementById('feat-in').addEventListener('click', () => zoom(0.5));
document.getElementById('feat-out').addEventListener('click', () => zoom(2));
document.getElementById('feat-full').addEventListener('click', () => setView(0, doc.n_bases));
modeBtns.forEach(b => b.addEventListener('click', () => {
mode = b.dataset.mode;
modeBtns.forEach(o => o.setAttribute('aria-pressed', String(o === b)));
render();
}));
const pointBase = ev => {
const p = svg.createSVGPoint();
p.x = ev.clientX; p.y = ev.clientY;
const q = p.matrixTransform(svg.getScreenCTM().inverse());
if (q.x < L || q.x > W - R) return null;
const base = Math.floor(start + (q.x - L) / G.plotW * nBasesShown());
return base >= 0 && base < doc.n_bases ? base : null;
};
const midBase = () => {
const [a, b] = Array.from(pointers.values());
const p = svg.createSVGPoint();
p.x = (a.x + b.x) / 2; p.y = (a.y + b.y) / 2;
const q = p.matrixTransform(svg.getScreenCTM().inverse());
return start + (Math.min(Math.max(q.x, L), W - R) - L) / G.plotW * nBasesShown();
};
const spread = () => {
const [a, b] = Array.from(pointers.values());
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
};
svg.addEventListener('pointerdown', ev => {
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
if (pointers.size === 2) {
drag = null;
const at = midBase();
pinch = { d0: spread(), width: nBasesShown(), at, frac: (at - start) / Math.max(1, nBasesShown()) };
} else if (pointers.size === 1) {
drag = { px: ev.clientX, start, end, moved: false };
}
});
svg.addEventListener('pointermove', ev => {
if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
if (pinch && pointers.size >= 2) {
const scale = pinch.d0 / spread();
const width = Math.max(40, Math.min(doc.n_bases, pinch.width * scale));
const f = Math.min(Math.max(pinch.frac, 0), 1);
setViewSoon(pinch.at - width * f, pinch.at + width * (1 - f));
return;
}
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 / G.plotW * (drag.end - drag.start);
setViewSoon(drag.start + shift, drag.end + shift);
return;
}
}
const base = pointBase(ev);
if (base !== null && base !== cursor && pinned === null) { cursor = base; focus(base, false); }
});
svg.addEventListener('pointerup', ev => {
if (drag && !drag.moved) {
const base = pointBase(ev);
if (base !== null) {
pinned = pinned === base ? null : base;
cursor = base;
focus(pinned === null ? cursor : pinned, true);
}
}
pointers.delete(ev.pointerId);
if (pointers.size < 2) pinch = null;
drag = null;
if (svg.hasPointerCapture(ev.pointerId)) svg.releasePointerCapture(ev.pointerId);
});
svg.addEventListener('pointercancel', ev => {
pointers.delete(ev.pointerId);
if (pointers.size < 2) pinch = null;
drag = null;
});
svg.addEventListener('pointerleave', () => {
if (!drag) focus(pinned === null ? cursor : pinned, false);
});
svg.addEventListener('keydown', ev => {
if (ev.key === '+' || ev.key === '=') { ev.preventDefault(); zoom(0.5); return; }
if (ev.key === '-' || ev.key === '_') { ev.preventDefault(); zoom(2); return; }
if (ev.key === 'Home') { ev.preventDefault(); setView(0, doc.n_bases); return; }
if (ev.key === 'Escape') {
if (pinned !== null) { ev.preventDefault(); pinned = null; focus(cursor, true); }
return;
}
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
pinned = pinned === cursor ? null : cursor;
focus(pinned === null ? cursor : pinned, true);
return;
}
if (!['ArrowLeft', 'ArrowRight'].includes(ev.key)) return;
ev.preventDefault();
const step = ev.shiftKey ? Math.max(1, Math.round(nBasesShown() / 20)) : 1;
cursor = Math.max(0, Math.min(doc.n_bases - 1, cursor + (ev.key === 'ArrowLeft' ? -step : step)));
pinned = cursor;
if (cursor < start || cursor > end) {
const width = nBasesShown();
setView(cursor - width / 2, cursor + width / 2);
} else focus(cursor, true);
});
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => render());
let settleRaf;
const settleDocks = () => {
cancelAnimationFrame(settleRaf);
settleRaf = requestAnimationFrame(() => docks.forEach(d => {
if (!d.floating() || d.el.hidden) return;
const box = d.el.getBoundingClientRect(), stage = stageEl().getBoundingClientRect();
d.place(box.left - stage.left, box.top - stage.top);
}));
};
new ResizeObserver(settleDocks).observe(stageEl());
window.addEventListener('resize', () => { syncGutter(); docks.forEach(d => d.settle()); settleDocks(); });
let raf;
new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(render); }).observe(host);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => render());
await load(index.windows[0].window_uid);
} catch (error) {
fail(error);
}
/* The (?) beside the lane toggle. Hover opens it for a reader with a mouse and
click pins it open, because a hover-opened panel that a click then shut would
make the click look broken. Only a pinned panel survives the pointer leaving.
The close is delayed a beat because the pointer has to cross a gap to reach
the panel it just opened. Nothing here depends on a loaded window, so the
button is live from the first paint. */
function wireHelp() {
const btn = document.getElementById('feat-help-toggle');
const panel = document.getElementById('feat-help-panel');
if (!btn || !panel) return;
let shut, pinned = false;
const set = open => {
clearTimeout(shut);
panel.hidden = !open;
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
};
const close = () => { if (!pinned) shut = setTimeout(() => set(false), 220); };
const unpin = () => { pinned = false; set(false); };
btn.addEventListener('click', () => {
pinned = !pinned;
set(pinned);
});
btn.addEventListener('pointerenter', () => set(true));
btn.addEventListener('focus', () => set(true));
btn.addEventListener('pointerleave', close);
panel.addEventListener('pointerenter', () => clearTimeout(shut));
panel.addEventListener('pointerleave', close);
/* A click inside the panel must not shut it, but a click anywhere else must. */
document.addEventListener('pointerdown', ev => {
if (!panel.hidden && !panel.contains(ev.target) && ev.target !== btn) unpin();
});
document.addEventListener('keydown', ev => {
if (ev.key === 'Escape' && !panel.hidden) { unpin(); btn.focus(); }
});
}
function fail(error) {
if (region) region.textContent = 'Feature windows';
if (readout) readout.textContent = 'The precomputed windows could not be loaded. Reload the page to try again.';
host.innerHTML = '
The interpretability results are described in the report.
';
if (window.console) console.error(error);
}
})();