Spaces:
Running
Split demo.html into modular CSS/JS assets
Browse filesdemo.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 +24 -8
- assets/js/banner.js +308 -0
- assets/js/sections/architecture.js +37 -0
- assets/js/sections/completion.js +337 -0
- assets/js/sections/data.js +115 -0
- assets/js/sections/folding.js +647 -0
- assets/js/sections/loss.js +163 -0
- assets/js/sections/sandbox.js +465 -0
- assets/js/sections/species.js +241 -0
- assets/js/sections/tokenizer.js +81 -0
- assets/js/sections/track.js +261 -0
- assets/js/sections/tree.js +340 -0
- assets/js/sections/umap.js +926 -0
- assets/js/sections/vep.js +274 -0
- assets/js/shared/config.js +33 -0
- assets/js/shared/helpers.js +75 -0
- assets/js/tabs.js +52 -0
- assets/styles/banner.css +92 -0
- assets/styles/base.css +26 -0
- assets/styles/controls.css +64 -0
- assets/styles/footer.css +11 -0
- assets/styles/header.css +61 -0
- assets/styles/layout.css +160 -0
- assets/styles/recipe.css +10 -0
- assets/styles/sandbox.css +177 -0
- assets/styles/section-folding.css +137 -0
- assets/styles/section-species.css +38 -0
- assets/styles/section-tree.css +207 -0
- assets/styles/section-umap.css +126 -0
- assets/styles/section-vep.css +32 -0
- assets/styles/sequence.css +139 -0
- demo.html +0 -0
|
@@ -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
|
| 95 |
-
#
|
| 96 |
-
# .js/.css aggressively by default (often
|
| 97 |
-
#
|
| 98 |
-
#
|
| 99 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
@app.middleware("http")
|
| 101 |
-
async def
|
| 102 |
response = await call_next(request)
|
| 103 |
-
if request.url.path.startswith(
|
| 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"
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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,"<").replace(/>/g,">")}</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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 · 0.5 · 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])} · ${formatBp(Math.round(Math.sqrt(lr[0] * lr[1])))} · ${formatBp(lr[1])}`
|
| 669 |
+
: "short · mid · 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 |
+
{ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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} <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 |
+
|
|
@@ -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 |
+
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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();
|
|
@@ -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 |
+
}
|
|
@@ -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; }
|
|
@@ -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; }
|
|
@@ -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; }
|
|
@@ -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; }
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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; }
|
|
@@ -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; }
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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; }
|
|
@@ -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; }
|
|
The diff for this file is too large to render.
See raw diff
|
|
|