// ========================================================================= // §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(); }); })();