Spaces:
Running on Zero
Running on Zero
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Carbon Helix Β· 2D</title> | |
| <style> | |
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Palette β scientific light mode. Warm parchment background, deep | |
| charcoal text, one warm amber accent, vivid base colors. The | |
| bases follow the standard bio convention (A green, T red, G ink, | |
| C blue) at high enough saturation to pop on the cream bg without | |
| glowing. No gradients, no neon β the look should read as a | |
| textbook figure, not a Stable-Diffusion product mockup. | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| :root { | |
| --bg-0: #f6f2e8; /* warm parchment */ | |
| --bg-1: #efe9d8; /* sidebar β slightly darker */ | |
| --bg-card: #fbfaf4; /* viz card β lighter, pulls focus */ | |
| --fg: #131922; /* deep charcoal */ | |
| --fg-mid: #4b5563; /* secondary text */ | |
| --fg-dim: #8b8478; /* warm gray */ | |
| --accent: #b46a2a; /* warm amber */ | |
| --accent-2: #7a4d1e; /* deeper amber for hover */ | |
| --border: rgba(19, 25, 34, 0.12); | |
| --border-2: rgba(19, 25, 34, 0.22); | |
| /* Bases */ | |
| --A: #168547; /* forest green */ | |
| --T: #c0302a; /* brick red */ | |
| --G: #1f2937; /* ink charcoal */ | |
| --C: #1f4f9c; /* deep blue */ | |
| --user-base: #a39d8b; /* warm muted gray */ | |
| /* Helix backbone + rungs */ | |
| --strand: #6b7280; | |
| --strand-2: #4b5563; /* contrast */ | |
| --rung: #cdd0d6; | |
| --rung-user: #d8d4c4; | |
| --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; | |
| --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { | |
| width: 100%; height: 100%; | |
| background: var(--bg-0); | |
| color: var(--fg); | |
| font-family: var(--sans); | |
| -webkit-font-smoothing: antialiased; | |
| overflow: hidden; | |
| } | |
| #app { | |
| display: grid; | |
| grid-template-rows: auto 1fr auto; | |
| height: 100vh; | |
| } | |
| /* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #header { | |
| display: flex; align-items: baseline; gap: 18px; | |
| padding: 18px 26px 14px; | |
| background: var(--bg-0); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .title { | |
| font-family: var(--serif); | |
| font-weight: 600; | |
| font-size: 22px; | |
| letter-spacing: -0.01em; | |
| color: var(--fg); | |
| } | |
| .subtitle { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--accent); | |
| padding: 2px 8px; | |
| border: 1px solid var(--accent); | |
| border-radius: 3px; | |
| } | |
| .model-info { | |
| color: var(--fg-mid); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| padding-left: 18px; margin-left: 6px; | |
| border-left: 1px solid var(--border); | |
| } | |
| .legend { | |
| margin-left: auto; | |
| display: flex; gap: 16px; | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| color: var(--fg-mid); | |
| } | |
| .legend-item { display: inline-flex; align-items: center; gap: 6px; } | |
| .legend-item .swatch { | |
| width: 9px; height: 9px; border-radius: 50%; | |
| } | |
| .legend-item.A .swatch { background: var(--A); } | |
| .legend-item.T .swatch { background: var(--T); } | |
| .legend-item.G .swatch { background: var(--G); } | |
| .legend-item.C .swatch { background: var(--C); } | |
| /* ββ Main βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #main { | |
| display: grid; | |
| grid-template-columns: 300px 1fr; | |
| overflow: hidden; | |
| } | |
| #sidebar { | |
| background: var(--bg-1); | |
| border-right: 1px solid var(--border); | |
| padding: 22px 22px 28px; | |
| overflow-y: auto; | |
| } | |
| #viz { | |
| display: flex; flex-direction: column; | |
| padding: 22px 32px; | |
| overflow: hidden; | |
| background: var(--bg-0); | |
| } | |
| /* ββ Sidebar controls βββββββββββββββββββββββββββββββββββ */ | |
| .row { margin-bottom: 20px; } | |
| .row > label { | |
| display: flex; justify-content: space-between; align-items: baseline; | |
| font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; | |
| color: var(--fg-mid); font-weight: 700; | |
| margin-bottom: 7px; | |
| } | |
| .row > label .val { | |
| color: var(--accent); font-variant-numeric: tabular-nums; | |
| font-family: var(--mono); font-size: 12px; | |
| letter-spacing: 0; | |
| } | |
| textarea, input[type="text"] { | |
| width: 100%; resize: vertical; min-height: 64px; | |
| background: var(--bg-card); | |
| border: 1px solid var(--border-2); | |
| border-radius: 4px; | |
| padding: 10px 12px; | |
| color: var(--fg); | |
| font-family: var(--mono); font-size: 13px; | |
| outline: none; | |
| letter-spacing: 0.04em; | |
| transition: border-color 0.15s, box-shadow 0.15s; | |
| } | |
| textarea:focus, input:focus { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 0 3px rgba(180, 106, 42, 0.12); | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; appearance: none; | |
| width: 100%; height: 3px; | |
| background: var(--accent); | |
| border-radius: 2px; outline: none; | |
| margin: 8px 0; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; appearance: none; | |
| width: 14px; height: 14px; border-radius: 50%; | |
| background: var(--bg-card); | |
| border: 2px solid var(--accent); | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 14px; height: 14px; border-radius: 50%; | |
| background: var(--bg-card); | |
| border: 2px solid var(--accent); | |
| cursor: pointer; | |
| } | |
| .chips { display: flex; flex-wrap: wrap; gap: 5px; } | |
| .chip { | |
| padding: 5px 11px; | |
| border-radius: 3px; | |
| background: transparent; | |
| border: 1px solid var(--border-2); | |
| color: var(--fg-mid); | |
| font-size: 11px; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| user-select: none; | |
| font-family: var(--mono); | |
| letter-spacing: 0.02em; | |
| } | |
| .chip:hover { color: var(--fg); border-color: var(--fg-mid); } | |
| .chip.active { | |
| background: var(--accent); | |
| color: var(--bg-card); | |
| border-color: var(--accent); | |
| } | |
| .preset-row { display: flex; gap: 5px; margin-top: 8px; } | |
| .preset-row .chip { flex: 1; text-align: center; } | |
| .btn-row { display: flex; gap: 8px; margin-top: 12px; } | |
| .btn { | |
| flex: 1; padding: 11px 14px; | |
| border-radius: 4px; border: 1px solid var(--border-2); | |
| font-family: var(--sans); font-weight: 700; font-size: 11px; | |
| letter-spacing: 0.10em; text-transform: uppercase; | |
| cursor: pointer; transition: all 0.15s; | |
| } | |
| .btn-primary { | |
| background: var(--accent); | |
| color: var(--bg-card); | |
| border-color: var(--accent); | |
| } | |
| .btn-primary:hover { background: var(--accent-2); border-color: var(--accent-2); } | |
| .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .btn-ghost { | |
| background: transparent; | |
| color: var(--fg); | |
| } | |
| .btn-ghost:hover { background: rgba(19, 25, 34, 0.05); border-color: var(--fg-mid); } | |
| .hint { | |
| font-size: 11px; color: var(--fg-dim); | |
| margin-top: 8px; | |
| line-height: 1.5; | |
| font-family: var(--sans); | |
| } | |
| .hint em { font-style: italic; color: var(--fg-mid); } | |
| /* ββ Viz / helix area ββββββββββββββββββββββββββββββββββββ */ | |
| #viz-title { | |
| font-family: var(--mono); | |
| font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase; | |
| color: var(--fg-mid); | |
| margin-bottom: 12px; | |
| display: flex; gap: 16px; align-items: baseline; | |
| } | |
| #viz-title .num { color: var(--fg); font-weight: 700; font-size: 13px; } | |
| #viz-title .user-num { color: var(--fg-dim); } | |
| #viz-title .gen-num { color: var(--accent); } | |
| #dna-scroll { | |
| flex: 1; | |
| min-height: 0; | |
| width: 100%; | |
| overflow-x: hidden; | |
| overflow-y: auto; | |
| background: var(--bg-card); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 20px 24px; | |
| scrollbar-width: thin; | |
| scrollbar-color: var(--accent) transparent; | |
| } | |
| #dna-scroll::-webkit-scrollbar { width: 8px; } | |
| #dna-scroll::-webkit-scrollbar-track { background: transparent; } | |
| #dna-scroll::-webkit-scrollbar-thumb { | |
| background: rgba(180, 106, 42, 0.4); | |
| border-radius: 4px; | |
| } | |
| #dna-empty { | |
| color: var(--fg-dim); | |
| font-family: var(--sans); | |
| font-style: italic; | |
| font-size: 13px; | |
| text-align: center; | |
| padding: 60px 0; | |
| } | |
| #dna-empty strong { font-style: normal; color: var(--accent); } | |
| #dna { | |
| display: flex; flex-direction: column; | |
| gap: 4px; | |
| } | |
| /* ββ A helix row: position label + SVG helix ββββββββββββ */ | |
| .row-block { | |
| display: flex; align-items: stretch; | |
| gap: 14px; | |
| } | |
| .row-pos { | |
| flex-shrink: 0; | |
| width: 38px; | |
| padding-top: 4px; | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--fg-dim); | |
| text-align: right; | |
| font-variant-numeric: tabular-nums; | |
| letter-spacing: 0.02em; | |
| border-right: 1px solid var(--border); | |
| padding-right: 12px; | |
| } | |
| .helix-svg { | |
| display: block; | |
| overflow: visible; | |
| } | |
| /* ββ SVG element styling βββββββββββββββββββββββββββββββββ */ | |
| .strand { | |
| fill: none; | |
| stroke: var(--strand); | |
| stroke-width: 1.5; | |
| stroke-linecap: round; | |
| stroke-linejoin: round; | |
| } | |
| .strand.back { | |
| stroke: var(--strand); | |
| stroke-width: 1; | |
| opacity: 0.45; | |
| } | |
| .rung { | |
| stroke: var(--rung); | |
| stroke-width: 1; | |
| stroke-linecap: round; | |
| } | |
| .rung.user { | |
| stroke: var(--rung-user); | |
| stroke-dasharray: 1.5 2; | |
| } | |
| .base { | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| font-weight: 700; | |
| text-anchor: middle; | |
| dominant-baseline: central; | |
| user-select: none; | |
| } | |
| .base.A { fill: var(--A); } | |
| .base.T { fill: var(--T); } | |
| .base.G { fill: var(--G); } | |
| .base.C { fill: var(--C); } | |
| .base.user { fill: var(--user-base); font-weight: 500; } | |
| /* Pop-in animation for newly streamed bases. SMIL is heavier than | |
| CSS transforms; instead, we set an `appearing` class on the | |
| newest base and clear it after one tick. */ | |
| .base.gen.appearing { | |
| animation: pop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1); | |
| transform-origin: center; | |
| transform-box: fill-box; | |
| } | |
| @keyframes pop { | |
| 0% { transform: scale(0); opacity: 0; } | |
| 100% { transform: scale(1); opacity: 1; } | |
| } | |
| /* User β gen boundary: a thin vertical accent line across the row | |
| and a tiny "β’" mark on the top edge. */ | |
| .boundary { | |
| stroke: var(--accent); | |
| stroke-width: 1.5; | |
| stroke-dasharray: 3 2; | |
| } | |
| .boundary-cap { | |
| fill: var(--accent); | |
| } | |
| /* ββ HUD ββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #hud { | |
| display: flex; gap: 32px; | |
| padding: 12px 26px; | |
| background: var(--bg-0); | |
| border-top: 1px solid var(--border); | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| } | |
| .hud-item { display: flex; gap: 8px; align-items: baseline; } | |
| .hud-item .k { | |
| color: var(--fg-mid); | |
| letter-spacing: 0.10em; text-transform: uppercase; | |
| font-size: 10px; | |
| } | |
| .hud-item .v { | |
| color: var(--fg); font-weight: 700; | |
| font-variant-numeric: tabular-nums; | |
| font-size: 13px; | |
| } | |
| .v.streaming { color: var(--accent); } | |
| .v.error { color: var(--T); } | |
| .v.done { color: var(--A); } | |
| #errbar { | |
| position: fixed; | |
| bottom: 60px; left: 50%; transform: translateX(-50%); | |
| background: var(--bg-card); | |
| border: 1px solid var(--T); | |
| color: var(--T); | |
| padding: 9px 18px; | |
| border-radius: 4px; | |
| font-family: var(--mono); font-size: 12px; | |
| z-index: 100; | |
| box-shadow: 0 4px 16px rgba(192, 48, 42, 0.15); | |
| } | |
| @media (max-width: 900px) { | |
| #main { grid-template-columns: 1fr; } | |
| #sidebar { border-right: none; border-bottom: 1px solid var(--border); } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <header id="header"> | |
| <span class="title">Carbon Helix</span> | |
| <span class="subtitle">2D</span> | |
| <span class="model-info" id="model-info">connectingβ¦</span> | |
| <div class="legend"> | |
| <span class="legend-item A"><span class="swatch"></span>A</span> | |
| <span class="legend-item T"><span class="swatch"></span>T</span> | |
| <span class="legend-item G"><span class="swatch"></span>G</span> | |
| <span class="legend-item C"><span class="swatch"></span>C</span> | |
| </div> | |
| </header> | |
| <div id="main"> | |
| <aside id="sidebar"> | |
| <div class="row"> | |
| <label>Seed DNA <span class="val" id="seed-len">0 bp</span></label> | |
| <textarea id="seed" spellcheck="false" autocomplete="off">ATGGCCATGGCC</textarea> | |
| <div class="hint">Type ACGT bases. They'll appear <em>in gray</em> as you type β the model picks up from there.</div> | |
| <div class="preset-row"> | |
| <span class="chip" data-preset="atg">ATG-start</span> | |
| <span class="chip" data-preset="tata">TATA box</span> | |
| <span class="chip" data-preset="random">Random</span> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <label>Metadata tags</label> | |
| <div class="chips" id="meta-chips"> | |
| <span class="chip" data-tag="vertebrate_mammalian">vertebrate_mammalian</span> | |
| <span class="chip" data-tag="protein_coding_region">protein_coding_region</span> | |
| <span class="chip" data-tag="invertebrate">invertebrate</span> | |
| <span class="chip" data-tag="plant">plant</span> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <label>Max tokens <span class="val" id="mt-val">128</span></label> | |
| <input type="range" id="mt" min="1" max="200" value="128" /> | |
| </div> | |
| <div class="row"> | |
| <label>Temperature <span class="val" id="t-val">0.70</span></label> | |
| <input type="range" id="t" min="0" max="200" value="70" /> | |
| </div> | |
| <div class="row"> | |
| <label>Top-p <span class="val" id="tp-val">0.90</span></label> | |
| <input type="range" id="tp" min="0" max="100" value="90" /> | |
| </div> | |
| <div class="btn-row"> | |
| <button id="generate" class="btn btn-primary">βΆ Generate</button> | |
| <button id="reset" class="btn btn-ghost">Reset</button> | |
| </div> | |
| </aside> | |
| <div id="viz"> | |
| <div id="viz-title"> | |
| <span><span class="num user-num" id="user-count">0</span> user bp</span> | |
| <span><span class="num gen-num" id="gen-count">0</span> generated bp</span> | |
| <span><span class="num" id="total-bp">0</span> total</span> | |
| </div> | |
| <div id="dna-scroll"> | |
| <div id="dna-empty" style="display:none">type a seed in the sidebar to start, then click <strong>Generate</strong></div> | |
| <div id="dna"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <footer id="hud"> | |
| <div class="hud-item"><span class="k">Status</span><span class="v" id="status">idle</span></div> | |
| <div class="hud-item"><span class="k">Tokens</span><span class="v" id="tok">0</span></div> | |
| <div class="hud-item"><span class="k">Rate</span><span class="v" id="rate">β</span><span class="k">tok/s</span></div> | |
| <div class="hud-item"><span class="k">Mean log-p</span><span class="v" id="meanlp">β</span></div> | |
| </footer> | |
| </div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.16.0/dist/index.min.js"; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // HELIX GEOMETRY | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // The two strands trace sinusoids 180Β° out of phase, so they cross | |
| // at every half-turn β that's the iconic "X" pattern of double- | |
| // helix side projections. Base letters sit ON the strand at each | |
| // position; the rung is a vertical line connecting the two letters. | |
| // At a crossing, both letters are at Y_CENTER and the rung has | |
| // zero length (visually clean β base pair is edge-on). | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const BP_PER_TURN = 10; // real DNA is ~10.5; round number reads cleanly | |
| const BASE_SPACING = 26; // px between consecutive bases | |
| const AMPLITUDE = 30; // px sine amplitude | |
| const ROW_HEIGHT = 120; | |
| const Y_CENTER = ROW_HEIGHT / 2; | |
| const SAMPLES_PER_BASE = 8; // smoothness of the strand curves | |
| const SVG_NS = "http://www.w3.org/2000/svg"; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // DOM REFS | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const seed = document.getElementById("seed"); | |
| const seedLen = document.getElementById("seed-len"); | |
| const mtIn = document.getElementById("mt"); | |
| const mtVal = document.getElementById("mt-val"); | |
| const tIn = document.getElementById("t"); | |
| const tVal = document.getElementById("t-val"); | |
| const tpIn = document.getElementById("tp"); | |
| const tpVal = document.getElementById("tp-val"); | |
| const genBtn = document.getElementById("generate"); | |
| const resetBtn = document.getElementById("reset"); | |
| const dnaEl = document.getElementById("dna"); | |
| const dnaScroll = document.getElementById("dna-scroll"); | |
| const dnaEmpty = document.getElementById("dna-empty"); | |
| const modelInfoEl = document.getElementById("model-info"); | |
| const userCountEl = document.getElementById("user-count"); | |
| const genCountEl = document.getElementById("gen-count"); | |
| const totalBpEl = document.getElementById("total-bp"); | |
| const hud = { | |
| status: document.getElementById("status"), | |
| tok: document.getElementById("tok"), | |
| rate: document.getElementById("rate"), | |
| meanlp: document.getElementById("meanlp"), | |
| }; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // STATE | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const COMP = { A: "T", T: "A", G: "C", C: "G", N: "N" }; | |
| const state = { | |
| user: [], // [{ base }] | |
| gen: [], // [{ base, lp }] | |
| }; | |
| let client = null; | |
| let activeJob = null; | |
| let runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: 0 }; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // HELPERS | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function cleanDna(s) { | |
| return (s || "").toUpperCase().replace(/[^ACGT]/g, ""); | |
| } | |
| function showError(msg) { | |
| let bar = document.getElementById("errbar"); | |
| if (!bar) { | |
| bar = document.createElement("div"); | |
| bar.id = "errbar"; | |
| document.body.appendChild(bar); | |
| } | |
| bar.textContent = msg; | |
| bar.style.display = "block"; | |
| clearTimeout(showError._t); | |
| showError._t = setTimeout(() => bar.remove(), 5500); | |
| } | |
| function getMetadata() { | |
| return [...document.querySelectorAll("#meta-chips .chip.active")] | |
| .map(c => `<${c.dataset.tag}>`).join(""); | |
| } | |
| // Compute how many bases fit on one helix row, given the current | |
| // viewport. Snap to multiples of 10 so position labels read cleanly | |
| // (1, 41, 81, β¦) and reflow on resize. | |
| function computeBasesPerRow() { | |
| const avail = dnaScroll.clientWidth | |
| - 38 /* .row-pos width */ | |
| - 14 /* gap */ | |
| - 12 /* row-pos right padding */ | |
| - 50; /* container padding slack */ | |
| const fit = Math.floor(avail / BASE_SPACING); | |
| const snapped = Math.floor(fit / 10) * 10; | |
| return Math.max(20, Math.min(80, snapped)); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // SVG-BASED HELIX RENDERING | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function svg(tag, attrs = {}) { | |
| const el = document.createElementNS(SVG_NS, tag); | |
| for (const k in attrs) el.setAttribute(k, attrs[k]); | |
| return el; | |
| } | |
| // Given the index of a base (0 = first base on this row), return its | |
| // (x, top_y, bot_y) coordinates. The +0.5 in the angle centers the | |
| // base in its slot β bases sit *between* the conceptual grid lines. | |
| function geom(i) { | |
| const x = i * BASE_SPACING + BASE_SPACING / 2; | |
| const angle = (i + 0.5) * 2 * Math.PI / BP_PER_TURN; | |
| const yTop = Y_CENTER + AMPLITUDE * Math.sin(angle); | |
| const yBot = Y_CENTER - AMPLITUDE * Math.sin(angle); | |
| return { x, yTop, yBot }; | |
| } | |
| // Build a smooth polyline path for the strand backbone β NΓSAMPLES | |
| // straight segments between bases is fine at our scale. | |
| function strandPath(n, sign) { | |
| const pts = []; | |
| for (let s = 0; s <= n * SAMPLES_PER_BASE; s++) { | |
| const t = s / SAMPLES_PER_BASE; // continuous base index | |
| const x = t * BASE_SPACING + BASE_SPACING / 2; | |
| const angle = (t + 0.5) * 2 * Math.PI / BP_PER_TURN; | |
| const y = Y_CENTER + sign * AMPLITUDE * Math.sin(angle); | |
| pts.push(`${x.toFixed(2)},${y.toFixed(2)}`); | |
| } | |
| return pts.join(" "); | |
| } | |
| // Build one row of the helix as an SVG. `rowBases` is the slice of | |
| // bases on this row, `userLen` is the global user-prefix length so | |
| // we can render the boundary marker if it falls inside this row. | |
| function buildRowSVG(rowStart, rowBases, userLen) { | |
| const n = rowBases.length; | |
| const width = n * BASE_SPACING + 6; | |
| const root = svg("svg", { | |
| class: "helix-svg", | |
| width, height: ROW_HEIGHT, | |
| viewBox: `0 0 ${width} ${ROW_HEIGHT}`, | |
| }); | |
| // Strand backbones. Two polylines, one strand drawn slightly | |
| // thicker than the other so the eye can follow the "front" strand | |
| // (the .back one fades into the background near the crossings). | |
| root.appendChild(svg("polyline", { | |
| class: "strand", | |
| points: strandPath(n, +1), | |
| })); | |
| root.appendChild(svg("polyline", { | |
| class: "strand back", | |
| points: strandPath(n, -1), | |
| })); | |
| // Boundary marker (vertical accent line + cap) | |
| const boundaryGlobal = userLen; | |
| if (boundaryGlobal > rowStart && boundaryGlobal < rowStart + n) { | |
| const bi = boundaryGlobal - rowStart; | |
| const bx = bi * BASE_SPACING; | |
| root.appendChild(svg("line", { | |
| class: "boundary", | |
| x1: bx, y1: 4, x2: bx, y2: ROW_HEIGHT - 4, | |
| })); | |
| root.appendChild(svg("circle", { | |
| class: "boundary-cap", | |
| cx: bx, cy: 4, r: 2.4, | |
| })); | |
| } | |
| // Rungs (one per base position) | |
| for (let i = 0; i < n; i++) { | |
| const { x, yTop, yBot } = geom(i); | |
| const globalIdx = rowStart + i; | |
| const kind = globalIdx < userLen ? "user" : "gen"; | |
| root.appendChild(svg("line", { | |
| class: `rung ${kind}`, | |
| x1: x, y1: yTop, x2: x, y2: yBot, | |
| })); | |
| } | |
| // Base letters (top strand, then complement on bottom strand) | |
| for (let i = 0; i < n; i++) { | |
| const { x, yTop, yBot } = geom(i); | |
| const b = rowBases[i]; | |
| const globalIdx = rowStart + i; | |
| const kind = globalIdx < userLen ? "user" : "gen"; | |
| const top = svg("text", { | |
| class: `base ${kind} ${b.base}`, | |
| x, y: yTop, | |
| }); | |
| top.textContent = b.base; | |
| if (b.lp != null) top.setAttribute("aria-label", `${b.base} logp ${b.lp.toFixed(2)}`); | |
| const comp = COMP[b.base] || "N"; | |
| const bot = svg("text", { | |
| class: `base ${kind} ${comp}`, | |
| x, y: yBot, | |
| }); | |
| bot.textContent = comp; | |
| root.appendChild(top); | |
| root.appendChild(bot); | |
| } | |
| return root; | |
| } | |
| // Full redraw. Slices the strand into rows of `perRow` bases and | |
| // builds one SVG per row. Profiled fine through 200 tokens Γ 6 bp. | |
| function redraw() { | |
| dnaEl.innerHTML = ""; | |
| const total = state.user.length + state.gen.length; | |
| refreshCounts(); | |
| if (total === 0) { | |
| dnaEmpty.style.display = "block"; | |
| return; | |
| } | |
| dnaEmpty.style.display = "none"; | |
| const perRow = computeBasesPerRow(); | |
| const userLen = state.user.length; | |
| const baseAt = (i) => i < userLen | |
| ? { base: state.user[i].base, lp: null } | |
| : { base: state.gen[i - userLen].base, lp: state.gen[i - userLen].lp }; | |
| for (let rowStart = 0; rowStart < total; rowStart += perRow) { | |
| const rowEnd = Math.min(rowStart + perRow, total); | |
| const rowArr = []; | |
| for (let i = rowStart; i < rowEnd; i++) rowArr.push(baseAt(i)); | |
| const block = document.createElement("div"); | |
| block.className = "row-block"; | |
| const pos = document.createElement("div"); | |
| pos.className = "row-pos"; | |
| pos.textContent = (rowStart + 1).toString(); | |
| block.appendChild(pos); | |
| block.appendChild(buildRowSVG(rowStart, rowArr, userLen)); | |
| dnaEl.appendChild(block); | |
| } | |
| scrollToEnd(); | |
| } | |
| function appendGeneratedBase(b, lp) { | |
| state.gen.push({ base: b, lp }); | |
| redraw(); | |
| } | |
| function refreshCounts() { | |
| userCountEl.textContent = state.user.length; | |
| genCountEl.textContent = state.gen.length; | |
| totalBpEl.textContent = state.user.length + state.gen.length; | |
| } | |
| function scrollToEnd() { | |
| requestAnimationFrame(() => { | |
| dnaScroll.scrollTop = dnaScroll.scrollHeight; | |
| }); | |
| } | |
| // Reflow on resize so the wrap point tracks the viewport. | |
| let _resizeTimer = null; | |
| window.addEventListener("resize", () => { | |
| clearTimeout(_resizeTimer); | |
| _resizeTimer = setTimeout(redraw, 100); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // INPUT HANDLERS | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function syncUserFromSeed() { | |
| const cleaned = cleanDna(seed.value); | |
| seedLen.textContent = `${cleaned.length} bp`; | |
| state.user = cleaned.split("").map(b => ({ base: b })); | |
| state.gen = []; // editing the seed invalidates prior generation | |
| redraw(); | |
| } | |
| function updateSliders() { | |
| mtVal.textContent = mtIn.value; | |
| tVal.textContent = (parseInt(tIn.value, 10) / 100).toFixed(2); | |
| tpVal.textContent = (parseInt(tpIn.value, 10) / 100).toFixed(2); | |
| } | |
| seed.addEventListener("input", syncUserFromSeed); | |
| mtIn.addEventListener("input", updateSliders); | |
| tIn.addEventListener("input", updateSliders); | |
| tpIn.addEventListener("input", updateSliders); | |
| const PRESETS = { | |
| atg: "ATGGCCATGGCC", | |
| tata: "TATAAAATATAA", | |
| random: () => Array.from({ length: 24 }, () => "ACGT"[Math.floor(Math.random() * 4)]).join(""), | |
| }; | |
| document.querySelectorAll("[data-preset]").forEach(chip => { | |
| chip.addEventListener("click", () => { | |
| const p = PRESETS[chip.dataset.preset]; | |
| seed.value = typeof p === "function" ? p() : p; | |
| syncUserFromSeed(); | |
| }); | |
| }); | |
| document.querySelectorAll("#meta-chips .chip").forEach(chip => { | |
| chip.addEventListener("click", () => chip.classList.toggle("active")); | |
| }); | |
| resetBtn.addEventListener("click", () => { | |
| state.gen = []; | |
| runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: 0 }; | |
| hud.status.textContent = "idle"; | |
| hud.status.className = "v"; | |
| hud.tok.textContent = "0"; | |
| hud.rate.textContent = "β"; | |
| hud.meanlp.textContent = "β"; | |
| redraw(); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // BACKEND CONNECTION | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function initClient(retries = 4) { | |
| for (let attempt = 1; attempt <= retries; attempt++) { | |
| try { | |
| client = await Client.connect(window.location.origin); | |
| const cfg = await fetch("/app-info") | |
| .then(r => r.ok ? r.json() : null).catch(() => null); | |
| if (cfg?.model) { | |
| modelInfoEl.textContent = `${cfg.model} Β· ${cfg.params_b}B params`; | |
| } else { | |
| modelInfoEl.textContent = "Carbon-3B Β· 3.45B params"; | |
| } | |
| return; | |
| } catch (e) { | |
| console.warn(`connect attempt ${attempt}/${retries}`, e); | |
| if (attempt < retries) { | |
| await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1))); | |
| } else { | |
| showError("backend connect failed: " + (e?.message || e)); | |
| modelInfoEl.textContent = "offline"; | |
| } | |
| } | |
| } | |
| } | |
| initClient(); | |
| function extractData(msg) { | |
| if (!msg) return null; | |
| const candidates = [msg.data, msg.output, msg]; | |
| for (const c of candidates) { | |
| if (c == null) continue; | |
| if (Array.isArray(c)) { | |
| for (const item of c) { | |
| if (item && typeof item === "object" && "event" in item) return item; | |
| } | |
| } else if (typeof c === "object" && "event" in c) { | |
| return c; | |
| } | |
| } | |
| return null; | |
| } | |
| function handleEvent(ev) { | |
| switch (ev.event) { | |
| case "start": | |
| hud.status.textContent = "streaming"; | |
| hud.status.className = "v streaming"; | |
| runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: performance.now() }; | |
| break; | |
| case "token": { | |
| const tokens = ev.tokens || []; | |
| const lps = ev.logprobs || []; | |
| for (let i = 0; i < tokens.length; i++) { | |
| const tok = tokens[i]; | |
| const lp = lps[i]; | |
| if (typeof tok === "string" && /^[ACGT]{6}$/.test(tok)) { | |
| for (let k = 0; k < 6; k++) appendGeneratedBase(tok[k], lp); | |
| runStats.tokens += 1; | |
| runStats.lpSum += lp; | |
| runStats.lpCount += 1; | |
| } | |
| } | |
| hud.tok.textContent = runStats.tokens; | |
| const elapsed = (performance.now() - runStats.t0) / 1000; | |
| if (elapsed > 0.2) hud.rate.textContent = (runStats.tokens / elapsed).toFixed(1); | |
| hud.meanlp.textContent = runStats.lpCount | |
| ? (runStats.lpSum / runStats.lpCount).toFixed(2) | |
| : "β"; | |
| break; | |
| } | |
| case "done": | |
| hud.status.textContent = "done"; | |
| hud.status.className = "v done"; | |
| genBtn.disabled = false; | |
| genBtn.textContent = "βΆ Generate"; | |
| activeJob = null; | |
| break; | |
| case "error": | |
| showError(ev.message || "unknown error"); | |
| hud.status.textContent = "error"; | |
| hud.status.className = "v error"; | |
| genBtn.disabled = false; | |
| genBtn.textContent = "βΆ Generate"; | |
| activeJob = null; | |
| break; | |
| } | |
| } | |
| async function startGenerate() { | |
| if (!client) { showError("client not ready β wait a moment and retry"); return; } | |
| const prompt = cleanDna(seed.value); | |
| if (!prompt) { showError("seed must contain at least one DNA base"); return; } | |
| state.gen = []; | |
| redraw(); | |
| runStats = { tokens: 0, lpSum: 0, lpCount: 0, t0: performance.now() }; | |
| hud.status.textContent = "streaming"; | |
| hud.status.className = "v streaming"; | |
| hud.tok.textContent = "0"; | |
| hud.rate.textContent = "β"; | |
| hud.meanlp.textContent = "β"; | |
| genBtn.disabled = true; | |
| genBtn.textContent = "β streaming"; | |
| const payload = { | |
| prompt, | |
| metadata: getMetadata(), | |
| max_tokens: parseInt(mtIn.value, 10), | |
| temperature: parseInt(tIn.value, 10) / 100, | |
| top_p: parseInt(tpIn.value, 10) / 100, | |
| }; | |
| try { | |
| const job = client.submit("/generate", payload); | |
| activeJob = job; | |
| for await (const msg of job) { | |
| const ev = extractData(msg); | |
| if (ev) handleEvent(ev); | |
| } | |
| } catch (e) { | |
| showError("stream failed: " + e.message); | |
| console.error(e); | |
| hud.status.textContent = "error"; | |
| hud.status.className = "v error"; | |
| genBtn.disabled = false; | |
| genBtn.textContent = "βΆ Generate"; | |
| activeJob = null; | |
| } | |
| } | |
| genBtn.addEventListener("click", () => { | |
| if (activeJob) { | |
| try { activeJob.cancel?.(); } catch {} | |
| activeJob = null; | |
| hud.status.textContent = "cancelled"; | |
| hud.status.className = "v"; | |
| genBtn.disabled = false; | |
| genBtn.textContent = "βΆ Generate"; | |
| return; | |
| } | |
| startGenerate(); | |
| }); | |
| seed.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { | |
| e.preventDefault(); | |
| if (!activeJob) startGenerate(); | |
| } | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // INIT | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| updateSliders(); | |
| syncUserFromSeed(); | |
| </script> | |
| </body> | |
| </html> | |