// =========================================================================
// §1, Gene completion + annotation overlay
// =========================================================================
(function initDemo1() {
const els = {
pills: document.getElementById("d1-pills"),
info: document.getElementById("d1-info"),
track: document.getElementById("d1-track"),
seq: document.getElementById("d1-seq"),
go: document.getElementById("d1-go"),
stop: document.getElementById("d1-stop"),
status: document.getElementById("d1-status"),
statusText: document.querySelector("#d1-status span:last-child"),
id: document.getElementById("d1-id"),
idExon: document.getElementById("d1-id-exon"),
idIntron:document.getElementById("d1-id-intron"),
tok: document.getElementById("d1-tok"),
lp: document.getElementById("d1-lp"),
ppl: document.getElementById("d1-ppl"),
};
let gene = null;
let prefixStart = 0;
let prefixEnd = 200;
let genEnd = 460; // end of generated region (genLen = genEnd - prefixEnd)
const MIN_PROMPT_BP = 6; // at least one BPE token's worth
const MIN_GEN_BP = 6;
const DEFAULT_PROMPT_BP = 200; // context bases before the prompt end
const DEFAULT_GEN_BP = 200; // bases the model is asked to generate
let abortCtrl = null;
let dragging = null; // "start" | "end" | "genend" | null
let promptBases = "";
let genText = "";
let genTokens = []; // [{text, logprob}]
let genTokenAtBase = []; // index into genTokens for each generated base
function setStatus(text, mode = "") {
els.statusText.textContent = text;
// No "idle" UI: an empty or "idle" text means the demo hasn't done
// anything meaningful yet → hide the pill entirely so the toolbar
// stays clean. setStatus("done · 432 bp", ...) or any non-idle text
// brings it back via the className reset.
const hide = !text || text === "idle";
els.status.className = "status" + (mode ? " " + mode : "") + (hide ? " is-hidden" : "");
}
function renderTrack() {
const W = 1000, H = 52;
if (!gene) { els.track.innerHTML = ""; return; }
const scaleX = (bp) => (bp / gene.length) * W;
// Track body sits y=12..40; arrow tips reach y=0 (start/genend, top) and y=52 (end, bottom).
const TRACK_TOP = 12, TRACK_BOT = 40, INTRON_Y = 26, EXON_Y = 20, EXON_H = 12;
// Triangle half-width and arrow vertical run: bumped so the draggable
// handles read clearly without dominating the timeline body.
const TRI_HW = 9, ARROW = 12;
let svg = "";
// Background line through introns
svg += ``;
// Exon rectangles
for (const e of gene.exons) {
const x = scaleX(e.start);
const w = Math.max(1, scaleX(e.end - e.start));
svg += ``;
}
// Selected prompt region (very faint, between handles)
const xStart = scaleX(prefixStart);
const xEnd = scaleX(prefixEnd);
svg += ``;
// Generated region (muted green box, between prompt-end and gen-end handles)
const xGenEnd = scaleX(genEnd);
svg += ``;
// START handle: vertical line through the track body + downward triangle on top.
svg += ``
+ ``
+ ``
+ ``
+ ``;
// END handle (prompt end / gen start): vertical line + upward triangle on bottom.
svg += ``
+ ``
+ ``
+ ``
+ ``;
// GEN-END handle: vertical line + downward triangle on top, green.
svg += ``
+ ``
+ ``
+ ``
+ ``;
els.track.innerHTML = svg;
}
function bpFromClientX(clientX) {
if (!gene) return 0;
const rect = els.track.getBoundingClientRect();
const frac = (clientX - rect.left) / rect.width;
return Math.max(0, Math.min(gene.length, Math.round(frac * gene.length)));
}
function renderInfo() {
if (!gene) { els.info.textContent = "loading genes…"; return; }
const promptLen = prefixEnd - prefixStart;
const genLen = genEnd - prefixEnd;
els.info.innerHTML = `${gene.symbol} · ${gene.blurb} · ${gene.length.toLocaleString("en-US")} bp`
+ ` · prompt: ${prefixStart}–${prefixEnd} (${promptLen} bp)`
+ ` · generate: ${prefixEnd}–${genEnd} (${genLen} bp)`;
}
function basesPerLine() {
// Match the existing index.html dynamic computation, but coarser.
const cs = getComputedStyle(els.seq);
const padL = parseFloat(cs.paddingLeft) || 0;
const padR = parseFloat(cs.paddingRight) || 0;
const contentW = els.seq.clientWidth - padL - padR;
// Approx ~9px per character at 12px JBM with 1px letter-spacing
const charW = 8.4;
const prefixW = 7 * charW; // " N "
const blockW = 10 * charW + charW; // 10 bases + space
if (contentW <= prefixW) return 60;
const blocks = Math.floor((contentW - prefixW) / blockW);
return Math.max(20, Math.min(blocks, 12) * 10);
}
function annotationAt(idx) {
if (!gene) return "intergenic";
for (const e of gene.exons) if (idx >= e.start && idx < e.end) return "exon";
return "intron";
}
function renderSequenceAndRef() {
const bpl = basesPerLine();
const prompt = promptBases;
const total = prompt + genText;
const lpRange = lpRangeOf(genTokens);
// Output: prompt in gray; generated colored by logprob, underlined green/red by ref match.
const colorOutput = (absIdx, base) => {
if (absIdx < prompt.length) {
return { style: `color:rgb(${PROMPT_RGB.join(",")})` };
}
const genIdx = absIdx - prompt.length;
const tok = genTokens[genTokenAtBase[genIdx]];
const [r, g, b] = logprobRgb(tok ? tok.logprob : null, lpRange);
const refBase = gene ? gene.seq[prefixEnd + genIdx] : undefined;
const ulColor = refBase == null
? "transparent"
: (base === refBase ? "#317f3f" : "#b00020");
return {
style: `color:rgb(${r},${g},${b});`
+ `text-decoration:underline;`
+ `text-decoration-color:${ulColor};`
+ `text-decoration-thickness:1.5px;`
+ `text-underline-offset:2px`
};
};
renderSeq(els.seq, total, bpl, colorOutput);
}
function updateStats() {
if (!gene || genText.length === 0) {
[els.id, els.idExon, els.idIntron, els.tok, els.lp, els.ppl].forEach(e => {
e.textContent = "·"; e.classList.add("muted");
});
return;
}
const refSlice = gene.seq.slice(prefixEnd, prefixEnd + genText.length);
let match = 0, total = 0;
let exonMatch = 0, exonTotal = 0;
let intronMatch = 0, intronTotal = 0;
for (let i = 0; i < genText.length; i++) {
if (i >= refSlice.length) break;
total++;
const ok = genText[i] === refSlice[i];
if (ok) match++;
const ann = annotationAt(prefixEnd + i);
if (ann === "exon") { exonTotal++; if (ok) exonMatch++; }
else if (ann === "intron") { intronTotal++; if (ok) intronMatch++; }
}
const pct = (n, d) => d > 0 ? `${((n/d)*100).toFixed(0)}%` : "·";
els.id.textContent = `${pct(match, total)} (${match}/${total})`;
els.idExon.textContent = exonTotal > 0 ? `${pct(exonMatch, exonTotal)} (${exonMatch}/${exonTotal})` : "·";
els.idIntron.textContent = intronTotal > 0 ? `${pct(intronMatch, intronTotal)} (${intronMatch}/${intronTotal})` : "·";
els.tok.textContent = String(genTokens.length);
const mlp = meanLogprob(genTokens);
els.lp.textContent = mlp == null ? "·" : mlp.toFixed(2);
els.ppl.textContent = mlp == null ? "·" : Math.exp(-mlp).toFixed(1);
[els.id, els.idExon, els.idIntron, els.tok, els.lp, els.ppl].forEach(e => e.classList.remove("muted"));
}
function reset() {
promptBases = gene ? gene.seq.slice(prefixStart, prefixEnd) : "";
genText = "";
genTokens = [];
genTokenAtBase = [];
renderInfo();
renderTrack();
renderSequenceAndRef();
updateStats();
}
async function generate() {
if (abortCtrl || !gene) return;
reset();
abortCtrl = new AbortController();
els.go.disabled = true;
els.stop.disabled = false;
setStatus("connecting…", "streaming");
const genLen = genEnd - prefixEnd;
try {
const resp = await fetch("/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: promptBases,
max_tokens: Math.ceil(genLen / 6) + 4, // tokens are ~6 bases each
temperature: 0.5,
top_p: 0.9,
}),
signal: abortCtrl.signal,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
setStatus("streaming", "streaming");
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop();
for (const ev of events) {
const line = ev.trim();
if (!line.startsWith("data:")) continue;
const data = JSON.parse(line.slice(5).trim());
if (data.error) throw new Error(data.error);
if (data.done) continue;
if (data.logprobs) {
const lp = data.logprobs;
for (let i = 0; i < lp.tokens.length; i++) {
const tokIdx = genTokens.length;
genTokens.push({ text: lp.tokens[i], logprob: lp.token_logprobs[i] });
for (let j = 0; j < lp.tokens[i].length; j++) genTokenAtBase.push(tokIdx);
}
}
if (data.text) {
const cleaned = data.text.toUpperCase().replace(/[^ACGTN]/g, "");
// Stop appending once we've covered the requested gen window.
const room = Math.max(0, genLen - genText.length);
genText += cleaned.slice(0, room);
renderSequenceAndRef();
updateStats();
if (genText.length >= genLen) abortCtrl?.abort();
}
}
}
setStatus("done");
} catch (e) {
if (e.name === "AbortError") setStatus("done");
else setStatus(e.message, "error");
} finally {
abortCtrl = null;
els.go.disabled = false;
els.stop.disabled = true;
renderSequenceAndRef();
updateStats();
}
}
function stop() { if (abortCtrl) abortCtrl.abort(); }
function selectGene(symbol) {
const g = GENES.find(x => x.symbol === symbol);
if (!g) return;
gene = g;
// Default selection: prompt = (intron context before the 2nd exon) +
// (first 35 bp of the 2nd exon). Generation = the rest of the 2nd exon.
// We use exons[1] (1st exon is usually 5' UTR). For very narrow exons
// the exon-context is shortened so at least 30 bp of generation room
// remains inside the exon.
const exon2 = (gene.exons && gene.exons.length >= 2) ? gene.exons[1] : null;
if (exon2) {
const exonLen = exon2.end - exon2.start;
const EXON_CONTEXT_BP = 35; // first 35 bp of the exon
const exonContextBp = Math.min(EXON_CONTEXT_BP, Math.max(0, exonLen - 30));
prefixEnd = exon2.start + exonContextBp;
prefixStart = Math.max(0, prefixEnd - DEFAULT_PROMPT_BP);
genEnd = Math.min(gene.length, exon2.end);
} else {
prefixStart = 0;
prefixEnd = Math.min(DEFAULT_PROMPT_BP, Math.max(MIN_PROMPT_BP, gene.length - DEFAULT_GEN_BP));
genEnd = Math.min(gene.length, prefixEnd + DEFAULT_GEN_BP);
}
els.pills.querySelectorAll(".pill").forEach(p => p.classList.toggle("active", p.dataset.gene === symbol));
reset();
}
function bindPills(container, attr, onSelect) {
container.querySelectorAll(".pill").forEach(p => {
p.addEventListener("click", () => {
container.querySelectorAll(".pill").forEach(x => x.classList.remove("active"));
p.classList.add("active");
onSelect(p.dataset[attr]);
});
});
}
// Bootstrap
loadGenes().then(allGenes => {
const genes = genesForSection(allGenes, "completion");
els.pills.innerHTML = genes.map((g, i) =>
``
).join("");
bindPills(els.pills, "gene", selectGene);
selectGene(genes[0].symbol);
}).catch(e => {
els.info.textContent = "failed to load genes: " + e.message;
});
els.go.addEventListener("click", generate);
els.stop.addEventListener("click", stop);
// Drag handles on the track to set the prompt range.
els.track.addEventListener("pointerdown", (e) => {
const target = e.target.closest(".handle");
if (!target || !gene) return;
dragging = target.dataset.role;
els.track.setPointerCapture(e.pointerId);
renderTrack(); // re-render so the picked handle shows its `.dragging` style
e.preventDefault();
});
els.track.addEventListener("pointermove", (e) => {
if (!dragging || !gene) return;
const bp = bpFromClientX(e.clientX);
if (dragging === "start") {
prefixStart = Math.max(0, Math.min(bp, prefixEnd - MIN_PROMPT_BP));
} else if (dragging === "end") {
prefixEnd = Math.max(prefixStart + MIN_PROMPT_BP, Math.min(bp, genEnd - MIN_GEN_BP));
} else if (dragging === "genend") {
genEnd = Math.max(prefixEnd + MIN_GEN_BP, Math.min(bp, gene.length));
}
reset();
});
const endDrag = (e) => {
if (!dragging) return;
dragging = null;
try { els.track.releasePointerCapture(e.pointerId); } catch (_) {}
renderTrack();
};
els.track.addEventListener("pointerup", endDrag);
els.track.addEventListener("pointercancel", endDrag);
window.addEventListener("resize", () => {
if (gene) renderSequenceAndRef();
});
})();