tfrere HF Staff Cursor commited on
Commit
39a61da
·
1 Parent(s): e9eb9ae

Split demo.html into modular CSS/JS assets

Browse files

demo.html had grown to 6477 lines with a single inline <style> (1240
lines) and a single inline <script> (4358 lines) — every change meant
hunting through one giant scrollback and risked breaking unrelated
sections. Extracted into /assets/:

styles/ 14 files, ~1280 lines: base, header, banner, layout
(incl. new section--two-col), controls, sequence,
section-{folding,umap,tree,vep,species}, recipe, sandbox,
footer
js/shared/ helpers.js (lerp, logprobRgb, renderSeq…) + config.js
(fetchConfig, loadConfig, loadGenes)
js/sections/ one IIFE per demo card: completion, vep, track, species,
folding, tokenizer, loss, data, architecture, sandbox,
umap, tree
js/ banner.js (DNA helix Canvas 2D), tabs.js (hash routing
+ bootstrap loadConfig)

Strategy: plain <script> tags, not ES modules. Each IIFE was extracted
verbatim and globals stay global — minimum behavioural risk. Migrating
to import/export is a separate refactor.

demo.html is now 918 lines of pure structure + <link>/<script> tags.
app.py mounts /assets as StaticFiles and the no-cache middleware
(previously /experiments/-only) now also covers /assets/ so Safari
doesn't serve stale CSS/JS during the design loop.

Also includes the §5 Folding two-column layout (.section--two-col):
narrative rail (eyebrow + title + lede + takeaway) sticks at 248px on
the left, demo claims the rest. Takeaway is softened to a margin-note
treatment in 2-col mode and restored to its green-bar look on mobile
where it stacks back to single-col.

Co-authored-by: Cursor <cursoragent@cursor.com>

app.py CHANGED
@@ -77,6 +77,18 @@ app = FastAPI()
77
  app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
78
  app.mount("/img", StaticFiles(directory=os.path.join(HERE, "img")), name="img")
79
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  # Side-by-side prototypes for alternate UMAP annotation styles. Mounted as a
81
  # static directory so the HTML files can fetch /umap and /umap_labels without
82
  # CORS, and so changes are picked up without restarting uvicorn (--reload).
@@ -91,16 +103,20 @@ if os.path.isdir(EXPERIMENTS):
91
  )
92
 
93
 
94
- # Disable browser caching for everything under /experiments. These are dev-
95
- # only prototypes we iterate on quickly Safari and Chrome both cache
96
- # .js/.css aggressively by default (often serving a stale file even after a
97
- # soft reload) and that has burned the design loop more than once. The cost
98
- # of always refetching a 30 KB scatter.js is negligible vs the cost of "I
99
- # don't see my changes, are you sure you saved it?".
 
 
 
 
100
  @app.middleware("http")
101
- async def no_cache_experiments(request: Request, call_next):
102
  response = await call_next(request)
103
- if request.url.path.startswith("/experiments/"):
104
  response.headers["Cache-Control"] = "no-store, must-revalidate"
105
  response.headers["Pragma"] = "no-cache"
106
  response.headers["Expires"] = "0"
 
77
  app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
78
  app.mount("/img", StaticFiles(directory=os.path.join(HERE, "img")), name="img")
79
 
80
+ # Modular CSS / JS for demo.html. demo.html used to be a 6 kLOC monolith
81
+ # with a single inline <style> and <script>; the assets/ tree splits it
82
+ # into per-section files. Mounted as static so the browser can fetch
83
+ # them by relative URL (/assets/styles/*.css, /assets/js/**/*.js).
84
+ ASSETS = os.path.join(HERE, "assets")
85
+ if os.path.isdir(ASSETS):
86
+ app.mount(
87
+ "/assets",
88
+ StaticFiles(directory=ASSETS),
89
+ name="assets",
90
+ )
91
+
92
  # Side-by-side prototypes for alternate UMAP annotation styles. Mounted as a
93
  # static directory so the HTML files can fetch /umap and /umap_labels without
94
  # CORS, and so changes are picked up without restarting uvicorn (--reload).
 
103
  )
104
 
105
 
106
+ # Disable browser caching for paths we iterate on during dev (the
107
+ # experiments/ playground and assets/ where the split CSS/JS live).
108
+ # Safari and Chrome both cache .js/.css aggressively by default (often
109
+ # serving a stale file even after a soft reload) and that has burned
110
+ # the design loop more than once. The cost of always refetching a
111
+ # 30 KB module is negligible vs the cost of "I don't see my changes,
112
+ # are you sure you saved it?".
113
+ _NO_CACHE_PREFIXES = ("/experiments/", "/assets/")
114
+
115
+
116
  @app.middleware("http")
117
+ async def no_cache_dev_assets(request: Request, call_next):
118
  response = await call_next(request)
119
+ if request.url.path.startswith(_NO_CACHE_PREFIXES):
120
  response.headers["Cache-Control"] = "no-store, must-revalidate"
121
  response.headers["Pragma"] = "no-cache"
122
  response.headers["Expires"] = "0"
assets/js/banner.js ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // Carbon banner — animated DNA helix (Canvas 2D)
3
+ //
4
+ // Original implementation rendered ~960 SVG <path> mutations per frame, which
5
+ // pinned the main thread at ~25% CPU non-stop. Canvas 2D draws the whole
6
+ // scene to a bitmap each frame with no DOM mutations — typically 10-50× cheaper
7
+ // for this kind of frame-by-frame animation. Math/colors are unchanged so the
8
+ // visual is pixel-equivalent.
9
+ // =========================================================================
10
+ (function initCarbonBanner() {
11
+ const banner = document.querySelector(".carbon-banner");
12
+ if (!banner) return;
13
+ const canvas = banner.querySelector(".cb-helix-canvas");
14
+ if (!canvas) return;
15
+ const ctx = canvas.getContext("2d");
16
+
17
+ const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
18
+ const sequence = ["A","T","A","A","C","G","A","C","T","T","C","C","C","T","A","T","T","G"];
19
+ const complement = { A: "T", T: "A", C: "G", G: "C" };
20
+
21
+ // All physical parameters live in the original SVG viewBox coordinate
22
+ // system. The applyVbTransform() call below maps that system onto the
23
+ // canvas backing store (DPR-aware), so we can keep the math identical.
24
+ const helix = {
25
+ startX: 868, endX: 1988, centerY: 318, amplitude: 88,
26
+ cycles: 3.62, speed: 0.00015,
27
+ // Doubled vs the original SVG (96 → 192). On <canvas> the per-segment cost
28
+ // is negligible, and tighter sampling kills the staircasing visible on
29
+ // the ribbon edges at high DPR.
30
+ rungCount: 27, segmentCount: 192,
31
+ bodyRadius: 6.4, shadowRadius: 8.4,
32
+ // glyphGap is half the punch-through height around each letter. Bumped vs
33
+ // the SVG-era 13.5 because rounded line caps eat ~0.7px of the visible
34
+ // gap on each side at this stroke width, making the rung feel cramped.
35
+ rungInset: 9.2, glyphGap: 16.5,
36
+ };
37
+ // Helix bbox in viewBox coords. The CSS-positioned <canvas> mirrors this
38
+ // box exactly (see .cb-helix-canvas in <style>).
39
+ const VB = { x: 858, y: 220, w: 1140, h: 196 };
40
+
41
+ const COLORS = {
42
+ shadow: "#aeb5ad",
43
+ body: "#e4e5dc",
44
+ bodyStroke: "rgba(49, 127, 63, 0.14)",
45
+ edge: "#2d332e",
46
+ green: "#317f3f",
47
+ };
48
+
49
+ // Stroke-only ATCG glyphs — same path strings the SVG version used.
50
+ // Path2D parses an SVG path string directly; ctx.stroke(path2d) is fast.
51
+ const glyphPaths = {
52
+ A: new Path2D("M -5 8 L 0 -8 L 5 8 M -3.1 2 L 3.1 2"),
53
+ C: new Path2D("M 5 -6 C 1 -9 -6 -7 -6 0 C -6 7 1 9 5 6"),
54
+ G: new Path2D("M 5 -6 C 1 -9 -6 -7 -6 0 C -6 7 1 9 5 6 M 5 1 L 1 1 M 5 1 L 5 6"),
55
+ T: new Path2D("M -6 -7 L 6 -7 M 0 -7 L 0 8"),
56
+ };
57
+
58
+ // --- Canvas sizing (DPR + viewBox→pixels mapping) ---------------------
59
+ let cssW = 0, cssH = 0, dpr = 1, uniformScale = 1, offsetX = 0, offsetY = 0;
60
+ function resize() {
61
+ const rect = canvas.getBoundingClientRect();
62
+ if (rect.width === 0 || rect.height === 0) return;
63
+ cssW = rect.width;
64
+ cssH = rect.height;
65
+ // Use the device's full DPR (typically 2 or 3 on Retina displays).
66
+ // The banner is small (~600×100 CSS px) so even DPR 3 stays cheap.
67
+ dpr = window.devicePixelRatio || 1;
68
+ canvas.width = Math.round(cssW * dpr);
69
+ canvas.height = Math.round(cssH * dpr);
70
+ const sx = canvas.width / VB.w;
71
+ const sy = canvas.height / VB.h;
72
+ uniformScale = Math.min(sx, sy);
73
+ offsetX = (canvas.width - VB.w * uniformScale) / 2;
74
+ offsetY = (canvas.height - VB.h * uniformScale) / 2;
75
+ }
76
+ function applyVbTransform() {
77
+ ctx.setTransform(
78
+ uniformScale, 0, 0, uniformScale,
79
+ -VB.x * uniformScale + offsetX,
80
+ -VB.y * uniformScale + offsetY,
81
+ );
82
+ }
83
+ // Convert a CSS-pixel size to viewBox units (so strokes/fonts stay crisp
84
+ // regardless of the canvas size — equivalent to vector-effect:non-scaling-stroke).
85
+ function px(cssPx) { return (cssPx * dpr) / uniformScale; }
86
+
87
+ // --- Math (verbatim from the SVG version) -----------------------------
88
+ function pointAt(x, offset, phase) {
89
+ const t = (x - helix.startX) / (helix.endX - helix.startX);
90
+ const theta = t * helix.cycles * Math.PI * 2 + phase + offset;
91
+ const slope = Math.cos(theta) * helix.amplitude * helix.cycles * Math.PI * 2 / (helix.endX - helix.startX);
92
+ const normalLength = Math.hypot(slope, 1);
93
+ return {
94
+ x,
95
+ y: helix.centerY + Math.sin(theta) * helix.amplitude,
96
+ z: Math.cos(theta),
97
+ nx: -slope / normalLength,
98
+ ny: 1 / normalLength,
99
+ };
100
+ }
101
+
102
+ // Pre-allocate sample buffers — never reallocated per frame.
103
+ const pointsA = new Array(helix.segmentCount + 1);
104
+ const pointsB = new Array(helix.segmentCount + 1);
105
+ function fillSamples(buf, offset, phase) {
106
+ const span = helix.endX - helix.startX;
107
+ for (let i = 0; i <= helix.segmentCount; i++) {
108
+ buf[i] = pointAt(helix.startX + (span * i) / helix.segmentCount, offset, phase);
109
+ }
110
+ }
111
+
112
+ // Pre-allocated segment list (avoids GC churn).
113
+ const segs = new Array(helix.segmentCount * 2);
114
+ for (let i = 0; i < segs.length; i++) segs[i] = { a: null, b: null, z: 0 };
115
+ function fillSegments() {
116
+ const N = helix.segmentCount;
117
+ for (let i = 0; i < N; i++) {
118
+ const a0 = pointsA[i], b0 = pointsA[i + 1];
119
+ const a1 = pointsB[i], b1 = pointsB[i + 1];
120
+ const s0 = segs[2 * i]; s0.a = a0; s0.b = b0; s0.z = (a0.z + b0.z) * 0.5;
121
+ const s1 = segs[2 * i + 1]; s1.a = a1; s1.b = b1; s1.z = (a1.z + b1.z) * 0.5;
122
+ }
123
+ }
124
+
125
+ // --- Drawing primitives (operate in viewBox space) --------------------
126
+ function ribbonPath(a, b, radius) {
127
+ ctx.beginPath();
128
+ ctx.moveTo(a.x + a.nx * radius, a.y + a.ny * radius);
129
+ ctx.lineTo(b.x + b.nx * radius, b.y + b.ny * radius);
130
+ ctx.lineTo(b.x - b.nx * radius, b.y - b.ny * radius);
131
+ ctx.lineTo(a.x - a.nx * radius, a.y - a.ny * radius);
132
+ ctx.closePath();
133
+ }
134
+ function lineEdge(a, b, radius) {
135
+ ctx.beginPath();
136
+ ctx.moveTo(a.x + a.nx * radius, a.y + a.ny * radius);
137
+ ctx.lineTo(b.x + b.nx * radius, b.y + b.ny * radius);
138
+ }
139
+ function drawSegment(seg) {
140
+ const z = seg.z;
141
+ const front = Math.max(0, Math.min(1, (z + 1) / 2));
142
+ const a = seg.a, b = seg.b;
143
+ // Soft outer shadow (depth cue)
144
+ ctx.globalAlpha = 0.05 + front * 0.12;
145
+ ctx.fillStyle = COLORS.shadow;
146
+ ribbonPath(a, b, helix.shadowRadius);
147
+ ctx.fill();
148
+ // Body ribbon (fill + thin green outline, same alpha — matches SVG opacity behavior)
149
+ ctx.globalAlpha = 0.5 + front * 0.42;
150
+ ribbonPath(a, b, helix.bodyRadius);
151
+ ctx.fillStyle = COLORS.body;
152
+ ctx.fill();
153
+ ctx.strokeStyle = COLORS.bodyStroke;
154
+ ctx.lineWidth = px(0.8);
155
+ ctx.stroke();
156
+ // Edges (top + bottom ink lines)
157
+ ctx.globalAlpha = 0.2 + front * 0.68;
158
+ ctx.strokeStyle = COLORS.edge;
159
+ ctx.lineWidth = px(1.15);
160
+ ctx.lineCap = "round";
161
+ ctx.lineJoin = "round";
162
+ lineEdge(a, b, helix.bodyRadius + 0.35);
163
+ ctx.stroke();
164
+ lineEdge(a, b, -(helix.bodyRadius + 0.35));
165
+ ctx.stroke();
166
+ }
167
+ function drawRung(x, yStart, letterYs, yEnd, gap) {
168
+ const start = Math.min(yStart, yEnd);
169
+ const end = Math.max(yStart, yEnd);
170
+ // Compute gaps where letters punch through the rung.
171
+ const ranges = [];
172
+ for (const y of letterYs) {
173
+ const f = Math.max(start, y - gap);
174
+ const t = Math.min(end, y + gap);
175
+ if (t > f) ranges.push([f, t]);
176
+ }
177
+ ranges.sort((u, v) => u[0] - v[0]);
178
+ const merged = [];
179
+ for (const r of ranges) {
180
+ const last = merged[merged.length - 1];
181
+ if (!last || r[0] > last[1]) merged.push([r[0], r[1]]);
182
+ else last[1] = Math.max(last[1], r[1]);
183
+ }
184
+ ctx.beginPath();
185
+ let cursor = start;
186
+ for (const [f, t] of merged) {
187
+ if (f - cursor > 0.7) { ctx.moveTo(x, cursor); ctx.lineTo(x, f); }
188
+ cursor = t;
189
+ }
190
+ if (end - cursor > 0.7) { ctx.moveTo(x, cursor); ctx.lineTo(x, end); }
191
+ ctx.stroke();
192
+ }
193
+ function drawGlyph(letter, x, y) {
194
+ ctx.save();
195
+ ctx.translate(x, y);
196
+ ctx.stroke(glyphPaths[letter]);
197
+ ctx.restore();
198
+ }
199
+
200
+ // --- Frame ------------------------------------------------------------
201
+ function drawFrame(phase) {
202
+ if (cssW === 0 || cssH === 0) return;
203
+ // Identity transform to clear the raw pixel grid, then re-apply viewBox map.
204
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
205
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
206
+ applyVbTransform();
207
+
208
+ fillSamples(pointsA, 0, phase);
209
+ fillSamples(pointsB, Math.PI, phase);
210
+ fillSegments();
211
+ segs.sort((u, v) => u.z - v.z);
212
+
213
+ // Pass 1: back segments (z < 0) — drawn under rungs/glyphs.
214
+ let i = 0;
215
+ for (; i < segs.length && segs[i].z < 0; i++) drawSegment(segs[i]);
216
+
217
+ // Rungs + ATCG glyphs (only those whose rung span makes them visible).
218
+ for (let k = 0; k < helix.rungCount; k++) {
219
+ const t = k / (helix.rungCount - 1);
220
+ const x = helix.startX + (helix.endX - helix.startX) * t;
221
+ const a = pointAt(x, 0, phase);
222
+ const b = pointAt(x, Math.PI, phase);
223
+ const yTop = Math.min(a.y, b.y);
224
+ const yBottom = Math.max(a.y, b.y);
225
+ const span = yBottom - yTop;
226
+ const inset = Math.min(helix.rungInset, Math.max(0, span * 0.5 - 3));
227
+ const visible = Math.max(0, Math.min(1, (span - 34) / 70));
228
+ const aLetterY = a.y + (b.y - a.y) * 0.34;
229
+ const bLetterY = b.y + (a.y - b.y) * 0.34;
230
+ const letterGap = Math.min(helix.glyphGap, Math.max(8.5, span * 0.16));
231
+
232
+ ctx.globalAlpha = 0.18 + visible * 0.72;
233
+ ctx.strokeStyle = COLORS.green;
234
+ ctx.lineWidth = px(1.35);
235
+ ctx.lineCap = "round";
236
+ drawRung(x, yTop + inset, [aLetterY, bLetterY], yBottom - inset, letterGap);
237
+
238
+ ctx.globalAlpha = 0.16 + visible * 0.84;
239
+ ctx.lineWidth = px(1.8);
240
+ ctx.lineCap = "square";
241
+ ctx.lineJoin = "miter";
242
+ const letter = sequence[k % sequence.length];
243
+ drawGlyph(letter, x, aLetterY);
244
+ drawGlyph(complement[letter], x, bLetterY);
245
+ }
246
+
247
+ // Pass 2: front segments (z >= 0) — drawn on top of rungs/glyphs.
248
+ for (; i < segs.length; i++) drawSegment(segs[i]);
249
+
250
+ ctx.globalAlpha = 1;
251
+ }
252
+
253
+ resize();
254
+
255
+ // Static frame for users who prefer reduced motion.
256
+ if (prefersReduced) {
257
+ drawFrame(0.6);
258
+ return;
259
+ }
260
+
261
+ // --- Animation loop, paused off-screen and on hidden tab --------------
262
+ // 30fps is visually indistinguishable from 60fps at this rotation speed
263
+ // and halves the paint workload.
264
+ const FRAME_INTERVAL_MS = 1000 / 30;
265
+ let rafId = 0, running = false, inViewport = true, lastFrameTs = 0;
266
+ function tick(ts) {
267
+ if (!running) return;
268
+ if (ts - lastFrameTs >= FRAME_INTERVAL_MS) {
269
+ lastFrameTs = ts;
270
+ drawFrame(ts * helix.speed);
271
+ }
272
+ rafId = requestAnimationFrame(tick);
273
+ }
274
+ function start() {
275
+ if (running || !inViewport || document.hidden) return;
276
+ running = true;
277
+ lastFrameTs = 0;
278
+ rafId = requestAnimationFrame(tick);
279
+ }
280
+ function stop() {
281
+ if (!running) return;
282
+ running = false;
283
+ cancelAnimationFrame(rafId);
284
+ }
285
+
286
+ const io = new IntersectionObserver(entries => {
287
+ inViewport = entries[0].isIntersecting;
288
+ if (inViewport) start();
289
+ else stop();
290
+ }, { rootMargin: "100px" });
291
+ io.observe(banner);
292
+
293
+ document.addEventListener("visibilitychange", () => {
294
+ if (document.hidden) stop();
295
+ else start();
296
+ });
297
+
298
+ // Re-size the backing store when the banner is resized (responsive layout).
299
+ const ro = new ResizeObserver(() => {
300
+ resize();
301
+ drawFrame(lastFrameTs * helix.speed);
302
+ });
303
+ ro.observe(canvas);
304
+
305
+ drawFrame(0);
306
+ start();
307
+ })();
308
+
assets/js/sections/architecture.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §10 — Architecture table
3
+ // =========================================================================
4
+ (function initDemo10() {
5
+ const tbl = document.getElementById("d10-arch");
6
+ const ROWS = [
7
+ ["Layers", "30", "32"],
8
+ ["Hidden size", "3,072", "4,096"],
9
+ ["FFN hidden size", "8,448", "14,336"],
10
+ ["Attention heads", "32", "32"],
11
+ ["KV groups (GQA)", "4", "8"],
12
+ ["Head dim", "96", "128"],
13
+ ["Activation", "SwiGLU", "SwiGLU"],
14
+ ["Normalization", "RMSNorm", "RMSNorm"],
15
+ ["Position encoding", "RoPE (θ=500k)", "RoPE (θ=500k)"],
16
+ ["Tied I/O embeddings", "✓", "✓"],
17
+ ["Context length", "8,192 tokens (≈49 kbp)", "8,192 tokens (≈49 kbp)"],
18
+ ];
19
+ let html = `<thead>
20
+ <tr>
21
+ <th style="text-align:left;padding:10px 6px 8px;border-bottom:1px solid #ddd;font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1.5px;font-weight:400"></th>
22
+ <th style="text-align:left;padding:10px 12px 8px;border-bottom:1px solid #ddd;font-size:11px;color:#1f1f1d;letter-spacing:1px">Carbon · 3B</th>
23
+ <th style="text-align:left;padding:10px 12px 8px;border-bottom:1px solid #ddd;font-size:11px;color:#1f1f1d;letter-spacing:1px">Carbon · 8B</th>
24
+ </tr>
25
+ </thead><tbody>`;
26
+ ROWS.forEach((r, i) => {
27
+ const bg = i % 2 === 0 ? "#f7f5ee" : "#fff";
28
+ html += `<tr style="background:${bg}">
29
+ <td style="padding:6px;color:#666;font-size:10px;text-transform:uppercase;letter-spacing:1px">${r[0]}</td>
30
+ <td style="padding:6px 12px;color:#1f1f1d">${r[1]}</td>
31
+ <td style="padding:6px 12px;color:#1f1f1d">${r[2]}</td>
32
+ </tr>`;
33
+ });
34
+ html += `</tbody>`;
35
+ tbl.innerHTML = html;
36
+ })();
37
+
assets/js/sections/completion.js ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §1 — Gene completion + annotation overlay
3
+ // =========================================================================
4
+ (function initDemo1() {
5
+ const els = {
6
+ pills: document.getElementById("d1-pills"),
7
+ info: document.getElementById("d1-info"),
8
+ track: document.getElementById("d1-track"),
9
+ seq: document.getElementById("d1-seq"),
10
+ go: document.getElementById("d1-go"),
11
+ stop: document.getElementById("d1-stop"),
12
+ status: document.getElementById("d1-status"),
13
+ statusText: document.querySelector("#d1-status span:last-child"),
14
+ id: document.getElementById("d1-id"),
15
+ idExon: document.getElementById("d1-id-exon"),
16
+ idIntron:document.getElementById("d1-id-intron"),
17
+ tok: document.getElementById("d1-tok"),
18
+ lp: document.getElementById("d1-lp"),
19
+ ppl: document.getElementById("d1-ppl"),
20
+ };
21
+
22
+ let gene = null;
23
+ let prefixStart = 0;
24
+ let prefixEnd = 200;
25
+ let genEnd = 260; // end of generated region (genLen = genEnd - prefixEnd)
26
+ const MIN_PROMPT_BP = 6; // at least one BPE token's worth
27
+ const MIN_GEN_BP = 6;
28
+ const DEFAULT_GEN_BP = 60;
29
+ let abortCtrl = null;
30
+ let dragging = null; // "start" | "end" | "genend" | null
31
+
32
+ let promptBases = "";
33
+ let genText = "";
34
+ let genTokens = []; // [{text, logprob}]
35
+ let genTokenAtBase = []; // index into genTokens for each generated base
36
+
37
+ function setStatus(text, mode = "") {
38
+ els.statusText.textContent = text;
39
+ els.status.className = "status" + (mode ? " " + mode : "");
40
+ }
41
+
42
+ function renderTrack() {
43
+ const W = 1000, H = 40;
44
+ if (!gene) { els.track.innerHTML = ""; return; }
45
+ const scaleX = (bp) => (bp / gene.length) * W;
46
+ // Track body sits y=8..32; arrows live at y=0..8 (start, top) and y=32..40 (end, bottom).
47
+ const TRACK_TOP = 8, TRACK_BOT = 32, INTRON_Y = 20, EXON_Y = 14, EXON_H = 12;
48
+ let svg = "";
49
+ // Background line through introns
50
+ svg += `<line class="intron" x1="0" y1="${INTRON_Y}" x2="${W}" y2="${INTRON_Y}"/>`;
51
+ // Exon rectangles
52
+ for (const e of gene.exons) {
53
+ const x = scaleX(e.start);
54
+ const w = Math.max(1, scaleX(e.end - e.start));
55
+ svg += `<rect class="exon" x="${x.toFixed(1)}" y="${EXON_Y}" width="${w.toFixed(1)}" height="${EXON_H}"/>`;
56
+ }
57
+ // Selected prompt region (very faint, between handles)
58
+ const xStart = scaleX(prefixStart);
59
+ const xEnd = scaleX(prefixEnd);
60
+ svg += `<rect class="prompt-region" x="${xStart.toFixed(1)}" y="${TRACK_TOP}" width="${(xEnd - xStart).toFixed(1)}" height="${TRACK_BOT - TRACK_TOP}"/>`;
61
+ // Generated region (muted green box, between prompt-end and gen-end handles)
62
+ const xGenEnd = scaleX(genEnd);
63
+ svg += `<rect class="gen-region" x="${xEnd.toFixed(1)}" y="${TRACK_TOP}" width="${(xGenEnd - xEnd).toFixed(1)}" height="${TRACK_BOT - TRACK_TOP}"/>`;
64
+ // START handle: vertical line through the track body + downward triangle on top.
65
+ svg += `<g class="handle${dragging === "start" ? " dragging" : ""}" data-role="start" transform="translate(${xStart.toFixed(1)},0)">`
66
+ + `<line x1="0" y1="${TRACK_TOP}" x2="0" y2="${TRACK_BOT}"/>`
67
+ + `<polygon points="-4,0 4,0 0,${TRACK_TOP}"/>`
68
+ + `<rect x="-8" y="0" width="16" height="${H}" fill="transparent"/>`
69
+ + `</g>`;
70
+ // END handle (prompt end / gen start): vertical line + upward triangle on bottom.
71
+ svg += `<g class="handle${dragging === "end" ? " dragging" : ""}" data-role="end" transform="translate(${xEnd.toFixed(1)},0)">`
72
+ + `<line x1="0" y1="${TRACK_TOP}" x2="0" y2="${TRACK_BOT}"/>`
73
+ + `<polygon points="0,${TRACK_BOT} -4,${H} 4,${H}"/>`
74
+ + `<rect x="-8" y="0" width="16" height="${H}" fill="transparent"/>`
75
+ + `</g>`;
76
+ // GEN-END handle: vertical line + downward triangle on top, green.
77
+ svg += `<g class="handle gen${dragging === "genend" ? " dragging" : ""}" data-role="genend" transform="translate(${xGenEnd.toFixed(1)},0)">`
78
+ + `<line x1="0" y1="${TRACK_TOP}" x2="0" y2="${TRACK_BOT}"/>`
79
+ + `<polygon points="-4,0 4,0 0,${TRACK_TOP}"/>`
80
+ + `<rect x="-8" y="0" width="16" height="${H}" fill="transparent"/>`
81
+ + `</g>`;
82
+ els.track.innerHTML = svg;
83
+ }
84
+
85
+ function bpFromClientX(clientX) {
86
+ if (!gene) return 0;
87
+ const rect = els.track.getBoundingClientRect();
88
+ const frac = (clientX - rect.left) / rect.width;
89
+ return Math.max(0, Math.min(gene.length, Math.round(frac * gene.length)));
90
+ }
91
+
92
+ function renderInfo() {
93
+ if (!gene) { els.info.textContent = "loading genes…"; return; }
94
+ const promptLen = prefixEnd - prefixStart;
95
+ const genLen = genEnd - prefixEnd;
96
+ els.info.innerHTML = `<strong>${gene.symbol}</strong> · ${gene.blurb} · <span style="color:#888">${gene.length.toLocaleString("en-US")} bp</span>`
97
+ + ` · <span style="color:#888">prompt: ${prefixStart}–${prefixEnd} (${promptLen} bp)</span>`
98
+ + ` · <span style="color:#317f3f">generate: ${prefixEnd}–${genEnd} (${genLen} bp)</span>`;
99
+ }
100
+
101
+ function basesPerLine() {
102
+ // Match the existing index.html dynamic computation, but coarser.
103
+ const cs = getComputedStyle(els.seq);
104
+ const padL = parseFloat(cs.paddingLeft) || 0;
105
+ const padR = parseFloat(cs.paddingRight) || 0;
106
+ const contentW = els.seq.clientWidth - padL - padR;
107
+ // Approx ~9px per character at 12px JBM with 1px letter-spacing
108
+ const charW = 8.4;
109
+ const prefixW = 7 * charW; // " N "
110
+ const blockW = 10 * charW + charW; // 10 bases + space
111
+ if (contentW <= prefixW) return 60;
112
+ const blocks = Math.floor((contentW - prefixW) / blockW);
113
+ return Math.max(20, Math.min(blocks, 12) * 10);
114
+ }
115
+
116
+ function annotationAt(idx) {
117
+ if (!gene) return "intergenic";
118
+ for (const e of gene.exons) if (idx >= e.start && idx < e.end) return "exon";
119
+ return "intron";
120
+ }
121
+
122
+ function renderSequenceAndRef() {
123
+ const bpl = basesPerLine();
124
+ const prompt = promptBases;
125
+ const total = prompt + genText;
126
+ const lpRange = lpRangeOf(genTokens);
127
+
128
+ // Output: prompt in gray; generated colored by logprob, underlined green/red by ref match.
129
+ const colorOutput = (absIdx, base) => {
130
+ if (absIdx < prompt.length) {
131
+ return { style: `color:rgb(${PROMPT_RGB.join(",")})` };
132
+ }
133
+ const genIdx = absIdx - prompt.length;
134
+ const tok = genTokens[genTokenAtBase[genIdx]];
135
+ const [r, g, b] = logprobRgb(tok ? tok.logprob : null, lpRange);
136
+ const refBase = gene ? gene.seq[prefixEnd + genIdx] : undefined;
137
+ const ulColor = refBase == null
138
+ ? "transparent"
139
+ : (base === refBase ? "#317f3f" : "#b00020");
140
+ return {
141
+ style: `color:rgb(${r},${g},${b});`
142
+ + `text-decoration:underline;`
143
+ + `text-decoration-color:${ulColor};`
144
+ + `text-decoration-thickness:1.5px;`
145
+ + `text-underline-offset:2px`
146
+ };
147
+ };
148
+ renderSeq(els.seq, total, bpl, colorOutput);
149
+ }
150
+
151
+ function updateStats() {
152
+ if (!gene || genText.length === 0) {
153
+ [els.id, els.idExon, els.idIntron, els.tok, els.lp, els.ppl].forEach(e => {
154
+ e.textContent = "—"; e.classList.add("muted");
155
+ });
156
+ return;
157
+ }
158
+ const refSlice = gene.seq.slice(prefixEnd, prefixEnd + genText.length);
159
+ let match = 0, total = 0;
160
+ let exonMatch = 0, exonTotal = 0;
161
+ let intronMatch = 0, intronTotal = 0;
162
+ for (let i = 0; i < genText.length; i++) {
163
+ if (i >= refSlice.length) break;
164
+ total++;
165
+ const ok = genText[i] === refSlice[i];
166
+ if (ok) match++;
167
+ const ann = annotationAt(prefixEnd + i);
168
+ if (ann === "exon") { exonTotal++; if (ok) exonMatch++; }
169
+ else if (ann === "intron") { intronTotal++; if (ok) intronMatch++; }
170
+ }
171
+ const pct = (n, d) => d > 0 ? `${((n/d)*100).toFixed(0)}%` : "—";
172
+ els.id.textContent = `${pct(match, total)} (${match}/${total})`;
173
+ els.idExon.textContent = exonTotal > 0 ? `${pct(exonMatch, exonTotal)} (${exonMatch}/${exonTotal})` : "—";
174
+ els.idIntron.textContent = intronTotal > 0 ? `${pct(intronMatch, intronTotal)} (${intronMatch}/${intronTotal})` : "—";
175
+ els.tok.textContent = String(genTokens.length);
176
+ const mlp = meanLogprob(genTokens);
177
+ els.lp.textContent = mlp == null ? "—" : mlp.toFixed(2);
178
+ els.ppl.textContent = mlp == null ? "—" : Math.exp(-mlp).toFixed(1);
179
+ [els.id, els.idExon, els.idIntron, els.tok, els.lp, els.ppl].forEach(e => e.classList.remove("muted"));
180
+ }
181
+
182
+ function reset() {
183
+ promptBases = gene ? gene.seq.slice(prefixStart, prefixEnd) : "";
184
+ genText = "";
185
+ genTokens = [];
186
+ genTokenAtBase = [];
187
+ renderInfo();
188
+ renderTrack();
189
+ renderSequenceAndRef();
190
+ updateStats();
191
+ }
192
+
193
+ async function generate() {
194
+ if (abortCtrl || !gene) return;
195
+ reset();
196
+ abortCtrl = new AbortController();
197
+ els.go.disabled = true;
198
+ els.stop.disabled = false;
199
+ setStatus("connecting…", "streaming");
200
+
201
+ const genLen = genEnd - prefixEnd;
202
+
203
+ try {
204
+ const resp = await fetch("/generate", {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({
208
+ prompt: promptBases,
209
+ max_tokens: Math.ceil(genLen / 6) + 4, // tokens are ~6 bases each
210
+ temperature: 0.5,
211
+ top_p: 0.9,
212
+ }),
213
+ signal: abortCtrl.signal,
214
+ });
215
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
216
+ setStatus("streaming", "streaming");
217
+
218
+ const reader = resp.body.getReader();
219
+ const decoder = new TextDecoder();
220
+ let buffer = "";
221
+ while (true) {
222
+ const { done, value } = await reader.read();
223
+ if (done) break;
224
+ buffer += decoder.decode(value, { stream: true });
225
+ const events = buffer.split("\n\n");
226
+ buffer = events.pop();
227
+ for (const ev of events) {
228
+ const line = ev.trim();
229
+ if (!line.startsWith("data:")) continue;
230
+ const data = JSON.parse(line.slice(5).trim());
231
+ if (data.error) throw new Error(data.error);
232
+ if (data.done) continue;
233
+ if (data.logprobs) {
234
+ const lp = data.logprobs;
235
+ for (let i = 0; i < lp.tokens.length; i++) {
236
+ const tokIdx = genTokens.length;
237
+ genTokens.push({ text: lp.tokens[i], logprob: lp.token_logprobs[i] });
238
+ for (let j = 0; j < lp.tokens[i].length; j++) genTokenAtBase.push(tokIdx);
239
+ }
240
+ }
241
+ if (data.text) {
242
+ const cleaned = data.text.toUpperCase().replace(/[^ACGTN]/g, "");
243
+ // Stop appending once we've covered the requested gen window.
244
+ const room = Math.max(0, genLen - genText.length);
245
+ genText += cleaned.slice(0, room);
246
+ renderSequenceAndRef();
247
+ updateStats();
248
+ if (genText.length >= genLen) abortCtrl?.abort();
249
+ }
250
+ }
251
+ }
252
+ setStatus("done");
253
+ } catch (e) {
254
+ if (e.name === "AbortError") setStatus("done");
255
+ else setStatus(e.message, "error");
256
+ } finally {
257
+ abortCtrl = null;
258
+ els.go.disabled = false;
259
+ els.stop.disabled = true;
260
+ renderSequenceAndRef();
261
+ updateStats();
262
+ }
263
+ }
264
+
265
+ function stop() { if (abortCtrl) abortCtrl.abort(); }
266
+
267
+ function selectGene(symbol) {
268
+ const g = GENES.find(x => x.symbol === symbol);
269
+ if (!g) return;
270
+ gene = g;
271
+ // Reset prompt + generate windows to defaults, clamped to this gene's length.
272
+ prefixStart = 0;
273
+ prefixEnd = Math.min(200, Math.max(MIN_PROMPT_BP, gene.length - DEFAULT_GEN_BP));
274
+ genEnd = Math.min(gene.length, prefixEnd + DEFAULT_GEN_BP);
275
+ els.pills.querySelectorAll(".pill").forEach(p => p.classList.toggle("active", p.dataset.gene === symbol));
276
+ reset();
277
+ }
278
+
279
+ function bindPills(container, attr, onSelect) {
280
+ container.querySelectorAll(".pill").forEach(p => {
281
+ p.addEventListener("click", () => {
282
+ container.querySelectorAll(".pill").forEach(x => x.classList.remove("active"));
283
+ p.classList.add("active");
284
+ onSelect(p.dataset[attr]);
285
+ });
286
+ });
287
+ }
288
+
289
+ // Bootstrap
290
+ loadGenes().then(genes => {
291
+ els.pills.innerHTML = genes.map((g, i) =>
292
+ `<button class="pill${i === 0 ? " active" : ""}" data-gene="${g.symbol}">${g.symbol}</button>`
293
+ ).join("");
294
+ bindPills(els.pills, "gene", selectGene);
295
+ selectGene(genes[0].symbol);
296
+ }).catch(e => {
297
+ els.info.textContent = "failed to load genes: " + e.message;
298
+ });
299
+
300
+ els.go.addEventListener("click", generate);
301
+ els.stop.addEventListener("click", stop);
302
+
303
+ // Drag handles on the track to set the prompt range.
304
+ els.track.addEventListener("pointerdown", (e) => {
305
+ const target = e.target.closest(".handle");
306
+ if (!target || !gene) return;
307
+ dragging = target.dataset.role;
308
+ els.track.setPointerCapture(e.pointerId);
309
+ renderTrack(); // re-render so the picked handle shows its `.dragging` style
310
+ e.preventDefault();
311
+ });
312
+ els.track.addEventListener("pointermove", (e) => {
313
+ if (!dragging || !gene) return;
314
+ const bp = bpFromClientX(e.clientX);
315
+ if (dragging === "start") {
316
+ prefixStart = Math.max(0, Math.min(bp, prefixEnd - MIN_PROMPT_BP));
317
+ } else if (dragging === "end") {
318
+ prefixEnd = Math.max(prefixStart + MIN_PROMPT_BP, Math.min(bp, genEnd - MIN_GEN_BP));
319
+ } else if (dragging === "genend") {
320
+ genEnd = Math.max(prefixEnd + MIN_GEN_BP, Math.min(bp, gene.length));
321
+ }
322
+ reset();
323
+ });
324
+ const endDrag = (e) => {
325
+ if (!dragging) return;
326
+ dragging = null;
327
+ try { els.track.releasePointerCapture(e.pointerId); } catch (_) {}
328
+ renderTrack();
329
+ };
330
+ els.track.addEventListener("pointerup", endDrag);
331
+ els.track.addEventListener("pointercancel", endDrag);
332
+
333
+ window.addEventListener("resize", () => {
334
+ if (gene) renderSequenceAndRef();
335
+ });
336
+ })();
337
+
assets/js/sections/data.js ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §9 — Data composition + signal-to-noise
3
+ // =========================================================================
4
+ (function initDemo9() {
5
+ const pieEl = document.getElementById("d9-pie");
6
+ const legendEl = document.getElementById("d9-legend");
7
+ const snrEl = document.getElementById("d9-snr");
8
+ const tplEl = document.getElementById("d9-templates");
9
+
10
+ const COMPOSITION = [
11
+ { label: "GENERator-v2", pct: 70, color: "#317f3f", desc: "annotation-aware functional genomic backbone (eukaryotic, gene-centric)" },
12
+ { label: "mRNA", pct: 16, color: "#2c5aa0", desc: "OpenGenome2 mature transcripts · RNA-level functional context" },
13
+ { label: "GTDB", pct: 10, color: "#c08030", desc: "OpenGenome2 prokaryotic genomes · compact bacterial structure" },
14
+ { label: "mRNA-splice", pct: 4, color: "#7a4baa", desc: "OpenGenome2 transcript-derived · splice-related signal" },
15
+ ];
16
+
17
+ const TEMPLATES = [
18
+ { pct: "50.0%", body: "<dna>SEQUENCE</dna>", note: "no metadata · default pre-training format" },
19
+ { pct: "16.7%", body: "<species_type><gene_type><dna>SEQUENCE</dna>", note: "both metadata fields" },
20
+ { pct: "16.7%", body: "<species_type><dna>SEQUENCE</dna>", note: "species-conditioned only" },
21
+ { pct: "16.7%", body: "<gene_type><dna>SEQUENCE</dna>", note: "gene-type-conditioned only" },
22
+ ];
23
+
24
+ function renderPie() {
25
+ const cx = 100, cy = 100, r = 88;
26
+ let acc = 0;
27
+ let svg = "";
28
+ for (const slice of COMPOSITION) {
29
+ const start = acc / 100 * Math.PI * 2 - Math.PI / 2;
30
+ acc += slice.pct;
31
+ const end = acc / 100 * Math.PI * 2 - Math.PI / 2;
32
+ const large = slice.pct > 50 ? 1 : 0;
33
+ const x1 = cx + r * Math.cos(start), y1 = cy + r * Math.sin(start);
34
+ const x2 = cx + r * Math.cos(end), y2 = cy + r * Math.sin(end);
35
+ svg += `<path d="M ${cx} ${cy} L ${x1.toFixed(1)} ${y1.toFixed(1)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(1)} ${y2.toFixed(1)} Z" fill="${slice.color}" opacity="0.85"/>`;
36
+ // Percentage label inside slice if room
37
+ if (slice.pct >= 8) {
38
+ const mid = (start + end) / 2;
39
+ const lr = r * 0.62;
40
+ const lx = cx + lr * Math.cos(mid);
41
+ const ly = cy + lr * Math.sin(mid);
42
+ svg += `<text x="${lx.toFixed(1)}" y="${(ly + 4).toFixed(1)}" font-family="JetBrains Mono" font-size="11" fill="#fff" text-anchor="middle" font-weight="500">${slice.pct}%</text>`;
43
+ }
44
+ }
45
+ // Inner hole for donut
46
+ svg += `<circle cx="${cx}" cy="${cy}" r="${r * 0.42}" fill="#f7f5ee"/>`;
47
+ svg += `<text x="${cx}" y="${cy - 2}" font-family="JetBrains Mono" font-size="11" fill="#1f1f1d" text-anchor="middle" font-weight="500">CARBON</text>`;
48
+ svg += `<text x="${cx}" y="${cy + 12}" font-family="JetBrains Mono" font-size="9" fill="#888" text-anchor="middle">corpus</text>`;
49
+ pieEl.innerHTML = svg;
50
+ }
51
+
52
+ function renderLegend() {
53
+ legendEl.innerHTML = COMPOSITION.map(s => `
54
+ <div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid #f0f0f0">
55
+ <span style="display:inline-block;flex:0 0 12px;height:12px;background:${s.color};border-radius:2px;margin-top:3px"></span>
56
+ <div style="flex:1">
57
+ <div><strong style="color:#1f1f1d">${s.label}</strong> <span style="color:#888">· ${s.pct}%</span></div>
58
+ <div style="color:#888;font-size:10px;margin-top:1px">${s.desc}</div>
59
+ </div>
60
+ </div>
61
+ `).join("");
62
+ }
63
+
64
+ function renderSNR() {
65
+ const W = 1000, H = 90;
66
+ const rowY = [22, 60];
67
+ const segH = 18;
68
+ let svg = "";
69
+ // Two rows: raw genome (sparse signal) vs curated (dense signal)
70
+ // Each row has a sequence of background + functional segments.
71
+ function paintRow(y, segs, label) {
72
+ svg += `<text x="6" y="${y + 13}" font-family="JetBrains Mono" font-size="10" fill="#666" letter-spacing="1">${label}</text>`;
73
+ // gutter for the label
74
+ const padL = 110;
75
+ let cursor = padL;
76
+ const rowW = W - padL - 12;
77
+ for (const seg of segs) {
78
+ const w = (seg.frac * rowW);
79
+ svg += `<rect x="${cursor.toFixed(1)}" y="${y}" width="${Math.max(0.5, w).toFixed(1)}" height="${segH}" fill="${seg.func ? '#317f3f' : '#ddd'}"/>`;
80
+ cursor += w;
81
+ }
82
+ }
83
+ // Raw: ~5% functional, scattered
84
+ const rawSegs = [
85
+ { frac: 0.18 }, { frac: 0.015, func: true }, { frac: 0.10 }, { frac: 0.02, func: true },
86
+ { frac: 0.30 }, { frac: 0.005, func: true }, { frac: 0.05 }, { frac: 0.01, func: true },
87
+ { frac: 0.18 }, { frac: 0.005, func: true }, { frac: 0.115 },
88
+ ];
89
+ paintRow(rowY[0], rawSegs, "RAW (5%)");
90
+ // Curated: ~46% functional, denser
91
+ const curSegs = [
92
+ { frac: 0.06 }, { frac: 0.10, func: true }, { frac: 0.04 }, { frac: 0.12, func: true },
93
+ { frac: 0.06 }, { frac: 0.08, func: true }, { frac: 0.05 }, { frac: 0.10, func: true },
94
+ { frac: 0.04 }, { frac: 0.06, func: true }, { frac: 0.05 }, { frac: 0.10, func: true },
95
+ { frac: 0.04 },
96
+ ];
97
+ paintRow(rowY[1], curSegs, "CURATED (46%)");
98
+ snrEl.innerHTML = svg;
99
+ }
100
+
101
+ function renderTemplates() {
102
+ let html = "";
103
+ for (const t of TEMPLATES) {
104
+ html += `<div style="text-align:right;color:#317f3f;font-weight:500">${t.pct}</div>`;
105
+ html += `<div><span style="background:#f4f4f4;padding:2px 6px;border-radius:2px;color:#1f1f1d">${t.body.replace(/</g,"&lt;").replace(/>/g,"&gt;")}</span> <span style="color:#888;font-size:10px;margin-left:8px">${t.note}</span></div>`;
106
+ }
107
+ tplEl.innerHTML = html;
108
+ }
109
+
110
+ renderPie();
111
+ renderLegend();
112
+ renderSNR();
113
+ renderTemplates();
114
+ })();
115
+
assets/js/sections/folding.js ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §5 — Folding (Carbon → ESMFold → 3Dmol side-by-side)
3
+ //
4
+ // Pipeline per click:
5
+ // reference : gene.seq exons concatenated → mRNA → longest ORF → AA
6
+ // carbon : gene.seq[0..prefix] → /generate (temp=0.7) → DNA continuation
7
+ // → longest ORF across 3 frames → AA
8
+ // both AA strings → POST /fold in parallel → 3Dmol cartoons
9
+ // stats : pLDDT mean for each, 1D identity between the two AA strings
10
+ //
11
+ // Reference uses exon annotation (a biological prior the model never sees)
12
+ // so we get the "true" protein for the chosen gene. Carbon gets only raw
13
+ // bases and has to figure the ORF out by itself — the asymmetry is exactly
14
+ // the point of the section.
15
+ // =========================================================================
16
+ (function initDemoFold() {
17
+ // --- Standard genetic code (canonical, no selenocysteine handling) -----
18
+ // Indexed by uppercase 3-letter codon. "*" marks the three stop codons.
19
+ const CODON_TABLE = {
20
+ TTT:"F",TTC:"F",TTA:"L",TTG:"L", CTT:"L",CTC:"L",CTA:"L",CTG:"L",
21
+ ATT:"I",ATC:"I",ATA:"I",ATG:"M", GTT:"V",GTC:"V",GTA:"V",GTG:"V",
22
+ TCT:"S",TCC:"S",TCA:"S",TCG:"S", CCT:"P",CCC:"P",CCA:"P",CCG:"P",
23
+ ACT:"T",ACC:"T",ACA:"T",ACG:"T", GCT:"A",GCC:"A",GCA:"A",GCG:"A",
24
+ TAT:"Y",TAC:"Y",TAA:"*",TAG:"*", CAT:"H",CAC:"H",CAA:"Q",CAG:"Q",
25
+ AAT:"N",AAC:"N",AAA:"K",AAG:"K", GAT:"D",GAC:"D",GAA:"E",GAG:"E",
26
+ TGT:"C",TGC:"C",TGA:"*",TGG:"W", CGT:"R",CGC:"R",CGA:"R",CGG:"R",
27
+ AGT:"S",AGC:"S",AGA:"R",AGG:"R", GGT:"G",GGC:"G",GGA:"G",GGG:"G",
28
+ };
29
+
30
+ // Walk a DNA string in 3-base steps starting at `frame`, look for ATG
31
+ // start codons, and translate from each one. Prefers an ORF ending on a
32
+ // clean stop codon; falls back to a truncated ORF (reached the end of
33
+ // `dna` with no stop) — that happens when Carbon mutates the canonical
34
+ // stop and the translation reads into the 3'UTR. Truncated ORFs are
35
+ // tagged so the UI can hint at that.
36
+ function findLongestORF(dna, minAA = 30) {
37
+ let bestClean = null;
38
+ let bestTrunc = null;
39
+ for (let frame = 0; frame < 3; frame++) {
40
+ let i = frame;
41
+ while (i <= dna.length - 3) {
42
+ if (dna.slice(i, i + 3) !== "ATG") { i += 3; continue; }
43
+ let aa = "";
44
+ let j = i;
45
+ let stoppedCleanly = false;
46
+ let invalid = false;
47
+ while (j + 3 <= dna.length) {
48
+ const a = CODON_TABLE[dna.slice(j, j + 3)];
49
+ if (!a) { invalid = true; break; } // Non-ACGT codon — bail.
50
+ if (a === "*") { stoppedCleanly = true; break; }
51
+ aa += a;
52
+ j += 3;
53
+ }
54
+ if (!invalid && aa.length >= minAA) {
55
+ const entry = { aa, frame, startBP: i, endBP: j, lenBP: j - i, truncated: !stoppedCleanly };
56
+ if (stoppedCleanly) {
57
+ if (!bestClean || aa.length > bestClean.aa.length) bestClean = entry;
58
+ } else {
59
+ if (!bestTrunc || aa.length > bestTrunc.aa.length) bestTrunc = entry;
60
+ }
61
+ }
62
+ i += 3;
63
+ }
64
+ }
65
+ return bestClean || bestTrunc;
66
+ }
67
+
68
+ // Splice exons out of a (genomic) DNA string using the given exon
69
+ // coordinates and return the mature mRNA. Exons whose `end` exceeds the
70
+ // DNA length are truncated; exons fully past the end are dropped. This
71
+ // lets us reuse the same routine for the reference (full genomic seq)
72
+ // and for Carbon's continuation (which may be shorter than the gene).
73
+ function spliceExons(dna, exons) {
74
+ const parts = [];
75
+ for (const e of exons) {
76
+ if (e.start >= dna.length) break;
77
+ parts.push(dna.slice(e.start, Math.min(e.end, dna.length)));
78
+ }
79
+ return parts.join("");
80
+ }
81
+
82
+ function translateReference(gene) {
83
+ return findLongestORF(spliceExons(gene.seq, gene.exons), 30);
84
+ }
85
+
86
+ // A gene is "demo-friendly" if Carbon can plausibly generate enough DNA
87
+ // in one shot to cover all exons. Anything past ~2500 bp of genomic DNA
88
+ // takes minutes on the live endpoint, so we hard-cap there and surface
89
+ // the limitation in the UI instead of silently producing a broken ORF.
90
+ const MAX_GENOMIC_BP = 2500;
91
+ function geneFeasibility(gene) {
92
+ const lastExonEnd = gene.exons.length ? gene.exons[gene.exons.length - 1].end : 0;
93
+ return { lastExonEnd, feasible: lastExonEnd <= MAX_GENOMIC_BP };
94
+ }
95
+
96
+ // Fraction of positions where two AA strings match. Compared over the
97
+ // shorter of the two — Carbon and ref may have wildly different ORF
98
+ // lengths (or the same), and we just want a 0-1 number for the stat row.
99
+ function identity1D(a, b) {
100
+ const n = Math.min(a.length, b.length);
101
+ if (n === 0) return 0;
102
+ let m = 0;
103
+ for (let i = 0; i < n; i++) if (a[i] === b[i]) m++;
104
+ return m / n;
105
+ }
106
+
107
+ // Drain the SSE response from /generate and return the concatenated DNA.
108
+ // Matches the framing already in §1 (one event per "data: …\n\n" block).
109
+ async function streamGenerate(prompt, maxTokens, temperature, abortSignal) {
110
+ const resp = await fetch("/generate", {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ prompt, max_tokens: maxTokens, temperature, top_p: 1.0 }),
114
+ signal: abortSignal,
115
+ });
116
+ if (!resp.ok) throw new Error(`/generate HTTP ${resp.status}`);
117
+ const reader = resp.body.getReader();
118
+ const decoder = new TextDecoder();
119
+ let buf = "";
120
+ let out = "";
121
+ while (true) {
122
+ const { value, done } = await reader.read();
123
+ if (done) break;
124
+ buf += decoder.decode(value, { stream: true });
125
+ const events = buf.split("\n\n");
126
+ buf = events.pop();
127
+ for (const ev of events) {
128
+ const line = ev.trim();
129
+ if (!line.startsWith("data:")) continue;
130
+ const data = JSON.parse(line.slice(5).trim());
131
+ if (data.error) throw new Error(data.error);
132
+ if (data.text) out += data.text;
133
+ }
134
+ }
135
+ return out.toUpperCase().replace(/[^ACGT]/g, "");
136
+ }
137
+
138
+ // --- DOM ---------------------------------------------------------------
139
+ let GENES_LOCAL = null;
140
+ let currentGeneSymbol = null;
141
+ let prefixLen = 200;
142
+ let viewerCarbon = null;
143
+ let viewerRef = null;
144
+ let abortCtrl = null;
145
+
146
+ const els = {
147
+ pills: document.getElementById("dfold-pills"),
148
+ prefixPills: document.getElementById("dfold-prefix-pills"),
149
+ info: document.getElementById("dfold-info"),
150
+ mrna: document.getElementById("dfold-mrna"),
151
+ aa: document.getElementById("dfold-aa"),
152
+ aaLabel: document.getElementById("dfold-aa-label"),
153
+ refAa: document.getElementById("dfold-ref-aa"),
154
+ refAaLabel: document.getElementById("dfold-ref-aa-label"),
155
+ go: document.getElementById("dfold-go"),
156
+ status: document.getElementById("dfold-status"),
157
+ statusText: document.querySelector("#dfold-status span:last-child"),
158
+ vCarbon: document.getElementById("dfold-viewer-carbon"),
159
+ vRef: document.getElementById("dfold-viewer-ref"),
160
+ nRes: document.getElementById("dfold-n"),
161
+ plddtC: document.getElementById("dfold-plddt-c"),
162
+ plddtR: document.getElementById("dfold-plddt-r"),
163
+ identity: document.getElementById("dfold-id"),
164
+ };
165
+
166
+ // No-ops gracefully when the status indicator isn't rendered (current
167
+ // cached-only UI doesn't ship one). All call sites are kept so the
168
+ // live-fold path stays a drop-in restore.
169
+ function setStatus(text, cls) {
170
+ if (!els.status) return;
171
+ els.status.className = "status" + (cls ? " " + cls : "");
172
+ if (els.statusText) els.statusText.textContent = text;
173
+ }
174
+
175
+ function renderInfo(extra = "") {
176
+ const g = GENES_LOCAL?.find(x => x.symbol === currentGeneSymbol);
177
+ if (!g) { els.info.textContent = "—"; return; }
178
+ const blurb = g.blurb ? ` · ${g.blurb}` : "";
179
+ els.info.innerHTML = `<strong>${g.symbol}</strong> · ${g.length.toLocaleString("en-US")} bp${blurb}` + (extra ? ` · ${extra}` : "");
180
+ }
181
+
182
+ // Render the "DNA → mRNA → protein" progression for the current gene
183
+ // by reusing the same splicing + ORF logic the rest of the pipeline
184
+ // runs on the reference side. The numbers shown are gene-intrinsic
185
+ // (architecture of the gene + canonical reference protein), so they
186
+ // hold whether the user has clicked fold yet or not — they materialise
187
+ // the splicing step that's otherwise invisible between the toolbar
188
+ // and the AA block.
189
+ //
190
+ // Prefix is "reference:" because every number here comes from the canonical
191
+ // sequence in genes.json, NOT from Carbon's prediction. Without the prefix
192
+ // it's easy to read the strip, scroll past it, and assume the AA block
193
+ // below shows that same length — but Carbon's ORF is usually shorter
194
+ // (e.g. HBB ref 147 aa vs Carbon 131 aa).
195
+ function renderMRNAInfo() {
196
+ const g = GENES_LOCAL?.find(x => x.symbol === currentGeneSymbol);
197
+ if (!g) { els.mrna.textContent = "—"; return; }
198
+ const mrna = spliceExons(g.seq, g.exons);
199
+ const orf = findLongestORF(mrna, 30);
200
+ const genomicBP = g.length;
201
+ const mrnaBP = mrna.length;
202
+ const nExons = g.exons.length;
203
+ if (!orf) {
204
+ els.mrna.innerHTML =
205
+ `<strong>${genomicBP.toLocaleString("en-US")} bp</strong> genomic` +
206
+ ` · <strong>${nExons}</strong> exon${nExons === 1 ? "" : "s"}` +
207
+ ` <span class="arrow">→</span> <strong>${mrnaBP.toLocaleString("en-US")} bp</strong> mRNA` +
208
+ ` <span class="arrow">→</span> no ORF ≥30 aa`;
209
+ return;
210
+ }
211
+ const trunc = orf.truncated
212
+ ? `<span class="mrna-trunc">truncated · no stop codon</span>` : "";
213
+ els.mrna.innerHTML =
214
+ `<strong>${genomicBP.toLocaleString("en-US")} bp</strong> genomic` +
215
+ ` · <strong>${nExons}</strong> exon${nExons === 1 ? "" : "s"}` +
216
+ ` <span class="arrow">→</span> <strong>${mrnaBP.toLocaleString("en-US")} bp</strong> mRNA` +
217
+ ` <span class="arrow">→</span> <strong>${orf.aa.length} aa</strong>` +
218
+ ` from ATG @ ${orf.startBP + 1}${trunc}`;
219
+ }
220
+
221
+ // Render Carbon's translated protein AND the reference protein side by
222
+ // side, with mismatches highlighted in red on both rows so the visitor
223
+ // can read the divergence in either direction. Mirrors §1's two-row
224
+ // model-output / reference layout so the visual grammar carries over.
225
+ //
226
+ // Length asymmetry handling:
227
+ // - When Carbon's ORF is shorter than the reference (typical case),
228
+ // positions past Carbon's end are highlighted on the reference row
229
+ // only — they materialise "Carbon stopped early".
230
+ // - When Carbon's ORF is longer than the reference (rarer), positions
231
+ // past the reference's end are highlighted on Carbon's row — they
232
+ // materialise "Carbon kept reading past the real stop codon".
233
+ function renderAAComparison(carbonAA, refAA) {
234
+ const nC = carbonAA.length;
235
+ const nR = refAA.length;
236
+
237
+ // Carbon row: render every position of carbon, highlight when c[i] != r[i]
238
+ // (or when ref ran out at i — extra Carbon residue).
239
+ const cParts = new Array(nC);
240
+ for (let i = 0; i < nC; i++) {
241
+ const c = carbonAA[i], r = refAA[i];
242
+ cParts[i] = (r === undefined || c !== r)
243
+ ? `<span class="ref-mismatch">${c}</span>` : c;
244
+ }
245
+ // Reference row: symmetric — render every position of ref, highlight
246
+ // when r[i] != c[i] (or when carbon ran out — Carbon stopped early).
247
+ const rParts = new Array(nR);
248
+ for (let i = 0; i < nR; i++) {
249
+ const r = refAA[i], c = carbonAA[i];
250
+ rParts[i] = (c === undefined || r !== c)
251
+ ? `<span class="ref-mismatch">${r}</span>` : r;
252
+ }
253
+ // Soft-wrap at 40 chars — the two columns are narrower than §1's
254
+ // single-column block, so a tighter wrap keeps lines from spilling
255
+ // and lets the eye scan Carbon ↔ Reference at the same y position.
256
+ const wrap = parts => {
257
+ let out = "";
258
+ for (let i = 0; i < parts.length; i += 40) out += parts.slice(i, i + 40).join("") + "\n";
259
+ return out;
260
+ };
261
+ els.aa.innerHTML = wrap(cParts);
262
+ els.refAa.innerHTML = wrap(rParts);
263
+
264
+ // Length-aware labels — the visitor sees that 131 ≠ 147 at a glance and
265
+ // doesn't have to cross-reference with the stat row at the bottom.
266
+ const lenTag = (n, prefix) =>
267
+ `<span class="aa-len-tag">${prefix}${n} aa</span>`;
268
+ const mismatches = (() => {
269
+ const k = Math.min(nC, nR);
270
+ let m = 0;
271
+ for (let i = 0; i < k; i++) if (carbonAA[i] !== refAA[i]) m++;
272
+ return m;
273
+ })();
274
+ els.aaLabel.innerHTML =
275
+ `<span class="seq-tag carbon">carbon</span>` +
276
+ lenTag(nC, "") +
277
+ `<span class="seq-label-stat">· ${mismatches} mismatches</span>`;
278
+ els.refAaLabel.innerHTML =
279
+ `<span class="seq-tag ref">reference</span>` +
280
+ lenTag(nR, "");
281
+ }
282
+
283
+ // Hydrate the viewers and stat row from a precomputed `fold_example`
284
+ // shipped in genes.json by scripts/precompute.py. Avoids a cold-start
285
+ // round-trip to the inference endpoints on first paint; the visitor
286
+ // can still trigger a fresh run with the ▶ fold button.
287
+ function hydrateFoldExample(ex) {
288
+ if (!ensureViewers()) return false;
289
+ setPending(false); // clear any leftover "fixture pending" state
290
+ renderStructure(viewerCarbon, ex.carbon_pdb);
291
+ renderStructure(viewerRef, ex.ref_pdb);
292
+ els.nRes.textContent = `${ex.carbon_aa.length} / ${ex.ref_aa.length}`;
293
+ els.plddtC.textContent = (ex.carbon_plddt_mean ?? 0).toFixed(1);
294
+ els.plddtR.textContent = (ex.ref_plddt_mean ?? 0).toFixed(1);
295
+ els.identity.textContent = (ex.identity_1d * 100).toFixed(1) + "%";
296
+ for (const el of [els.nRes, els.plddtC, els.plddtR, els.identity]) {
297
+ el.classList.remove("muted");
298
+ }
299
+ renderAAComparison(ex.carbon_aa, ex.ref_aa);
300
+ setStatus("cached example", "");
301
+ return true;
302
+ }
303
+
304
+ // Used when a gene has no precomputed fold_example. In the shipped
305
+ // cached-only build this happens for genes whose fixture is still
306
+ // queued for precompute (e.g. when the Carbon HF endpoint was in
307
+ // error during the last `python scripts/precompute.py --folds` run).
308
+ // We surface that state explicitly via an overlay on both viewers so
309
+ // it doesn't read as a bug.
310
+ function resetFoldUI() {
311
+ els.aa.innerHTML = "— fixture pending · precompute hasn't run yet for this gene —";
312
+ for (const el of [els.nRes, els.plddtC, els.plddtR, els.identity]) {
313
+ el.textContent = "—";
314
+ el.classList.add("muted");
315
+ }
316
+ if (viewerCarbon) { viewerCarbon.removeAllModels(); viewerCarbon.render(); }
317
+ if (viewerRef) { viewerRef.removeAllModels(); viewerRef.render(); }
318
+ if (ensureViewers()) setPending(true, "fixture pending");
319
+ }
320
+
321
+ function selectGene(symbol) {
322
+ currentGeneSymbol = symbol;
323
+ els.pills.querySelectorAll(".pill").forEach(p =>
324
+ p.classList.toggle("active", p.dataset.gene === symbol)
325
+ );
326
+ renderInfo();
327
+ renderMRNAInfo();
328
+ const g = GENES_LOCAL?.find(x => x.symbol === symbol);
329
+ if (g?.fold_example) {
330
+ // 3Dmol might not be loaded on the very first paint; retry shortly.
331
+ if (!hydrateFoldExample(g.fold_example)) {
332
+ setTimeout(() => hydrateFoldExample(g.fold_example), 300);
333
+ }
334
+ } else {
335
+ setStatus("idle", "");
336
+ resetFoldUI();
337
+ }
338
+ }
339
+
340
+ // No-ops in the cached-only build — the prefix selector isn't rendered.
341
+ // Kept here so re-adding the .pills element in the toolbar wires it
342
+ // back up without a JS change.
343
+ function bindPrefixPills() {
344
+ if (!els.prefixPills) return;
345
+ els.prefixPills.querySelectorAll(".pill").forEach(p => {
346
+ p.addEventListener("click", () => {
347
+ prefixLen = +p.dataset.prefix;
348
+ els.prefixPills.querySelectorAll(".pill").forEach(x => x.classList.remove("active"));
349
+ p.classList.add("active");
350
+ });
351
+ });
352
+ }
353
+
354
+ async function postFold(sequence) {
355
+ const resp = await fetch("/fold", {
356
+ method: "POST",
357
+ headers: { "Content-Type": "application/json" },
358
+ body: JSON.stringify({ sequence }),
359
+ });
360
+ return resp.json();
361
+ }
362
+
363
+ function makeViewer(host) {
364
+ if (!window.$3Dmol) return null;
365
+ host.innerHTML = "";
366
+ const v = $3Dmol.createViewer(host, { backgroundColor: "#fafaf7", antialias: true });
367
+ // 3Dmol installs a wheel listener on its internal canvas that zooms
368
+ // the camera AND preventDefaults the page scroll. We only want orbit
369
+ // controls; scroll should keep scrolling the page. Intercept wheel
370
+ // events at the host in capture phase and stopImmediatePropagation
371
+ // so 3Dmol never sees them. No preventDefault → browser scroll runs.
372
+ // We also use this hook to bump the idle-rotation timer below so
373
+ // ambient spin pauses the instant the visitor touches a viewer.
374
+ host.addEventListener("wheel", (e) => {
375
+ e.stopImmediatePropagation();
376
+ bumpInteraction();
377
+ }, { capture: true, passive: true });
378
+ for (const ev of ["pointerdown", "touchstart"]) {
379
+ host.addEventListener(ev, bumpInteraction, { capture: true, passive: true });
380
+ }
381
+ return v;
382
+ }
383
+
384
+ // ── Idle auto-rotation ────────────────────────────────────────────
385
+ // Gentle constant-velocity Y-spin while the visitor isn't interacting,
386
+ // to give the side-by-side comparison some life without forcing them
387
+ // to drag every time. Any pointer/wheel input pauses immediately;
388
+ // after IDLE_DELAY_MS of silence we ramp the spin back in over RAMP_MS
389
+ // with an ease-in-out so the resume isn't jarring. We rotate only
390
+ // viewerCarbon — linkViewer mirrors it onto viewerRef in the same
391
+ // frame, so the two cartoons stay perfectly in sync.
392
+ const IDLE_ROT_DELAY_MS = 2500;
393
+ const IDLE_ROT_RAMP_MS = 900;
394
+ const IDLE_ROT_MAX_DPS = 1; // ~one revolution per minute
395
+ const PREFERS_REDUCED_MOTION = window.matchMedia
396
+ ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
397
+ : false;
398
+ let lastInteractionAt = performance.now();
399
+ let idleRotRAF = 0;
400
+ let idleRotLastT = 0;
401
+ let idleRotSectionVisible = true;
402
+ function bumpInteraction() { lastInteractionAt = performance.now(); }
403
+ function idleRotStep(now) {
404
+ idleRotRAF = 0;
405
+ if (!viewerCarbon || !viewerRef) return;
406
+ const dt = idleRotLastT ? Math.min(100, now - idleRotLastT) : 16;
407
+ idleRotLastT = now;
408
+ const idle = now - lastInteractionAt;
409
+ if (idle >= IDLE_ROT_DELAY_MS && idleRotSectionVisible && !PREFERS_REDUCED_MOTION) {
410
+ const k = Math.min(1, (idle - IDLE_ROT_DELAY_MS) / IDLE_ROT_RAMP_MS);
411
+ const eased = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2;
412
+ const deg = IDLE_ROT_MAX_DPS * eased * (dt / 1000);
413
+ if (deg > 0) viewerCarbon.rotate(deg, "y", 0, false);
414
+ }
415
+ idleRotRAF = requestAnimationFrame(idleRotStep);
416
+ }
417
+ function startIdleRotation() {
418
+ if (idleRotRAF || PREFERS_REDUCED_MOTION) return;
419
+ idleRotLastT = 0;
420
+ idleRotRAF = requestAnimationFrame(idleRotStep);
421
+ }
422
+
423
+ // Pause the rAF loop when the §5 section is offscreen — no point
424
+ // burning frames on cartoons the visitor can't see.
425
+ function watchFoldingVisibility() {
426
+ const section = document.getElementById("folding");
427
+ if (!section || !window.IntersectionObserver) return;
428
+ new IntersectionObserver((entries) => {
429
+ for (const e of entries) idleRotSectionVisible = e.isIntersecting;
430
+ }, { threshold: 0.01 }).observe(section);
431
+ }
432
+ watchFoldingVisibility();
433
+
434
+ // Create both viewers (idempotent) and link them so an orbit drag on
435
+ // one propagates to the other. Mirrors the side-by-side "synced
436
+ // cameras" setup PyMOL/ChimeraX use for structure comparison — the
437
+ // visitor sees the same orientation of both proteins, which is what
438
+ // makes the visual comparison meaningful. (Wheel zoom is intentionally
439
+ // disabled in makeViewer so scroll keeps scrolling the page.)
440
+ let viewersLinked = false;
441
+ function ensureViewers() {
442
+ if (!window.$3Dmol) return false;
443
+ if (!viewerCarbon) { viewerCarbon = makeViewer(els.vCarbon); attachOverlay(els.vCarbon); }
444
+ if (!viewerRef) { viewerRef = makeViewer(els.vRef); attachOverlay(els.vRef); }
445
+ if (!viewersLinked && viewerCarbon && viewerRef &&
446
+ typeof viewerCarbon.linkViewer === "function") {
447
+ viewerCarbon.linkViewer(viewerRef);
448
+ viewerRef.linkViewer(viewerCarbon);
449
+ viewersLinked = true;
450
+ }
451
+ startIdleRotation();
452
+ return !!(viewerCarbon && viewerRef);
453
+ }
454
+
455
+ // Inject the "running" overlay once per viewer host. CSS keeps it
456
+ // hidden until the host gets the .running class via setRunning().
457
+ function attachOverlay(host) {
458
+ if (host.querySelector(".fold-overlay")) return;
459
+ const o = document.createElement("div");
460
+ o.className = "fold-overlay";
461
+ o.innerHTML = '<span class="dot"></span><span class="fold-overlay-label">computing</span>';
462
+ host.appendChild(o);
463
+ }
464
+
465
+ // Toggle the running state on both viewers + the stat row. The cached
466
+ // cartoon stays underneath at low opacity so the visitor still has
467
+ // visual context while waiting (vs blanking everything to a spinner).
468
+ function setRunning(running, label = "computing") {
469
+ for (const host of [els.vCarbon, els.vRef]) {
470
+ host.classList.toggle("running", running);
471
+ if (running) {
472
+ const t = host.querySelector(".fold-overlay-label");
473
+ if (t) t.textContent = label;
474
+ }
475
+ }
476
+ for (const el of [els.nRes, els.plddtC, els.plddtR, els.identity]) {
477
+ el.classList.toggle("muted", running);
478
+ }
479
+ if (els.go) els.go.textContent = running ? "running…" : "▶ fold";
480
+ }
481
+
482
+ // Mirror of setRunning for the "fixture not ready" state. Reuses the
483
+ // same overlay markup but a different CSS class, so the two states
484
+ // can never visually conflict.
485
+ function setPending(pending, label = "fixture pending") {
486
+ for (const host of [els.vCarbon, els.vRef]) {
487
+ host.classList.toggle("pending", pending);
488
+ if (pending) {
489
+ const t = host.querySelector(".fold-overlay-label");
490
+ if (t) t.textContent = label;
491
+ }
492
+ }
493
+ }
494
+
495
+ // Editorial pLDDT palette. The three anchor colours match the legend
496
+ // bar under the viewers (#b00020 demo-red / #f0e8e0 paper-beige /
497
+ // #2c5aa0 demo-blue) — same tones used throughout §1 mismatches and
498
+ // §2 base coloring, so the cartoons land in the same visual world as
499
+ // the rest of the page instead of 3Dmol's stock primary rwb.
500
+ const PLDDT_STOPS = [
501
+ { v: 50, rgb: [0xb0, 0x00, 0x20] },
502
+ { v: 75, rgb: [0xf0, 0xe8, 0xe0] },
503
+ { v: 100, rgb: [0x2c, 0x5a, 0xa0] },
504
+ ];
505
+ function plddtToColor(plddt) {
506
+ const x = Math.max(PLDDT_STOPS[0].v, Math.min(PLDDT_STOPS[PLDDT_STOPS.length - 1].v, plddt));
507
+ for (let i = 0; i < PLDDT_STOPS.length - 1; i++) {
508
+ const a = PLDDT_STOPS[i], b = PLDDT_STOPS[i + 1];
509
+ if (x >= a.v && x <= b.v) {
510
+ const k = (x - a.v) / (b.v - a.v);
511
+ const r = Math.round(a.rgb[0] + (b.rgb[0] - a.rgb[0]) * k);
512
+ const g = Math.round(a.rgb[1] + (b.rgb[1] - a.rgb[1]) * k);
513
+ const bl = Math.round(a.rgb[2] + (b.rgb[2] - a.rgb[2]) * k);
514
+ return (r << 16) | (g << 8) | bl;
515
+ }
516
+ }
517
+ return 0x888888;
518
+ }
519
+
520
+ function renderStructure(viewer, pdb) {
521
+ if (!viewer) return;
522
+ viewer.removeAllModels();
523
+ viewer.addModel(pdb, "pdb");
524
+ // Slightly thinner ribbons + softer arrows than the 3Dmol defaults
525
+ // to better match the demo's "editorial diagram" feel rather than a
526
+ // textbook figure. colorfunc reads pLDDT from the PDB B-factor column.
527
+ viewer.setStyle({}, {
528
+ cartoon: {
529
+ colorfunc: (atom) => plddtToColor(atom.b || 50),
530
+ thickness: 0.5,
531
+ arrows: true,
532
+ tubes: false,
533
+ opacity: 0.95,
534
+ },
535
+ });
536
+ viewer.zoomTo();
537
+ viewer.render();
538
+ // No spin — the linked viewers share the camera, so a manual drag
539
+ // by the visitor rotates both at once. Two independent spin loops
540
+ // would desynchronise the cartoons visually.
541
+ }
542
+
543
+ async function runFold() {
544
+ if (!window.$3Dmol) { setStatus("3Dmol not loaded — retry in a sec", "error"); return; }
545
+ const gene = GENES_LOCAL?.find(g => g.symbol === currentGeneSymbol);
546
+ if (!gene) return;
547
+
548
+ // Bail early on genes whose introns push the last exon past our live-
549
+ // demo budget. The pipeline would otherwise spend minutes generating
550
+ // intronic filler that gets spliced out anyway.
551
+ const f = geneFeasibility(gene);
552
+ if (!f.feasible) {
553
+ setStatus(
554
+ `${gene.symbol} spans ${f.lastExonEnd.toLocaleString("en-US")} bp of genomic DNA — ` +
555
+ `outside what Carbon can generate live. Try HBB or INS.`,
556
+ "error"
557
+ );
558
+ return;
559
+ }
560
+
561
+ abortCtrl?.abort();
562
+ abortCtrl = new AbortController();
563
+ if (els.go) els.go.disabled = true;
564
+ ensureViewers(); // overlay must exist before we toggle .running on it
565
+
566
+ try {
567
+ // --- Reference: spliced mRNA → longest ORF → AA -------------------
568
+ const refORF = translateReference(gene);
569
+ if (!refORF) throw new Error(`reference ${gene.symbol} has no valid ORF`);
570
+
571
+ // --- Carbon: prompt → /generate → splice → ORF → AA ---------------
572
+ // We ask Carbon to extend the prompt far enough to cover the gene's
573
+ // last exon. Then we apply the SAME exon coordinates as the reference
574
+ // to assemble a mature mRNA from Carbon's output. Without this splice
575
+ // step the Carbon side reads through introns and produces nonsense
576
+ // that has nothing to do with the model's actual coding-region skill.
577
+ const promptDNA = gene.seq.slice(0, prefixLen).toUpperCase().replace(/[^ACGT]/g, "");
578
+ const targetBP = f.lastExonEnd;
579
+ const genBP = Math.max(0, targetBP - prefixLen) + 60; // 60-bp safety margin
580
+ const maxTokens = Math.ceil(genBP / 6) + 8;
581
+
582
+ setRunning(true, `generating · ${targetBP} bp`);
583
+ setStatus(`carbon generating (${prefixLen}→${targetBP} bp)…`, "streaming");
584
+ const continuation = await streamGenerate(promptDNA, maxTokens, 0.7, abortCtrl.signal);
585
+ const carbonDNA = (promptDNA + continuation).slice(0, prefixLen + genBP);
586
+ const carbonMRNA = spliceExons(carbonDNA, gene.exons);
587
+ const carbonORF = findLongestORF(carbonMRNA, 30);
588
+ if (!carbonORF) {
589
+ throw new Error(
590
+ "Carbon's spliced mRNA didn't yield an ORF ≥30 aa — likely a premature stop in an early exon"
591
+ );
592
+ }
593
+
594
+ // --- Fold both in parallel ----------------------------------------
595
+ setRunning(true, "folding · esmfold");
596
+ setStatus("folding both…", "streaming");
597
+ const [carbonR, refR] = await Promise.all([
598
+ postFold(carbonORF.aa),
599
+ postFold(refORF.aa),
600
+ ]);
601
+ if (carbonR.error) throw new Error("carbon fold: " + carbonR.error);
602
+ if (refR.error) throw new Error("ref fold: " + refR.error);
603
+
604
+ // --- Render -------------------------------------------------------
605
+ renderStructure(viewerCarbon, carbonR.pdb);
606
+ renderStructure(viewerRef, refR.pdb);
607
+
608
+ const idn = identity1D(carbonORF.aa, refORF.aa);
609
+ els.nRes.textContent = `${carbonORF.aa.length} / ${refORF.aa.length}`;
610
+ els.plddtC.textContent = (carbonR.plddt_mean ?? 0).toFixed(1);
611
+ els.plddtR.textContent = (refR.plddt_mean ?? 0).toFixed(1);
612
+ els.identity.textContent = (idn * 100).toFixed(1) + "%";
613
+ for (const el of [els.nRes, els.plddtC, els.plddtR, els.identity]) {
614
+ el.classList.remove("muted");
615
+ }
616
+
617
+ renderAAComparison(carbonORF.aa, refORF.aa);
618
+
619
+ const cacheTag = (carbonR.cached || refR.cached) ? " (cache hit)" : "";
620
+ setStatus("done" + cacheTag, "");
621
+ } catch (e) {
622
+ if (e.name === "AbortError") setStatus("aborted", "");
623
+ else setStatus("error: " + (e.message || e), "error");
624
+ } finally {
625
+ setRunning(false);
626
+ abortCtrl = null;
627
+ if (els.go) els.go.disabled = false;
628
+ }
629
+ }
630
+
631
+ // --- Bootstrap ---------------------------------------------------------
632
+ loadGenes().then(genes => {
633
+ GENES_LOCAL = genes;
634
+ els.pills.innerHTML = genes.map((g, i) =>
635
+ `<button class="pill${i === 0 ? " active" : ""}" data-gene="${g.symbol}">${g.symbol}</button>`
636
+ ).join("");
637
+ els.pills.querySelectorAll(".pill").forEach(p =>
638
+ p.addEventListener("click", () => selectGene(p.dataset.gene))
639
+ );
640
+ selectGene(genes[0].symbol);
641
+ bindPrefixPills();
642
+ els.go?.addEventListener("click", runFold);
643
+ }).catch(e => {
644
+ els.info.textContent = "failed to load genes: " + (e.message || e);
645
+ });
646
+ })();
647
+
assets/js/sections/loss.js ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §8 — Training objective: CE vs FNS
3
+ // =========================================================================
4
+ (function initDemo8() {
5
+ const targetPills = document.getElementById("d8-target-pills");
6
+ const modePills = document.getElementById("d8-mode-pills");
7
+ const canvas = document.getElementById("d8-canvas");
8
+ const schedule = document.getElementById("d8-schedule");
9
+
10
+ let target = "TATATA";
11
+ let mode = "ce";
12
+
13
+ // Stable candidate set per target (target itself + near-misses + far-miss + unrelated)
14
+ function candidatesFor(t) {
15
+ // Generate near-misses: flip one base at each of a few positions
16
+ const flip = (s, i, b) => s.slice(0,i) + b + s.slice(i+1);
17
+ const t1 = flip(t, 5, t[5] === "A" ? "T" : "A"); // last base flipped
18
+ const t2 = flip(flip(t, 4, t[4] === "A" ? "G" : "A"), 5, t[5] === "A" ? "G" : "A"); // 2 flipped
19
+ const t3 = flip(flip(flip(t, 0, t[0] === "A" ? "C" : "A"), 2, t[2] === "T" ? "G" : "T"), 4, t[4] === "A" ? "C" : "A"); // 3 flipped
20
+ const tFar = "CGCGCG"; // mostly different
21
+ const candidates = [t, t1, t2, t3, tFar];
22
+ return [...new Set(candidates)].slice(0, 5);
23
+ }
24
+
25
+ function nMatches(a, b) {
26
+ let n = 0;
27
+ for (let i = 0; i < 6; i++) if (a[i] === b[i]) n++;
28
+ return n;
29
+ }
30
+
31
+ function ceCredit(c, t) {
32
+ return c === t ? 1.0 : 0.0; // all-or-nothing
33
+ }
34
+ function fnsCredit(c, t) {
35
+ return nMatches(c, t) / 6.0; // fraction of bases matching
36
+ }
37
+
38
+ function render() {
39
+ const cands = candidatesFor(target);
40
+ let html = "";
41
+ html += `<div style="display:grid;grid-template-columns:140px 1fr 1fr;gap:8px 14px;align-items:center;font-family:'JetBrains Mono',monospace;font-size:11px">`;
42
+ // Header
43
+ html += `<div></div>`;
44
+ html += `<div style="font-size:9px;color:${mode==='fns'?'#aaa':'#1f1f1d'};text-transform:uppercase;letter-spacing:1.5px">${mode==='fns'?'cross-entropy':'cross-entropy'} <span style="font-weight:500">${mode==='ce'||mode==='both'?'· active':''}</span></div>`;
45
+ html += `<div style="font-size:9px;color:${mode==='ce'?'#aaa':'#1f1f1d'};text-transform:uppercase;letter-spacing:1.5px">FNS <span style="font-weight:500">${mode==='fns'||mode==='both'?'· active':''}</span></div>`;
46
+
47
+ cands.forEach(c => {
48
+ const isTarget = c === t1Equal(c, target);
49
+ // Highlight matching positions
50
+ let badges = "";
51
+ for (let i = 0; i < 6; i++) {
52
+ const match = c[i] === target[i];
53
+ const color = match ? "#317f3f" : "#bc2e25";
54
+ const bg = match ? "rgba(49,127,63,0.10)" : "rgba(188,46,37,0.08)";
55
+ badges += `<span style="display:inline-block;background:${bg};color:${color};padding:2px 5px;margin:1px;border-radius:2px;font-weight:${match?500:400}">${c[i]}</span>`;
56
+ }
57
+ const isExact = c === target;
58
+ const labelText = isExact ? "exact target" : `${nMatches(c, target)}/6 match`;
59
+ html += `<div style="display:flex;flex-direction:column;gap:2px">
60
+ <div>${badges}</div>
61
+ <div style="font-size:9px;color:${isExact?'#317f3f':'#888'};letter-spacing:1px;text-transform:uppercase;padding-left:4px">${labelText}</div>
62
+ </div>`;
63
+
64
+ // CE column
65
+ const ceVal = ceCredit(c, target);
66
+ html += creditCell(ceVal, mode === "fns", c === target ? "credit = 1" : "credit = 0", "#317f3f", "#bc2e25");
67
+
68
+ // FNS column
69
+ const fnsVal = fnsCredit(c, target);
70
+ const fnsLabel = c === target ? "credit = 1" : `credit = ${fnsVal.toFixed(2)} (${nMatches(c, target)}/6)`;
71
+ html += creditCell(fnsVal, mode === "ce", fnsLabel, "#317f3f", "#bc2e25");
72
+ });
73
+ html += `</div>`;
74
+ canvas.innerHTML = html;
75
+
76
+ renderSchedule();
77
+ }
78
+
79
+ function t1Equal(a, b) { return a; } // helper kept for symmetry
80
+
81
+ function creditCell(value, dimmed, label, posColor, negColor) {
82
+ // value in [0, 1]; render as a horizontal bar
83
+ const w = (value * 100).toFixed(0);
84
+ const opacity = dimmed ? 0.25 : 1;
85
+ const barColor = value === 0 ? negColor : (value < 1 ? "#888" : posColor);
86
+ return `<div style="opacity:${opacity}">
87
+ <div style="position:relative;height:10px;background:#f0f0f0;border-radius:2px;overflow:hidden">
88
+ <div style="position:absolute;inset:0 auto 0 0;width:${w}%;background:${barColor}"></div>
89
+ </div>
90
+ <div style="font-size:9px;color:#888;margin-top:3px;letter-spacing:0.5px">${label}</div>
91
+ </div>`;
92
+ }
93
+
94
+ function renderSchedule() {
95
+ const W = 1000, H = 110, padL = 24, padR = 24, padT = 14, padB = 28;
96
+ const innerW = W - padL - padR;
97
+ const switchAt = 0.65; // where CE→FNS switch happens
98
+ const switchX = padL + innerW * switchAt;
99
+ let svg = "";
100
+
101
+ // Background two-tone (CE phase + FNS phase)
102
+ svg += `<rect x="${padL}" y="${padT}" width="${(switchX - padL).toFixed(1)}" height="${H - padT - padB}" fill="#1f1f1d" opacity="0.04"/>`;
103
+ svg += `<rect x="${switchX.toFixed(1)}" y="${padT}" width="${(W - padR - switchX).toFixed(1)}" height="${H - padT - padB}" fill="#317f3f" opacity="0.06"/>`;
104
+
105
+ // Mock loss curve: smooth descent then a "staircase" that gets cleaned by the FNS switch
106
+ const points = [];
107
+ const N = 200;
108
+ for (let i = 0; i <= N; i++) {
109
+ const x = padL + (i / N) * innerW;
110
+ const t = i / N;
111
+ let lp;
112
+ if (t < switchAt) {
113
+ // Smooth-ish descent, with a small wobble approaching switch
114
+ lp = 4.0 * Math.exp(-3.5 * t) + 1.2 + 0.04 * Math.sin(t * 30);
115
+ if (t > switchAt - 0.08) lp += 0.5 * (t - (switchAt - 0.08)) / 0.08; // staircase climb
116
+ } else {
117
+ // After switch, smooth continued descent
118
+ const dt = t - switchAt;
119
+ lp = 1.4 + 0.7 * Math.exp(-6 * dt);
120
+ }
121
+ const y = padT + (1 - (lp - 1.0) / 4.0) * (H - padT - padB);
122
+ points.push([x, y]);
123
+ }
124
+ let d = "";
125
+ points.forEach(([x, y], i) => { d += (i === 0 ? "M" : "L") + x.toFixed(1) + " " + y.toFixed(1); });
126
+ svg += `<path d="${d}" fill="none" stroke="#1f1f1d" stroke-width="1.4"/>`;
127
+
128
+ // Switch marker
129
+ svg += `<line x1="${switchX.toFixed(1)}" y1="${padT}" x2="${switchX.toFixed(1)}" y2="${H - padB}" stroke="#317f3f" stroke-width="1.5" stroke-dasharray="3,3"/>`;
130
+ svg += `<text x="${switchX.toFixed(1)}" y="${(padT - 4)}" font-family="JetBrains Mono" font-size="9" fill="#317f3f" text-anchor="middle" letter-spacing="1">CE → FNS</text>`;
131
+
132
+ // Phase labels
133
+ svg += `<text x="${(padL + (switchX - padL) / 2).toFixed(1)}" y="${(H - padB + 14).toFixed(1)}" font-family="JetBrains Mono" font-size="10" fill="#666" text-anchor="middle" letter-spacing="1">CROSS-ENTROPY · learn joint structure</text>`;
134
+ svg += `<text x="${(switchX + (W - padR - switchX) / 2).toFixed(1)}" y="${(H - padB + 14).toFixed(1)}" font-family="JetBrains Mono" font-size="10" fill="#317f3f" text-anchor="middle" letter-spacing="1">FNS · smooth, BF16-stable refinement</text>`;
135
+ // y-axis label
136
+ svg += `<text x="${padL}" y="${(padT - 4)}" font-family="JetBrains Mono" font-size="9" fill="#aaa" letter-spacing="0.5">training loss</text>`;
137
+ // staircase callout
138
+ const staircaseX = padL + innerW * (switchAt - 0.04);
139
+ svg += `<text x="${staircaseX.toFixed(1)}" y="${(H - padB - 4).toFixed(1)}" font-family="JetBrains Mono" font-size="9" fill="#bc2e25" text-anchor="end">↑ staircase</text>`;
140
+
141
+ schedule.innerHTML = svg;
142
+ }
143
+
144
+ // Bind
145
+ targetPills.querySelectorAll(".pill").forEach(p => {
146
+ p.addEventListener("click", () => {
147
+ targetPills.querySelectorAll(".pill").forEach(x => x.classList.remove("active"));
148
+ p.classList.add("active");
149
+ target = p.dataset.target;
150
+ render();
151
+ });
152
+ });
153
+ modePills.querySelectorAll(".pill").forEach(p => {
154
+ p.addEventListener("click", () => {
155
+ modePills.querySelectorAll(".pill").forEach(x => x.classList.remove("active"));
156
+ p.classList.add("active");
157
+ mode = p.dataset.mode;
158
+ render();
159
+ });
160
+ });
161
+ render();
162
+ })();
163
+
assets/js/sections/sandbox.js ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // Sandbox (tab 3) — DNA continuation playground
3
+ // =========================================================================
4
+ (function initSandbox() {
5
+ const els = {
6
+ prompt: document.getElementById("sb-prompt"),
7
+ maxTokens: document.getElementById("sb-max-tokens"),
8
+ temperature: document.getElementById("sb-temperature"),
9
+ topP: document.getElementById("sb-top-p"),
10
+ generate: document.getElementById("sb-generate-btn"),
11
+ stop: document.getElementById("sb-stop-btn"),
12
+ clear: document.getElementById("sb-clear-btn"),
13
+ modeBtns: document.getElementById("sb-mode-btns"),
14
+ copy: document.getElementById("sb-copy-btn"),
15
+ seq: document.getElementById("sb-seq"),
16
+ meta: document.getElementById("sb-meta"),
17
+ status: document.getElementById("sb-status"),
18
+ statusText: document.getElementById("sb-status-text"),
19
+ legend: document.getElementById("sb-legend"),
20
+ statPrompt: document.getElementById("sb-stat-prompt"),
21
+ statGen: document.getElementById("sb-stat-gen"),
22
+ statTok: document.getElementById("sb-stat-tok"),
23
+ statTime: document.getElementById("sb-stat-time"),
24
+ statRate: document.getElementById("sb-stat-rate"),
25
+ statGc: document.getElementById("sb-stat-gc"),
26
+ statLp: document.getElementById("sb-stat-lp"),
27
+ statPpl: document.getElementById("sb-stat-ppl"),
28
+ };
29
+
30
+ const BASE_RGB = {
31
+ A: [58, 138, 62], C: [46, 107, 184], G: [181, 137, 30], T: [181, 58, 58], N: [136, 136, 136],
32
+ };
33
+ const PROMPT_RGB_S = [170, 170, 170];
34
+ const DARK_RGB_S = [31, 31, 29];
35
+ const MID_RGB_S = [136, 136, 136];
36
+ const RED_RGB_S = [188, 46, 37];
37
+ const BG_ALPHA = 0.12;
38
+
39
+ let promptBases = "";
40
+ let genText = "";
41
+ let genTokens = [];
42
+ let genTokenAtBase = [];
43
+ let abortCtrl = null;
44
+ let startTime = 0;
45
+ let timer = null;
46
+ let colorMode = "none";
47
+ let charMetrics = null;
48
+ let lpRange = null;
49
+
50
+ function recomputeLpRange() {
51
+ if (!genTokens.length) { lpRange = null; updateLegend(); return; }
52
+ let min = Infinity, max = -Infinity, sum = 0, n = 0;
53
+ for (const t of genTokens) {
54
+ if (t.logprob == null || isNaN(t.logprob)) continue;
55
+ if (t.logprob < min) min = t.logprob;
56
+ if (t.logprob > max) max = t.logprob;
57
+ sum += t.logprob; n++;
58
+ }
59
+ lpRange = n ? { min, mid: sum / n, max } : null;
60
+ updateLegend();
61
+ }
62
+ function updateLegend() {
63
+ const minEl = document.getElementById("sb-lp-min");
64
+ const midEl = document.getElementById("sb-lp-mid");
65
+ const maxEl = document.getElementById("sb-lp-max");
66
+ const bar = document.getElementById("sb-legend-bar");
67
+ if (!lpRange) {
68
+ minEl.textContent = midEl.textContent = maxEl.textContent = "—";
69
+ bar.style.background = "linear-gradient(to right, #bc2e25, #888, #1f1f1d)";
70
+ } else {
71
+ const { min, mid, max } = lpRange;
72
+ minEl.textContent = min.toFixed(1);
73
+ midEl.textContent = mid.toFixed(1);
74
+ maxEl.textContent = max.toFixed(1);
75
+ const midPct = max > min ? ((mid - min) / (max - min)) * 100 : 50;
76
+ bar.style.background = `linear-gradient(to right, #bc2e25 0%, #888 ${midPct.toFixed(1)}%, #1f1f1d 100%)`;
77
+ }
78
+ updateLpChart();
79
+ }
80
+ function updateLpChart() {
81
+ const svg = document.getElementById("sb-lp-chart");
82
+ if (!svg) return;
83
+ if (!lpRange || genTokens.length < 2) { svg.innerHTML = ""; return; }
84
+ const W = 200, H = 40, pad = 2;
85
+ const { min, max } = lpRange;
86
+ const yTop = pad, yBot = H - pad;
87
+ const yScale = (lp) => yTop + (1 - (lp - min) / Math.max(1e-9, max - min)) * (yBot - yTop);
88
+ const n = genTokens.length;
89
+ const target = 1000;
90
+ let step = 1;
91
+ while (Math.ceil(n / step) > target) step *= 2;
92
+ const xScale = (i) => (n === 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));
93
+ let d = "";
94
+ let started = false;
95
+ for (let i = 0; i < n; i += step) {
96
+ const lp = genTokens[i].logprob;
97
+ if (lp == null || isNaN(lp)) continue;
98
+ d += (started ? "L" : "M") + xScale(i).toFixed(1) + " " + yScale(lp).toFixed(1);
99
+ started = true;
100
+ }
101
+ if ((n - 1) % step !== 0) {
102
+ const lp = genTokens[n - 1].logprob;
103
+ if (lp != null && !isNaN(lp)) {
104
+ d += "L" + xScale(n - 1).toFixed(1) + " " + yScale(lp).toFixed(1);
105
+ }
106
+ }
107
+ const midPct = max > min ? ((lpRange.mid - min) / (max - min)) * 100 : 50;
108
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
109
+ svg.innerHTML = `
110
+ <defs>
111
+ <linearGradient id="sb-lp-grad" gradientUnits="userSpaceOnUse" x1="0" y1="${H - pad}" x2="0" y2="${pad}">
112
+ <stop offset="0%" stop-color="#bc2e25"/>
113
+ <stop offset="${midPct.toFixed(1)}%" stop-color="#888"/>
114
+ <stop offset="100%" stop-color="#1f1f1d"/>
115
+ </linearGradient>
116
+ </defs>
117
+ <path d="${d}" fill="none" stroke="url(#sb-lp-grad)" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
118
+ `;
119
+ }
120
+ function logprobRgbSb(lp) {
121
+ if (lp == null || isNaN(lp) || !lpRange) return DARK_RGB_S;
122
+ const { min, mid, max } = lpRange;
123
+ if (max === min) return MID_RGB_S;
124
+ if (lp >= mid) {
125
+ const denom = max - mid;
126
+ const t = denom > 0 ? Math.min(1, Math.max(0, (max - lp) / denom)) : 0;
127
+ return lerpRgb(DARK_RGB_S, MID_RGB_S, t);
128
+ }
129
+ const denom = mid - min;
130
+ const t = denom > 0 ? Math.min(1, Math.max(0, (mid - lp) / denom)) : 0;
131
+ return lerpRgb(MID_RGB_S, RED_RGB_S, t);
132
+ }
133
+
134
+ function autoGrow() {
135
+ els.prompt.style.height = "auto";
136
+ els.prompt.style.height = els.prompt.scrollHeight + "px";
137
+ }
138
+ function cleanPrompt(s) { return s.toUpperCase().replace(/[^ACGTN]/g, ""); }
139
+
140
+ els.prompt.addEventListener("input", () => {
141
+ const cleaned = cleanPrompt(els.prompt.value);
142
+ if (cleaned !== els.prompt.value) {
143
+ const pos = els.prompt.selectionStart;
144
+ els.prompt.value = cleaned;
145
+ els.prompt.setSelectionRange(pos, pos);
146
+ }
147
+ autoGrow();
148
+ });
149
+
150
+ function rgbForBase(absIdx, base) {
151
+ if (absIdx < promptBases.length) return PROMPT_RGB_S;
152
+ if (colorMode === "bases") return BASE_RGB[base] || DARK_RGB_S;
153
+ if (colorMode === "logprob") {
154
+ const genIdx = absIdx - promptBases.length;
155
+ const tok = genTokens[genTokenAtBase[genIdx]];
156
+ return tok ? logprobRgbSb(tok.logprob) : DARK_RGB_S;
157
+ }
158
+ return DARK_RGB_S;
159
+ }
160
+ function measureSeqChars() {
161
+ const probe = document.createElement("div");
162
+ probe.style.cssText = "position:absolute;visibility:hidden;top:-9999px;font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:400;letter-spacing:1.5px;white-space:pre";
163
+ probe.textContent = " 1 ";
164
+ document.body.appendChild(probe);
165
+ const prefixW = probe.getBoundingClientRect().width;
166
+ probe.textContent = "AAAAAAAAAA ";
167
+ const blockW = probe.getBoundingClientRect().width;
168
+ document.body.removeChild(probe);
169
+ charMetrics = { prefixW, blockW };
170
+ }
171
+ function basesPerLineSb() {
172
+ if (!charMetrics) measureSeqChars();
173
+ const cs = getComputedStyle(els.seq);
174
+ const padL = parseFloat(cs.paddingLeft) || 0;
175
+ const padR = parseFloat(cs.paddingRight) || 0;
176
+ const contentW = els.seq.clientWidth - padL - padR;
177
+ if (contentW <= 0 || !charMetrics.blockW) return 60;
178
+ const blocks = Math.floor((contentW - charMetrics.prefixW) / charMetrics.blockW);
179
+ return Math.max(10, Math.min(blocks, 30) * 10);
180
+ }
181
+ function colorKey(absIdx, base) {
182
+ if (absIdx < promptBases.length) return "p";
183
+ if (colorMode === "none") return "g";
184
+ if (colorMode === "bases") return "b" + base;
185
+ if (colorMode === "logprob") return "t" + genTokenAtBase[absIdx - promptBases.length];
186
+ return "g";
187
+ }
188
+ function buildLineHTML(start, lineBases) {
189
+ const pos = String(start + 1).padStart(5, " ");
190
+ let html = `<span class="sb-pos">${pos}</span> `;
191
+ let j = 0;
192
+ while (j < lineBases.length) {
193
+ if (j > 0 && j % 10 === 0) html += " ";
194
+ const startAbs = start + j;
195
+ const startKey = colorKey(startAbs, lineBases[j]);
196
+ const blockEnd = Math.min(lineBases.length, Math.floor(j / 10) * 10 + 10);
197
+ let runEnd = j + 1;
198
+ while (runEnd < blockEnd && colorKey(start + runEnd, lineBases[runEnd]) === startKey) runEnd++;
199
+ const runText = lineBases.slice(j, runEnd);
200
+ const [r, g, b] = rgbForBase(startAbs, lineBases[j]);
201
+ const tinted = colorMode !== "none" && startAbs >= promptBases.length;
202
+ const bg = tinted ? `;background:rgba(${r},${g},${b},${BG_ALPHA})` : "";
203
+ html += `<span style="color:rgb(${r},${g},${b})${bg}">${runText}</span>`;
204
+ j = runEnd;
205
+ }
206
+ return html;
207
+ }
208
+ function updateTail() {
209
+ const prev = els.seq.querySelector(".sb-seq-line.tail");
210
+ if (prev) prev.classList.remove("tail");
211
+ const last = els.seq.lastElementChild;
212
+ if (abortCtrl && last && last.classList.contains("sb-seq-line")) last.classList.add("tail");
213
+ }
214
+ function lpRangeShifted(prev, curr) {
215
+ if (!prev || !curr) return prev !== curr;
216
+ const range = Math.max(0.1, prev.max - prev.min);
217
+ const tol = Math.max(0.2, range * 0.05);
218
+ return Math.abs(prev.min - curr.min) > tol
219
+ || Math.abs(prev.mid - curr.mid) > tol
220
+ || Math.abs(prev.max - curr.max) > tol;
221
+ }
222
+ let lastRenderedMode = null;
223
+ let lastRenderedBpl = null;
224
+ let lastRenderedLpRange = null;
225
+ function fullRender(bpl) {
226
+ const total = promptBases + genText;
227
+ if (!total) {
228
+ els.seq.classList.add("empty");
229
+ els.seq.textContent = "prompt + generated bases will stream here";
230
+ } else {
231
+ els.seq.classList.remove("empty");
232
+ const parts = [];
233
+ for (let i = 0; i < total.length; i += bpl) {
234
+ parts.push(`<div class="sb-seq-line">${buildLineHTML(i, total.slice(i, i + bpl))}</div>`);
235
+ }
236
+ els.seq.innerHTML = parts.join("");
237
+ }
238
+ lastRenderedMode = colorMode;
239
+ lastRenderedBpl = bpl;
240
+ lastRenderedLpRange = lpRange ? { ...lpRange } : null;
241
+ updateTail();
242
+ }
243
+ function incrementalRender(bpl) {
244
+ const total = promptBases + genText;
245
+ const totalLines = Math.ceil(total.length / bpl);
246
+ const lineDivs = els.seq.children;
247
+ if (lineDivs.length > 0) {
248
+ const lastIdx = lineDivs.length - 1;
249
+ const start = lastIdx * bpl;
250
+ lineDivs[lastIdx].innerHTML = buildLineHTML(start, total.slice(start, start + bpl));
251
+ }
252
+ if (totalLines > lineDivs.length) {
253
+ const parts = [];
254
+ for (let li = lineDivs.length; li < totalLines; li++) {
255
+ const start = li * bpl;
256
+ parts.push(`<div class="sb-seq-line">${buildLineHTML(start, total.slice(start, start + bpl))}</div>`);
257
+ }
258
+ els.seq.insertAdjacentHTML("beforeend", parts.join(""));
259
+ }
260
+ lastRenderedLpRange = lpRange ? { ...lpRange } : null;
261
+ updateTail();
262
+ }
263
+ function renderSequence() {
264
+ if (colorMode === "logprob") recomputeLpRange();
265
+ const total = promptBases + genText;
266
+ els.copy.disabled = total.length === 0;
267
+ const bpl = basesPerLineSb();
268
+ const totalLines = total ? Math.ceil(total.length / bpl) : 0;
269
+ const renderedLines = els.seq.children.length;
270
+ const needFull =
271
+ !total ||
272
+ lastRenderedMode !== colorMode ||
273
+ lastRenderedBpl !== bpl ||
274
+ totalLines < renderedLines ||
275
+ (colorMode === "logprob" && lpRangeShifted(lastRenderedLpRange, lpRange));
276
+ if (needFull) fullRender(bpl);
277
+ else incrementalRender(bpl);
278
+ }
279
+ let renderQueued = false;
280
+ function scheduleRender() {
281
+ if (renderQueued) return;
282
+ renderQueued = true;
283
+ requestAnimationFrame(() => { renderQueued = false; renderSequence(); });
284
+ }
285
+ function gcContent(s) {
286
+ if (!s) return null;
287
+ let gc = 0;
288
+ for (const c of s) if (c === "G" || c === "C") gc++;
289
+ return (gc / s.length) * 100;
290
+ }
291
+ function meanLpSb() {
292
+ if (!genTokens.length) return null;
293
+ let sum = 0, n = 0;
294
+ for (const t of genTokens) {
295
+ if (t.logprob != null && !isNaN(t.logprob)) { sum += t.logprob; n++; }
296
+ }
297
+ return n ? sum / n : null;
298
+ }
299
+ function updateStats() {
300
+ els.statPrompt.innerHTML = `${promptBases.length}<span class="sb-unit">bp</span>`;
301
+ els.statGen.innerHTML = `${genText.length}<span class="sb-unit">bp</span>`;
302
+ els.statTok.textContent = genTokens.length;
303
+ const elapsed = startTime ? (performance.now() - startTime) / 1000 : 0;
304
+ els.statTime.innerHTML = `${elapsed.toFixed(1)}<span class="sb-unit">s</span>`;
305
+ const rate = elapsed > 0 ? Math.round(genText.length / elapsed) : 0;
306
+ els.statRate.innerHTML = `${rate}<span class="sb-unit">bp/s</span>`;
307
+ const gc = gcContent(genText);
308
+ els.statGc.textContent = gc == null ? "—" : `${gc.toFixed(1)}%`;
309
+ const mlp = meanLpSb();
310
+ els.statLp.textContent = mlp == null ? "—" : mlp.toFixed(2);
311
+ els.statPpl.textContent = mlp == null ? "—" : Math.exp(-mlp).toFixed(1);
312
+ }
313
+ function setStatus(text, mode = "") {
314
+ els.statusText.textContent = text;
315
+ els.status.className = "sb-status" + (mode ? " " + mode : "");
316
+ }
317
+
318
+ els.modeBtns.querySelectorAll(".sb-mode-btn").forEach(b => {
319
+ b.addEventListener("click", () => {
320
+ colorMode = b.dataset.mode;
321
+ els.modeBtns.querySelectorAll(".sb-mode-btn").forEach(x => x.classList.toggle("active", x === b));
322
+ els.legend.classList.toggle("show", colorMode === "logprob");
323
+ renderSequence();
324
+ });
325
+ });
326
+
327
+ async function generate() {
328
+ if (abortCtrl) return;
329
+ promptBases = cleanPrompt(els.prompt.value);
330
+ genText = "";
331
+ genTokens = [];
332
+ genTokenAtBase = [];
333
+ startTime = performance.now();
334
+ abortCtrl = new AbortController();
335
+ els.generate.disabled = true;
336
+ els.stop.disabled = false;
337
+ setStatus("connecting…", "streaming");
338
+ renderSequence();
339
+ updateStats();
340
+ timer = setInterval(updateStats, 100);
341
+ const body = {
342
+ prompt: promptBases,
343
+ max_tokens: parseInt(els.maxTokens.value),
344
+ temperature: parseFloat(els.temperature.value),
345
+ top_p: parseFloat(els.topP.value),
346
+ };
347
+ try {
348
+ const resp = await fetch("/generate", {
349
+ method: "POST",
350
+ headers: { "Content-Type": "application/json" },
351
+ body: JSON.stringify(body),
352
+ signal: abortCtrl.signal,
353
+ });
354
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
355
+ setStatus("streaming", "streaming");
356
+ const reader = resp.body.getReader();
357
+ const decoder = new TextDecoder();
358
+ let buffer = "";
359
+ while (true) {
360
+ const { done, value } = await reader.read();
361
+ if (done) break;
362
+ buffer += decoder.decode(value, { stream: true });
363
+ const events = buffer.split("\n\n");
364
+ buffer = events.pop();
365
+ for (const ev of events) {
366
+ const line = ev.trim();
367
+ if (!line.startsWith("data:")) continue;
368
+ const data = JSON.parse(line.slice(5).trim());
369
+ if (data.error) throw new Error(data.error);
370
+ if (data.done) continue;
371
+ if (data.logprobs) {
372
+ const lp = data.logprobs;
373
+ for (let i = 0; i < lp.tokens.length; i++) {
374
+ const tokIdx = genTokens.length;
375
+ genTokens.push({
376
+ text: lp.tokens[i],
377
+ logprob: lp.token_logprobs[i],
378
+ top: lp.top_logprobs[i],
379
+ });
380
+ for (let j = 0; j < lp.tokens[i].length; j++) genTokenAtBase.push(tokIdx);
381
+ }
382
+ }
383
+ if (data.text) {
384
+ genText += cleanPrompt(data.text);
385
+ scheduleRender();
386
+ }
387
+ }
388
+ }
389
+ setStatus("done");
390
+ } catch (e) {
391
+ if (e.name === "AbortError") setStatus("stopped");
392
+ else setStatus(e.message, "error");
393
+ } finally {
394
+ abortCtrl = null;
395
+ clearInterval(timer);
396
+ updateStats();
397
+ renderSequence();
398
+ els.generate.disabled = false;
399
+ els.stop.disabled = true;
400
+ }
401
+ }
402
+ function stop() { if (abortCtrl) abortCtrl.abort(); }
403
+ function clearAll() {
404
+ if (abortCtrl) return;
405
+ promptBases = "";
406
+ genText = "";
407
+ genTokens = [];
408
+ genTokenAtBase = [];
409
+ startTime = 0;
410
+ renderSequence();
411
+ updateStats();
412
+ setStatus("idle");
413
+ }
414
+ els.generate.addEventListener("click", generate);
415
+ els.stop.addEventListener("click", stop);
416
+ els.clear.addEventListener("click", clearAll);
417
+ els.copy.addEventListener("click", async () => {
418
+ const text = promptBases + genText;
419
+ if (!text) return;
420
+ try {
421
+ await navigator.clipboard.writeText(text);
422
+ els.copy.classList.add("copied");
423
+ els.copy.textContent = "copied";
424
+ } catch {
425
+ els.copy.textContent = "failed";
426
+ }
427
+ setTimeout(() => {
428
+ els.copy.classList.remove("copied");
429
+ els.copy.textContent = "copy";
430
+ }, 1200);
431
+ });
432
+ els.prompt.addEventListener("keydown", e => {
433
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
434
+ e.preventDefault();
435
+ generate();
436
+ }
437
+ });
438
+ document.querySelectorAll("#panel-sandbox .sb-ex-btn").forEach(btn => {
439
+ btn.addEventListener("click", () => {
440
+ els.prompt.value = btn.dataset.ex;
441
+ autoGrow();
442
+ els.prompt.focus();
443
+ });
444
+ });
445
+ // Init meta from /config (fires regardless of which tab is active).
446
+ // Reuses the shared promise from fetchConfig() — no double network roundtrip.
447
+ fetchConfig().then(cfg => {
448
+ els.meta.textContent = `${cfg.model} · ${cfg.endpoint}`;
449
+ }).catch(() => { els.meta.textContent = "config unavailable"; });
450
+
451
+ updateStats();
452
+ autoGrow();
453
+
454
+ let roPending = false;
455
+ const ro = new ResizeObserver(() => {
456
+ if (roPending) return;
457
+ roPending = true;
458
+ requestAnimationFrame(() => { roPending = false; renderSequence(); });
459
+ });
460
+ ro.observe(els.seq);
461
+ if (document.fonts && document.fonts.ready) {
462
+ document.fonts.ready.then(() => { charMetrics = null; renderSequence(); });
463
+ }
464
+ })();
465
+
assets/js/sections/species.js ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §4 — Same gene across species
3
+ // =========================================================================
4
+ (function initDemo4() {
5
+ const els = {
6
+ pills: document.getElementById("d4-pills"),
7
+ prefixPills: document.getElementById("d4-prefix-pills"),
8
+ genPills: document.getElementById("d4-gen-pills"),
9
+ info: document.getElementById("d4-info"),
10
+ rows: document.getElementById("d4-rows"),
11
+ go: document.getElementById("d4-go"),
12
+ status: document.getElementById("d4-status"),
13
+ statusText: document.querySelector("#d4-status span:last-child"),
14
+ };
15
+
16
+ let SPECIES_DATA = null;
17
+ let entry = null; // { symbol, species: [...] }
18
+ let prefixLen = 200;
19
+ let genLen = 200;
20
+ // Per species: { genText, genTokens, genTokenAtBase, status }
21
+ let runState = {};
22
+
23
+ function setStatus(text, mode = "") {
24
+ els.statusText.textContent = text;
25
+ els.status.className = "status" + (mode ? " " + mode : "");
26
+ }
27
+
28
+ function basesPerLine(el) {
29
+ const cs = getComputedStyle(el);
30
+ const padL = parseFloat(cs.paddingLeft) || 0;
31
+ const padR = parseFloat(cs.paddingRight) || 0;
32
+ const contentW = el.clientWidth - padL - padR;
33
+ const charW = 7.4;
34
+ const prefixW = 7 * charW;
35
+ const blockW = 10 * charW + charW;
36
+ if (contentW <= prefixW) return 60;
37
+ const blocks = Math.floor((contentW - prefixW) / blockW);
38
+ return Math.max(20, Math.min(blocks, 12) * 10);
39
+ }
40
+
41
+ function renderRow(s) {
42
+ const wrap = document.createElement("div");
43
+ wrap.className = "species-row";
44
+ wrap.dataset.id = s.species_id;
45
+
46
+ const stat = runState[s.species_id] || {};
47
+ const genText = stat.genText || "";
48
+ const refSlice = s.seq.slice(prefixLen, prefixLen + genLen);
49
+ let match = 0, total = 0;
50
+ for (let i = 0; i < genText.length && i < refSlice.length; i++) {
51
+ total++;
52
+ if (genText[i] === refSlice[i]) match++;
53
+ }
54
+ const idPct = total > 0 ? `${((match / total) * 100).toFixed(0)}%` : "—";
55
+ const meanLp = stat.genTokens ? meanLogprob(stat.genTokens) : null;
56
+
57
+ wrap.innerHTML = `
58
+ <div class="species-meta">
59
+ <div class="species-name" style="border-left-color:${s.color}">${s.common}</div>
60
+ <div class="species-sub">${s.ortholog_symbol}</div>
61
+ <div class="species-sub">chr${s.chrom} · strand ${s.strand}</div>
62
+ </div>
63
+ <div>
64
+ <div class="species-seq" data-role="output">— click "run all" to generate —</div>
65
+ <div class="species-seq" data-role="ref" style="margin-top:4px"></div>
66
+ </div>
67
+ <div class="species-stats">
68
+ <div class="stat-id">${idPct}</div>
69
+ <div class="stat-sub">${total > 0 ? `${match}/${total} bases` : "not run"}</div>
70
+ ${meanLp == null ? "" : `<div class="stat-sub">logP ${meanLp.toFixed(2)}</div>`}
71
+ </div>
72
+ `;
73
+
74
+ const outEl = wrap.querySelector('[data-role="output"]');
75
+ const refEl = wrap.querySelector('[data-role="ref"]');
76
+
77
+ if (genText.length === 0 && (stat.status === "idle" || !stat.status)) {
78
+ outEl.classList.add("empty");
79
+ outEl.textContent = "— click \"run all\" to generate —";
80
+ refEl.style.display = "none";
81
+ } else if (stat.status === "error") {
82
+ outEl.classList.add("empty");
83
+ outEl.style.color = "#b00020";
84
+ outEl.textContent = stat.error || "error";
85
+ refEl.style.display = "none";
86
+ } else {
87
+ outEl.classList.remove("empty");
88
+ const bpl = basesPerLine(outEl);
89
+ const total = (s.seq.slice(0, prefixLen) + genText);
90
+ const lpRange = stat.genTokens ? lpRangeOf(stat.genTokens) : null;
91
+ const colorOut = (absIdx) => {
92
+ if (absIdx < prefixLen) return { style: `color:rgb(${PROMPT_RGB.join(",")})` };
93
+ const tok = stat.genTokens && stat.genTokenAtBase
94
+ ? stat.genTokens[stat.genTokenAtBase[absIdx - prefixLen]]
95
+ : null;
96
+ const [r, g, b] = logprobRgb(tok ? tok.logprob : null, lpRange);
97
+ return { style: `color:rgb(${r},${g},${b})` };
98
+ };
99
+ renderSeq(outEl, total, bpl, colorOut);
100
+
101
+ // Reference (only the generated span)
102
+ if (genText.length > 0) {
103
+ const refSpanStart = prefixLen;
104
+ const refSpanEnd = Math.min(s.length, prefixLen + genLen);
105
+ const refSeq = s.seq.slice(refSpanStart, refSpanEnd);
106
+ const colorRef = (absIdx, base) => {
107
+ // absIdx is local to refSeq (starts at 0)
108
+ const genIdx = absIdx;
109
+ if (genIdx >= genText.length) return { style: "color:#ccc" };
110
+ const matches = genText[genIdx] === base;
111
+ return matches
112
+ ? { style: "color:#bbb" }
113
+ : { style: "color:#b00020;background:rgba(188,46,37,0.18)" };
114
+ };
115
+ const bpl2 = basesPerLine(refEl);
116
+ renderSeq(refEl, refSeq, bpl2, colorRef);
117
+ refEl.style.display = "";
118
+ } else {
119
+ refEl.style.display = "none";
120
+ }
121
+ }
122
+
123
+ els.rows.appendChild(wrap);
124
+ }
125
+
126
+ function renderAll() {
127
+ els.rows.innerHTML = "";
128
+ if (!entry) return;
129
+ for (const s of entry.species) renderRow(s);
130
+ }
131
+
132
+ async function generateForSpecies(s) {
133
+ const prompt = s.seq.slice(0, prefixLen);
134
+ const stat = { genText: "", genTokens: [], genTokenAtBase: [], status: "running" };
135
+ runState[s.species_id] = stat;
136
+ renderAll();
137
+ try {
138
+ const resp = await fetch("/generate", {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify({
142
+ prompt, max_tokens: Math.ceil(genLen / 6) + 4, temperature: 1.0, top_p: 1.0,
143
+ }),
144
+ });
145
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
146
+ const reader = resp.body.getReader();
147
+ const decoder = new TextDecoder();
148
+ let buffer = "";
149
+ while (true) {
150
+ const { done, value } = await reader.read();
151
+ if (done) break;
152
+ buffer += decoder.decode(value, { stream: true });
153
+ const events = buffer.split("\n\n");
154
+ buffer = events.pop();
155
+ for (const ev of events) {
156
+ const line = ev.trim();
157
+ if (!line.startsWith("data:")) continue;
158
+ const data = JSON.parse(line.slice(5).trim());
159
+ if (data.error) throw new Error(data.error);
160
+ if (data.done) continue;
161
+ if (data.logprobs) {
162
+ const lp = data.logprobs;
163
+ for (let i = 0; i < lp.tokens.length; i++) {
164
+ const tokIdx = stat.genTokens.length;
165
+ stat.genTokens.push({ text: lp.tokens[i], logprob: lp.token_logprobs[i] });
166
+ for (let j = 0; j < lp.tokens[i].length; j++) stat.genTokenAtBase.push(tokIdx);
167
+ }
168
+ }
169
+ if (data.text) {
170
+ const cleaned = data.text.toUpperCase().replace(/[^ACGTN]/g, "");
171
+ const room = Math.max(0, genLen - stat.genText.length);
172
+ stat.genText += cleaned.slice(0, room);
173
+ renderAll();
174
+ if (stat.genText.length >= genLen) return;
175
+ }
176
+ }
177
+ }
178
+ stat.status = "done";
179
+ } catch (e) {
180
+ stat.status = "error";
181
+ stat.error = e.message;
182
+ throw e;
183
+ } finally {
184
+ renderAll();
185
+ }
186
+ }
187
+
188
+ async function runAll() {
189
+ if (!entry) return;
190
+ runState = {};
191
+ setStatus("running…", "streaming");
192
+ els.go.disabled = true;
193
+ try {
194
+ for (const s of entry.species) {
195
+ try { await generateForSpecies(s); } catch (e) { /* keep going across species */ }
196
+ }
197
+ setStatus("done");
198
+ } finally {
199
+ els.go.disabled = false;
200
+ }
201
+ }
202
+
203
+ function selectGene(symbol) {
204
+ entry = SPECIES_DATA.find(x => x.symbol === symbol);
205
+ if (!entry) return;
206
+ els.pills.querySelectorAll(".pill").forEach(p => p.classList.toggle("active", p.dataset.gene === symbol));
207
+ els.info.innerHTML = `<strong>${entry.symbol}</strong> · same gene, ${entry.species.length} species · prefix from each species' own canonical transcript`;
208
+ runState = {};
209
+ renderAll();
210
+ setStatus("idle");
211
+ }
212
+
213
+ function bindPills(container, attr, onSelect) {
214
+ container.querySelectorAll(".pill").forEach(p => {
215
+ p.addEventListener("click", () => {
216
+ container.querySelectorAll(".pill").forEach(x => x.classList.remove("active"));
217
+ p.classList.add("active");
218
+ onSelect(p.dataset[attr]);
219
+ });
220
+ });
221
+ }
222
+
223
+ fetch("/species").then(r => r.json()).then(data => {
224
+ SPECIES_DATA = data;
225
+ els.pills.innerHTML = data.map((g, i) =>
226
+ `<button class="pill${i === 0 ? " active" : ""}" data-gene="${g.symbol}">${g.symbol}</button>`
227
+ ).join("");
228
+ els.pills.querySelectorAll(".pill").forEach(p => {
229
+ p.addEventListener("click", () => selectGene(p.dataset.gene));
230
+ });
231
+ selectGene(data[0].symbol);
232
+ }).catch(e => {
233
+ els.info.textContent = "failed to load species: " + e.message;
234
+ });
235
+
236
+ bindPills(els.prefixPills, "prefix", (v) => { prefixLen = +v; runState = {}; renderAll(); });
237
+ bindPills(els.genPills, "gen", (v) => { genLen = +v; runState = {}; renderAll(); });
238
+ els.go.addEventListener("click", runAll);
239
+ window.addEventListener("resize", () => renderAll());
240
+ })();
241
+
assets/js/sections/tokenizer.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §7 — Tokenizer (1-mer vs 6-mer)
3
+ // =========================================================================
4
+ (function initDemo7() {
5
+ const els = {
6
+ input: document.getElementById("d7-input"),
7
+ len: document.getElementById("d7-len"),
8
+ oneSeq: document.getElementById("d7-1mer"),
9
+ sixSeq: document.getElementById("d7-6mer"),
10
+ oneTok: document.getElementById("d7-1mer-tok"),
11
+ sixTok: document.getElementById("d7-6mer-tok"),
12
+ oneAtt: document.getElementById("d7-1mer-att"),
13
+ sixAtt: document.getElementById("d7-6mer-att"),
14
+ bars: document.getElementById("d7-bars"),
15
+ speedup: document.getElementById("d7-speedup"),
16
+ };
17
+ // 8-color palette for 6-mer tokens (cycle); 1-mer uses base coloring.
18
+ const TOKEN_PALETTE = ["#317f3f","#2c5aa0","#c08030","#7a4baa","#2a8a8a","#b03b6e","#5a6e30","#a87a30"];
19
+ const BASE_FILL = { A:"#3a8a3e", C:"#2e6bb8", G:"#b5891e", T:"#b53a3a", N:"#888" };
20
+
21
+ function clean(s) { return (s || "").toUpperCase().replace(/[^ACGTN]/g, ""); }
22
+
23
+ function render() {
24
+ const seq = clean(els.input.value);
25
+ els.len.textContent = `${seq.length} bp`;
26
+
27
+ // 1-mer: each base its own pill
28
+ let one = "";
29
+ for (let i = 0; i < seq.length; i++) {
30
+ const b = seq[i];
31
+ one += `<span style="display:inline-block;background:${BASE_FILL[b]||"#888"};color:#fff;padding:2px 4px;margin:1px;border-radius:2px;font-size:10px;letter-spacing:0">${b}</span>`;
32
+ }
33
+ els.oneSeq.classList.toggle("empty", seq.length === 0);
34
+ els.oneSeq.innerHTML = seq.length ? one : "—";
35
+
36
+ // 6-mer: chunks of 6, alternating palette colors
37
+ let six = "";
38
+ for (let i = 0; i < seq.length; i += 6) {
39
+ const chunk = seq.slice(i, i + 6);
40
+ const isFull = chunk.length === 6;
41
+ const c = TOKEN_PALETTE[(i / 6) % TOKEN_PALETTE.length];
42
+ const padded = isFull ? chunk : chunk + "•".repeat(6 - chunk.length);
43
+ six += `<span style="display:inline-block;background:${c};color:#fff;padding:3px 7px;margin:2px;border-radius:3px;font-size:11px;letter-spacing:1px;${isFull?"":"opacity:0.55"}">${padded}</span>`;
44
+ }
45
+ els.sixSeq.classList.toggle("empty", seq.length === 0);
46
+ els.sixSeq.innerHTML = seq.length ? six : "—";
47
+
48
+ const n1 = seq.length;
49
+ const n6 = Math.ceil(seq.length / 6);
50
+ els.oneTok.textContent = n1.toLocaleString("en-US");
51
+ els.sixTok.textContent = n6.toLocaleString("en-US");
52
+ els.oneAtt.innerHTML = `${(n1*n1).toLocaleString("en-US")}<span style="color:#999;font-size:9px;margin-left:3px">L²</span>`;
53
+ els.sixAtt.innerHTML = `${(n6*n6).toLocaleString("en-US")}<span style="color:#999;font-size:9px;margin-left:3px">L²</span>`;
54
+
55
+ // Speedup bars: visualize attention cost ratio
56
+ const maxCost = n1 * n1 || 1;
57
+ const W = 1000, H = 70, padL = 110, padR = 80, rowH = 18, padT = 12;
58
+ let svg = "";
59
+ const rows = [
60
+ { label: "1-mer", n: n1, cost: n1 * n1, color: "#888" },
61
+ { label: "6-mer", n: n6, cost: n6 * n6, color: "#317f3f" },
62
+ ];
63
+ rows.forEach((r, i) => {
64
+ const y = padT + i * (rowH + 8);
65
+ svg += `<text x="${padL - 8}" y="${y + 13}" font-family="JetBrains Mono" font-size="11" fill="#333" text-anchor="end">${r.label}</text>`;
66
+ const w = (r.cost / maxCost) * (W - padL - padR);
67
+ svg += `<rect x="${padL}" y="${y}" width="${Math.max(2, w)}" height="${rowH}" fill="${r.color}"/>`;
68
+ svg += `<text x="${padL + w + 6}" y="${y + 13}" font-family="JetBrains Mono" font-size="10" fill="#333">${r.cost.toLocaleString("en-US")}</text>`;
69
+ });
70
+ els.bars.setAttribute("viewBox", `0 0 ${W} ${H}`);
71
+ els.bars.style.height = `${H}px`;
72
+ els.bars.innerHTML = svg;
73
+
74
+ const ratio = n1 > 0 ? (n1 * n1) / Math.max(1, n6 * n6) : 36;
75
+ els.speedup.textContent = `${ratio.toFixed(1)}× cheaper attention`;
76
+ }
77
+
78
+ els.input.addEventListener("input", render);
79
+ render();
80
+ })();
81
+
assets/js/sections/track.js ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §3 — Likelihood track over a real gene
3
+ // =========================================================================
4
+ (function initDemo3() {
5
+ const els = {
6
+ pills: document.getElementById("d3-pills"),
7
+ info: document.getElementById("d3-info"),
8
+ track: document.getElementById("d3-track"),
9
+ chart: document.getElementById("d3-chart"),
10
+ bpLabel: document.getElementById("d3-bp-label"),
11
+ go: document.getElementById("d3-go"),
12
+ status: document.getElementById("d3-status"),
13
+ statusText: document.querySelector("#d3-status span:last-child"),
14
+ meanExon: document.getElementById("d3-mean-exon"),
15
+ meanIntron: document.getElementById("d3-mean-intron"),
16
+ delta: document.getElementById("d3-delta"),
17
+ tokens: document.getElementById("d3-tokens"),
18
+ mean: document.getElementById("d3-mean"),
19
+ };
20
+
21
+ let gene = null;
22
+ let scoreData = null; // { tokens, token_logprobs, scoredLength }
23
+ const cache = {}; // cache scored data by gene symbol so re-clicking is instant
24
+ const MAX_WINDOW = 6000;
25
+
26
+ function setStatus(text, mode = "") {
27
+ els.statusText.textContent = text;
28
+ els.status.className = "status" + (mode ? " " + mode : "");
29
+ }
30
+
31
+ function renderTrack(scoredLen) {
32
+ const W = 1000, H = 28;
33
+ if (!gene) { els.track.innerHTML = ""; return; }
34
+ const total = scoredLen || gene.length;
35
+ const scaleX = (bp) => (bp / total) * W;
36
+ let svg = "";
37
+ svg += `<line class="intron" x1="0" y1="${H/2}" x2="${W}" y2="${H/2}"/>`;
38
+ for (const e of gene.exons) {
39
+ if (e.start > total) continue;
40
+ const x = scaleX(e.start);
41
+ const w = Math.max(1, scaleX(Math.min(e.end, total) - e.start));
42
+ svg += `<rect class="exon" x="${x.toFixed(1)}" y="6" width="${w.toFixed(1)}" height="16"/>`;
43
+ }
44
+ els.track.innerHTML = svg;
45
+ }
46
+
47
+ function renderChart() {
48
+ const W = 1000, H = 140, padT = 6, padB = 16;
49
+ if (!scoreData || !gene) {
50
+ els.chart.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" font-family="JetBrains Mono" font-size="11" fill="#bbb">— score the gene to see the likelihood track —</text>`;
51
+ return;
52
+ }
53
+ const tokens = scoreData.tokens;
54
+ const lps = scoreData.token_logprobs;
55
+ const padBases = scoreData.pad_bases || 0;
56
+ // Skip <dna>, plus the first DNA token when it contains left-pad phantoms.
57
+ const points = [];
58
+ let cursor = 0; // bp into the *padded* sequence
59
+ let firstDnaSkipped = false;
60
+ for (let i = 0; i < tokens.length; i++) {
61
+ const tlen = tokens[i].length;
62
+ if (tokens[i] === "<dna>") continue;
63
+ if (padBases > 0 && !firstDnaSkipped) {
64
+ firstDnaSkipped = true;
65
+ cursor += tlen;
66
+ continue;
67
+ }
68
+ const lp = lps[i];
69
+ if (lp != null && !isNaN(lp)) {
70
+ // Map midpoint back to gene-relative coords
71
+ const genePos = (cursor - padBases) + tlen / 2;
72
+ points.push({ pos: genePos, lp });
73
+ }
74
+ cursor += tlen;
75
+ }
76
+ if (!points.length) {
77
+ els.chart.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" font-family="JetBrains Mono" font-size="11" fill="#bbb">no logprobs returned</text>`;
78
+ return;
79
+ }
80
+ const scoredLen = scoreData.scoredLength;
81
+
82
+ // Y range
83
+ let lpMin = Infinity, lpMax = -Infinity;
84
+ for (const p of points) { if (p.lp < lpMin) lpMin = p.lp; if (p.lp > lpMax) lpMax = p.lp; }
85
+ // Pad a touch so extremes don't touch the edges
86
+ const lpPad = Math.max(0.2, (lpMax - lpMin) * 0.05);
87
+ const yMin = lpMin - lpPad;
88
+ const yMax = lpMax + lpPad;
89
+ const xScale = (bp) => (bp / scoredLen) * W;
90
+ const yScale = (lp) => padT + (1 - (lp - yMin) / Math.max(1e-9, yMax - yMin)) * (H - padT - padB);
91
+
92
+ let svg = "";
93
+
94
+ // Exon shading background bands
95
+ for (const e of gene.exons) {
96
+ if (e.start > scoredLen) continue;
97
+ const x = xScale(e.start);
98
+ const w = xScale(Math.min(e.end, scoredLen)) - x;
99
+ svg += `<rect x="${x.toFixed(1)}" y="0" width="${Math.max(1, w).toFixed(1)}" height="${H}" fill="#317f3f" opacity="0.08"/>`;
100
+ }
101
+
102
+ // Smoothed line: a moving average over the points (window=5)
103
+ const win = 5;
104
+ const smoothed = points.map((p, i) => {
105
+ let s = 0, c = 0;
106
+ for (let j = Math.max(0, i - win); j <= Math.min(points.length - 1, i + win); j++) {
107
+ s += points[j].lp; c++;
108
+ }
109
+ return { pos: p.pos, lp: s / c };
110
+ });
111
+
112
+ // Raw points as faint dots
113
+ let dots = "";
114
+ for (const p of points) {
115
+ dots += `<circle cx="${xScale(p.pos).toFixed(1)}" cy="${yScale(p.lp).toFixed(1)}" r="0.9" fill="#888" opacity="0.35"/>`;
116
+ }
117
+ svg += dots;
118
+
119
+ // Smoothed path on top
120
+ let d = "";
121
+ smoothed.forEach((p, i) => {
122
+ d += (i === 0 ? "M" : "L") + xScale(p.pos).toFixed(1) + " " + yScale(p.lp).toFixed(1);
123
+ });
124
+ svg += `<path d="${d}" fill="none" stroke="#1f1f1d" stroke-width="1.2" stroke-linejoin="round"/>`;
125
+
126
+ // Y-axis ticks
127
+ const tickLps = [yMin + (yMax - yMin) * 0.1, yMin + (yMax - yMin) * 0.5, yMin + (yMax - yMin) * 0.9];
128
+ for (const tl of tickLps) {
129
+ const ty = yScale(tl).toFixed(1);
130
+ svg += `<line x1="0" y1="${ty}" x2="${W}" y2="${ty}" stroke="#eee" stroke-width="1"/>`;
131
+ svg += `<text x="4" y="${(parseFloat(ty) - 2).toFixed(1)}" font-family="JetBrains Mono" font-size="9" fill="#aaa">${tl.toFixed(1)}</text>`;
132
+ }
133
+
134
+ els.chart.innerHTML = svg;
135
+ els.bpLabel.textContent = `${scoredLen.toLocaleString("en-US")} bp scored`;
136
+ }
137
+
138
+ function updateStats() {
139
+ if (!scoreData || !gene) {
140
+ [els.meanExon, els.meanIntron, els.delta, els.tokens, els.mean].forEach(e => {
141
+ e.textContent = "—"; e.classList.add("muted");
142
+ });
143
+ return;
144
+ }
145
+ const tokens = scoreData.tokens;
146
+ const lps = scoreData.token_logprobs;
147
+ const padBases = scoreData.pad_bases || 0;
148
+ let cursor = 0;
149
+ let firstDnaSkipped = false;
150
+ let exonSum = 0, exonN = 0;
151
+ let intronSum = 0, intronN = 0;
152
+ let allSum = 0, allN = 0;
153
+ function annAt(idx) {
154
+ for (const e of gene.exons) if (idx >= e.start && idx < e.end) return "exon";
155
+ return "intron";
156
+ }
157
+ for (let i = 0; i < tokens.length; i++) {
158
+ if (tokens[i] === "<dna>") continue;
159
+ const tlen = tokens[i].length;
160
+ if (padBases > 0 && !firstDnaSkipped) {
161
+ firstDnaSkipped = true;
162
+ cursor += tlen;
163
+ continue;
164
+ }
165
+ const lp = lps[i];
166
+ if (lp != null && !isNaN(lp)) {
167
+ const mid = (cursor - padBases) + tlen / 2;
168
+ const a = annAt(Math.floor(mid));
169
+ if (a === "exon") { exonSum += lp; exonN++; }
170
+ else { intronSum += lp; intronN++; }
171
+ allSum += lp; allN++;
172
+ }
173
+ cursor += tlen;
174
+ }
175
+ const fmt = (s, n) => n > 0 ? (s / n).toFixed(2) : "—";
176
+ els.meanExon.textContent = fmt(exonSum, exonN);
177
+ els.meanIntron.textContent = fmt(intronSum, intronN);
178
+ if (exonN > 0 && intronN > 0) {
179
+ const d = (exonSum / exonN) - (intronSum / intronN);
180
+ els.delta.textContent = (d >= 0 ? "+" : "") + d.toFixed(2);
181
+ } else {
182
+ els.delta.textContent = "—";
183
+ }
184
+ els.tokens.textContent = String(allN);
185
+ els.mean.textContent = fmt(allSum, allN);
186
+ [els.meanExon, els.meanIntron, els.delta, els.tokens, els.mean].forEach(e => e.classList.remove("muted"));
187
+ }
188
+
189
+ async function score() {
190
+ if (!gene) return;
191
+ const cached = cache[gene.symbol];
192
+ if (cached) {
193
+ scoreData = cached;
194
+ renderTrack(scoreData.scoredLength);
195
+ renderChart();
196
+ updateStats();
197
+ setStatus("cached");
198
+ return;
199
+ }
200
+ setStatus("scoring (cold endpoint takes ~30s)…", "streaming");
201
+ els.go.disabled = true;
202
+ try {
203
+ const seq = gene.seq.slice(0, MAX_WINDOW);
204
+ const r = await fetch("/score", {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({ sequence: seq, max_window: MAX_WINDOW }),
208
+ });
209
+ const data = await r.json();
210
+ if (data.error) throw new Error(data.error);
211
+ data.scoredLength = seq.length;
212
+ cache[gene.symbol] = data;
213
+ scoreData = data;
214
+ renderTrack(data.scoredLength);
215
+ renderChart();
216
+ updateStats();
217
+ setStatus("done");
218
+ } catch (e) {
219
+ setStatus(e.message, "error");
220
+ } finally {
221
+ els.go.disabled = false;
222
+ }
223
+ }
224
+
225
+ function selectGene(symbol) {
226
+ const g = GENES.find(x => x.symbol === symbol);
227
+ if (!g) return;
228
+ gene = g;
229
+ els.pills.querySelectorAll(".pill").forEach(p => p.classList.toggle("active", p.dataset.gene === symbol));
230
+ els.info.innerHTML = `<strong>${gene.symbol}</strong> · ${gene.blurb} · <span style="color:#888">${Math.min(gene.length, MAX_WINDOW).toLocaleString("en-US")} bp will be scored${gene.length > MAX_WINDOW ? ` (of ${gene.length.toLocaleString("en-US")})` : ""}</span>`;
231
+ scoreData = cache[symbol] || null;
232
+ renderTrack(scoreData ? scoreData.scoredLength : Math.min(gene.length, MAX_WINDOW));
233
+ renderChart();
234
+ updateStats();
235
+ setStatus(scoreData ? "cached" : "idle");
236
+ }
237
+
238
+ loadGenes().then(genes => {
239
+ // Hydrate cache from precomputed tracks
240
+ for (const g of genes) {
241
+ if (g.track) {
242
+ cache[g.symbol] = {
243
+ tokens: g.track.tokens,
244
+ token_logprobs: g.track.token_logprobs,
245
+ scoredLength: g.track.scored_length,
246
+ pad_bases: g.track.pad_bases || 0,
247
+ };
248
+ }
249
+ }
250
+ els.pills.innerHTML = genes.map((g, i) =>
251
+ `<button class="pill${i === 0 ? " active" : ""}" data-gene="${g.symbol}">${g.symbol}</button>`
252
+ ).join("");
253
+ els.pills.querySelectorAll(".pill").forEach(p => {
254
+ p.addEventListener("click", () => selectGene(p.dataset.gene));
255
+ });
256
+ selectGene(genes[0].symbol);
257
+ });
258
+
259
+ els.go.addEventListener("click", score);
260
+ })();
261
+
assets/js/sections/tree.js ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §7 — Species tree (Carbon-derived phylogeny)
3
+ //
4
+ // Renders the precomputed species_tree.json (built once by
5
+ // scripts/build_species_tree.py) as:
6
+ // - an SVG dendrogram spine on the left, with rounded Bezier elbows
7
+ // so the branches feel organic rather than CAD-like
8
+ // - a column of right-aligned data tracks (italic name, kingdom chip,
9
+ // log-scaled sequence count bar, NCBI agreement glyph)
10
+ // The two halves share the same row height (ROW_H px) so each leaf in
11
+ // the SVG lines up exactly with its row in the data tracks.
12
+ //
13
+ // User can toggle:
14
+ // - linkage = ward | upgma → swaps the precomputed layout
15
+ // - scope = kingdom | sister → swaps the NCBI agreement metric
16
+ // Hovering a row pops a tooltip listing the top-3 nearest neighbours
17
+ // in embedding space + their cosine distances.
18
+ // =========================================================================
19
+ (function initDemoSpeciesTree() {
20
+ const root = document.getElementById("demoSpeciesTree");
21
+ if (!root) return;
22
+
23
+ const ROW_H = 22; // px — must match .tree-row height in CSS
24
+ // SVG-internal padding kept at 0: vertical alignment with the rows
25
+ // grid is handled entirely by the CSS padding on .tree-spine /
26
+ // .tree-rows (both = 12px top). Doubling it would shift the spine
27
+ // down by 12px relative to the row labels.
28
+ const SPINE_PAD_TOP = 0;
29
+ const SPINE_PAD_BOTTOM = 0;
30
+ const ROWS_PAD_TOP = 12; // must match .tree-rows padding
31
+ const ROWS_PAD_BOTTOM = 28;
32
+ const SPINE_LABEL_INSET = 4; // tiny gap between spine tip and labels
33
+
34
+ const KINGDOM_COLOR = {
35
+ vertebrates: "#1f1f1d",
36
+ invertebrates: "#7a6242",
37
+ plants: "#317f3f",
38
+ fungi: "#a9762f",
39
+ bacteria: "#b00020",
40
+ viruses: "#2c5aa0",
41
+ };
42
+
43
+ const els = {
44
+ spine: document.getElementById("dtree-spine"),
45
+ svg: document.getElementById("dtree-svg"),
46
+ rows: document.getElementById("dtree-rows"),
47
+ info: document.getElementById("dtree-info"),
48
+ score: document.getElementById("dtree-score"),
49
+ scoreSx: document.getElementById("dtree-score-suffix"),
50
+ nSp: document.getElementById("dtree-n"),
51
+ nSeq: document.getElementById("dtree-nseq"),
52
+ tooltip: document.getElementById("dtree-tooltip"),
53
+ frame: root.querySelector(".tree-frame"),
54
+ pillsLink: document.getElementById("dtree-link-pills"),
55
+ pillsScope: document.getElementById("dtree-scope-pills"),
56
+ };
57
+
58
+ let tree = null;
59
+ let state = { linkage: "ward", scope: "kingdom" };
60
+ let agreement = {}; // species -> 'match' | 'mismatch' | 'solo'
61
+ let nnTop = {}; // species -> [{name, dist, sameKingdom, sameClade}, ...]
62
+
63
+ // -------- nearest-neighbour computation --------
64
+ function buildNN() {
65
+ const sp = tree.species;
66
+ const D = tree.distance_matrix;
67
+ const kingdom = Object.fromEntries(sp.map((s, i) => [s, tree.kingdom[i]]));
68
+ const clade = Object.fromEntries(sp.map((s, i) => [s, tree.expected_clade[i]]));
69
+ const cladeSize = {};
70
+ sp.forEach(s => { const c = clade[s]; cladeSize[c] = (cladeSize[c] || 0) + 1; });
71
+
72
+ nnTop = {};
73
+ agreement = {};
74
+ for (let i = 0; i < sp.length; i++) {
75
+ // sort other species by distance ascending
76
+ const ranked = [];
77
+ for (let j = 0; j < sp.length; j++) {
78
+ if (j === i) continue;
79
+ ranked.push({
80
+ name: sp[j], dist: D[i][j],
81
+ sameKingdom: kingdom[sp[j]] === kingdom[sp[i]],
82
+ sameClade: clade[sp[j]] === clade[sp[i]],
83
+ });
84
+ }
85
+ ranked.sort((a, b) => a.dist - b.dist);
86
+ nnTop[sp[i]] = ranked.slice(0, 3);
87
+
88
+ // agreement state for the active scope
89
+ const nn = ranked[0];
90
+ if (state.scope === "kingdom") {
91
+ agreement[sp[i]] = nn.sameKingdom ? "match" : "mismatch";
92
+ } else {
93
+ if ((cladeSize[clade[sp[i]]] || 0) <= 1) agreement[sp[i]] = "solo";
94
+ else agreement[sp[i]] = nn.sameClade ? "match" : "mismatch";
95
+ }
96
+ }
97
+ }
98
+
99
+ // -------- score chip --------
100
+ function updateScore() {
101
+ let m = 0, total = 0;
102
+ Object.values(agreement).forEach(v => {
103
+ if (v === "solo") return;
104
+ total += 1;
105
+ if (v === "match") m += 1;
106
+ });
107
+ const pct = total ? Math.round(100 * m / total) : 0;
108
+ els.score.textContent = `${m} / ${total}`;
109
+ els.scoreSx.textContent = `match ncbi ${state.scope} (${pct}%)`;
110
+ }
111
+
112
+ // -------- SVG dendrogram spine --------
113
+ // scipy gives icoord/dcoord for each merge:
114
+ // icoord = [xL, xL, xR, xR] (in "leaf-index space": leaves at 5,15,25,...)
115
+ // dcoord = [yChildL, yMerge, yMerge, yChildR] (in distance space)
116
+ // We need to render this with leaf-index → vertical row position
117
+ // and distance → horizontal x position (with root on the LEFT, tips
118
+ // on the RIGHT, so labels sit next to the leaves).
119
+ function renderSpine() {
120
+ const layout = tree[state.linkage === "ward" ? "layout_ward" : "layout_upgma"];
121
+ const leafOrder = layout.leaf_order;
122
+ const ic = layout.icoord;
123
+ const dc = layout.dcoord;
124
+
125
+ const N = leafOrder.length;
126
+ const innerH = N * ROW_H;
127
+ const totalH = innerH + SPINE_PAD_TOP + SPINE_PAD_BOTTOM;
128
+
129
+ const w = els.spine.clientWidth || 320;
130
+ els.svg.style.height = totalH + "px";
131
+ els.svg.setAttribute("viewBox", `0 0 ${w} ${totalH}`);
132
+
133
+ // distance domain
134
+ let dmax = 0;
135
+ dc.forEach(arr => arr.forEach(v => { if (v > dmax) dmax = v; }));
136
+ if (dmax === 0) dmax = 1;
137
+
138
+ // In mobile (<=720px) the right-hand .tree-rows block stacks BELOW
139
+ // the spine instead of beside it, so the spine renders inline labels
140
+ // (kingdom chip + species name) at each tip — otherwise the user sees
141
+ // an unlabelled dendrogram followed by a list, which doesn't connect.
142
+ const isMobile = window.matchMedia("(max-width: 720px)").matches;
143
+ const padL = 4; // a hair from the SVG left edge (= root)
144
+ const padR = isMobile ? 130 : SPINE_LABEL_INSET; // room for inline labels in mobile
145
+ const innerW = w - padL - padR;
146
+
147
+ const xOfDist = d => padL + (1 - d / dmax) * innerW;
148
+ const yOfLeafIdx = idx => SPINE_PAD_TOP + (idx + 0.5) * ROW_H;
149
+ const yOfICoord = ix => yOfLeafIdx((ix - 5) / 10);
150
+
151
+ // Classic dendrogram elbows: top arm horizontal → vertical → bottom
152
+ // arm horizontal, sharp 90° corners. Single <path> for perf.
153
+ let d = "";
154
+ for (let i = 0; i < ic.length; i++) {
155
+ const xs = ic[i], ys = dc[i];
156
+ const yTop = yOfICoord(xs[0]);
157
+ const yBot = yOfICoord(xs[3]);
158
+ const xMerge = xOfDist(ys[1]);
159
+ const xTopArm = xOfDist(ys[0]);
160
+ const xBotArm = xOfDist(ys[3]);
161
+ d += ` M ${xTopArm} ${yTop}`
162
+ + ` L ${xMerge} ${yTop}`
163
+ + ` L ${xMerge} ${yBot}`
164
+ + ` L ${xBotArm} ${yBot}`;
165
+ }
166
+
167
+ // Pastilles at each leaf tip — coloured by kingdom — that visually
168
+ // connect the muted-grey tree spine to the kingdom-coloured tracks
169
+ // on the right. They also act as a discrete "row marker" so the eye
170
+ // can follow horizontally even where the spine background tint is
171
+ // very pale (chicken / frog / zebrafish in the vertebrate band).
172
+ const kingdom = Object.fromEntries(tree.species.map((s, i) => [s, tree.kingdom[i]]));
173
+ let tips = "";
174
+ for (let i = 0; i < leafOrder.length; i++) {
175
+ const sp = leafOrder[i];
176
+ const cy = yOfLeafIdx(i);
177
+ const cx = w - padR;
178
+ const k = kingdom[sp];
179
+ const fill = (
180
+ k === "vertebrates" ? "#1f1f1d" :
181
+ k === "invertebrates" ? "#7a6242" :
182
+ k === "plants" ? "#317f3f" :
183
+ k === "fungi" ? "#a9762f" :
184
+ k === "bacteria" ? "#b00020" :
185
+ k === "viruses" ? "#2c5aa0" : "#888"
186
+ );
187
+ tips += `<circle cx="${cx}" cy="${cy}" r="2.4" fill="${fill}" stroke="#fff" stroke-width="0.8"/>`;
188
+ if (isMobile) {
189
+ // chip + species label rendered inline at the tip — only visible
190
+ // in mobile (desktop hides them via CSS, see .leaf-svg-label).
191
+ tips += `<rect class="leaf-svg-chip" x="${cx + 6}" y="${cy - 4}" width="8" height="8" fill="${fill}"/>`;
192
+ tips += `<text class="leaf-svg-label" x="${cx + 18}" y="${cy + 3.5}" fill="${fill}">${sp.replace(/_/g, " ")}</text>`;
193
+ }
194
+ }
195
+
196
+ els.svg.innerHTML =
197
+ `<path d="${d}" fill="none" stroke="#bbb8ad" stroke-width="1"
198
+ stroke-linecap="square" stroke-linejoin="miter"
199
+ shape-rendering="crispEdges" />` + tips;
200
+ return leafOrder;
201
+ }
202
+
203
+ // -------- rows --------
204
+ function renderRows(leafOrder) {
205
+ const counts = Object.fromEntries(tree.species.map((s, i) => [s, tree.counts[i]]));
206
+ const kingdom = Object.fromEntries(tree.species.map((s, i) => [s, tree.kingdom[i]]));
207
+ const maxLog = Math.log10(Math.max(...tree.counts) + 1);
208
+
209
+ let html = "";
210
+ for (let i = 0; i < leafOrder.length; i++) {
211
+ const sp = leafOrder[i];
212
+ const k = kingdom[sp];
213
+ const c = counts[sp];
214
+ const logFrac = Math.log10(c + 1) / maxLog;
215
+ const a = agreement[sp] || "solo";
216
+ const glyph = a === "match" ? "✓" : a === "mismatch" ? "✗" : "—";
217
+ html +=
218
+ `<div class="tree-row" data-species="${sp}" data-kingdom="${k}">` +
219
+ `<div class="tree-chip" style="background:${KINGDOM_COLOR[k]}"></div>` +
220
+ `<div class="tree-name">${sp.replace(/_/g, " ")}</div>` +
221
+ `<div class="tree-bar">` +
222
+ `<div class="bar-track">` +
223
+ `<div class="bar-fill" style="width:${(logFrac * 100).toFixed(1)}%"></div>` +
224
+ `</div>` +
225
+ `<div class="bar-num">${c.toLocaleString("en-US")}</div>` +
226
+ `</div>` +
227
+ `<div class="tree-ncbi" data-state="${a}">${glyph}</div>` +
228
+ `</div>`;
229
+ }
230
+ els.rows.innerHTML = html;
231
+ bindRowHover();
232
+ }
233
+
234
+ // -------- hover tooltip --------
235
+ function bindRowHover() {
236
+ els.rows.querySelectorAll(".tree-row").forEach(rowEl => {
237
+ rowEl.addEventListener("mouseenter", () => {
238
+ const sp = rowEl.dataset.species;
239
+ const top = nnTop[sp] || [];
240
+ const expected = state.scope === "kingdom"
241
+ ? "same kingdom" : "same ncbi sister clade";
242
+ let tt =
243
+ `<div class="tt-title">${sp.replace(/_/g, " ")} · top neighbours</div>`;
244
+ top.forEach((nb, i) => {
245
+ const isExpected = state.scope === "kingdom" ? nb.sameKingdom : nb.sameClade;
246
+ const cls = isExpected ? "tt-name expected" : "tt-name";
247
+ const glyph = isExpected ? "✓" : "·";
248
+ tt +=
249
+ `<div class="tt-pair">` +
250
+ `<span class="tt-glyph">${glyph}</span>` +
251
+ `<span class="${cls}">${nb.name.replace(/_/g, " ")}</span>` +
252
+ `<span class="tt-dist">${nb.dist.toFixed(4)}</span>` +
253
+ `</div>`;
254
+ });
255
+ tt += `<div class="tt-pair" style="margin-top:6px;color:#888">` +
256
+ `<span class="tt-glyph"></span>` +
257
+ `<span style="font-size:9px">match = ${expected}</span></div>`;
258
+ els.tooltip.innerHTML = tt;
259
+ els.tooltip.classList.add("show");
260
+ });
261
+ rowEl.addEventListener("mousemove", (ev) => {
262
+ const fr = els.frame.getBoundingClientRect();
263
+ const tt = els.tooltip;
264
+ const x = ev.clientX - fr.left + 12;
265
+ const y = ev.clientY - fr.top + 12;
266
+ // keep on-screen
267
+ const ttw = tt.offsetWidth, tth = tt.offsetHeight;
268
+ const fitX = (x + ttw > fr.width) ? (ev.clientX - fr.left - ttw - 12) : x;
269
+ const fitY = (y + tth > fr.height) ? (ev.clientY - fr.top - tth - 12) : y;
270
+ tt.style.left = fitX + "px";
271
+ tt.style.top = fitY + "px";
272
+ });
273
+ rowEl.addEventListener("mouseleave", () => {
274
+ els.tooltip.classList.remove("show");
275
+ });
276
+ });
277
+ }
278
+
279
+ // -------- toggles --------
280
+ function bindToggles() {
281
+ els.pillsLink.querySelectorAll(".pill").forEach(p => {
282
+ p.addEventListener("click", () => {
283
+ if (p.classList.contains("active")) return;
284
+ els.pillsLink.querySelectorAll(".pill").forEach(b => b.classList.remove("active"));
285
+ p.classList.add("active");
286
+ state.linkage = p.dataset.link;
287
+ rerender();
288
+ });
289
+ });
290
+ els.pillsScope.querySelectorAll(".pill").forEach(p => {
291
+ p.addEventListener("click", () => {
292
+ if (p.classList.contains("active")) return;
293
+ els.pillsScope.querySelectorAll(".pill").forEach(b => b.classList.remove("active"));
294
+ p.classList.add("active");
295
+ state.scope = p.dataset.scope;
296
+ // scope only changes agreement, not layout — but it's cheap to redo all
297
+ buildNN();
298
+ const layout = tree[state.linkage === "ward" ? "layout_ward" : "layout_upgma"];
299
+ renderRows(layout.leaf_order);
300
+ updateScore();
301
+ });
302
+ });
303
+ }
304
+
305
+ function rerender() {
306
+ buildNN();
307
+ const order = renderSpine();
308
+ renderRows(order);
309
+ updateScore();
310
+ }
311
+
312
+ // -------- bootstrap --------
313
+ fetch("/species_tree")
314
+ .then(r => r.json())
315
+ .then(t => {
316
+ tree = t;
317
+ els.nSp.textContent = tree.species.length;
318
+ els.nSeq.textContent = tree.n_total_points.toLocaleString("en-US");
319
+ bindToggles();
320
+ rerender();
321
+ // The SVG width depends on the laid-out grid → recompute on resize.
322
+ const ro = new ResizeObserver(() => {
323
+ const order = renderSpine();
324
+ renderRows(order); // re-bind row hover after rebuild
325
+ });
326
+ ro.observe(els.spine);
327
+ // matchMedia covers the exact breakpoint transition where the
328
+ // spine width may not actually change (e.g. desktop window shrunk
329
+ // to 720px → still occupies the same column width but switches
330
+ // role from "tree" to "tree + inline labels").
331
+ const mq = window.matchMedia("(max-width: 720px)");
332
+ const onMQ = () => { const order = renderSpine(); renderRows(order); };
333
+ if (mq.addEventListener) mq.addEventListener("change", onMQ);
334
+ else mq.addListener(onMQ); // Safari < 14 fallback
335
+ })
336
+ .catch(err => {
337
+ els.info.textContent = "failed to load species tree: " + (err.message || err);
338
+ });
339
+ })();
340
+
assets/js/sections/umap.js ADDED
@@ -0,0 +1,926 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §6 — UMAP scatter (WebGL, 571K points)
3
+ //
4
+ // Loads a binary-packed scatter (int16 quantized 2D positions + 4 uint8 category
5
+ // columns — species, biotype, strand, gc_content) and renders it via WebGL
6
+ // gl.POINTS with a 1D palette texture for coloring. Toggle between coloring axes
7
+ // (species / biotype / strand / gc) rebinds a single byte-attribute buffer and
8
+ // swaps the palette texture — no re-upload of the 571K vertex stream. Hover
9
+ // lookup uses a flat grid index so picking stays O(small) regardless of total
10
+ // point count.
11
+ // =========================================================================
12
+ (function initDemoUmap() {
13
+ const canvas = document.getElementById("dumap-canvas");
14
+ if (!canvas) return;
15
+ const tooltip = document.getElementById("dumap-tooltip");
16
+ const overlay = document.getElementById("dumap-overlay");
17
+ const info = document.getElementById("dumap-info");
18
+ const legend = document.getElementById("dumap-legend");
19
+ const resetBtn = document.getElementById("dumap-reset");
20
+ // The UMAP toolbar used to ship a `<span class="status">` indicator that
21
+ // showed "loading…" / "loaded 571k pts · 1274 ms" / "error" next to the
22
+ // pills. Removed because (a) loading is already explained by the fullscreen
23
+ // overlay, (b) the post-load metric was telemetry-grade detail not visitor-
24
+ // grade insight. Calls into setStatus below survive as no-ops so the live
25
+ // load path doesn't have to be rewritten.
26
+ const status = null;
27
+ const statusText = null;
28
+ const colorPills = document.querySelectorAll("#dumap-color-pills .pill");
29
+ const elN = document.getElementById("dumap-n");
30
+ const elNsp = document.getElementById("dumap-nsp");
31
+ const elFps = document.getElementById("dumap-fps");
32
+ const annContainer = document.getElementById("dumap-annotations");
33
+
34
+ // ---- Palettes ----------------------------------------------------------
35
+ // 27 species grouped into 6 kingdoms — each kingdom gets a hue band.
36
+ // Within a band, lightness varies to keep adjacent species distinguishable.
37
+ // Order MUST match labels.species (= the order from scripts/build_real_umap.py).
38
+ const SPECIES_PALETTE = [
39
+ // vertebrates (10) — blue/indigo/violet band
40
+ [40,80,160], [60,100,180], [80,120,195], [100,140,210], [120,160,225],
41
+ [140,100,200], [160,120,215], [125,90,170], [105,75,150], [85,60,130],
42
+ // invertebrates (2) — orange band
43
+ [220,110,30], [240,160,70],
44
+ // plants (5) — olive/lime band (intentionally different from Carbon's
45
+ // signal-green #317f3f so the UI chrome doesn't blend with the data)
46
+ [85,140,55], [115,170,75], [145,200,100], [175,220,135], [205,240,170],
47
+ // fungi (5) — magenta/rose band
48
+ [180,40,110], [200,70,140], [220,100,160], [235,130,175], [245,160,190],
49
+ // bacteria (3) — ochre/amber band
50
+ [180,140,40], [200,160,60], [220,180,80],
51
+ // viruses (2) — deep red band (outliers, intentionally dramatic)
52
+ [160,30,40], [200,50,55],
53
+ ];
54
+ // protein_coding is ~80% of the points — using a saturated colour for it
55
+ // floods the canvas and erases the three minority biotypes. We give it a
56
+ // washed-out sage instead (still readable as "the green class") and crank
57
+ // the saturation on the rare classes so they pop on top of the carpet.
58
+ const BIOTYPE_PALETTE = [
59
+ [180,205,180], // protein_coding — washed sage (volume class)
60
+ [210,55,45], // lncRNA — vivid Carbon red
61
+ [40,100,200], // snRNA — vivid blue
62
+ [240,160,30], // misc_RNA — amber (was gray, invisible)
63
+ ];
64
+ const STRAND_PALETTE = [
65
+ [49,127,63], // + (forward)
66
+ [188,46,37], // - (reverse)
67
+ ];
68
+ // Continuous gradient for gc_content (uint8 0..255 → [0, 1]).
69
+ // 3-stop: low GC (AT-rich) reads as cool steel, mid as neutral, high
70
+ // GC (GC-rich) as warm amber — natural "density" feel without
71
+ // colliding with the categorical palettes.
72
+ function buildGCPalette() {
73
+ const out = [];
74
+ for (let i = 0; i < 256; i++) {
75
+ const t = i / 255;
76
+ let r, g, b;
77
+ if (t < 0.5) {
78
+ const u = t * 2;
79
+ r = Math.round(60 + (170 - 60) * u);
80
+ g = Math.round(90 + (170 - 90) * u);
81
+ b = Math.round(160 + (170 - 160) * u);
82
+ } else {
83
+ const u = (t - 0.5) * 2;
84
+ r = Math.round(170 + (230 - 170) * u);
85
+ g = Math.round(170 + (190 - 170) * u);
86
+ b = Math.round(170 + (50 - 170) * u);
87
+ }
88
+ out.push([r, g, b]);
89
+ }
90
+ return out;
91
+ }
92
+ const GC_PALETTE = buildGCPalette();
93
+ // Continuous gradient for log10(gene length). Sequential single-hue
94
+ // ordering (deep teal → warm sand → terracotta) so the eye reads it as
95
+ // "more vs less" rather than "category A vs B". Picked to be visually
96
+ // distinct from GC's divergent steel→amber ramp so the two continuous
97
+ // overlays don't read as the same axis at a glance.
98
+ function buildLengthPalette() {
99
+ const out = [];
100
+ const A = [25, 70, 90]; // 0% short
101
+ const B = [180, 165, 130]; // 50% mid
102
+ const C = [200, 105, 65]; // 100% long
103
+ for (let i = 0; i < 256; i++) {
104
+ const t = i / 255;
105
+ let lo, hi, u;
106
+ if (t < 0.5) { lo = A; hi = B; u = t * 2; }
107
+ else { lo = B; hi = C; u = (t - 0.5) * 2; }
108
+ out.push([
109
+ Math.round(lo[0] + (hi[0] - lo[0]) * u),
110
+ Math.round(lo[1] + (hi[1] - lo[1]) * u),
111
+ Math.round(lo[2] + (hi[2] - lo[2]) * u),
112
+ ]);
113
+ }
114
+ return out;
115
+ }
116
+ const LENGTH_PALETTE = buildLengthPalette();
117
+ const PALETTES = {
118
+ species: SPECIES_PALETTE,
119
+ biotype: BIOTYPE_PALETTE,
120
+ strand: STRAND_PALETTE,
121
+ gc: GC_PALETTE,
122
+ length: LENGTH_PALETTE,
123
+ };
124
+
125
+ // Format a bp count for the hover tooltip: "873 bp", "12.4 kb", "2.4 Mb".
126
+ // Picks the smallest unit that keeps the displayed number under ~1000,
127
+ // mirroring how genome browsers (UCSC, Ensembl) write spans.
128
+ function formatBp(bp) {
129
+ if (!Number.isFinite(bp) || bp < 0) return "—";
130
+ if (bp < 1000) return `${bp.toLocaleString("en-US")} bp`;
131
+ if (bp < 1_000_000) return `${(bp / 1000).toFixed(bp < 10_000 ? 2 : 1)} kb`;
132
+ return `${(bp / 1_000_000).toFixed(bp < 10_000_000 ? 2 : 1)} Mb`;
133
+ }
134
+
135
+ // ---- State -------------------------------------------------------------
136
+ let gl, program;
137
+ let posBuf; // int16 interleaved x,y
138
+ let catBufs = {}; // { species|biotype|strand|gc|length: GLBuffer of uint8 }
139
+ let paletteTex;
140
+ let n = 0;
141
+ let labels = null; // see scripts/build_real_umap.py for the full schema
142
+ // Raw category bytes — kept on CPU side too for tooltip lookups.
143
+ let cats = { species: null, biotype: null, strand: null, gc: null, length: null };
144
+ // Per-point gene names — lazy-fetched from /umap_names AFTER the WebGL
145
+ // render is up so the heavy text strip never gates first paint. Stays
146
+ // null in the window between scatter render and names land; tooltip
147
+ // falls back to em-dash in that interval. Re-aligned to the shuffled
148
+ // order via `shufflePerm` so names line up with positions.
149
+ let names = null;
150
+ let shufflePerm = null;
151
+ // World bounds + current colorBy axis.
152
+ let bounds = [0,0,0,0];
153
+ let colorBy = "species";
154
+ // Viewport: translate (tx, ty) + scale around origin, in NDC space.
155
+ // The whole world is fit into [-0.95, 0.95]² at initial zoom.
156
+ let view = { tx: 0, ty: 0, scale: 1 };
157
+ let dpr = Math.max(1, window.devicePixelRatio || 1);
158
+ let needsRedraw = false;
159
+ // Spatial grid for hover (built once after data load, in normalized world space).
160
+ let grid = null;
161
+
162
+ function setStatus(state, text) {
163
+ if (!status) return;
164
+ status.classList.remove("streaming", "error");
165
+ if (state === "streaming") status.classList.add("streaming");
166
+ if (state === "error") status.classList.add("error");
167
+ statusText.textContent = text;
168
+ }
169
+
170
+ // ---- WebGL setup -------------------------------------------------------
171
+ const VS = `
172
+ attribute vec2 a_pos; // raw int16, normalized via attribPointer (-1..1)
173
+ attribute float a_cat; // category index (uint8 -> float)
174
+ uniform vec3 u_xform; // x: scale, y: tx, z: ty
175
+ uniform float u_pointSize;
176
+ varying float v_cat;
177
+ void main() {
178
+ vec2 world = a_pos * u_xform.x + vec2(u_xform.y, u_xform.z);
179
+ gl_Position = vec4(world, 0.0, 1.0);
180
+ gl_PointSize = u_pointSize;
181
+ v_cat = a_cat;
182
+ }
183
+ `;
184
+ const FS = `
185
+ precision mediump float;
186
+ varying float v_cat;
187
+ uniform sampler2D u_palette;
188
+ uniform float u_paletteN;
189
+ uniform float u_alpha;
190
+ void main() {
191
+ vec2 d = gl_PointCoord - 0.5;
192
+ float r = length(d);
193
+ float aa = smoothstep(0.50, 0.42, r);
194
+ if (aa <= 0.001) discard;
195
+ float a = aa * u_alpha;
196
+ float t = (v_cat + 0.5) / u_paletteN;
197
+ vec3 color = texture2D(u_palette, vec2(t, 0.5)).rgb;
198
+ // Pre-multiplied output matches blendFunc(ONE, ONE_MINUS_SRC_ALPHA)
199
+ // and prevents the dense-overlap brightening you get with straight
200
+ // alpha (which would need blendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)).
201
+ gl_FragColor = vec4(color * a, a);
202
+ }
203
+ `;
204
+ function compile(type, src) {
205
+ const sh = gl.createShader(type);
206
+ gl.shaderSource(sh, src);
207
+ gl.compileShader(sh);
208
+ if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
209
+ throw new Error("shader compile: " + gl.getShaderInfoLog(sh));
210
+ }
211
+ return sh;
212
+ }
213
+ function setupGL() {
214
+ gl = canvas.getContext("webgl", {
215
+ antialias: true, alpha: true, premultipliedAlpha: true,
216
+ preserveDrawingBuffer: false,
217
+ });
218
+ if (!gl) throw new Error("WebGL unavailable");
219
+ program = gl.createProgram();
220
+ gl.attachShader(program, compile(gl.VERTEX_SHADER, VS));
221
+ gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FS));
222
+ gl.linkProgram(program);
223
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
224
+ throw new Error("program link: " + gl.getProgramInfoLog(program));
225
+ }
226
+ gl.useProgram(program);
227
+
228
+ // Standard premultiplied-alpha additive-ish blending — points blend over
229
+ // the paper background and over each other cleanly at dense overlaps.
230
+ gl.enable(gl.BLEND);
231
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
232
+ // Transparent clear — the .umap-frame CSS background (paper tone) shows
233
+ // through, keeping the canvas in tune with the rest of the page.
234
+ gl.clearColor(0, 0, 0, 0);
235
+
236
+ paletteTex = gl.createTexture();
237
+ gl.bindTexture(gl.TEXTURE_2D, paletteTex);
238
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
239
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
240
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
241
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
242
+ }
243
+
244
+ function uploadPalette(palette) {
245
+ const n = palette.length;
246
+ const buf = new Uint8Array(n * 3);
247
+ for (let i = 0; i < n; i++) {
248
+ buf[i*3] = palette[i][0];
249
+ buf[i*3+1] = palette[i][1];
250
+ buf[i*3+2] = palette[i][2];
251
+ }
252
+ gl.bindTexture(gl.TEXTURE_2D, paletteTex);
253
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, n, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, buf);
254
+ gl.uniform1f(gl.getUniformLocation(program, "u_paletteN"), n);
255
+ }
256
+
257
+ // ---- Data load ---------------------------------------------------------
258
+ // Mulberry32: tiny seeded PRNG, ~10 lines, good enough for visual shuffling.
259
+ // Picked over Math.random() because we want the same layout across reloads
260
+ // (so users can describe what they see and we can reproduce it).
261
+ function mulberry32(seed) {
262
+ return function() {
263
+ seed = (seed + 0x6D2B79F5) | 0;
264
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
265
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
266
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
267
+ };
268
+ }
269
+
270
+ // Fisher-Yates over N parallel arrays: pos16 (2 entries / point, x then y)
271
+ // and catArrays (1 entry / point, e.g. species / biotype / strand / gc).
272
+ // Mutating the typed arrays in place avoids allocating a 16 MB reshuffled
273
+ // buffer — important at 571 K points.
274
+ //
275
+ // Returns a Uint32Array `perm` where perm[i] = original-index now sitting
276
+ // at slot i. We use it to re-align the deferred-loaded gene names strip
277
+ // onto the same shuffled order without re-running the PRNG (which would
278
+ // require keeping its state in sync, fragile).
279
+ function shuffleParallel(pos16, catArrays, n, seed) {
280
+ const rand = mulberry32(seed);
281
+ const perm = new Uint32Array(n);
282
+ for (let i = 0; i < n; i++) perm[i] = i;
283
+ for (let i = n - 1; i > 0; i--) {
284
+ const j = (rand() * (i + 1)) | 0;
285
+ if (i === j) continue;
286
+ const xi = pos16[2*i], yi = pos16[2*i + 1];
287
+ pos16[2*i] = pos16[2*j]; pos16[2*i + 1] = pos16[2*j + 1];
288
+ pos16[2*j] = xi; pos16[2*j + 1] = yi;
289
+ for (const a of catArrays) {
290
+ const t = a[i]; a[i] = a[j]; a[j] = t;
291
+ }
292
+ const pt = perm[i]; perm[i] = perm[j]; perm[j] = pt;
293
+ }
294
+ return perm;
295
+ }
296
+
297
+ async function loadData() {
298
+ setStatus("streaming", "loading…");
299
+ const t0 = performance.now();
300
+ const [binResp, labelsResp] = await Promise.all([
301
+ fetch("/umap"),
302
+ fetch("/umap_labels"),
303
+ ]);
304
+ if (!binResp.ok) throw new Error("fetch /umap failed: " + binResp.status);
305
+ const buf = await binResp.arrayBuffer();
306
+ labels = await labelsResp.json();
307
+
308
+ // Parse header (matches scripts/build_real_umap.py — 64-byte header).
309
+ // Layout:
310
+ // u32 [magic, n_points, n_species, n_biotypes, n_strands, flags] (24 b)
311
+ // f32 [x2d_min, x2d_max, y2d_min, y2d_max] (16 b)
312
+ // f32 [x3d_min, x3d_max, y3d_min, y3d_max, z3d_min, z3d_max] (24 b)
313
+ // flags bit0 = has_3D, bit1 = has gc_content, bit2 = has length.
314
+ const hdrU32 = new Uint32Array(buf, 0, 6);
315
+ const magic = hdrU32[0];
316
+ if (magic !== 0xCAB0FA1D) throw new Error("bad magic: " + magic.toString(16));
317
+ n = hdrU32[1];
318
+ const flags = hdrU32[5];
319
+ const has3D = (flags & 0b001) !== 0;
320
+ const hasGC = (flags & 0b010) !== 0;
321
+ const hasLen = (flags & 0b100) !== 0;
322
+ const hdrF32 = new Float32Array(buf, 24, 10);
323
+ bounds = [hdrF32[0], hdrF32[1], hdrF32[2], hdrF32[3]];
324
+ // bounds_3d (hdrF32[4..10]) is parsed but unused — the v1 viewer
325
+ // renders the 2D projection only. Kept in the binary so a future
326
+ // 3D mode can switch attribute streams without re-fetching.
327
+
328
+ let off = 64;
329
+ const pos16 = new Int16Array(buf, off, n * 2); off += n * 2 * 2;
330
+ if (has3D) {
331
+ // Skip pos_3d (int16 × 3 × n). Loaded into RAM is unnecessary
332
+ // for v1 — the binary stays small enough that re-fetching for
333
+ // a 3D mode is fine, and skipping keeps GPU memory tight.
334
+ off += n * 3 * 2;
335
+ }
336
+ cats.species = new Uint8Array(buf, off, n); off += n;
337
+ cats.biotype = new Uint8Array(buf, off, n); off += n;
338
+ cats.strand = new Uint8Array(buf, off, n); off += n;
339
+ if (hasGC) {
340
+ cats.gc = new Uint8Array(buf, off, n); off += n;
341
+ }
342
+ if (hasLen) {
343
+ cats.length = new Uint8Array(buf, off, n); off += n;
344
+ }
345
+ const catKeys = ["species", "biotype", "strand"];
346
+ if (hasGC) catKeys.push("gc");
347
+ if (hasLen) catKeys.push("length");
348
+
349
+ // Deterministic shuffle of the parallel arrays. The binary is sorted by
350
+ // species (= order of viz.csv), so without this protein_coding (≈80% of
351
+ // points) systematically lands on top of the minority biotypes/rare
352
+ // species and visually erases them. A fixed seed keeps the layout stable
353
+ // across reloads — same dot in the same place every time. Mulberry32 is
354
+ // good enough and one line; Fisher-Yates over 571 K entries is ~30 ms.
355
+ shufflePerm = shuffleParallel(pos16, catKeys.map(k => cats[k]), n, 0xC4B0FA1D);
356
+
357
+ // Upload to GPU.
358
+ posBuf = gl.createBuffer();
359
+ gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
360
+ gl.bufferData(gl.ARRAY_BUFFER, pos16, gl.STATIC_DRAW);
361
+ for (const key of catKeys) {
362
+ const b = gl.createBuffer();
363
+ gl.bindBuffer(gl.ARRAY_BUFFER, b);
364
+ gl.bufferData(gl.ARRAY_BUFFER, cats[key], gl.STATIC_DRAW);
365
+ catBufs[key] = b;
366
+ }
367
+
368
+ // Wire attributes (position is constant; category attribute is rebound on toggle).
369
+ const posLoc = gl.getAttribLocation(program, "a_pos");
370
+ gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
371
+ gl.enableVertexAttribArray(posLoc);
372
+ // normalize=true → int16 mapped to [-1, 1] in shader — exactly the
373
+ // quantization we did in the Python packer.
374
+ gl.vertexAttribPointer(posLoc, 2, gl.SHORT, true, 0, 0);
375
+
376
+ // Build spatial grid (in [-1, 1]² normalized world space).
377
+ buildGrid(pos16);
378
+
379
+ // Annotations are computed *after* the data is in: the cluster centroids
380
+ // they pin to are read off the actual UMAP layout we just loaded, so
381
+ // labels point at where vertebrates / fungi / lncRNAs / etc. ended up
382
+ // landing in this run — not at hardcoded positions that would drift if
383
+ // Dana ever re-runs the projection with different params.
384
+ buildAnnotations(pos16);
385
+
386
+ elN.textContent = n.toLocaleString("en-US");
387
+ elN.classList.remove("muted");
388
+ elNsp.textContent = labels.species.length;
389
+ elNsp.classList.remove("muted");
390
+
391
+ const ms = (performance.now() - t0) | 0;
392
+ setStatus("idle", `loaded ${(n/1000)|0}k pts · ${ms} ms`);
393
+ info.textContent = `${n.toLocaleString("en-US")} sequences · ${labels.species.length} species · drag to pan, wheel to zoom`;
394
+ overlay.classList.add("hidden");
395
+
396
+ return pos16;
397
+ }
398
+
399
+ // ---- Annotations -------------------------------------------------------
400
+ // Cluster names sit directly on top of each cluster centroid — no leader
401
+ // lines, no margin labels, no body copy. Each annotation is just a
402
+ // (mode, target, key) triple: the mode says which colorBy it applies
403
+ // to, target is a data-space (-1..1) point, key is the bold name we
404
+ // print on top of it. Different sets fire under different colorings
405
+ // (kingdoms under "species", RNA-class pockets under "biotype", etc.),
406
+ // and the labels track the cluster as the user pans/zooms.
407
+ //
408
+ // We render the label DOM once on data load and only mutate left/top
409
+ // per frame, so updateAnnotations() runs in <0.1 ms.
410
+ let annotations = [];
411
+
412
+ // Median over the (x, y) of points matching `predicate`. Median (vs mean)
413
+ // because every UMAP cluster has a long tail; we want the dot on the
414
+ // visible bulk, not drifting toward stragglers.
415
+ //
416
+ // Two-pass strategy: first try a strided sample (stride ≈ n/5000) to
417
+ // stay <1 ms on 571 K points for the common case. If the predicate's
418
+ // class is rare enough that the sample yields zero matches (e.g. viruses
419
+ // here = 21 points across 571 K, ~0.004%), fall back to a full scan with
420
+ // the same predicate — still <50 ms, and only happens once per dataset.
421
+ function clusterCentroid(pos16, predicate) {
422
+ const collect = (stride) => {
423
+ const xs = [], ys = [];
424
+ for (let i = 0; i < n; i += stride) {
425
+ if (!predicate(i)) continue;
426
+ xs.push(pos16[2*i] / 32767);
427
+ ys.push(pos16[2*i + 1] / 32767);
428
+ }
429
+ return [xs, ys];
430
+ };
431
+ const stride = Math.max(1, (n / 5000) | 0);
432
+ let [xs, ys] = collect(stride);
433
+ if (xs.length === 0 && stride > 1) [xs, ys] = collect(1);
434
+ if (xs.length === 0) return null;
435
+ xs.sort((a, b) => a - b);
436
+ ys.sort((a, b) => a - b);
437
+ const m = xs.length >> 1;
438
+ return [xs[m], ys[m]];
439
+ }
440
+
441
+ function buildAnnotations(pos16) {
442
+ // Species → kingdom mapping is shipped in umap_labels.json by
443
+ // scripts/build_real_umap.py (the same KINGDOMS dict that drives the
444
+ // species ordering / palette banding).
445
+ const speciesKingdom = labels.species_kingdom || {};
446
+ const kingdomOf = sIdx => speciesKingdom[labels.species[sIdx]] || null;
447
+
448
+ const ck = (k) => clusterCentroid(pos16, i => kingdomOf(cats.species[i]) === k);
449
+ const cb = (id) => clusterCentroid(pos16, i => cats.biotype[i] === id);
450
+ const gc = (lo, hi) => clusterCentroid(pos16, i => {
451
+ const t = cats.gc ? cats.gc[i] / 255 : 0.5;
452
+ return t >= lo && t <= hi;
453
+ });
454
+
455
+ // Each entry: a target in data-space NDC and the cluster name to
456
+ // print on top of it. No anchors, no body copy — the placement is
457
+ // entirely data-driven (clusterCentroid) and the editorial commentary
458
+ // lives in the "What to look for" prose under the chart.
459
+ //
460
+ // For "gc" we point at the high/low poles instead of the median (the
461
+ // median sits in the middle of the bulk, where a gradient label
462
+ // wouldn't help). For "strand" there's nothing to label — the
463
+ // interesting fact is the *absence* of structure, not a location.
464
+ annotations = [
465
+ // ---- species → kingdom macro-clusters
466
+ { mode: "species", target: ck("vertebrates"), key: "Vertebrates" },
467
+ { mode: "species", target: ck("invertebrates"), key: "Invertebrates" },
468
+ { mode: "species", target: ck("plants"), key: "Plants" },
469
+ { mode: "species", target: ck("fungi"), key: "Fungi" },
470
+ { mode: "species", target: ck("bacteria"), key: "Bacteria" },
471
+ { mode: "species", target: ck("viruses"), key: "Viruses" },
472
+
473
+ // ---- biotype → RNA-class pockets
474
+ { mode: "biotype", target: cb(0), key: "Protein-coding" },
475
+ { mode: "biotype", target: cb(1), key: "lncRNAs" },
476
+ { mode: "biotype", target: cb(2), key: "snRNAs" },
477
+ { mode: "biotype", target: cb(3), key: "misc_RNA" },
478
+
479
+ // ---- gc → composition poles. Thresholds picked from the actual
480
+ // gc histogram of this dataset (peak ≈ 0.4): low ≤ 0.25 grabs the
481
+ // AT-rich tail, high ≥ 0.60 grabs the GC-rich tail.
482
+ { mode: "gc", target: gc(0.0, 0.25), key: "AT-rich" },
483
+ { mode: "gc", target: gc(0.60, 1.0), key: "GC-rich" },
484
+ ].filter(a => a.target);
485
+
486
+ renderAnnotationsDOM();
487
+ updateAnnotations();
488
+ }
489
+
490
+ // Build the label DOM *once* per dataset — subsequent updates only
491
+ // mutate left/top, never innerHTML, so updateAnnotations() runs in
492
+ // <0.1 ms and never triggers a layout thrash.
493
+ function renderAnnotationsDOM() {
494
+ if (!annContainer) return;
495
+ annContainer.innerHTML = annotations
496
+ .map((a, i) => `<div id="ann-label-${i}" class="ann-label">${a.key}</div>`)
497
+ .join("");
498
+ }
499
+
500
+ // Per-frame: project each annotation's data-space target through the
501
+ // current view transform and place the label on top of it (CSS handles
502
+ // the centring via translate(-50%, -50%)). Annotations whose mode ≠
503
+ // colorBy or whose target sat off-canvas after pan/zoom get hidden.
504
+ function updateAnnotations() {
505
+ if (!annotations.length) return;
506
+ const rect = canvas.getBoundingClientRect();
507
+ const W = rect.width, H = rect.height;
508
+ if (W === 0 || H === 0) return;
509
+
510
+ const baseScale = 0.92;
511
+ const dataToScreen = (dx, dy) => [
512
+ ((dx * baseScale * view.scale + view.tx) + 1) / 2 * W,
513
+ (1 - (dy * baseScale * view.scale + view.ty)) / 2 * H,
514
+ ];
515
+ // Margin so a label whose centre is just past the edge still shows
516
+ // partially rather than popping. Tuned for the current font-size /
517
+ // halo combo; bump if you grow the type.
518
+ const margin = 60;
519
+
520
+ annotations.forEach((a, i) => {
521
+ const label = document.getElementById(`ann-label-${i}`);
522
+ if (!label) return;
523
+
524
+ const visible = a.mode === colorBy;
525
+ if (!visible) { label.style.display = "none"; return; }
526
+
527
+ const [tx, ty] = dataToScreen(a.target[0], a.target[1]);
528
+ if (tx < -margin || tx > W + margin || ty < -margin || ty > H + margin) {
529
+ label.style.display = "none";
530
+ return;
531
+ }
532
+ label.style.display = "";
533
+ label.style.left = tx + "px";
534
+ label.style.top = ty + "px";
535
+ });
536
+ }
537
+
538
+ // ---- Spatial grid (hover picking) --------------------------------------
539
+ // We store, per cell, a list of point indices whose normalized (x,y) falls
540
+ // in that cell. At hover, look up the cell under the cursor plus the 8
541
+ // neighbors, then scan for the nearest point within a screen-space radius.
542
+ const GRID_N = 128;
543
+ function buildGrid(pos16) {
544
+ const cells = new Array(GRID_N * GRID_N);
545
+ for (let i = 0; i < cells.length; i++) cells[i] = null;
546
+ for (let i = 0; i < n; i++) {
547
+ // pos16 entries are in [-32767, 32767] → normalize to [0, GRID_N).
548
+ const x = (pos16[2*i] + 32767) / 65534;
549
+ const y = (pos16[2*i + 1] + 32767) / 65534;
550
+ const cx = Math.min(GRID_N - 1, Math.max(0, (x * GRID_N) | 0));
551
+ const cy = Math.min(GRID_N - 1, Math.max(0, (y * GRID_N) | 0));
552
+ const id = cy * GRID_N + cx;
553
+ const list = cells[id];
554
+ if (list === null) cells[id] = [i];
555
+ else list.push(i);
556
+ }
557
+ grid = cells;
558
+ }
559
+
560
+ // ---- Render ------------------------------------------------------------
561
+ function resize() {
562
+ const rect = canvas.getBoundingClientRect();
563
+ if (rect.width === 0 || rect.height === 0) return false;
564
+ dpr = Math.max(1, window.devicePixelRatio || 1);
565
+ const w = Math.round(rect.width * dpr);
566
+ const h = Math.round(rect.height * dpr);
567
+ if (canvas.width !== w || canvas.height !== h) {
568
+ canvas.width = w; canvas.height = h;
569
+ }
570
+ gl.viewport(0, 0, w, h);
571
+ return true;
572
+ }
573
+
574
+ let lastFrameTs = 0, frameCount = 0, fpsTs = 0;
575
+ function draw() {
576
+ needsRedraw = false;
577
+ if (!resize()) return;
578
+ gl.clear(gl.COLOR_BUFFER_BIT);
579
+
580
+ // The vertex shader does world = pos * scale + (tx, ty). We choose scale
581
+ // so the data (normalized to [-1, 1]) fits in [-0.92, 0.92] of NDC at
582
+ // zoom 1, with a tiny margin so points at the edge aren't clipped.
583
+ const baseScale = 0.92;
584
+ gl.uniform3f(gl.getUniformLocation(program, "u_xform"),
585
+ baseScale * view.scale, view.tx, view.ty);
586
+ // Point size scales sub-linearly with zoom — denser areas stay readable
587
+ // but the dots get visibly bigger when you zoom in.
588
+ const ps = Math.min(8.0, Math.max(1.4, 1.4 + 0.6 * Math.log2(view.scale + 1))) * dpr;
589
+ gl.uniform1f(gl.getUniformLocation(program, "u_pointSize"), ps);
590
+ // Alpha rises with zoom so individual dots stay readable, but starts low
591
+ // so the dense 571 K cloud doesn't blow out at zoom 1.
592
+ const alpha = Math.min(0.85, Math.max(0.22, 0.22 + 0.20 * Math.log2(view.scale + 1)));
593
+ gl.uniform1f(gl.getUniformLocation(program, "u_alpha"), alpha);
594
+
595
+ gl.drawArrays(gl.POINTS, 0, n);
596
+
597
+ // Annotation overlay — labels and leader lines anchored to data-space
598
+ // centroids. Cheap (<0.1 ms for ~5 visible labels), so we just run it
599
+ // every frame instead of trying to detect view changes.
600
+ updateAnnotations();
601
+
602
+ // FPS counter — sampled, not per-frame.
603
+ const now = performance.now();
604
+ frameCount++;
605
+ if (now - fpsTs > 500) {
606
+ const fps = (frameCount * 1000) / (now - fpsTs);
607
+ elFps.textContent = `${fps.toFixed(0)} fps`;
608
+ elFps.classList.remove("muted");
609
+ fpsTs = now;
610
+ frameCount = 0;
611
+ }
612
+ lastFrameTs = now;
613
+ }
614
+ function requestRedraw() {
615
+ if (needsRedraw) return;
616
+ needsRedraw = true;
617
+ requestAnimationFrame(draw);
618
+ }
619
+
620
+ // ---- Color toggle ------------------------------------------------------
621
+ function setColorBy(key) {
622
+ colorBy = key;
623
+ const catLoc = gl.getAttribLocation(program, "a_cat");
624
+ gl.bindBuffer(gl.ARRAY_BUFFER, catBufs[key]);
625
+ gl.enableVertexAttribArray(catLoc);
626
+ // Unnormalized — we want the raw byte value in the shader.
627
+ gl.vertexAttribPointer(catLoc, 1, gl.UNSIGNED_BYTE, false, 0, 0);
628
+ uploadPalette(PALETTES[key]);
629
+ renderLegend();
630
+ requestRedraw();
631
+ }
632
+
633
+ // ---- Legend ------------------------------------------------------------
634
+ // Render a continuous-gradient legend bar for one of the two continuous
635
+ // overlays (gc, length). Both share the same SVG shape; they just differ
636
+ // in palette and tick labels — so we factor the duplication out.
637
+ function renderGradientLegend(palette, ticksHtml) {
638
+ const uid = Math.random().toString(36).slice(2, 8);
639
+ const stops = palette
640
+ .filter((_, i) => i % 8 === 0) // 32 stops is plenty for a 1D bar
641
+ .map((c, i, a) => `<stop offset="${(i / (a.length - 1)) * 100}%" stop-color="rgb(${c[0]},${c[1]},${c[2]})"/>`)
642
+ .join("");
643
+ legend.innerHTML =
644
+ `<span class="item gc-grad">
645
+ <svg width="160" height="10" aria-hidden="true">
646
+ <defs><linearGradient id="umap-grad-${uid}" x1="0" x2="1">${stops}</linearGradient></defs>
647
+ <rect width="160" height="10" fill="url(#umap-grad-${uid})"/>
648
+ </svg>
649
+ <span class="gc-ticks">${ticksHtml}</span>
650
+ </span>`;
651
+ }
652
+ function renderLegend() {
653
+ if (!labels) return;
654
+ // gc_content is continuous — render a horizontal gradient bar with
655
+ // 0.0 / 0.5 / 1.0 ticks instead of one swatch per value (would be
656
+ // 256 entries, useless visually).
657
+ if (colorBy === "gc") {
658
+ renderGradientLegend(GC_PALETTE, "0.0 &middot; 0.5 &middot; 1.0");
659
+ return;
660
+ }
661
+ if (colorBy === "length") {
662
+ // Tick labels span the full bp range using formatBp(). Geometric
663
+ // midpoint (sqrt of low × high) rather than arithmetic so the
664
+ // middle tick lands at the *log-scale* centre of the gradient,
665
+ // which is what the colour ramp is keyed on.
666
+ const lr = labels.length_bp_range;
667
+ const ticks = lr
668
+ ? `${formatBp(lr[0])} &middot; ${formatBp(Math.round(Math.sqrt(lr[0] * lr[1])))} &middot; ${formatBp(lr[1])}`
669
+ : "short &middot; mid &middot; long";
670
+ renderGradientLegend(LENGTH_PALETTE, ticks);
671
+ return;
672
+ }
673
+ const palette = PALETTES[colorBy];
674
+ const itemLabels = (colorBy === "species") ? labels.species
675
+ : (colorBy === "biotype") ? labels.biotypes
676
+ : labels.strands;
677
+ legend.innerHTML = itemLabels.map((name, i) => {
678
+ const [r, g, b] = palette[i % palette.length];
679
+ return `<span class="item"><span class="swatch" style="background:rgb(${r},${g},${b})"></span>${name}</span>`;
680
+ }).join("");
681
+ }
682
+
683
+ // ---- Pan / zoom / hover ------------------------------------------------
684
+ // Reset is a no-op when we're already at the fit-the-data view, so the
685
+ // button switches to a disabled state in that case — same affordance as
686
+ // a back-button greying out at the top of the history stack. Avoids a
687
+ // distracting always-active control on first paint.
688
+ function updateResetEnabled() {
689
+ if (!resetBtn) return;
690
+ const atDefault = view.tx === 0 && view.ty === 0 && view.scale === 1;
691
+ resetBtn.disabled = atDefault;
692
+ }
693
+ function resetView() {
694
+ view = { tx: 0, ty: 0, scale: 1 };
695
+ updateResetEnabled();
696
+ requestRedraw();
697
+ }
698
+
699
+ // Keep the viewport always full of data. The data spans [-0.92, 0.92]·scale
700
+ // in world space; the viewport spans [-1, 1]. As long as 0.92·scale ≥ 1
701
+ // (zoom ≥ ~1.087), there's "slack" we can pan within: |tx| ≤ 0.92·scale-1.
702
+ // Below that — i.e. at minimum zoom where the UMAP fits the viewport with
703
+ // margin — we snap to (0, 0) so the data stays centered and no white edge
704
+ // creeps in. Paired with the scale clamp in the wheel handler, this means
705
+ // "fully zoomed out" = "UMAP exactly fit, perfectly centered".
706
+ function clampPan() {
707
+ const m = Math.max(0, 0.92 * view.scale - 1);
708
+ if (m === 0) {
709
+ view.tx = 0; view.ty = 0;
710
+ } else {
711
+ view.tx = Math.max(-m, Math.min(m, view.tx));
712
+ view.ty = Math.max(-m, Math.min(m, view.ty));
713
+ }
714
+ }
715
+
716
+ // Convert a clientX/Y to NDC (-1..1) and to normalized data space ([-1, 1]).
717
+ function clientToNDC(e) {
718
+ const rect = canvas.getBoundingClientRect();
719
+ return {
720
+ x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
721
+ y: -((e.clientY - rect.top) / rect.height) * 2 + 1,
722
+ };
723
+ }
724
+ function ndcToData(ndc) {
725
+ const baseScale = 0.92;
726
+ return {
727
+ x: (ndc.x - view.tx) / (baseScale * view.scale),
728
+ y: (ndc.y - view.ty) / (baseScale * view.scale),
729
+ };
730
+ }
731
+
732
+ let panning = false, panLast = null;
733
+ canvas.addEventListener("pointerdown", e => {
734
+ canvas.setPointerCapture(e.pointerId);
735
+ panning = true;
736
+ panLast = { x: e.clientX, y: e.clientY };
737
+ canvas.classList.add("panning");
738
+ hideTooltip();
739
+ });
740
+ canvas.addEventListener("pointermove", e => {
741
+ if (panning) {
742
+ const rect = canvas.getBoundingClientRect();
743
+ const dx = ((e.clientX - panLast.x) / rect.width) * 2;
744
+ const dy = -((e.clientY - panLast.y) / rect.height) * 2;
745
+ view.tx += dx; view.ty += dy;
746
+ clampPan();
747
+ updateResetEnabled();
748
+ panLast = { x: e.clientX, y: e.clientY };
749
+ requestRedraw();
750
+ } else {
751
+ handleHover(e);
752
+ }
753
+ });
754
+ function endPan(e) {
755
+ if (!panning) return;
756
+ panning = false;
757
+ canvas.classList.remove("panning");
758
+ try { canvas.releasePointerCapture(e.pointerId); } catch {}
759
+ }
760
+ canvas.addEventListener("pointerup", endPan);
761
+ canvas.addEventListener("pointercancel", endPan);
762
+ canvas.addEventListener("pointerleave", () => hideTooltip());
763
+
764
+ canvas.addEventListener("wheel", e => {
765
+ e.preventDefault();
766
+ const ndc = clientToNDC(e);
767
+ // Zoom factor — natural feeling on both trackpad and mouse wheel.
768
+ const factor = Math.exp(-e.deltaY * 0.0018);
769
+ // Min scale = 1 means "fully zoomed out = UMAP fits the viewport". We
770
+ // intentionally don't let the visitor zoom out further: there's no
771
+ // information past the data bounds, and the empty margin makes the
772
+ // dataset feel small. Max 50× keeps individual points pickable.
773
+ const newScale = Math.min(50, Math.max(1, view.scale * factor));
774
+ const k = newScale / view.scale;
775
+ // Zoom around the cursor: shift translate so the point under the cursor
776
+ // stays under the cursor.
777
+ view.tx = ndc.x - (ndc.x - view.tx) * k;
778
+ view.ty = ndc.y - (ndc.y - view.ty) * k;
779
+ view.scale = newScale;
780
+ clampPan();
781
+ updateResetEnabled();
782
+ requestRedraw();
783
+ hideTooltip();
784
+ }, { passive: false });
785
+
786
+ resetBtn.addEventListener("click", resetView);
787
+
788
+ // ---- Hover picking -----------------------------------------------------
789
+ // De-quantise a uint8 length byte back to bp. Inverse of the
790
+ // packing step in scripts/build_real_umap.py:
791
+ // bp = round(10 ** (log_min + b/255 * (log_max - log_min)))
792
+ function lengthBpAt(idx) {
793
+ if (!cats.length || !labels || !labels.length_log10_range) return null;
794
+ const [lo, hi] = labels.length_log10_range;
795
+ const t = cats.length[idx] / 255;
796
+ return Math.round(Math.pow(10, lo + t * (hi - lo)));
797
+ }
798
+ function escapeHtml(s) {
799
+ return String(s).replace(/[&<>"']/g, (c) => (
800
+ { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]
801
+ ));
802
+ }
803
+ function showTooltip(idx, x, y) {
804
+ const sp = labels.species[cats.species[idx]];
805
+ const bt = labels.biotypes[cats.biotype[idx]];
806
+ const st = labels.strands[cats.strand[idx]];
807
+ const gc = cats.gc ? (cats.gc[idx] / 255).toFixed(2) : "—";
808
+ const bp = lengthBpAt(idx);
809
+ const lenStr = bp == null ? "—" : formatBp(bp);
810
+ // `names` may still be null if the user hovers very early (race
811
+ // between WebGL paint and /umap_names landing) — fall back to em-
812
+ // dash; the row will silently fill in once the strip arrives.
813
+ const nameStr = names ? escapeHtml(names[idx] || "—") : "—";
814
+ tooltip.innerHTML =
815
+ `<div><span class="t-label">name</span>${nameStr}</div>` +
816
+ `<div><span class="t-label">species</span>${sp}</div>` +
817
+ `<div><span class="t-label">biotype</span>${bt}</div>` +
818
+ `<div><span class="t-label">length</span>${lenStr}</div>` +
819
+ `<div><span class="t-label">strand</span>${st} &nbsp; <span class="t-label">gc</span>${gc}</div>`;
820
+ tooltip.style.left = x + "px";
821
+ tooltip.style.top = y + "px";
822
+ tooltip.classList.add("visible");
823
+ }
824
+ function hideTooltip() { tooltip.classList.remove("visible"); }
825
+
826
+ function handleHover(e) {
827
+ if (!grid) return;
828
+ const ndc = clientToNDC(e);
829
+ const data = ndcToData(ndc);
830
+ // Convert data-space (-1..1) into grid coords.
831
+ const gx = (data.x + 1) * 0.5 * GRID_N;
832
+ const gy = (data.y + 1) * 0.5 * GRID_N;
833
+ const cx = Math.floor(gx), cy = Math.floor(gy);
834
+ if (cx < -1 || cx > GRID_N || cy < -1 || cy > GRID_N) return hideTooltip();
835
+
836
+ // Adaptive search radius: at higher zoom, we want a tighter pick radius.
837
+ // ~8px screen radius converted to data space.
838
+ const rect = canvas.getBoundingClientRect();
839
+ const screenR = 8;
840
+ const dataR = (screenR / rect.width) * 2 / (0.92 * view.scale);
841
+ const dataR2 = dataR * dataR;
842
+
843
+ let best = -1, bestD2 = dataR2;
844
+ const cellSpan = Math.max(1, Math.ceil(dataR * GRID_N * 0.5) + 1);
845
+ for (let dy = -cellSpan; dy <= cellSpan; dy++) {
846
+ const yy = cy + dy;
847
+ if (yy < 0 || yy >= GRID_N) continue;
848
+ for (let dx = -cellSpan; dx <= cellSpan; dx++) {
849
+ const xx = cx + dx;
850
+ if (xx < 0 || xx >= GRID_N) continue;
851
+ const list = grid[yy * GRID_N + xx];
852
+ if (!list) continue;
853
+ for (let k = 0; k < list.length; k++) {
854
+ const idx = list[k];
855
+ // Recompute the point's normalized [-1, 1] position from posBuf16
856
+ // — we don't keep it on CPU, but we can re-derive from int16 cheaply.
857
+ const px = posSnapshot[2*idx] / 32767;
858
+ const py = posSnapshot[2*idx + 1] / 32767;
859
+ const ex = px - data.x, ey = py - data.y;
860
+ const d2 = ex*ex + ey*ey;
861
+ if (d2 < bestD2) { bestD2 = d2; best = idx; }
862
+ }
863
+ }
864
+ }
865
+ if (best === -1) return hideTooltip();
866
+ // Place tooltip near cursor, offset to the right & above.
867
+ const relX = e.clientX - rect.left;
868
+ const relY = e.clientY - rect.top;
869
+ showTooltip(best, relX, relY);
870
+ }
871
+
872
+ // We need an unattached CPU-side copy of positions for hover hit-testing
873
+ // because WebGL buffers aren't readable from JS without a roundtrip.
874
+ let posSnapshot = null;
875
+
876
+ // ---- Bootstrap ---------------------------------------------------------
877
+ setupGL();
878
+
879
+ colorPills.forEach(p => {
880
+ p.addEventListener("click", () => {
881
+ colorPills.forEach(x => x.classList.toggle("active", x === p));
882
+ setColorBy(p.dataset.color);
883
+ });
884
+ });
885
+
886
+ // Defer loading until the umap section is near the viewport — 571K points
887
+ // doesn't need to fight for bandwidth on first paint.
888
+ const io = new IntersectionObserver(async (entries) => {
889
+ if (!entries[0].isIntersecting) return;
890
+ io.disconnect();
891
+ try {
892
+ const pos16 = await loadData();
893
+ posSnapshot = pos16;
894
+ setColorBy("species"); // initial coloring + first draw
895
+
896
+ // Two-phase load: heavy gene-name strip (~6.5 MB plain text,
897
+ // ~1.9 MB gzipped) lands AFTER the WebGL render is up. The
898
+ // tooltip silently upgrades from "—" to the real name as soon
899
+ // as it's parsed and re-aligned to the shuffled order. Failures
900
+ // here are non-fatal — the scatter still works without names.
901
+ if (labels && labels.has_names) {
902
+ fetch("/umap_names")
903
+ .then(r => r.ok ? r.text() : Promise.reject(new Error("names " + r.status)))
904
+ .then(txt => {
905
+ const raw = txt.split("\n");
906
+ if (raw.length < n) {
907
+ console.warn(`/umap_names short: ${raw.length} < ${n}, ignoring`);
908
+ return;
909
+ }
910
+ const aligned = new Array(n);
911
+ for (let i = 0; i < n; i++) aligned[i] = raw[shufflePerm[i]];
912
+ names = aligned;
913
+ })
914
+ .catch(err => console.warn("gene names load failed:", err));
915
+ }
916
+ } catch (err) {
917
+ console.error(err);
918
+ setStatus("error", "load failed");
919
+ overlay.textContent = "load failed · " + err.message;
920
+ }
921
+ }, { rootMargin: "400px" });
922
+ io.observe(canvas);
923
+
924
+ window.addEventListener("resize", () => requestRedraw());
925
+ })();
926
+
assets/js/sections/vep.js ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // §2 — VEP: ref vs alt allele likelihood
3
+ // =========================================================================
4
+ (function initDemo2() {
5
+ const els = {
6
+ pills: document.getElementById("d2-pills"),
7
+ info: document.getElementById("d2-info"),
8
+ window: document.getElementById("d2-window"),
9
+ result: document.getElementById("d2-result"),
10
+ bars: document.getElementById("d2-bars"),
11
+ go: document.getElementById("d2-go"),
12
+ all: document.getElementById("d2-all"),
13
+ status: document.getElementById("d2-status"),
14
+ statusText: document.querySelector("#d2-status span:last-child"),
15
+ };
16
+
17
+ let VARIANTS = null;
18
+ let selected = null;
19
+ const cache = {}; // by rs id → { refSum, altSum, refLps, altLps }
20
+
21
+ function setStatus(text, mode = "") {
22
+ els.statusText.textContent = text;
23
+ els.status.className = "status" + (mode ? " " + mode : "");
24
+ }
25
+
26
+ function altWindow(v) {
27
+ return v.ref_window.slice(0, v.var_offset) + v.alt + v.ref_window.slice(v.var_offset + 1);
28
+ }
29
+
30
+ function renderWindowDisplay(v, mode = "ref") {
31
+ if (!v) { els.window.innerHTML = "—"; return; }
32
+ const left = v.ref_window.slice(0, v.var_offset);
33
+ const right = v.ref_window.slice(v.var_offset + 1);
34
+ const cls = mode === "ref" ? "var-ref" : "var-alt";
35
+ const base = mode === "ref" ? v.ref : v.alt;
36
+ els.window.innerHTML = `<span class="ctx">${left}</span><span class="${cls}">${base}</span><span class="ctx">${right}</span>`;
37
+ }
38
+
39
+ function renderResult(v) {
40
+ if (!v) { els.result.innerHTML = ""; return; }
41
+ const c = cache[v.rs];
42
+ if (!c) {
43
+ els.result.innerHTML = `<div style="grid-column:1/-1;color:#aaa;font-style:italic">click "score" to compute likelihoods…</div>`;
44
+ return;
45
+ }
46
+ // Map sums to a common scale: take min/max across both for visual ratio.
47
+ const lo = Math.min(c.refSum, c.altSum);
48
+ const hi = Math.max(c.refSum, c.altSum);
49
+ // Logprobs are negative; scale bar width as |sum| / |min(both)| so the more-negative gets a longer bar.
50
+ const range = Math.abs(lo);
51
+ const wRef = (Math.abs(c.refSum) / range) * 100;
52
+ const wAlt = (Math.abs(c.altSum) / range) * 100;
53
+ const delta = c.altSum - c.refSum;
54
+ const dColor = delta < -0.5 ? "#bc2e25" : (delta > 0.5 ? "#317f3f" : "#888");
55
+ els.result.innerHTML = `
56
+ <div class="row-label">ref · ${v.ref}</div>
57
+ <div class="row-bar ref"><div class="fill" style="width:${wRef.toFixed(1)}%"></div></div>
58
+ <div class="row-val">${c.refSum.toFixed(2)}</div>
59
+ <div class="row-label">alt · ${v.alt}</div>
60
+ <div class="row-bar alt"><div class="fill" style="width:${wAlt.toFixed(1)}%"></div></div>
61
+ <div class="row-val">${c.altSum.toFixed(2)}</div>
62
+ <div class="row-label">Δ</div>
63
+ <div></div>
64
+ <div class="row-val row-delta" style="color:${dColor}">${(delta >= 0 ? "+" : "") + delta.toFixed(2)}</div>
65
+ `;
66
+ }
67
+
68
+ function renderForestBars() {
69
+ if (!VARIANTS) return;
70
+ const W = 1000, rowH = 32, padL = 280, padR = 60, padT = 36, padB = 50;
71
+ // Sort variants by Δ ascending (most surprising-to-the-model first), but
72
+ // keep unscored ones at the bottom in their original order.
73
+ const indexed = VARIANTS.map((v, i) => ({ v, idx: i, d: cache[v.rs] ? cache[v.rs].altSum - cache[v.rs].refSum : null }));
74
+ const scored = indexed.filter(x => x.d != null).sort((a, b) => a.d - b.d);
75
+ const unscored = indexed.filter(x => x.d == null);
76
+ const ordered = scored.concat(unscored);
77
+ const H = padT + ordered.length * rowH + padB;
78
+ els.bars.setAttribute("viewBox", `0 0 ${W} ${H}`);
79
+ els.bars.setAttribute("height", H);
80
+
81
+ if (!scored.length) {
82
+ els.bars.innerHTML = `<text x="${W/2}" y="${H/2}" text-anchor="middle" font-family="JetBrains Mono" font-size="11" fill="#bbb">— score variants to populate the comparison —</text>`;
83
+ return;
84
+ }
85
+ const absMax = Math.max(2, ...scored.map(x => Math.abs(x.d)));
86
+ const innerW = W - padL - padR;
87
+ const center = padL + innerW / 2;
88
+ const scale = (innerW / 2) / absMax;
89
+ const sigColor = (s) => s === "Pathogenic" ? "#bc2e25" : s === "Benign" ? "#317f3f" : "#e69500";
90
+
91
+ // Bar color: encode the *model's* opinion of the alt allele
92
+ // - Δ < 0 : red (model finds alt unusual). Saturation ~ |Δ|.
93
+ // - Δ > 0 : charcoal (model is fine with / prefers alt).
94
+ // - |Δ| ≈ 0 : muted gray.
95
+ function barColor(d) {
96
+ const ad = Math.abs(d);
97
+ if (ad < 0.5) return "#bbb";
98
+ const t = Math.min(1, ad / 4); // 4 = saturation point; bigger Δ doesn't get redder
99
+ if (d < 0) {
100
+ // gray → red
101
+ return `rgb(${lerp(170, 216, t)},${lerp(170, 58, t)},${lerp(170, 42, t)})`;
102
+ }
103
+ // gray → charcoal
104
+ return `rgb(${lerp(170, 40, t)},${lerp(170, 40, t)},${lerp(170, 40, t)})`;
105
+ }
106
+ const VALUE_INSIDE_MIN = 44;
107
+
108
+ let svg = "";
109
+
110
+ // --- Top axis: directional caption ---
111
+ svg += `<text x="${padL.toFixed(1)}" y="14" font-family="JetBrains Mono" font-size="9" fill="#bc2e25" letter-spacing="1">← MODEL SURPRISED BY ALT</text>`;
112
+ svg += `<text x="${(W - padR).toFixed(1)}" y="14" font-family="JetBrains Mono" font-size="9" fill="#333" letter-spacing="1" text-anchor="end">MODEL FINE WITH ALT →</text>`;
113
+ svg += `<text x="${center.toFixed(1)}" y="14" font-family="JetBrains Mono" font-size="9" fill="#888" text-anchor="middle" letter-spacing="1">Δ (alt − ref)</text>`;
114
+ svg += `<text x="${(padL - 12).toFixed(1)}" y="14" font-family="JetBrains Mono" font-size="9" fill="#888" text-anchor="end" letter-spacing="1">VARIANT</text>`;
115
+
116
+ // Faint shading: pathogenic-expected zone (left of 0)
117
+ svg += `<rect x="${padL.toFixed(1)}" y="${(padT - 4).toFixed(1)}" width="${(center - padL).toFixed(1)}" height="${(ordered.length * rowH + 8).toFixed(1)}" fill="#bc2e25" opacity="0.04"/>`;
118
+
119
+ // Center line
120
+ svg += `<line x1="${center}" y1="${padT - 4}" x2="${center}" y2="${H - padB + 4}" stroke="#bbb" stroke-width="1"/>`;
121
+ // Axis ticks
122
+ for (const t of [-absMax, -absMax/2, 0, absMax/2, absMax]) {
123
+ const x = center + t * scale;
124
+ svg += `<line x1="${x.toFixed(1)}" y1="${(H - padB).toFixed(1)}" x2="${x.toFixed(1)}" y2="${(H - padB + 4).toFixed(1)}" stroke="#aaa"/>`;
125
+ svg += `<text x="${x.toFixed(1)}" y="${(H - padB + 14).toFixed(1)}" font-family="JetBrains Mono" font-size="9" fill="#888" text-anchor="middle">${t.toFixed(1)}</text>`;
126
+ }
127
+
128
+ // --- Rows ---
129
+ ordered.forEach(({ v, d }, i) => {
130
+ const y = padT + i * rowH + rowH / 2;
131
+
132
+ // Curated category dot next to the variant name
133
+ const dotR = 4;
134
+ const dotX = padL - 12 - dotR;
135
+ svg += `<circle cx="${dotX.toFixed(1)}" cy="${(y - 0.5).toFixed(1)}" r="${dotR}" fill="${sigColor(v.sig)}"><title>${v.sig}</title></circle>`;
136
+
137
+ // Variant name + tiny category label
138
+ svg += `<text x="${(dotX - dotR - 6).toFixed(1)}" y="${(y - 1).toFixed(1)}" font-family="JetBrains Mono" font-size="11" fill="#222" text-anchor="end">${v.name}</text>`;
139
+ svg += `<text x="${(dotX - dotR - 6).toFixed(1)}" y="${(y + 11).toFixed(1)}" font-family="JetBrains Mono" font-size="9" fill="${sigColor(v.sig)}" text-anchor="end">${v.sig.toLowerCase()}</text>`;
140
+
141
+ if (d == null) {
142
+ svg += `<text x="${(center + 6).toFixed(1)}" y="${(y + 4).toFixed(1)}" font-family="JetBrains Mono" font-size="10" fill="#ccc">— not scored —</text>`;
143
+ return;
144
+ }
145
+
146
+ const x = center + d * scale;
147
+ const color = barColor(d);
148
+ const barX = Math.min(center, x);
149
+ const barW = Math.max(2, Math.abs(x - center));
150
+ svg += `<rect x="${barX.toFixed(1)}" y="${(y - 8).toFixed(1)}" width="${barW.toFixed(1)}" height="14" fill="${color}" stroke="${v === selected ? '#1f1f1d' : 'none'}" stroke-width="${v === selected ? 1 : 0}"/>`;
151
+
152
+ const label = (d >= 0 ? "+" : "") + d.toFixed(2);
153
+ const insideOK = barW >= VALUE_INSIDE_MIN && Math.abs(d) >= 0.5; // color is dark enough only away from neutral
154
+ if (insideOK) {
155
+ const tx = x + (d >= 0 ? -5 : 5);
156
+ const anchor = d >= 0 ? "end" : "start";
157
+ svg += `<text x="${tx.toFixed(1)}" y="${(y + 3.5).toFixed(1)}" font-family="JetBrains Mono" font-size="10" fill="#fff" text-anchor="${anchor}" font-weight="500">${label}</text>`;
158
+ } else {
159
+ const tx = x + (d >= 0 ? 5 : -5);
160
+ const anchor = d >= 0 ? "start" : "end";
161
+ svg += `<text x="${tx.toFixed(1)}" y="${(y + 3.5).toFixed(1)}" font-family="JetBrains Mono" font-size="10" fill="#333" text-anchor="${anchor}">${label}</text>`;
162
+ }
163
+ });
164
+
165
+ // --- Bottom caption ---
166
+ const capY = H - padB + 32;
167
+ svg += `<text x="${padL.toFixed(1)}" y="${capY}" font-family="JetBrains Mono" font-size="9" fill="#888" letter-spacing="0.5">expected for pathogenic loss-of-function: Δ ≪ 0 · expected for benign / common: Δ ≈ 0</text>`;
168
+
169
+ els.bars.innerHTML = svg;
170
+ }
171
+
172
+ async function scoreOne(v) {
173
+ if (cache[v.rs]) return cache[v.rs];
174
+ const ref = v.ref_window;
175
+ const alt = altWindow(v);
176
+ // Score both in parallel
177
+ const [refResp, altResp] = await Promise.all([
178
+ fetch("/score", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ sequence: ref }) }).then(r => r.json()),
179
+ fetch("/score", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ sequence: alt }) }).then(r => r.json()),
180
+ ]);
181
+ if (refResp.error) throw new Error("ref: " + refResp.error);
182
+ if (altResp.error) throw new Error("alt: " + altResp.error);
183
+ const sumLp = (lps) => {
184
+ let s = 0, n = 0;
185
+ for (const lp of lps) {
186
+ if (lp != null && !isNaN(lp)) { s += lp; n++; }
187
+ }
188
+ return { sum: s, n };
189
+ };
190
+ const r = sumLp(refResp.token_logprobs);
191
+ const a = sumLp(altResp.token_logprobs);
192
+ const result = {
193
+ refSum: r.sum, altSum: a.sum, n: r.n,
194
+ refLps: refResp.token_logprobs, altLps: altResp.token_logprobs,
195
+ };
196
+ cache[v.rs] = result;
197
+ return result;
198
+ }
199
+
200
+ async function scoreSelected() {
201
+ if (!selected) return;
202
+ setStatus(`scoring ${selected.name}…`, "streaming");
203
+ els.go.disabled = true; els.all.disabled = true;
204
+ try {
205
+ await scoreOne(selected);
206
+ renderResult(selected);
207
+ renderForestBars();
208
+ setStatus("done");
209
+ } catch (e) {
210
+ setStatus(e.message, "error");
211
+ } finally {
212
+ els.go.disabled = false; els.all.disabled = false;
213
+ }
214
+ }
215
+
216
+ async function scoreAll() {
217
+ setStatus("scoring all…", "streaming");
218
+ els.go.disabled = true; els.all.disabled = true;
219
+ try {
220
+ // Sequential to be polite to the endpoint and to allow incremental UI updates.
221
+ for (const v of VARIANTS) {
222
+ if (cache[v.rs]) continue;
223
+ await scoreOne(v);
224
+ renderForestBars();
225
+ }
226
+ if (selected) renderResult(selected);
227
+ setStatus("done");
228
+ } catch (e) {
229
+ setStatus(e.message, "error");
230
+ } finally {
231
+ els.go.disabled = false; els.all.disabled = false;
232
+ }
233
+ }
234
+
235
+ function selectVariant(rs) {
236
+ const v = VARIANTS.find(x => x.rs === rs);
237
+ if (!v) return;
238
+ selected = v;
239
+ els.pills.querySelectorAll(".pill").forEach(p => p.classList.toggle("active", p.dataset.rs === rs));
240
+ els.info.innerHTML = `<strong>${v.name}</strong> · ${v.blurb} · <span style="color:#888">chr${v.chrom}:${v.pos.toLocaleString("en-US")} · ${v.ref}>${v.alt} (gene strand)</span>`;
241
+ renderWindowDisplay(v, "ref");
242
+ renderResult(v);
243
+ renderForestBars();
244
+ }
245
+
246
+ fetch("/variants").then(r => r.json()).then(data => {
247
+ VARIANTS = data;
248
+ // Hydrate cache from precomputed scores if present
249
+ for (const v of data) {
250
+ if (v.score) {
251
+ cache[v.rs] = {
252
+ refSum: v.score.ref_sum,
253
+ altSum: v.score.alt_sum,
254
+ refLps: v.score.ref_logprobs,
255
+ altLps: v.score.alt_logprobs,
256
+ n: v.score.n,
257
+ };
258
+ }
259
+ }
260
+ els.pills.innerHTML = data.map((v, i) =>
261
+ `<button class="pill sig-${v.sig}${i === 0 ? " active" : ""}" data-rs="${v.rs}" title="${v.blurb}">${v.gene} ${v.ref}>${v.alt}</button>`
262
+ ).join("");
263
+ els.pills.querySelectorAll(".pill").forEach(p => {
264
+ p.addEventListener("click", () => selectVariant(p.dataset.rs));
265
+ });
266
+ selectVariant(data[0].rs);
267
+ }).catch(e => {
268
+ els.info.textContent = "failed to load variants: " + e.message;
269
+ });
270
+
271
+ els.go.addEventListener("click", scoreSelected);
272
+ els.all.addEventListener("click", scoreAll);
273
+ })();
274
+
assets/js/shared/config.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // Init: config + load gene catalog (used by §1, §3)
3
+ // =========================================================================
4
+ // Memoize the *promise* (not the value) so concurrent callers share one fetch.
5
+ // The previous "if (GENES) return GENES" pattern was racy: two callers firing
6
+ // before the first response landed both triggered a network request.
7
+ let CONFIG_PROMISE = null;
8
+ function fetchConfig() {
9
+ if (!CONFIG_PROMISE) {
10
+ CONFIG_PROMISE = fetch("/config").then(r => r.json());
11
+ }
12
+ return CONFIG_PROMISE;
13
+ }
14
+ async function loadConfig() {
15
+ try {
16
+ const cfg = await fetchConfig();
17
+ document.getElementById("meta").textContent = cfg.model;
18
+ document.getElementById("footer-model").textContent = cfg.model;
19
+ } catch {
20
+ document.getElementById("meta").textContent = "config unavailable";
21
+ }
22
+ }
23
+
24
+ // Keep the resolved value on the global `GENES` for downstream code that
25
+ // reaches into it synchronously inside loadGenes().then() callbacks.
26
+ let GENES = null;
27
+ let GENES_PROMISE = null;
28
+ function loadGenes() {
29
+ if (!GENES_PROMISE) {
30
+ GENES_PROMISE = fetch("/genes").then(r => r.json()).then(g => { GENES = g; return g; });
31
+ }
32
+ return GENES_PROMISE;
33
+ }
assets/js/shared/helpers.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // Shared helpers
3
+ // =========================================================================
4
+ const DARK_RGB = [31, 31, 29];
5
+ const MID_RGB = [136, 136, 136];
6
+ const RED_RGB = [188, 46, 37];
7
+ const PROMPT_RGB = [170, 170, 170];
8
+
9
+ function lerp(a, b, t) { return Math.round(a + (b - a) * t); }
10
+ function lerpRgb(c1, c2, t) {
11
+ return [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)];
12
+ }
13
+ function logprobRgb(lp, range) {
14
+ if (lp == null || isNaN(lp) || !range) return DARK_RGB;
15
+ const { min, mid, max } = range;
16
+ if (max === min) return MID_RGB;
17
+ if (lp >= mid) {
18
+ const denom = max - mid;
19
+ const t = denom > 0 ? Math.min(1, Math.max(0, (max - lp) / denom)) : 0;
20
+ return lerpRgb(DARK_RGB, MID_RGB, t);
21
+ }
22
+ const denom = mid - min;
23
+ const t = denom > 0 ? Math.min(1, Math.max(0, (mid - lp) / denom)) : 0;
24
+ return lerpRgb(MID_RGB, RED_RGB, t);
25
+ }
26
+ function lpRangeOf(tokens) {
27
+ let min = Infinity, max = -Infinity, sum = 0, n = 0;
28
+ for (const t of tokens) {
29
+ const lp = t.logprob;
30
+ if (lp == null || isNaN(lp)) continue;
31
+ if (lp < min) min = lp;
32
+ if (lp > max) max = lp;
33
+ sum += lp; n++;
34
+ }
35
+ return n ? { min, mid: sum / n, max } : null;
36
+ }
37
+ function meanLogprob(tokens) {
38
+ const r = lpRangeOf(tokens);
39
+ return r ? r.mid : null;
40
+ }
41
+
42
+ // Render a sequence line-by-line with optional per-base coloring fn `colorAt(absIdx, base)`.
43
+ // 10-bp blocks separated by 2 spaces, position number prefix.
44
+ function renderSeq(el, seq, basesPerLine, colorAt) {
45
+ if (!seq) {
46
+ el.classList.add("empty");
47
+ el.textContent = "—";
48
+ return;
49
+ }
50
+ el.classList.remove("empty");
51
+ const parts = [];
52
+ for (let i = 0; i < seq.length; i += basesPerLine) {
53
+ const lineSeq = seq.slice(i, i + basesPerLine);
54
+ const pos = String(i + 1).padStart(5, " ");
55
+ let html = `<span class="pos">${pos}</span> `;
56
+ let j = 0;
57
+ while (j < lineSeq.length) {
58
+ if (j > 0 && j % 10 === 0) html += " ";
59
+ const absIdx = i + j;
60
+ const c = colorAt(absIdx, lineSeq[j]);
61
+ // Group identical-style runs within the 10-base block.
62
+ const blockEnd = Math.min(lineSeq.length, Math.floor(j / 10) * 10 + 10);
63
+ let runEnd = j + 1;
64
+ while (runEnd < blockEnd) {
65
+ const cn = colorAt(i + runEnd, lineSeq[runEnd]);
66
+ if (cn.style !== c.style) break;
67
+ runEnd++;
68
+ }
69
+ html += `<span style="${c.style}">${lineSeq.slice(j, runEnd)}</span>`;
70
+ j = runEnd;
71
+ }
72
+ parts.push(`<div>${html}</div>`);
73
+ }
74
+ el.innerHTML = parts.join("");
75
+ }
assets/js/tabs.js ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================================
2
+ // Tab switching + hash routing
3
+ // =========================================================================
4
+ (function initTabs() {
5
+ const TABS = ["demo", "model", "sandbox"];
6
+ const tabButtons = document.querySelectorAll("#tab-nav .tab");
7
+ const panels = document.querySelectorAll(".tab-panel");
8
+
9
+ function setTab(name, opts = {}) {
10
+ if (!TABS.includes(name)) name = "demo";
11
+ document.body.dataset.tab = name;
12
+ tabButtons.forEach(b => b.classList.toggle("active", b.dataset.tab === name));
13
+ panels.forEach(p => p.classList.toggle("active", p.dataset.tab === name));
14
+ if (opts.scroll !== false) window.scrollTo({ top: 0, behavior: opts.smooth ? "smooth" : "auto" });
15
+ if (opts.updateHash !== false) {
16
+ // Preserve any anchor inside the tab if requested
17
+ if (opts.anchor) location.hash = opts.anchor;
18
+ else if (location.hash.replace("#", "") !== name) location.hash = name;
19
+ }
20
+ }
21
+
22
+ // Map a section anchor → which tab contains it
23
+ const SECTION_TO_TAB = {
24
+ completion: "demo", vep: "demo", track: "demo", species: "demo", folding: "demo", umap: "demo",
25
+ tokenizer: "model", loss: "model", data: "model", architecture: "model",
26
+ sandbox: "sandbox",
27
+ };
28
+
29
+ function applyHash() {
30
+ const hash = location.hash.replace(/^#/, "");
31
+ if (!hash) { setTab("demo", { updateHash: false }); return; }
32
+ if (TABS.includes(hash)) { setTab(hash, { updateHash: false }); return; }
33
+ if (SECTION_TO_TAB[hash]) {
34
+ setTab(SECTION_TO_TAB[hash], { updateHash: false, scroll: false });
35
+ // Defer scroll until panel is visible
36
+ requestAnimationFrame(() => {
37
+ const el = document.getElementById(hash);
38
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
39
+ });
40
+ return;
41
+ }
42
+ setTab("demo", { updateHash: false });
43
+ }
44
+
45
+ tabButtons.forEach(b => {
46
+ b.addEventListener("click", () => setTab(b.dataset.tab));
47
+ });
48
+ window.addEventListener("hashchange", applyHash);
49
+ applyHash();
50
+ })();
51
+
52
+ loadConfig();
assets/styles/banner.css ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* banner.css — editorial Carbon hero banner.
2
+ Scoped to .carbon-banner. The SVG self-sizes from its viewBox aspect
3
+ ratio (drives the banner height); the animated DNA helix is rendered
4
+ on a Canvas overlay positioned to mirror the original helix bbox. */
5
+
6
+ .banner-wrap {
7
+ max-width: 1080px;
8
+ margin: 0 auto;
9
+ padding: 24px 32px 0;
10
+ }
11
+ .carbon-banner {
12
+ --paper: #f7f5ee;
13
+ --ink: #1f1f1d;
14
+ --muted: #8c918b;
15
+ --hairline: #b9bcb7;
16
+ --green: #317f3f;
17
+ --red: #bc2e25;
18
+
19
+ display: block;
20
+ width: 100%;
21
+ position: relative;
22
+ overflow: hidden;
23
+ background:
24
+ radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.035), transparent 1px),
25
+ radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.03), transparent 1px),
26
+ linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
27
+ var(--paper);
28
+ background-size: 7px 7px, 11px 11px, auto, auto;
29
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
30
+ }
31
+ /* SVG self-sizes from its viewBox aspect ratio — drives the banner height. */
32
+ .carbon-banner .carbon-art { display: block; width: 100%; height: auto; }
33
+ /* Canvas overlay for the animated DNA helix.
34
+ Positioned to mirror the original helix bbox in viewBox space:
35
+ x ∈ [858, 1998] of [40, 2010] → left 41.52%, width 57.87%
36
+ y ∈ [220, 416] of [50, 590] → top 31.48%, height 36.30% */
37
+ .carbon-banner .cb-helix-canvas {
38
+ position: absolute;
39
+ left: 41.52%;
40
+ top: 31.48%;
41
+ width: 57.87%;
42
+ height: 36.30%;
43
+ pointer-events: none;
44
+ display: block;
45
+ }
46
+ .carbon-banner .cb-mono {
47
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
48
+ letter-spacing: 0.24em;
49
+ text-transform: uppercase;
50
+ }
51
+ .carbon-banner .cb-rule { stroke: var(--hairline); stroke-width: 1; vector-effect: non-scaling-stroke; }
52
+ .carbon-banner .cb-label { fill: var(--ink); font-size: 17px; letter-spacing: 0.24em; }
53
+ .carbon-banner .cb-label-green { fill: var(--green); }
54
+ .carbon-banner .cb-label-red { fill: var(--red); }
55
+ .carbon-banner .cb-carbon-word {
56
+ fill: var(--ink);
57
+ font-family: "Arial Narrow", "Helvetica Neue", Arial, sans-serif;
58
+ font-size: 255px;
59
+ font-stretch: condensed;
60
+ font-weight: 900;
61
+ letter-spacing: -0.02em;
62
+ }
63
+ .carbon-banner .cb-paper-grain { mix-blend-mode: multiply; opacity: 0.62; pointer-events: none; }
64
+ .carbon-banner .cb-helix-shadow { fill: #aeb5ad; opacity: 0.15; }
65
+ .carbon-banner .cb-helix-body {
66
+ fill: #e4e5dc;
67
+ stroke: rgba(49, 127, 63, 0.14);
68
+ stroke-width: 0.8;
69
+ }
70
+ .carbon-banner .cb-helix-edge {
71
+ fill: none; stroke: #2d332e; stroke-width: 1.15;
72
+ stroke-linecap: round; stroke-linejoin: round;
73
+ vector-effect: non-scaling-stroke;
74
+ }
75
+ .carbon-banner .cb-helix-texture { fill: url(#cb-helixGrain); opacity: 0.46; }
76
+ .carbon-banner .cb-base-pair {
77
+ fill: none; stroke: var(--green); stroke-width: 1.35;
78
+ stroke-linecap: round; vector-effect: non-scaling-stroke;
79
+ }
80
+ .carbon-banner .cb-base-letter-node {
81
+ will-change: transform, opacity;
82
+ transform-box: fill-box; transform-origin: 0 0;
83
+ }
84
+ .carbon-banner .cb-base-glyph {
85
+ fill: none; stroke: var(--green); stroke-width: 1.8;
86
+ stroke-linecap: square; stroke-linejoin: miter;
87
+ vector-effect: non-scaling-stroke;
88
+ }
89
+ .carbon-banner .cb-tiny-bars rect { fill: var(--green); }
90
+ @media (max-width: 760px) {
91
+ .banner-wrap { padding: 12px 16px 0; }
92
+ }
assets/styles/base.css ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* base.css — global reset, page background, container, scrollbar.
2
+ Defines the @keyframes (pulse) referenced from .status, sandbox, and
3
+ .fold-viewer.running so it has to load before any consumer. */
4
+
5
+ * { margin: 0; padding: 0; box-sizing: border-box; }
6
+ html { scroll-behavior: smooth; }
7
+ body {
8
+ font-family: "Inter", "Helvetica Neue", sans-serif;
9
+ font-size: 13px; font-weight: 300; line-height: 1.7;
10
+ color: #1f1f1d;
11
+ background:
12
+ radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.035), transparent 1px),
13
+ radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.03), transparent 1px),
14
+ linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
15
+ #f7f5ee;
16
+ background-size: 7px 7px, 11px 11px, auto, auto;
17
+ padding: 0;
18
+ }
19
+ .container { max-width: 760px; margin: 0 auto; padding: 48px 32px 96px; }
20
+ .container.wide { max-width: 1080px; }
21
+
22
+ @keyframes pulse { 50% { opacity: 0.3; } }
23
+
24
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
25
+ ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
26
+ ::-webkit-scrollbar-track { background: transparent; }
assets/styles/controls.css ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* controls.css — shared interactive atoms used by every demo card:
2
+ the demo card frame, demo-toolbar row, action buttons, pill toggles,
3
+ stub placeholders for unbuilt demos, and the .status pill (idle /
4
+ streaming / error). */
5
+
6
+ /* --- Demo card (the interactive box inside each section) --- */
7
+ .demo {
8
+ background: #fff; border: 1px solid #ddd;
9
+ padding: 24px; margin: 24px 0;
10
+ }
11
+ .demo-toolbar {
12
+ display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
13
+ margin-bottom: 16px;
14
+ font-family: "JetBrains Mono", monospace; font-size: 10px;
15
+ color: #666; text-transform: uppercase; letter-spacing: 1px;
16
+ }
17
+ .demo-toolbar .spacer { flex: 1; }
18
+
19
+ /* --- Buttons --- */
20
+ button.action, .pill {
21
+ font-family: "JetBrains Mono", monospace;
22
+ font-size: 10px; font-weight: 400;
23
+ padding: 5px 11px; border: 1px solid #ccc; border-radius: 3px;
24
+ background: #fff; color: #555; cursor: pointer;
25
+ text-transform: uppercase; letter-spacing: 1.5px;
26
+ transition: all 0.15s;
27
+ }
28
+ button.action:hover, .pill:hover { border-color: #888; color: #1f1f1d; }
29
+ button.action.primary { background: #1f1f1d; color: #fff; border-color: #1f1f1d; }
30
+ button.action.primary:hover { background: #000; }
31
+ button.action:disabled { opacity: 0.4; cursor: not-allowed; }
32
+ button.action.primary:disabled { background: #888; border-color: #888; }
33
+ .pill.active { background: #1f1f1d; color: #fff; border-color: #1f1f1d; }
34
+ .pills { display: inline-flex; gap: 4px; }
35
+ .pills .pill { font-size: 9px; padding: 4px 8px; }
36
+
37
+ /* --- Stub placeholder for unbuilt demos --- */
38
+ .stub {
39
+ padding: 48px 24px; text-align: center; color: #999;
40
+ font-family: "JetBrains Mono", monospace; font-size: 11px;
41
+ text-transform: uppercase; letter-spacing: 2px;
42
+ border: 1px dashed #ddd; background: #fff;
43
+ }
44
+ .stub-tag {
45
+ display: inline-block; padding: 2px 8px;
46
+ border: 1px solid #ddd; border-radius: 2px;
47
+ font-size: 9px; color: #aaa; margin-bottom: 8px;
48
+ }
49
+
50
+ /* --- Status pill (idle / streaming / error) shared by demo toolbars --- */
51
+ .status {
52
+ font-family: "JetBrains Mono", monospace;
53
+ font-size: 10px; color: #666;
54
+ text-transform: uppercase; letter-spacing: 1.5px;
55
+ display: inline-flex; align-items: center; gap: 6px;
56
+ margin-left: 8px;
57
+ }
58
+ .status .dot {
59
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
60
+ background: #888;
61
+ }
62
+ /* `pulse` keyframe lives in base.css so it's defined before any consumer. */
63
+ .status.streaming .dot { background: #317f3f; animation: pulse 1.2s ease-in-out infinite; }
64
+ .status.error { color: #b00020; }
assets/styles/footer.css ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* footer.css — page footer: model identifier line at the bottom. */
2
+
3
+ footer {
4
+ text-align: center; padding: 48px 32px;
5
+ color: #999; font-size: 11px;
6
+ font-family: "JetBrains Mono", monospace;
7
+ letter-spacing: 1px; text-transform: uppercase;
8
+ border-top: 1px solid #eee;
9
+ }
10
+ footer a { color: #666; text-decoration: none; }
11
+ footer a:hover { color: #1f1f1d; }
assets/styles/header.css ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* header.css — sticky page header (CARBON title + tagline + tab strip)
2
+ and the tab-panel show/hide toggles driven by .active. */
3
+
4
+ header {
5
+ border-bottom: 1px solid #ccc; /* separator line under the tab strip */
6
+ padding: 24px 32px 0; /* no bottom padding — tabs sit on the line */
7
+ margin-bottom: 0;
8
+ background:
9
+ radial-gradient(circle at 22% 32%, rgba(0, 0, 0, 0.035), transparent 1px),
10
+ radial-gradient(circle at 78% 64%, rgba(0, 0, 0, 0.03), transparent 1px),
11
+ linear-gradient(90deg, rgba(49, 127, 63, 0.025), transparent 34%, transparent 66%, rgba(49, 127, 63, 0.025)),
12
+ #f7f5ee;
13
+ background-size: 7px 7px, 11px 11px, auto, auto;
14
+ position: sticky; top: 0; z-index: 10;
15
+ /* Promote to its own compositing layer so the gradient pattern doesn't
16
+ force the entire viewport to repaint on every scroll event. */
17
+ will-change: transform;
18
+ }
19
+ .header-inner {
20
+ max-width: 1080px; margin: 0 auto;
21
+ display: flex; justify-content: space-between; align-items: flex-end;
22
+ flex-wrap: wrap; gap: 16px 32px;
23
+ }
24
+ .header-title { padding-bottom: 14px; }
25
+ h1 {
26
+ font-family: "JetBrains Mono", monospace;
27
+ font-size: 16px; font-weight: 400; letter-spacing: 2px;
28
+ }
29
+ .tagline {
30
+ font-family: "JetBrains Mono", monospace;
31
+ color: #888; font-size: 10px; font-weight: 300;
32
+ letter-spacing: 1px; margin-top: 4px; text-transform: uppercase;
33
+ }
34
+ nav#tab-nav {
35
+ display: flex;
36
+ font-family: "JetBrains Mono", monospace;
37
+ font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px;
38
+ margin-bottom: -1px; /* tabs overlap header's bottom border by 1px */
39
+ position: relative;
40
+ z-index: 1;
41
+ }
42
+ nav#tab-nav .tab {
43
+ width: 130px;
44
+ padding: 10px 14px;
45
+ font-family: inherit; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase;
46
+ color: #888; background: #f0f0f0;
47
+ border: 1px solid #ccc;
48
+ border-radius: 3px 3px 0 0;
49
+ cursor: pointer; transition: background 0.15s, color 0.15s;
50
+ }
51
+ nav#tab-nav .tab + .tab { margin-left: -1px; } /* shared border between adjacent tabs */
52
+ nav#tab-nav .tab:hover { color: #1f1f1d; background: #f6f6f6; }
53
+ nav#tab-nav .tab.active {
54
+ color: #1f1f1d; background: #f7f5ee;
55
+ border-bottom-color: #f7f5ee; /* hides bottom border so tab merges into content */
56
+ z-index: 2;
57
+ }
58
+
59
+ /* --- Tab panels --- */
60
+ .tab-panel { display: none; }
61
+ .tab-panel.active { display: block; }
assets/styles/layout.css ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* layout.css — page-wide structural primitives:
2
+ tab-lede (paragraph below banner), default vertical section rhythm,
3
+ part dividers (DEMO / MODEL signposts), section eyebrow / title /
4
+ lede / takeaway typography, and the .section--two-col layout that
5
+ parks narrative in a sticky rail next to the demo. */
6
+
7
+ /* --- Tab lede (short narrative paragraph below the banner) --- */
8
+ .tab-lede {
9
+ max-width: 1080px; margin: 4px auto 0;
10
+ padding: 14px 32px 0;
11
+ }
12
+ .tab-lede p {
13
+ color: #555; font-size: 14px; line-height: 1.7;
14
+ max-width: 760px; margin: 0;
15
+ }
16
+
17
+ /* --- Sections --- */
18
+ section {
19
+ border-bottom: 1px solid #eee;
20
+ padding: 64px 0;
21
+ }
22
+ section:last-of-type { border-bottom: none; }
23
+
24
+ /* --- Part dividers (DEMO / MODEL) --- */
25
+ .part-divider {
26
+ text-align: center;
27
+ padding: 80px 32px 48px;
28
+ border-top: 1px solid #eee;
29
+ border-bottom: 1px solid #eee;
30
+ background: linear-gradient(to bottom, rgba(49,127,63,0.04), transparent);
31
+ }
32
+ .part-divider .part-eyebrow {
33
+ font-family: "JetBrains Mono", monospace;
34
+ font-size: 10px; color: #317f3f;
35
+ letter-spacing: 4px; text-transform: uppercase;
36
+ margin-bottom: 6px;
37
+ }
38
+ .part-divider h2 {
39
+ font-family: "JetBrains Mono", monospace;
40
+ font-size: 26px; font-weight: 300; letter-spacing: -0.3px;
41
+ margin-bottom: 8px;
42
+ }
43
+ .part-divider p {
44
+ color: #666; font-size: 13px; max-width: 580px; margin: 0 auto;
45
+ }
46
+
47
+ .section-num {
48
+ font-family: "JetBrains Mono", monospace;
49
+ font-size: 10px; color: #317f3f;
50
+ letter-spacing: 2px; text-transform: uppercase;
51
+ margin-bottom: 8px;
52
+ }
53
+ .section-title {
54
+ font-family: "JetBrains Mono", monospace;
55
+ font-size: 22px; font-weight: 400; letter-spacing: -0.3px;
56
+ margin-bottom: 24px;
57
+ color: #1f1f1d;
58
+ }
59
+ .lede {
60
+ color: #444; font-size: 14px; margin-bottom: 32px;
61
+ max-width: 640px;
62
+ }
63
+ .takeaway {
64
+ margin-top: 32px;
65
+ padding: 16px 20px; border-left: 3px solid #317f3f;
66
+ background: #f4f8f4; color: #333;
67
+ font-size: 13px; max-width: 640px;
68
+ }
69
+ .takeaway strong {
70
+ font-family: "JetBrains Mono", monospace;
71
+ font-weight: 500; letter-spacing: 1px; text-transform: uppercase;
72
+ font-size: 10px; color: #317f3f; display: block; margin-bottom: 4px;
73
+ }
74
+
75
+ /* === Two-column section layout =========================================
76
+ The default layout stacks vertically: title → lede → demo → takeaway.
77
+ For demo-heavy sections that means narrative and visualization never
78
+ share visual space — by the time the visitor is mid-demo, the lede
79
+ is scrolled away, and the takeaway only appears after they've
80
+ finished. .section--two-col places the narrative (eyebrow + title +
81
+ lede + takeaway) in a sticky rail on the left and lets the demo
82
+ claim the bulk of the width on the right. Narration stays in view
83
+ while the visitor scrolls through the demo, turning the takeaway
84
+ into a live margin note rather than a post-mortem.
85
+
86
+ Layout math: container.wide is 1080px max with 32px padding =>
87
+ 1016px usable. 248px rail + 32px gap + 736px demo. Below 900px we
88
+ collapse to single-column and unstick the rail. */
89
+ .section--two-col {
90
+ display: grid;
91
+ grid-template-columns: 248px 1fr;
92
+ column-gap: 32px;
93
+ align-items: start;
94
+ /* Land cleanly under the sticky header on anchor jumps (#folding). */
95
+ scroll-margin-top: 104px;
96
+ }
97
+ .section--two-col .section-narrative {
98
+ position: sticky;
99
+ /* Sticky header is ~88px tall (title + tab strip on its border);
100
+ +16px so the rail doesn't kiss the underline. */
101
+ top: 104px;
102
+ align-self: start;
103
+ /* Cap on short viewports so a tall narrative still fits without
104
+ pushing demo content off-screen. The narrative scrolls inside
105
+ its own track if it ever overflows. */
106
+ max-height: calc(100vh - 120px);
107
+ overflow-y: auto;
108
+ scrollbar-width: thin;
109
+ }
110
+ /* The 640px cap on .lede / .takeaway exists to keep line length
111
+ readable in the single-column layout. Inside a 248px rail that cap
112
+ is moot — drop it so the text fills the rail. */
113
+ .section--two-col .section-narrative .lede,
114
+ .section--two-col .section-narrative .takeaway {
115
+ max-width: none;
116
+ }
117
+ .section--two-col .section-narrative .lede {
118
+ font-size: 13px;
119
+ margin-bottom: 20px;
120
+ }
121
+ /* In 2-col mode the takeaway sits next to the demo as a margin note,
122
+ so the heavy green-bar treatment becomes too loud. Soften to a
123
+ calm hairline + neutral type. */
124
+ .section--two-col .section-narrative .takeaway {
125
+ margin-top: 0;
126
+ padding: 14px 16px;
127
+ border-left: 2px solid #d8d5c8;
128
+ background: transparent;
129
+ font-size: 12px;
130
+ color: #555;
131
+ }
132
+ .section--two-col .section-narrative .takeaway strong {
133
+ color: #1f1f1d;
134
+ }
135
+ /* The demo claims its full grid track. The default 24px y-margin
136
+ was for the single-column rhythm and isn't needed here. */
137
+ .section--two-col .demo {
138
+ margin: 0;
139
+ }
140
+ @media (max-width: 900px) {
141
+ .section--two-col {
142
+ grid-template-columns: 1fr;
143
+ row-gap: 16px;
144
+ }
145
+ .section--two-col .section-narrative {
146
+ position: static;
147
+ max-height: none;
148
+ overflow: visible;
149
+ }
150
+ .section--two-col .section-narrative .takeaway {
151
+ /* Restore a touch of the editorial green band on mobile, since
152
+ it's no longer competing with a sticky sibling. */
153
+ margin-top: 8px;
154
+ border-left-color: #317f3f;
155
+ background: #f4f8f4;
156
+ }
157
+ .section--two-col .demo {
158
+ margin: 8px 0 0;
159
+ }
160
+ }
assets/styles/recipe.css ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /* recipe.css — overrides for the Recipe panel demos (tokenizer, loss,
2
+ data, architecture). Most of those demos rely on the shared
3
+ .demo / .stat-row / .seq-block primitives, so this file only carries
4
+ the narrow-viewport overrides their inline grid layouts need. */
5
+
6
+ /* Model section responsive overrides */
7
+ @media (max-width: 720px) {
8
+ #demo7 #d7-cols { grid-template-columns: 1fr !important; }
9
+ #demo9 > div:first-child { grid-template-columns: 1fr !important; }
10
+ }
assets/styles/sandbox.css ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* sandbox.css — Sandbox panel scoped styles (every selector is prefixed
2
+ with #panel-sandbox to avoid leaking onto the Demo / Recipe panels).
3
+ Originally ported from the legacy index.html sandbox. */
4
+
5
+ #panel-sandbox .sb-section-title {
6
+ font-family: "JetBrains Mono", monospace;
7
+ font-size: 11px; font-weight: 400;
8
+ text-transform: uppercase; letter-spacing: 2px; color: #444;
9
+ margin-top: 24px; margin-bottom: 8px;
10
+ border-bottom: 1px solid #ccc; padding-bottom: 4px;
11
+ }
12
+ #panel-sandbox .sb-examples {
13
+ display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;
14
+ align-items: center;
15
+ }
16
+ #panel-sandbox .sb-examples-label {
17
+ font-family: "JetBrains Mono", monospace;
18
+ font-size: 10px; color: #999; text-transform: uppercase; letter-spacing: 1px;
19
+ margin-right: 4px;
20
+ }
21
+ #panel-sandbox .sb-ex-btn {
22
+ font-family: "JetBrains Mono", monospace;
23
+ font-size: 10px; padding: 3px 8px;
24
+ border: 1px solid #ddd; border-radius: 3px;
25
+ background: #fff; color: #666; cursor: pointer;
26
+ transition: all 0.15s;
27
+ }
28
+ #panel-sandbox .sb-ex-btn:hover { border-color: #888; color: #1f1f1d; }
29
+ #panel-sandbox .sb-ex-btn .sb-ex-label {
30
+ color: #aaa; font-size: 9px; margin-left: 6px; text-transform: uppercase;
31
+ letter-spacing: 0.5px;
32
+ }
33
+ #panel-sandbox .sb-prompt-area, #panel-sandbox input[type=number] {
34
+ font-family: "JetBrains Mono", monospace;
35
+ font-size: 12px; font-weight: 300; color: #1f1f1d;
36
+ background: #fff; border: 1px solid #ddd; border-radius: 3px;
37
+ padding: 8px 12px; outline: none; transition: border 0.15s;
38
+ }
39
+ #panel-sandbox .sb-prompt-area:focus,
40
+ #panel-sandbox input[type=number]:focus { border-color: #1f1f1d; }
41
+ #panel-sandbox .sb-prompt-area {
42
+ width: 100%; resize: none; overflow: hidden;
43
+ letter-spacing: 1px; line-height: 1.7;
44
+ min-height: 36px;
45
+ }
46
+ #panel-sandbox .sb-controls {
47
+ display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
48
+ margin-top: 10px;
49
+ }
50
+ #panel-sandbox .sb-control {
51
+ display: flex; align-items: center; gap: 6px;
52
+ font-family: "JetBrains Mono", monospace; font-size: 10px; color: #666;
53
+ text-transform: uppercase; letter-spacing: 1px;
54
+ }
55
+ #panel-sandbox .sb-control input[type=number] {
56
+ width: 64px; padding: 4px 6px; font-size: 11px; text-align: right;
57
+ }
58
+ #panel-sandbox .sb-mode-group {
59
+ display: flex; align-items: center; gap: 6px;
60
+ font-family: "JetBrains Mono", monospace; font-size: 10px; color: #666;
61
+ text-transform: uppercase; letter-spacing: 1px;
62
+ }
63
+ #panel-sandbox .sb-mode-btns { display: flex; }
64
+ #panel-sandbox .sb-mode-btn {
65
+ font-family: "JetBrains Mono", monospace;
66
+ font-size: 10px; padding: 4px 9px;
67
+ border: 1px solid #ccc; border-right: none;
68
+ background: #fff; color: #666; cursor: pointer;
69
+ text-transform: uppercase; letter-spacing: 1px;
70
+ transition: all 0.15s;
71
+ }
72
+ #panel-sandbox .sb-mode-btn:first-child { border-radius: 3px 0 0 3px; }
73
+ #panel-sandbox .sb-mode-btn:last-child { border-right: 1px solid #ccc; border-radius: 0 3px 3px 0; }
74
+ #panel-sandbox .sb-mode-btn:hover { color: #1f1f1d; }
75
+ #panel-sandbox .sb-mode-btn.active { background: #1f1f1d; color: #fff; border-color: #1f1f1d; }
76
+
77
+ #panel-sandbox .sb-button-row { margin-left: auto; display: flex; gap: 6px; }
78
+
79
+ #panel-sandbox .sb-status {
80
+ font-family: "JetBrains Mono", monospace;
81
+ font-size: 10px; color: #666;
82
+ text-transform: uppercase; letter-spacing: 1.5px;
83
+ margin-top: 10px; min-height: 14px;
84
+ }
85
+ #panel-sandbox .sb-status.error { color: #b00020; text-transform: none; letter-spacing: 0.3px; }
86
+ #panel-sandbox .sb-status .dot {
87
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
88
+ background: #888; margin-right: 6px; vertical-align: middle;
89
+ }
90
+ /* `pulse` keyframe lives in base.css. */
91
+ #panel-sandbox .sb-status.streaming .dot { background: #317f3f; animation: pulse 1.2s ease-in-out infinite; }
92
+
93
+ #panel-sandbox .sb-output-row {
94
+ display: grid;
95
+ grid-template-columns: minmax(0, 1fr) 200px;
96
+ gap: 16px;
97
+ align-items: start;
98
+ }
99
+ @media (max-width: 720px) {
100
+ #panel-sandbox .sb-output-row { grid-template-columns: 1fr; }
101
+ }
102
+
103
+ #panel-sandbox .sb-seq-wrap { position: relative; }
104
+ #panel-sandbox .sb-copy-btn {
105
+ position: absolute; top: 8px; right: 8px; z-index: 2;
106
+ font-family: "JetBrains Mono", monospace;
107
+ font-size: 9px; font-weight: 400;
108
+ padding: 3px 8px; border: 1px solid #ddd; border-radius: 3px;
109
+ background: #fff; color: #666; cursor: pointer;
110
+ text-transform: uppercase; letter-spacing: 1px;
111
+ transition: all 0.15s;
112
+ }
113
+ #panel-sandbox .sb-copy-btn:hover { border-color: #888; color: #1f1f1d; }
114
+ #panel-sandbox .sb-copy-btn:disabled { opacity: 0; pointer-events: none; }
115
+ #panel-sandbox .sb-copy-btn.copied { background: #317f3f; color: #fff; border-color: #317f3f; }
116
+
117
+ #panel-sandbox .sb-seq-block {
118
+ font-family: "JetBrains Mono", monospace;
119
+ background: #f4f4f4; border: 1px solid #ddd;
120
+ padding: 16px 20px; overflow-x: auto;
121
+ white-space: pre; font-size: 12px; font-weight: 400;
122
+ line-height: 1.85; letter-spacing: 1.5px;
123
+ min-height: 80px;
124
+ }
125
+ #panel-sandbox .sb-seq-block.empty { color: #aaa; font-weight: 300; letter-spacing: normal; }
126
+ #panel-sandbox .sb-seq-line { white-space: pre; }
127
+ #panel-sandbox .sb-pos { color: #bbb; user-select: none; font-weight: 300; }
128
+ #panel-sandbox .sb-seq-line.tail::after {
129
+ content: "";
130
+ display: inline-block; width: 7px; height: 14px;
131
+ background: #1f1f1d; vertical-align: text-bottom;
132
+ margin-left: 2px;
133
+ animation: blink 1s step-end infinite;
134
+ }
135
+ @keyframes blink { 50% { opacity: 0; } }
136
+
137
+ #panel-sandbox .sb-stats {
138
+ position: sticky; top: 120px;
139
+ border: 1px solid #ddd; background: #fff;
140
+ }
141
+ #panel-sandbox .sb-stat {
142
+ display: flex; justify-content: space-between; align-items: baseline;
143
+ padding: 8px 12px;
144
+ border-bottom: 1px solid #eee;
145
+ font-family: "JetBrains Mono", monospace;
146
+ }
147
+ #panel-sandbox .sb-stat:last-child { border-bottom: none; }
148
+ #panel-sandbox .sb-stat-label {
149
+ font-size: 9px; color: #999;
150
+ text-transform: uppercase; letter-spacing: 1.2px; font-weight: 300;
151
+ }
152
+ #panel-sandbox .sb-stat-value {
153
+ font-size: 12px; font-weight: 400; color: #1f1f1d;
154
+ font-variant-numeric: tabular-nums;
155
+ }
156
+ #panel-sandbox .sb-stat-value .sb-unit { font-size: 9px; color: #999; margin-left: 3px; font-weight: 300; }
157
+
158
+ #panel-sandbox .sb-legend {
159
+ margin-top: 8px;
160
+ padding: 8px 12px;
161
+ background: #fff; border: 1px solid #ddd;
162
+ font-family: "JetBrains Mono", monospace;
163
+ font-size: 9px; color: #888;
164
+ text-transform: uppercase; letter-spacing: 1px;
165
+ display: none;
166
+ }
167
+ #panel-sandbox .sb-legend.show { display: block; }
168
+ #panel-sandbox .sb-legend-bar {
169
+ height: 6px; margin: 4px 0 3px;
170
+ background: linear-gradient(to right, #bc2e25, #888, #1f1f1d);
171
+ border-radius: 1px;
172
+ }
173
+ #panel-sandbox .sb-legend-row { display: flex; justify-content: space-between; }
174
+ #panel-sandbox .sb-lp-chart {
175
+ display: block; width: 100%; height: 40px;
176
+ margin-top: 8px;
177
+ }
assets/styles/section-folding.css ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* section-folding.css — §5 Folding viewers and AA pair grid.
2
+ Two square 3Dmol canvases side by side, optional loading overlay,
3
+ pLDDT colour legend, mRNA flow strip, and the carbon/reference AA
4
+ row pair with its shared mismatch legend. */
5
+
6
+ /* --- §5 Folding viewers --- */
7
+ /* Two square 3Dmol canvases side by side. On narrow screens (<720px) we
8
+ stack them vertically so each viewer keeps a comfortable size. */
9
+ .fold-grid {
10
+ display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
11
+ margin-top: 12px;
12
+ }
13
+ .fold-viewer-col { display: flex; flex-direction: column; }
14
+ .fold-viewer-label {
15
+ font-family: "JetBrains Mono", monospace;
16
+ font-size: 9px; color: #888; text-transform: uppercase; letter-spacing: 1.5px;
17
+ margin-bottom: 4px;
18
+ }
19
+ .fold-viewer {
20
+ position: relative;
21
+ width: 100%; aspect-ratio: 1 / 1;
22
+ background: #fafaf7;
23
+ border: 1px solid #eee;
24
+ overflow: hidden;
25
+ }
26
+ .fold-viewer canvas { display: block; transition: opacity .15s ease-out; }
27
+ .fold-viewer .fold-empty {
28
+ position: absolute; inset: 0;
29
+ display: flex; align-items: center; justify-content: center;
30
+ font-family: "JetBrains Mono", monospace; font-size: 10px;
31
+ color: #bbb; letter-spacing: 1.5px; text-transform: uppercase;
32
+ pointer-events: none;
33
+ }
34
+ /* Loading overlay shown over the cartoon while runFold() is in flight.
35
+ The cached cartoon stays dimly visible underneath so the visitor can
36
+ still compare to it once the fresh result lands. */
37
+ .fold-viewer .fold-overlay {
38
+ position: absolute; inset: 0;
39
+ display: none; align-items: center; justify-content: center; gap: 8px;
40
+ background: rgba(250, 250, 247, 0.72);
41
+ font-family: "JetBrains Mono", monospace; font-size: 10px;
42
+ color: #555; letter-spacing: 1.5px; text-transform: uppercase;
43
+ pointer-events: none;
44
+ }
45
+ .fold-viewer .fold-overlay .dot {
46
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
47
+ background: #317f3f; animation: pulse 1.2s ease-in-out infinite;
48
+ }
49
+ .fold-viewer.running .fold-overlay { display: flex; }
50
+ .fold-viewer.running canvas { opacity: 0.28; }
51
+ /* Same overlay reused for genes whose precomputed fixture isn't ready
52
+ yet (HF endpoint downtime, fresh symbol added to the list, etc.).
53
+ Canvas fades almost fully so the empty WebGL frame doesn't read as
54
+ a bug — the overlay carries the explanation instead. */
55
+ .fold-viewer.pending .fold-overlay { display: flex; }
56
+ .fold-viewer.pending canvas { opacity: 0.08; }
57
+ .fold-legend {
58
+ font-family: "JetBrains Mono", monospace;
59
+ font-size: 9px; color: #888; text-transform: uppercase; letter-spacing: 1.2px;
60
+ display: flex; align-items: center; gap: 8px;
61
+ margin-top: 10px;
62
+ }
63
+ /* pLDDT colour key: red = low confidence, beige = mid, blue = high.
64
+ These three anchor colours are mirrored in plddtToColor() (JS) so
65
+ the cartoon ribbons land in the same palette as this legend bar. */
66
+ .fold-legend-bar {
67
+ width: 120px; height: 6px;
68
+ background: linear-gradient(to right, #b00020 0%, #f0e8e0 50%, #2c5aa0 100%);
69
+ border-radius: 1px;
70
+ }
71
+ /* Inline warning chip used when a gene is too long for the live fold
72
+ pipeline (introns push the last exon past our generation budget). */
73
+ .fold-warn {
74
+ color: #b00020;
75
+ background: rgba(188, 46, 37, 0.10);
76
+ padding: 1px 6px;
77
+ border-radius: 2px;
78
+ }
79
+ /* Materialises the DNA → mRNA → protein arrow under the gene info,
80
+ using the same monospace family/colour family as the rest of the
81
+ metadata strip. The chevron is drawn with → to read as a flow,
82
+ not a list. */
83
+ .mrna-info {
84
+ font-family: "JetBrains Mono", monospace;
85
+ font-size: 11px;
86
+ color: #888;
87
+ margin: 4px 0 16px;
88
+ letter-spacing: 0.3px;
89
+ }
90
+ .mrna-info .arrow { color: #b8b8b6; padding: 0 6px; }
91
+ .mrna-info strong { color: #555; font-weight: 500; }
92
+ .mrna-info .mrna-trunc {
93
+ color: #b00020;
94
+ background: rgba(188, 46, 37, 0.08);
95
+ padding: 0 4px;
96
+ margin-left: 6px;
97
+ border-radius: 2px;
98
+ }
99
+ /* Two-column AA grid: Carbon (left) / Reference (right), mirroring the
100
+ fold-grid below so the eye lines up "carbon prediction → carbon
101
+ fold" on one side and "reference truth → reference fold" on the
102
+ other. Stacks on narrow screens to keep each line readable. */
103
+ .fold-aa-grid {
104
+ display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
105
+ margin-top: 4px;
106
+ }
107
+ .fold-aa-col { display: flex; flex-direction: column; min-width: 0; }
108
+ /* Soft-wrap as a safety net if the wrapped 40-char line ever still
109
+ overflows (very narrow viewport, big font-size override, etc.).
110
+ The JS still inserts \n every 40 chars so Carbon and Reference
111
+ line up row-by-row in the common case. */
112
+ .fold-aa-col .seq-block { white-space: pre-wrap; word-break: break-all; overflow-x: visible; }
113
+ @media (max-width: 720px) {
114
+ .fold-grid { grid-template-columns: 1fr; }
115
+ .fold-aa-grid { grid-template-columns: 1fr; }
116
+ }
117
+
118
+ /* Shared highlight legend for the carbon/reference AA pair. The legend
119
+ sat duplicated inside each row's seq-label, which crowded the labels
120
+ to two lines at half-card width. Lifting it out (mirrors how
121
+ .fold-legend works for the pLDDT viewers below) lets each row's label
122
+ stay on a single line. */
123
+ .fold-aa-legend {
124
+ font-family: "JetBrains Mono", monospace;
125
+ font-size: 9px; color: #888; text-transform: uppercase; letter-spacing: 1.2px;
126
+ display: flex; align-items: center; flex-wrap: wrap;
127
+ gap: 4px 10px;
128
+ margin-top: 10px;
129
+ }
130
+ .fold-aa-legend-swatch {
131
+ display: inline-block;
132
+ width: 10px; height: 10px;
133
+ background: rgba(188, 46, 37, 0.18);
134
+ border: 1px solid rgba(188, 46, 37, 0.35);
135
+ border-radius: 1px;
136
+ }
137
+ .fold-aa-legend-sep { color: #c8c5b8; }
assets/styles/section-species.css ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* section-species.css — §4 Same gene across species. Each species gets
2
+ a row with three columns: meta (kingdom-coloured name), aligned
3
+ sequence block, and identity stats. */
4
+
5
+ .species-row {
6
+ display: grid;
7
+ grid-template-columns: 140px 1fr 110px;
8
+ gap: 16px;
9
+ align-items: start;
10
+ padding: 14px 0;
11
+ border-top: 1px solid #eee;
12
+ }
13
+ .species-row:first-child { border-top: none; }
14
+ .species-meta {
15
+ font-family: "JetBrains Mono", monospace;
16
+ font-size: 11px;
17
+ }
18
+ .species-name {
19
+ font-weight: 500; color: #1f1f1d;
20
+ text-transform: uppercase; letter-spacing: 1px; font-size: 11px;
21
+ border-left: 4px solid;
22
+ padding-left: 8px;
23
+ }
24
+ .species-sub { color: #888; font-size: 10px; margin-top: 4px; padding-left: 12px; }
25
+ .species-stats {
26
+ text-align: right; font-family: "JetBrains Mono", monospace;
27
+ font-size: 11px; color: #666;
28
+ }
29
+ .species-stats .stat-id { font-size: 16px; color: #1f1f1d; font-weight: 500; font-variant-numeric: tabular-nums; }
30
+ .species-stats .stat-sub { font-size: 10px; color: #999; margin-top: 2px; }
31
+ .species-seq {
32
+ font-family: "JetBrains Mono", monospace;
33
+ background: #f7f5ee; border: 1px solid #eee;
34
+ padding: 8px 12px; overflow-x: auto;
35
+ white-space: pre; font-size: 11px;
36
+ line-height: 1.7; letter-spacing: 0.5px;
37
+ }
38
+ .species-seq.empty { color: #ccc; padding: 18px 12px; text-align: center; }
assets/styles/section-tree.css ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* section-tree.css — §7 Species tree (Carbon-derived phylogeny).
2
+ Layout mirrors §6's editorial card. The grid is a 2-column structure:
3
+ left = SVG dendrogram spine (Bezier elbows), right = aligned data
4
+ tracks (italic name + kingdom chip + log count bar + NCBI agreement).
5
+ Every row gets a subtle kingdom background tint so the eye reads
6
+ coherent blocks without us drawing extra lines. */
7
+
8
+ .tree-toolbar {
9
+ display: flex; align-items: center; flex-wrap: wrap;
10
+ gap: 12px;
11
+ font-family: "JetBrains Mono", monospace; font-size: 11px;
12
+ color: #666;
13
+ margin-bottom: 14px;
14
+ }
15
+ .tree-toolbar .spacer { flex: 1; }
16
+ .tree-toolbar .pills {
17
+ display: inline-flex; gap: 0;
18
+ border: 1px solid #d8d5c8; border-radius: 3px; overflow: hidden;
19
+ }
20
+ .tree-toolbar .pills .pill {
21
+ background: #fff; border: 0; padding: 5px 11px;
22
+ font: inherit; color: #666; cursor: pointer;
23
+ border-right: 1px solid #d8d5c8;
24
+ text-transform: lowercase;
25
+ }
26
+ .tree-toolbar .pills .pill:last-child { border-right: 0; }
27
+ .tree-toolbar .pills .pill.active {
28
+ background: #1f1f1d; color: #f7f5ee;
29
+ }
30
+ /* Big agreement score chip up top — the headline metric for §7. */
31
+ .tree-score {
32
+ display: inline-flex; align-items: baseline; gap: 8px;
33
+ background: #f0eee5; border: 1px solid #d8d5c8;
34
+ padding: 6px 12px; border-radius: 3px;
35
+ font-family: "JetBrains Mono", monospace;
36
+ color: #1f1f1d;
37
+ }
38
+ .tree-score-num {
39
+ font-size: 15px; font-weight: 600;
40
+ font-variant-numeric: tabular-nums;
41
+ color: #317f3f;
42
+ }
43
+ .tree-score-suffix {
44
+ font-size: 10px; color: #888;
45
+ text-transform: uppercase; letter-spacing: 1.2px;
46
+ }
47
+ /* Main grid : SVG spine on the left, aligned tracks on the right.
48
+ The spine's height matches exactly N * row_h so each leaf lands on
49
+ its track row. We never use grid here (rows differ in semantics) —
50
+ just two columns of equal-height children rendered in JS. */
51
+ .tree-grid {
52
+ display: grid;
53
+ grid-template-columns: minmax(280px, 1.5fr) minmax(0, 1.6fr);
54
+ gap: 0;
55
+ margin-top: 6px;
56
+ background: #fff;
57
+ border: 1px solid #e5e3da;
58
+ }
59
+ .tree-spine {
60
+ position: relative;
61
+ padding: 12px 0 28px 12px;
62
+ }
63
+ .tree-spine svg {
64
+ display: block; width: 100%; height: 100%;
65
+ overflow: visible;
66
+ }
67
+ /* Inline labels at each tip — only used in mobile (where the .tree-rows
68
+ panel stacks below). On desktop they're rendered but hidden so we
69
+ don't have to invalidate the SVG on viewport changes. */
70
+ .tree-spine svg .leaf-svg-label,
71
+ .tree-spine svg .leaf-svg-chip { display: none; }
72
+ .tree-spine svg .leaf-svg-label {
73
+ font-family: "JetBrains Mono", monospace;
74
+ font-size: 11px; font-style: italic;
75
+ dominant-baseline: middle;
76
+ }
77
+ .tree-spine .axis-label {
78
+ position: absolute; bottom: 6px; left: 12px;
79
+ font-family: "JetBrains Mono", monospace;
80
+ font-size: 9px; color: #888;
81
+ text-transform: uppercase; letter-spacing: 1.2px;
82
+ }
83
+ .tree-rows {
84
+ display: flex; flex-direction: column;
85
+ padding: 12px 0 28px 0;
86
+ }
87
+ .tree-row {
88
+ display: grid;
89
+ /* chip · name · bar · ncbi — name column is FIXED width so every
90
+ row's bar starts at the exact same X. (max-content gets broken
91
+ here by .tree-name's overflow:hidden, which makes each cell
92
+ size to its own content instead of the column's longest item.) */
93
+ grid-template-columns: 10px 115px minmax(60px, 1fr) 24px;
94
+ gap: 10px;
95
+ align-items: center;
96
+ padding: 0 14px 0 12px;
97
+ height: 22px; /* must equal ROW_H in the JS tree renderer */
98
+ transition: background 0.12s ease-out;
99
+ cursor: default;
100
+ }
101
+ .tree-row:hover { background: rgba(31, 31, 29, 0.04); }
102
+ .tree-row.dim { opacity: 0.35; }
103
+ /* Subtle background stripe by kingdom — matches the §6 palette */
104
+ .tree-row[data-kingdom="vertebrates"] { background-image: linear-gradient(to right, rgba(31, 31, 29, 0.04), transparent 40px); }
105
+ .tree-row[data-kingdom="invertebrates"] { background-image: linear-gradient(to right, rgba(122, 98, 66, 0.07), transparent 40px); }
106
+ .tree-row[data-kingdom="plants"] { background-image: linear-gradient(to right, rgba(49, 127, 63, 0.07), transparent 40px); }
107
+ .tree-row[data-kingdom="fungi"] { background-image: linear-gradient(to right, rgba(169, 118, 47, 0.07), transparent 40px); }
108
+ .tree-row[data-kingdom="bacteria"] { background-image: linear-gradient(to right, rgba(176, 0, 32, 0.07), transparent 40px); }
109
+ .tree-row[data-kingdom="viruses"] { background-image: linear-gradient(to right, rgba(44, 90, 160, 0.07), transparent 40px); }
110
+ .tree-row .tree-name {
111
+ font-family: "JetBrains Mono", monospace;
112
+ font-size: 12px; font-style: italic;
113
+ color: #1f1f1d; white-space: nowrap;
114
+ overflow: hidden; text-overflow: ellipsis;
115
+ }
116
+ .tree-row[data-kingdom="vertebrates"] .tree-name { color: #1f1f1d; }
117
+ .tree-row[data-kingdom="invertebrates"] .tree-name { color: #7a6242; }
118
+ .tree-row[data-kingdom="plants"] .tree-name { color: #317f3f; }
119
+ .tree-row[data-kingdom="fungi"] .tree-name { color: #a9762f; }
120
+ .tree-row[data-kingdom="bacteria"] .tree-name { color: #b00020; }
121
+ .tree-row[data-kingdom="viruses"] .tree-name { color: #2c5aa0; }
122
+ .tree-row .tree-chip {
123
+ width: 10px; height: 10px; border-radius: 2px;
124
+ }
125
+ .tree-row .tree-bar {
126
+ /* Two-column inner grid so the rail always spans 1fr (= same start
127
+ AND same end across rows), and the count sits in a fixed-width
128
+ right column → chiffres et fin de rail s'alignent partout. */
129
+ display: grid;
130
+ grid-template-columns: 1fr 46px;
131
+ gap: 8px;
132
+ align-items: center;
133
+ height: 22px;
134
+ }
135
+ .tree-row .tree-bar .bar-track {
136
+ position: relative; height: 6px;
137
+ background: #efece1; border-radius: 1.5px;
138
+ overflow: hidden;
139
+ }
140
+ .tree-row .tree-bar .bar-fill {
141
+ position: absolute; left: 0; top: 0; bottom: 0;
142
+ background: #c8c4b3; border-radius: 1.5px;
143
+ }
144
+ .tree-row .tree-bar .bar-num {
145
+ font-family: "JetBrains Mono", monospace;
146
+ font-size: 9px; color: #888;
147
+ font-variant-numeric: tabular-nums;
148
+ white-space: nowrap; text-align: right;
149
+ }
150
+ .tree-row .tree-ncbi {
151
+ text-align: center;
152
+ font-family: "JetBrains Mono", monospace;
153
+ font-size: 14px; font-weight: 700;
154
+ line-height: 1; user-select: none;
155
+ }
156
+ .tree-row .tree-ncbi[data-state="match"] { color: #317f3f; }
157
+ .tree-row .tree-ncbi[data-state="mismatch"] { color: #b00020; }
158
+ .tree-row .tree-ncbi[data-state="solo"] { color: #c8c5b9; }
159
+ /* Tooltip floats over the grid on row hover, fed with top-3 NN. */
160
+ .tree-tooltip {
161
+ position: absolute;
162
+ background: #1f1f1d; color: #f7f5ee;
163
+ font-family: "JetBrains Mono", monospace;
164
+ font-size: 10px; padding: 8px 11px; border-radius: 3px;
165
+ pointer-events: none; z-index: 50;
166
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
167
+ opacity: 0; transition: opacity 0.1s ease-out;
168
+ line-height: 1.5; white-space: nowrap;
169
+ }
170
+ .tree-tooltip.show { opacity: 1; }
171
+ .tree-tooltip .tt-title {
172
+ text-transform: uppercase; letter-spacing: 1.2px;
173
+ font-size: 9px; color: #888;
174
+ margin-bottom: 4px;
175
+ }
176
+ .tree-tooltip .tt-pair {
177
+ display: flex; gap: 10px; align-items: baseline;
178
+ font-variant-numeric: tabular-nums;
179
+ }
180
+ .tree-tooltip .tt-glyph { color: #888; width: 14px; }
181
+ .tree-tooltip .tt-name { color: #f7f5ee; }
182
+ .tree-tooltip .tt-name.expected { color: #317f3f; font-weight: 600; }
183
+ .tree-tooltip .tt-dist { color: #888; margin-left: auto; }
184
+ .tree-frame { position: relative; } /* anchor for the tooltip */
185
+ /* Footer legend strip + scoping caption */
186
+ .tree-legend {
187
+ display: flex; gap: 18px; flex-wrap: wrap;
188
+ margin-top: 10px;
189
+ font-family: "JetBrains Mono", monospace;
190
+ font-size: 10px; color: #666;
191
+ }
192
+ .tree-legend-item { display: flex; align-items: center; gap: 5px; }
193
+ .tree-legend-swatch { width: 9px; height: 9px; border-radius: 2px; }
194
+ .tree-legend-glyph { font-size: 12px; font-weight: 700; line-height: 1; }
195
+ .tree-caption {
196
+ margin-top: 6px;
197
+ font-family: "JetBrains Mono", monospace;
198
+ font-size: 10px; color: #999;
199
+ line-height: 1.6;
200
+ }
201
+ @media (max-width: 720px) {
202
+ .tree-grid { grid-template-columns: 1fr; }
203
+ .tree-spine { border-bottom: 1px solid #e5e3da; padding-right: 12px; }
204
+ .tree-spine svg .leaf-svg-label,
205
+ .tree-spine svg .leaf-svg-chip { display: inline; }
206
+ .tree-row { grid-template-columns: 10px 110px minmax(60px, 1fr) 22px; padding: 0 10px; }
207
+ }
assets/styles/section-umap.css ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* section-umap.css — §6 Embedding space (WebGL scatter, 571K points)
2
+ styling: framing canvas, hover tooltip, status overlay, in-place
3
+ cluster annotations, and the colour legend strip below. */
4
+
5
+ .umap-frame {
6
+ position: relative;
7
+ width: 100%;
8
+ aspect-ratio: 16 / 10;
9
+ /* Slight off-white that matches the editorial paper tone (body uses
10
+ #f7f5ee). Pure white made the desaturated minority biotypes vanish
11
+ into the page and made the saturated palette look harsh. */
12
+ background: #fbfaf6;
13
+ border: 1px solid #e5e3da;
14
+ overflow: hidden;
15
+ }
16
+ .umap-canvas {
17
+ position: absolute; inset: 0;
18
+ width: 100%; height: 100%;
19
+ display: block;
20
+ cursor: grab;
21
+ touch-action: none;
22
+ }
23
+ .umap-canvas.panning { cursor: grabbing; }
24
+ .umap-tooltip {
25
+ position: absolute;
26
+ pointer-events: none;
27
+ background: #1f1f1d; color: #f7f5ee;
28
+ font-family: "JetBrains Mono", monospace;
29
+ font-size: 10px; line-height: 1.4;
30
+ padding: 6px 9px;
31
+ border-radius: 2px;
32
+ white-space: nowrap;
33
+ opacity: 0;
34
+ transform: translate(8px, -100%);
35
+ transition: opacity 0.12s;
36
+ z-index: 4;
37
+ }
38
+ .umap-tooltip.visible { opacity: 0.96; }
39
+ .umap-tooltip .t-label {
40
+ color: #8c918b;
41
+ text-transform: uppercase; letter-spacing: 1px;
42
+ font-size: 8px;
43
+ margin-right: 4px;
44
+ }
45
+ .umap-status-overlay {
46
+ position: absolute; inset: 0;
47
+ display: flex; align-items: center; justify-content: center;
48
+ color: #aaa;
49
+ font-family: "JetBrains Mono", monospace;
50
+ font-size: 11px; letter-spacing: 1.5px;
51
+ text-transform: uppercase;
52
+ background: rgba(247, 245, 238, 0.85);
53
+ pointer-events: none;
54
+ transition: opacity 0.2s;
55
+ }
56
+ .umap-status-overlay.hidden { opacity: 0; }
57
+
58
+ /* Cluster-name annotations sit DIRECTLY on top of each cluster's
59
+ centroid — no leader lines, no margin anchors, no body text.
60
+ Editorial choice: the names *are* the annotation, set in heavier
61
+ type with a strong paper-coloured halo so they read clean against
62
+ coloured data underneath. Cf. classic Hillis radial trees, Tufte's
63
+ "small multiples with labels-in-place". pointer-events:none so the
64
+ canvas keeps every hover. */
65
+ .umap-annotations {
66
+ position: absolute; inset: 0;
67
+ pointer-events: none;
68
+ z-index: 3;
69
+ }
70
+ .ann-label {
71
+ position: absolute;
72
+ /* Centre on (left, top) — JS sets those to the screen-space
73
+ coordinates of the cluster centroid. */
74
+ transform: translate(-50%, -50%);
75
+ white-space: nowrap;
76
+ font-family: "Inter", sans-serif;
77
+ font-size: 13px;
78
+ font-weight: 700;
79
+ letter-spacing: 0.2px;
80
+ color: #1f1f1d;
81
+ /* Real glyph outline (not a rectangle, not a blurred halo). The
82
+ trick is `paint-order: stroke fill` — without it `-webkit-text-
83
+ stroke` paints centred on the glyph contour, eating half its
84
+ inside and turning bold type into spindly outlined type. With
85
+ stroke-then-fill, the cream stroke is painted first as a
86
+ silhouette, then the dark fill drops on top at full thickness.
87
+ Net visible: a clean ~2.5 px cream halo following each letter
88
+ shape exactly. Reads cleanly over any colour underneath
89
+ (including the saturated vertebrates blue + AT-rich teal that
90
+ killed earlier blur-shadow attempts). */
91
+ -webkit-text-stroke: 5px #fbfaf6;
92
+ text-stroke: 5px #fbfaf6;
93
+ paint-order: stroke fill;
94
+ transition: opacity 0.18s;
95
+ }
96
+ .umap-legend {
97
+ display: flex; flex-wrap: wrap;
98
+ gap: 6px 14px;
99
+ margin-top: 10px;
100
+ font-family: "JetBrains Mono", monospace;
101
+ font-size: 10px;
102
+ color: #666;
103
+ }
104
+ .umap-legend .swatch {
105
+ display: inline-block;
106
+ width: 9px; height: 9px;
107
+ margin-right: 5px;
108
+ vertical-align: middle;
109
+ border-radius: 2px;
110
+ }
111
+ .umap-legend .item {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ cursor: default;
115
+ }
116
+ .umap-legend .item.gc-grad {
117
+ gap: 8px;
118
+ }
119
+ .umap-legend .item.gc-grad svg {
120
+ border-radius: 2px;
121
+ display: block;
122
+ }
123
+ .umap-legend .item.gc-grad .gc-ticks {
124
+ letter-spacing: 0.5px;
125
+ color: #888;
126
+ }
assets/styles/section-vep.css ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* section-vep.css — §2 Variant effect predictor demo: the centred
2
+ sequence window with ref/alt highlight, the per-allele likelihood
3
+ bars, and the significance pills (.sig-Pathogenic / Risk / Benign)
4
+ that decorate the gene chips in the toolbar. */
5
+
6
+ .vep-window {
7
+ font-family: "JetBrains Mono", monospace;
8
+ background: #f7f7f7; border: 1px solid #e0e0e0;
9
+ padding: 14px 18px; margin: 8px 0;
10
+ font-size: 13px; line-height: 1.6; letter-spacing: 1px;
11
+ overflow-x: auto; white-space: pre;
12
+ text-align: center;
13
+ }
14
+ .vep-window .ctx { color: #888; }
15
+ .vep-window .var-ref { background: rgba(49, 127, 63, 0.18); color: #215a2a; padding: 0 4px; border-radius: 2px; font-weight: 500; }
16
+ .vep-window .var-alt { background: rgba(188, 46, 37, 0.20); color: #b00020; padding: 0 4px; border-radius: 2px; font-weight: 500; }
17
+ .vep-result {
18
+ display: grid; grid-template-columns: 100px 1fr 80px;
19
+ gap: 8px 12px; align-items: center;
20
+ margin-top: 12px; font-family: "JetBrains Mono", monospace; font-size: 11px;
21
+ }
22
+ .vep-result .row-label { color: #666; text-transform: uppercase; letter-spacing: 1px; font-size: 10px; }
23
+ .vep-result .row-bar { height: 14px; background: #f0f0f0; border-radius: 2px; position: relative; overflow: hidden; }
24
+ .vep-result .row-bar .fill { position: absolute; top: 0; bottom: 0; left: 0; }
25
+ .vep-result .row-bar.ref .fill { background: #317f3f; }
26
+ .vep-result .row-bar.alt .fill { background: #bc2e25; }
27
+ .vep-result .row-val { text-align: right; color: #1f1f1d; font-variant-numeric: tabular-nums; }
28
+ .vep-result .row-delta { font-weight: 500; }
29
+
30
+ .pill.sig-Pathogenic { border-left: 3px solid #bc2e25; }
31
+ .pill.sig-Risk { border-left: 3px solid #e69500; }
32
+ .pill.sig-Benign { border-left: 3px solid #317f3f; }
assets/styles/sequence.css ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* sequence.css — utilities for any section that displays nucleotide or
2
+ amino-acid sequences: gene track SVG (used by §1, §3, §5), the .seq-
3
+ label header (with carbon/reference chips and length tags), the
4
+ .seq-block monospace box, the .stat-row at the bottom of demos, and
5
+ small reference-mismatch highlight styles shared by §1 and §5. */
6
+
7
+ /* --- Gene track + gene-info (shared by §1, §3, §5) --- */
8
+ .gene-info {
9
+ font-family: "JetBrains Mono", monospace;
10
+ font-size: 11px; color: #666;
11
+ margin: 4px 0 12px;
12
+ min-height: 14px;
13
+ }
14
+ .gene-info strong { color: #1f1f1d; font-weight: 500; }
15
+ .gene-track {
16
+ width: 100%; height: 28px; display: block;
17
+ margin: 4px 0 8px;
18
+ }
19
+ .gene-track.draggable { height: 40px; touch-action: none; }
20
+ .gene-track .exon { fill: #317f3f; }
21
+ .gene-track .intron { stroke: #aaa; stroke-width: 1; }
22
+ .gene-track .playhead { stroke: #bc2e25; stroke-width: 2; }
23
+ .gene-track .gen-region { fill: #317f3f; opacity: 0.15; }
24
+ .gene-track .prompt-region { fill: #1f1f1d; opacity: 0.04; }
25
+ .gene-track .handle { cursor: ew-resize; }
26
+ .gene-track .handle line { stroke: #1f1f1d; stroke-width: 1.5; }
27
+ .gene-track .handle polygon { fill: #1f1f1d; }
28
+ .gene-track .handle:hover line,
29
+ .gene-track .handle.dragging line { stroke: #000; stroke-width: 2; }
30
+ .gene-track .handle:hover polygon,
31
+ .gene-track .handle.dragging polygon { fill: #000; }
32
+ .gene-track .handle.gen line { stroke: #317f3f; }
33
+ .gene-track .handle.gen polygon { fill: #317f3f; }
34
+ .gene-track .handle.gen:hover line,
35
+ .gene-track .handle.gen.dragging line { stroke: #1f5024; stroke-width: 2; }
36
+ .gene-track .handle.gen:hover polygon,
37
+ .gene-track .handle.gen.dragging polygon { fill: #1f5024; }
38
+ .gene-track text { font-family: "JetBrains Mono", monospace; font-size: 9px; fill: #888; }
39
+
40
+ /* Instant tooltips (no native-title delay) for legend items. */
41
+ .legend-tip { position: relative; }
42
+ .legend-tip:hover::after {
43
+ content: attr(data-tip);
44
+ position: absolute;
45
+ bottom: calc(100% + 8px);
46
+ left: 50%;
47
+ transform: translateX(-50%);
48
+ background: #1f1f1d;
49
+ color: #f7f5ee;
50
+ padding: 6px 10px;
51
+ border-radius: 3px;
52
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
53
+ font-family: 'Inter', sans-serif;
54
+ font-size: 11px;
55
+ font-weight: 400;
56
+ letter-spacing: normal;
57
+ text-transform: none;
58
+ white-space: normal;
59
+ width: max-content;
60
+ max-width: 260px;
61
+ line-height: 1.4;
62
+ z-index: 10;
63
+ pointer-events: none;
64
+ }
65
+ .legend-tip:hover::before {
66
+ content: "";
67
+ position: absolute;
68
+ bottom: calc(100% + 2px);
69
+ left: 50%;
70
+ transform: translateX(-50%);
71
+ border: 4px solid transparent;
72
+ border-top-color: #1f1f1d;
73
+ z-index: 10;
74
+ pointer-events: none;
75
+ }
76
+ .track-axis-label {
77
+ font-family: "JetBrains Mono", monospace; font-size: 9px;
78
+ color: #888; text-transform: uppercase; letter-spacing: 1px;
79
+ display: flex; justify-content: space-between; margin-top: -4px;
80
+ }
81
+
82
+ /* --- seq-label header row + chips + length tag (used by §1, §3, §5) --- */
83
+ .seq-label {
84
+ font-family: "JetBrains Mono", monospace;
85
+ font-size: 9px; color: #888; text-transform: uppercase; letter-spacing: 1.5px;
86
+ margin-top: 14px; margin-bottom: 4px; display: flex; gap: 12px; align-items: center;
87
+ }
88
+ .seq-label .legend-swatch {
89
+ display: inline-block; width: 8px; height: 8px; vertical-align: middle;
90
+ margin-right: 4px; border-radius: 1px;
91
+ }
92
+ /* Inline tag chips used in §5 to disambiguate carbon vs reference rows.
93
+ Same shape/size, different colour band so the eye instantly maps a
94
+ row of AAs to the correct identity without re-reading the full label. */
95
+ .seq-label .seq-tag {
96
+ display: inline-block;
97
+ font-size: 9px; letter-spacing: 1.5px;
98
+ padding: 1px 6px; margin-right: 8px;
99
+ border-radius: 2px;
100
+ text-transform: uppercase;
101
+ font-weight: 600;
102
+ }
103
+ .seq-label .seq-tag.carbon { background: #1f1f1d; color: #f7f5ee; }
104
+ .seq-label .seq-tag.ref { background: #f0eee5; color: #555; border: 1px solid #d8d5c8; }
105
+ .seq-label .aa-len-tag {
106
+ color: #1f1f1d;
107
+ font-variant-numeric: tabular-nums;
108
+ text-transform: none;
109
+ letter-spacing: 0.3px;
110
+ }
111
+ /* In-label red stat used by the carbon row (e.g. "· 96 mismatches").
112
+ Defined as a class so the JS doesn't have to inline color styles. */
113
+ .seq-label .seq-label-stat { color: #b00020; }
114
+
115
+ /* --- stat row at the bottom of every demo --- */
116
+ .stat-row {
117
+ display: flex; flex-wrap: wrap; gap: 24px;
118
+ margin-top: 14px; padding-top: 12px; border-top: 1px solid #eee;
119
+ font-family: "JetBrains Mono", monospace; font-size: 11px;
120
+ }
121
+ .stat-pair { display: flex; flex-direction: column; gap: 2px; }
122
+ .stat-pair-label { font-size: 9px; color: #999; text-transform: uppercase; letter-spacing: 1.2px; }
123
+ .stat-pair-val { color: #1f1f1d; font-variant-numeric: tabular-nums; }
124
+ .stat-pair-val.muted { color: #aaa; }
125
+
126
+ /* --- Sequence display (shared with sandbox, used outside #panel-sandbox) --- */
127
+ .seq-block {
128
+ font-family: "JetBrains Mono", monospace;
129
+ background: #f7f7f7; border: 1px solid #e0e0e0;
130
+ padding: 14px 18px; overflow-x: auto;
131
+ white-space: pre; font-size: 12px; font-weight: 400;
132
+ line-height: 1.85; letter-spacing: 1px;
133
+ }
134
+ .seq-block.empty { color: #aaa; font-weight: 300; letter-spacing: normal; }
135
+ .pos { color: #bbb; user-select: none; font-weight: 300; }
136
+
137
+ /* Mismatch highlighting in reference row (§1, §5). */
138
+ .ref-mismatch { background: rgba(188, 46, 37, 0.18); color: #b00020; }
139
+ .ref-match { color: #999; }
demo.html CHANGED
The diff for this file is too large to render. See raw diff