viz: extract N-panel-agnostic module; homepage modal reuses it for single-run view

- panel-grid.js (new): exports mountPanels({host, controls, stems}) → {destroy}.
  Moved createPanel + shared control wiring + linked-hover + pad-to-match
  time mapping out of compare.js. Stem-count-agnostic; works for 1, 2, or N.
- Panel DOM is cloned from <template id=compare-panel-tpl> on each page.
- compare.js is now a ~10-line shim: parse ?a=&b=, call mountPanels.
- Per-panel color is viridis-sampled by index/N (middle viridis for N=1,
  ends-of-palette for N=2, linear lerp for N≤8, cycle at N≥9). Set as
  --panel-color on the panel element; CSS reads it for tag/time-seg.
- Homepage <dialog id=run-modal> + run-modal.js hijack the 'embedding' link
  (plain click → modal; meta/ctrl/middle-click still opens plotly HTML).
  Dialog close disposes every panel's renderer/geometry/material.
- .compare-grid → repeat(auto-fit, minmax(360px, 1fr)) handles N=1..many,
  replaces the <900px one-column media rule.
- Runs list: relabel Prefect's 'Late' state as 'Queued' — more honest
  description of what the runner is doing at the concurrency cap.
This commit is contained in:
Michael Pilosov 2026-04-22 16:17:01 -06:00
parent 47f56b57c8
commit 44de8deeeb
8 changed files with 984 additions and 778 deletions

View File

@ -590,6 +590,11 @@ def _run_view(run: Dict[str, Any]) -> Dict[str, Any]:
rid = run.get("id", "") rid = run.get("id", "")
state_type = (run.get("state_type") or "PENDING").upper() state_type = (run.get("state_type") or "PENDING").upper()
state_name = run.get("state_name") or state_type.title() state_name = run.get("state_name") or state_type.title()
# Prefect labels a scheduled run that's past its start time "Late". The
# runner is simply busy / awaiting its turn at the concurrency cap — show
# "Queued" instead, which is the honest description of the state.
if state_name == "Late":
state_name = "Queued"
start = run.get("start_time") or run.get("expected_start_time") or run.get("created") start = run.get("start_time") or run.get("expected_start_time") or run.get("created")
params = run.get("parameters") or {} params = run.get("parameters") or {}
# estimated_run_time recomputes server-side (ticks up while RUNNING); # estimated_run_time recomputes server-side (ticks up while RUNNING);

View File

@ -1,733 +1,11 @@
// compare.js — side-by-side animated scatter for two embedding runs. // compare.js — thin shim that parses ?a=&b= and hands off to panel-grid.js.
//
// Reads ?a=<stemA>&b=<stemB> from the URL, fetches /api/runs/<stem>/frames.json
// for each, and renders them into two linked three.js panels with a shared
// play/scrub/speed control strip. Linked hover: cursor on a point in one
// panel highlights the same point_id in the other.
import * as THREE from 'three'; import { mountPanels } from './panel-grid.js?v=1';
// -------- URL / DOM wiring ------------------------------------------------
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const STEM_A = params.get('a') || ''; const stems = [params.get('a') || '', params.get('b') || ''].filter(Boolean);
const STEM_B = params.get('b') || '';
const layout = document.getElementById('compare-layout'); const host = document.getElementById('panel-host');
const panelElA = layout.querySelector('.compare-panel[data-slot="a"]'); const controls = document.getElementById('compare-controls');
const panelElB = layout.querySelector('.compare-panel[data-slot="b"]');
const playBtn = document.getElementById('cc-play'); mountPanels({ host, controls, stems });
const scrub = document.getElementById('cc-scrub');
const speedSel = document.getElementById('cc-speed');
const syncSel = document.getElementById('cc-sync');
const motionSel = document.getElementById('cc-motion');
const colorSel = document.getElementById('cc-color');
const timeAEl = document.getElementById('cc-time').querySelector('[data-role="time-a"]');
const timeBEl = document.getElementById('cc-time').querySelector('[data-role="time-b"]');
// -------- small helpers ---------------------------------------------------
// Build a soft-edged circular sprite for THREE.Points. A plain texture with
// radial alpha gives anti-aliased round dots without fragment-shader work.
function makeDiskTexture(size = 64) {
const c = document.createElement('canvas');
c.width = c.height = size;
const g = c.getContext('2d');
const r = size / 2;
const grd = g.createRadialGradient(r, r, 0, r, r, r);
grd.addColorStop(0.0, 'rgba(255,255,255,1)');
grd.addColorStop(0.55, 'rgba(255,255,255,1)');
grd.addColorStop(0.85, 'rgba(255,255,255,0.35)');
grd.addColorStop(1.0, 'rgba(255,255,255,0)');
g.fillStyle = grd;
g.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(c);
tex.needsUpdate = true;
return tex;
}
const DISK_TEX = makeDiskTexture();
// Continuous ramp (blue → orange), matching dataset-picker.js exactly.
function rampContinuous(t, out) {
const hue = (1 - t) * 215 + t * 28;
const sat = 0.62;
const lit = 0.50 + (t - 0.5) * 0.08;
return (out || new THREE.Color()).setHSL(hue / 360, sat, lit);
}
// 8-color categorical palette — same hex list as dataset-picker.js.
const CATEGORICAL_HEX = [
'#1f4e5f', '#c97b3f', '#8b5a9f', '#5a8560',
'#c74a5e', '#6b7d8f', '#b89f51', '#4a6fa5',
];
const CATEGORICAL = CATEGORICAL_HEX.map(h => new THREE.Color(h));
// Precompute per-point RGB, indexed by position in data.point_ids.
// If the server attached labels + label_kind, color by that (ramp for
// continuous, palette for categorical) to match the dataset picker. Points
// with null labels (jitter-added, id >= num_points) stay (0,0,0) = black.
// Falls back to a frame-0-present ordinal ramp when no labels are present.
function buildIdColorsRGB(data) {
const n = data.point_ids.length;
const rgb = new Float32Array(n * 3);
const labels = data.labels || [];
const kind = data.label_kind || null;
const hasRealLabels = kind && labels.some(v => v != null && v !== '');
if (hasRealLabels) {
const tmp = new THREE.Color();
if (kind === 'categorical') {
for (let i = 0; i < n; i++) {
const v = labels[i];
if (v == null) continue;
const c = CATEGORICAL[((v | 0) % CATEGORICAL.length + CATEGORICAL.length) % CATEGORICAL.length];
rgb[i * 3 + 0] = c.r;
rgb[i * 3 + 1] = c.g;
rgb[i * 3 + 2] = c.b;
}
} else {
let lo = Infinity, hi = -Infinity;
for (const v of labels) { if (v == null) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
const range = (hi - lo) || 1;
for (let i = 0; i < n; i++) {
const v = labels[i];
if (v == null) continue;
rampContinuous((v - lo) / range, tmp);
rgb[i * 3 + 0] = tmp.r;
rgb[i * 3 + 1] = tmp.g;
rgb[i * 3 + 2] = tmp.b;
}
}
return rgb;
}
// Fallback: rainbow-by-ordinal over frame-0-present points.
const frame0 = data.frames[0];
if (!frame0) return rgb;
const originalPositions = [];
for (let i = 0; i < n; i++) {
if (frame0.x[i] != null && frame0.y[i] != null
&& !Number.isNaN(frame0.x[i]) && !Number.isNaN(frame0.y[i])) {
originalPositions.push(i);
}
}
originalPositions.sort((a, b) => data.point_ids[a] - data.point_ids[b]);
const nOrig = originalPositions.length;
const tmp = new THREE.Color();
for (let k = 0; k < nOrig; k++) {
const t = nOrig > 1 ? k / (nOrig - 1) : 0.5;
rampContinuous(t, tmp);
const idx = originalPositions[k];
rgb[idx * 3 + 0] = tmp.r;
rgb[idx * 3 + 1] = tmp.g;
rgb[idx * 3 + 2] = tmp.b;
}
return rgb;
}
function readVar(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function readPanelBg(el) {
const v = getComputedStyle(el).getPropertyValue('--picker-panel').trim();
return v || readVar('--panel', '#ffffff');
}
// Panel accent colors keyed by slot. Resolved from CSS vars so they flip
// with the theme via 'themechange'.
function panelAccent(slot) {
return slot === 'a' ? readVar('--accent', '#1f4e5f') : readVar('--warm', '#a77a2c');
}
function highlightColor() {
return readVar('--alarm', '#8a3a2a');
}
// -------- Panel factory ---------------------------------------------------
// Returns { setFrame, setBounds, setHighlight, onHover, resize, dispose, state }
function createPanel({ slotId, panelEl, data }) {
const canvasEl = panelEl.querySelector('[data-role="canvas"]');
const statusEl = panelEl.querySelector('[data-role="status"]');
const scene = new THREE.Scene();
scene.background = new THREE.Color(readPanelBg(panelEl));
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
canvasEl.appendChild(renderer.domElement);
// Pre-allocate a buffer sized for num_points. Each frame we repack the
// non-null points into the prefix and call setDrawRange(0, count).
const maxN = data.point_ids.length;
const positions = new Float32Array(maxN * 3);
const colors = new Float32Array(maxN * 3);
const ids = new Int32Array(maxN); // packed point_id per drawn vertex
const idColorRGB = buildIdColorsRGB(data);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.setDrawRange(0, 0);
const matMono = new THREE.PointsMaterial({
size: 6.0,
sizeAttenuation: false,
map: DISK_TEX,
transparent: true,
alphaTest: 0.2,
depthWrite: false,
color: new THREE.Color(panelAccent(slotId)),
});
const matRainbow = new THREE.PointsMaterial({
size: 6.0,
sizeAttenuation: false,
map: DISK_TEX,
// Opaque + alpha cutoff rather than alpha blending: with a ramp, many
// overlapping translucent points average out to the middle of the ramp
// (blue + orange → green), washing out the whole cloud. Hard edges keep
// each point's true color visible.
transparent: false,
alphaTest: 0.5,
vertexColors: true,
});
let mat = matMono;
const points = new THREE.Points(geo, mat);
scene.add(points);
// Highlight overlay — a second 1-vertex Points object drawn on top.
const hiPos = new Float32Array(3);
const hiGeo = new THREE.BufferGeometry();
hiGeo.setAttribute('position', new THREE.BufferAttribute(hiPos, 3));
hiGeo.setDrawRange(0, 0);
const hiMat = new THREE.PointsMaterial({
size: 14.0,
sizeAttenuation: false,
map: DISK_TEX,
transparent: true,
alphaTest: 0.05,
depthWrite: false,
color: new THREE.Color(highlightColor()),
opacity: 0.9,
});
const hiPoints = new THREE.Points(hiGeo, hiMat);
// Ensure highlight renders above base points
hiPoints.renderOrder = 1;
scene.add(hiPoints);
// ---- current-frame packed state (kept in closure for hover picking) ----
// packedX/packedY/packedId of length = current draw count.
let packedN = 0;
const packedX = new Float32Array(maxN);
const packedY = new Float32Array(maxN);
const packedId = new Int32Array(maxN);
// Camera frame rectangle (world coords) currently applied.
const camRect = { xmin: -1, xmax: 1, ymin: -1, ymax: 1 };
function applyCamRect(rect) {
const pad = 0.05;
const dx = rect.xmax - rect.xmin || 1;
const dy = rect.ymax - rect.ymin || 1;
const cx = (rect.xmin + rect.xmax) / 2;
const cy = (rect.ymin + rect.ymax) / 2;
const rx = dx * (0.5 + pad);
const ry = dy * (0.5 + pad);
// Fit-to-larger-axis so points never get squashed when the panel aspect
// doesn't match the bounds aspect. We compute the viewport aspect here
// so the ortho frustum covers the data and then some.
const rect2 = canvasEl.getBoundingClientRect();
const vw = Math.max(1, rect2.width);
const vh = Math.max(1, rect2.height);
const aspect = vw / vh;
const dataAspect = (rx * 2) / (ry * 2);
let halfW, halfH;
if (aspect > dataAspect) {
// viewport wider than data: expand X to fit
halfH = ry;
halfW = ry * aspect;
} else {
halfW = rx;
halfH = rx / aspect;
}
camera.left = cx - halfW;
camera.right = cx + halfW;
camera.top = cy + halfH;
camera.bottom = cy - halfH;
camera.updateProjectionMatrix();
camRect.xmin = rect.xmin;
camRect.xmax = rect.xmax;
camRect.ymin = rect.ymin;
camRect.ymax = rect.ymax;
}
function resize() {
const rect = canvasEl.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width));
const h = Math.max(1, Math.floor(rect.height));
renderer.setSize(w, h, false);
// Re-apply the current cam rect so the aspect fit recomputes.
applyCamRect(camRect);
}
function setBounds(b) {
applyCamRect({ xmin: b.x[0], xmax: b.x[1], ymin: b.y[0], ymax: b.y[1] });
}
function applyColorsFromTheme() {
matMono.color.set(panelAccent(slotId));
hiMat.color.set(highlightColor());
scene.background = new THREE.Color(readPanelBg(panelEl));
}
function setColorMode(mode) {
const next = mode === 'mono' ? matMono : matRainbow;
if (points.material !== next) {
points.material = next;
mat = next;
}
}
// Copy the precomputed per-id RGB into the packed position `j`.
function writePackedColor(j, i) {
colors[j * 3 + 0] = idColorRGB[i * 3 + 0];
colors[j * 3 + 1] = idColorRGB[i * 3 + 1];
colors[j * 3 + 2] = idColorRGB[i * 3 + 2];
}
// Pack frame `f` into the geometry buffer, skipping null x/y.
function setFrame(f) {
const frame = data.frames[f];
if (!frame) return;
const xs = frame.x, ys = frame.y;
const ptIds = data.point_ids;
let j = 0;
for (let i = 0; i < xs.length; i++) {
const x = xs[i], y = ys[i];
if (x === null || y === null || x === undefined || y === undefined
|| Number.isNaN(x) || Number.isNaN(y)) continue;
positions[j * 3 + 0] = x;
positions[j * 3 + 1] = y;
positions[j * 3 + 2] = 0;
writePackedColor(j, i);
packedX[j] = x;
packedY[j] = y;
packedId[j] = ptIds[i];
j++;
}
packedN = j;
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
geo.setDrawRange(0, packedN);
applyHighlightForCurrentFrame();
}
// Pack an interpolated frame. uLocal is a continuous index in [0, T-1].
// Points missing in either adjacent frame are skipped for the duration of
// that transition (no connect-back to the last-known position).
function setFrameInterpolated(uLocal) {
const T = data.frames.length;
if (T === 0) return;
if (uLocal <= 0) return setFrame(0);
if (uLocal >= T - 1) return setFrame(T - 1);
const f0 = Math.floor(uLocal);
const f1 = f0 + 1;
const t = uLocal - f0;
const fr0 = data.frames[f0], fr1 = data.frames[f1];
const x0 = fr0.x, y0 = fr0.y, x1 = fr1.x, y1 = fr1.y;
const ptIds = data.point_ids;
let j = 0;
for (let i = 0; i < x0.length; i++) {
const a = x0[i], b = x1[i], c = y0[i], d = y1[i];
if (a === null || a === undefined || Number.isNaN(a)
|| b === null || b === undefined || Number.isNaN(b)
|| c === null || c === undefined || Number.isNaN(c)
|| d === null || d === undefined || Number.isNaN(d)) continue;
const xi = a + (b - a) * t;
const yi = c + (d - c) * t;
positions[j * 3 + 0] = xi;
positions[j * 3 + 1] = yi;
positions[j * 3 + 2] = 0;
writePackedColor(j, i);
packedX[j] = xi;
packedY[j] = yi;
packedId[j] = ptIds[i];
j++;
}
packedN = j;
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
geo.setDrawRange(0, packedN);
applyHighlightForCurrentFrame();
}
// ---- highlight by point_id ---------------------------------------------
let currentHighlightId = -1;
function applyHighlightForCurrentFrame() {
if (currentHighlightId < 0) {
hiGeo.setDrawRange(0, 0);
return;
}
// Linear scan — packedN <= 5000 and this only runs on hover.
for (let i = 0; i < packedN; i++) {
if (packedId[i] === currentHighlightId) {
hiPos[0] = packedX[i];
hiPos[1] = packedY[i];
hiPos[2] = 0.01;
hiGeo.attributes.position.needsUpdate = true;
hiGeo.setDrawRange(0, 1);
return;
}
}
// Not present this frame.
hiGeo.setDrawRange(0, 0);
}
function setHighlight(pointId) {
currentHighlightId = (pointId === null || pointId === undefined) ? -1 : pointId;
applyHighlightForCurrentFrame();
}
// Convert clientX/Y to world coords (ortho, no rotation).
function clientToWorld(clientX, clientY) {
const rect = canvasEl.getBoundingClientRect();
const u = (clientX - rect.left) / Math.max(1, rect.width);
const v = (clientY - rect.top) / Math.max(1, rect.height);
const wx = camera.left + u * (camera.right - camera.left);
const wy = camera.top - v * (camera.top - camera.bottom);
return { wx, wy };
}
// Nearest-point pick with a screen-space radius cap. Returns point_id or -1.
function pickAt(clientX, clientY) {
if (packedN === 0) return -1;
const { wx, wy } = clientToWorld(clientX, clientY);
// Screen-pixel radius -> world radius
const rect = canvasEl.getBoundingClientRect();
const worldPerPx = (camera.right - camera.left) / Math.max(1, rect.width);
const pickPx = 14;
const maxR = pickPx * worldPerPx;
const maxR2 = maxR * maxR;
let bestI = -1;
let bestD2 = Infinity;
for (let i = 0; i < packedN; i++) {
const dx = packedX[i] - wx;
const dy = packedY[i] - wy;
const d2 = dx * dx + dy * dy;
if (d2 < bestD2 && d2 < maxR2) {
bestD2 = d2;
bestI = i;
}
}
return bestI < 0 ? -1 : packedId[bestI];
}
function render() {
renderer.render(scene, camera);
}
function dispose() {
geo.dispose();
hiGeo.dispose();
matMono.dispose();
matRainbow.dispose();
hiMat.dispose();
renderer.dispose();
if (renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
}
// Hide the status overlay once we have data.
statusEl.style.display = 'none';
return {
slotId,
canvasEl,
panelEl,
data,
setFrame,
setFrameInterpolated,
setColorMode,
setBounds,
setHighlight,
resize,
render,
pickAt,
applyColorsFromTheme,
dispose,
get packedN() { return packedN; },
get camRect() { return camRect; },
applyCamRect,
};
}
// -------- error rendering -------------------------------------------------
function renderError(panelEl, stem, msg) {
const statusEl = panelEl.querySelector('[data-role="status"]');
statusEl.style.display = '';
statusEl.classList.add('is-error');
statusEl.textContent = `could not load ${stem}: ${msg}`;
// Keep header readable
panelEl.querySelector('[data-role="embedder"]').textContent = '—';
panelEl.querySelector('[data-role="generator"]').textContent = '—';
panelEl.querySelector('[data-role="params"]').textContent = '(error)';
}
const PARAM_FIELDS = [
{ key: 'num_points', prefix: 'N' },
{ key: 'num_timesteps', prefix: 'T' },
{ key: 'jitter_scale', prefix: 'J' },
{ key: 'seed', prefix: 's' },
];
function renderHeader(panelEl, data) {
const m = data.meta || {};
panelEl.querySelector('[data-role="embedder"]').textContent = m.embedder || '—';
panelEl.querySelector('[data-role="generator"]').textContent = m.generator || '—';
const host = panelEl.querySelector('[data-role="params"]');
host.textContent = '';
PARAM_FIELDS.forEach(({ key, prefix }, i) => {
if (i > 0) host.appendChild(document.createTextNode(' / '));
const span = document.createElement('span');
span.className = 'param';
span.dataset.key = key;
span.textContent = `${prefix}${m[key] ?? '?'}`;
host.appendChild(span);
});
}
// Toggle .diff on header spans where the two panels disagree.
function markParamDiffs(metaA, metaB) {
if (!metaA || !metaB) return;
for (const { key } of PARAM_FIELDS) {
const differs = metaA[key] !== metaB[key];
for (const panelEl of [panelElA, panelElB]) {
const span = panelEl.querySelector(`[data-role="params"] .param[data-key="${key}"]`);
if (span) span.classList.toggle('diff', differs);
}
}
for (const role of ['embedder', 'generator']) {
const differs = metaA[role] !== metaB[role];
for (const panelEl of [panelElA, panelElB]) {
const span = panelEl.querySelector(`[data-role="${role}"]`);
if (span) span.classList.toggle('diff', differs);
}
}
}
// -------- main ------------------------------------------------------------
async function fetchFrames(stem) {
const res = await fetch(`/api/runs/${encodeURIComponent(stem)}/frames.json`, { cache: 'no-store' });
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
return res.json();
}
async function main() {
if (!STEM_A || !STEM_B) {
renderError(panelElA, STEM_A || '(missing)', 'no stem in ?a=');
renderError(panelElB, STEM_B || '(missing)', 'no stem in ?b=');
return;
}
// Fetch in parallel; each panel's failure is independent.
const [resA, resB] = await Promise.allSettled([
fetchFrames(STEM_A),
fetchFrames(STEM_B),
]);
const panels = { a: null, b: null };
if (resA.status === 'fulfilled') {
renderHeader(panelElA, resA.value);
panels.a = createPanel({ slotId: 'a', panelEl: panelElA, data: resA.value });
} else {
renderError(panelElA, STEM_A, resA.reason?.message || String(resA.reason));
}
if (resB.status === 'fulfilled') {
renderHeader(panelElB, resB.value);
panels.b = createPanel({ slotId: 'b', panelEl: panelElB, data: resB.value });
} else {
renderError(panelElB, STEM_B, resB.reason?.message || String(resB.reason));
}
if (panels.a && panels.b) markParamDiffs(panels.a.data.meta, panels.b.data.meta);
// Nothing loaded — no animation loop to start.
if (!panels.a && !panels.b) return;
// Initial bounds + first frame for each panel.
for (const p of Object.values(panels)) {
if (!p) continue;
p.setBounds(p.data.bounds);
p.resize();
p.setFrame(0);
}
// ---- time mapping -----------------------------------------------------
// Scrubber is 0..1000, mapped to a shared global frame index uGlobal in
// [0, maxT-1]. Each panel clamps uGlobal to its own (T-1) — so a shorter
// timeline pads its last frame until the longer timeline finishes, and
// both panels advance at the same wall-clock tempo.
const SCRUB_MAX = 1000;
function framesOf(p) { return p ? p.data.frames.length : 0; }
function timeLabelForAtLocal(p, uLocal) {
if (!p) return '—';
const T = framesOf(p);
if (T <= 0) return '—';
const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal)));
return p.data.times?.[idx] ?? String(idx);
}
function applyU(u) {
u = Math.max(0, Math.min(1, u));
const smooth = motionSel.value === 'smooth';
const tMax = maxT();
const uGlobal = u * (tMax - 1);
let uLocalA = 0, uLocalB = 0;
for (const [slot, p] of Object.entries(panels)) {
if (!p) continue;
const T = framesOf(p);
if (T <= 0) continue;
const uLocal = Math.min(uGlobal, T - 1);
if (slot === 'a') uLocalA = uLocal; else uLocalB = uLocal;
if (smooth) {
p.setFrameInterpolated(uLocal);
} else {
const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal)));
p.setFrame(idx);
}
}
timeAEl.textContent = timeLabelForAtLocal(panels.a, uLocalA);
timeBEl.textContent = timeLabelForAtLocal(panels.b, uLocalB);
}
// ---- axes sync mode ---------------------------------------------------
function applySync() {
const mode = syncSel.value;
if (mode === 'locked' && panels.a && panels.b) {
const ba = panels.a.data.bounds, bb = panels.b.data.bounds;
const union = {
x: [Math.min(ba.x[0], bb.x[0]), Math.max(ba.x[1], bb.x[1])],
y: [Math.min(ba.y[0], bb.y[0]), Math.max(ba.y[1], bb.y[1])],
};
panels.a.setBounds(union);
panels.b.setBounds(union);
} else {
if (panels.a) panels.a.setBounds(panels.a.data.bounds);
if (panels.b) panels.b.setBounds(panels.b.data.bounds);
}
}
syncSel.addEventListener('change', applySync);
applySync();
// ---- play loop --------------------------------------------------------
// Base step: 400ms per frame at 1x, divided by speed. The loop advances
// the scrub by (1 / maxT) per step so both panels traverse their whole
// timeline in the same wall-clock duration when T differs.
let playing = false;
let lastTs = 0;
function maxT() {
const ta = framesOf(panels.a);
const tb = framesOf(panels.b);
return Math.max(ta, tb, 2);
}
function baseMsPerFrame() { return 1600 / parseFloat(speedSel.value || '1'); }
// u is the authoritative playhead (float in [0, 1]). The scrubber mirrors
// it for display; reading u back from the scrubber would quantize to the
// scrubber's integer step and stall the loop at small du.
let u = 0;
function tick(ts) {
requestAnimationFrame(tick);
for (const p of Object.values(panels)) p?.render();
if (!playing) { lastTs = ts; return; }
if (!lastTs) { lastTs = ts; return; }
const dt = ts - lastTs;
lastTs = ts;
if (dt <= 0) return;
const perFrame = baseMsPerFrame();
const T = maxT();
u += dt / (perFrame * (T - 1));
if (u >= 1) u -= Math.floor(u); // wrap 0..1
scrub.value = String(Math.round(u * SCRUB_MAX));
applyU(u);
}
requestAnimationFrame(tick);
function setPlaying(v) {
playing = v;
lastTs = 0;
playBtn.textContent = playing ? '▮▮' : '▶';
playBtn.setAttribute('aria-label', playing ? 'pause' : 'play');
}
playBtn.addEventListener('click', () => setPlaying(!playing));
scrub.addEventListener('input', () => {
u = parseFloat(scrub.value) / SCRUB_MAX;
applyU(u);
});
speedSel.addEventListener('change', () => { lastTs = 0; });
motionSel.addEventListener('change', () => {
applyU(parseFloat(scrub.value) / SCRUB_MAX);
});
function applyColorMode() {
const mode = colorSel.value;
for (const p of Object.values(panels)) p?.setColorMode(mode);
}
colorSel.addEventListener('change', applyColorMode);
applyColorMode();
// ---- linked hover -----------------------------------------------------
function wireHover(pA, pB) {
if (!pA) return;
const el = pA.canvasEl;
el.addEventListener('mousemove', (ev) => {
const id = pA.pickAt(ev.clientX, ev.clientY);
pA.setHighlight(id >= 0 ? id : null);
if (pB) pB.setHighlight(id >= 0 ? id : null);
});
el.addEventListener('mouseleave', () => {
pA.setHighlight(null);
if (pB) pB.setHighlight(null);
});
}
wireHover(panels.a, panels.b);
wireHover(panels.b, panels.a);
// ---- resize + theme ---------------------------------------------------
const ro = new ResizeObserver(() => {
for (const p of Object.values(panels)) p?.resize();
});
if (panels.a) ro.observe(panels.a.canvasEl);
if (panels.b) ro.observe(panels.b.canvasEl);
document.addEventListener('themechange', () => {
for (const p of Object.values(panels)) p?.applyColorsFromTheme();
});
// Initialise the label + scrub at 0.
applyU(0);
}
main().catch((err) => {
console.error(err);
renderError(panelElA, STEM_A, 'init failed: ' + err.message);
renderError(panelElB, STEM_B, 'init failed: ' + err.message);
});

View File

@ -0,0 +1,756 @@
// panel-grid.js — reusable N-panel animated-scatter grid.
//
// Single public export: mountPanels({ host, controls, stems }) mounts one
// three.js panel per stem inside `host`, and wires the shared control strip
// in `controls` to drive all of them (play/scrub, speed, motion, axes, color,
// linked hover). Works identically for 1, 2, or N stems.
import * as THREE from 'three';
// ---- viridis (8-stop, RGB lerp) -----------------------------------------
const VIRIDIS_HEX = [
'#440154', '#482878', '#3E4989', '#31688E',
'#26828E', '#1F9E89', '#6DCD59', '#FDE725',
];
const VIRIDIS_RGB = VIRIDIS_HEX.map(h => [
parseInt(h.slice(1, 3), 16),
parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16),
]);
function lerpStops(t) {
const n = VIRIDIS_RGB.length;
if (t <= 0) return VIRIDIS_RGB[0];
if (t >= 1) return VIRIDIS_RGB[n - 1];
const x = t * (n - 1);
const i = Math.floor(x);
const f = x - i;
const a = VIRIDIS_RGB[i];
const b = VIRIDIS_RGB[i + 1];
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f),
];
}
function rgbToHex([r, g, b]) {
const h = (v) => v.toString(16).padStart(2, '0');
return `#${h(r)}${h(g)}${h(b)}`;
}
function viridisColor(i, total) {
if (total >= 9) return VIRIDIS_HEX[i % VIRIDIS_HEX.length];
if (total <= 1) return rgbToHex(lerpStops(0.5));
return rgbToHex(lerpStops(i / (total - 1)));
}
// ---- tiny helpers -------------------------------------------------------
function makeDiskTexture(size = 64) {
const c = document.createElement('canvas');
c.width = c.height = size;
const g = c.getContext('2d');
const r = size / 2;
const grd = g.createRadialGradient(r, r, 0, r, r, r);
grd.addColorStop(0.0, 'rgba(255,255,255,1)');
grd.addColorStop(0.55, 'rgba(255,255,255,1)');
grd.addColorStop(0.85, 'rgba(255,255,255,0.35)');
grd.addColorStop(1.0, 'rgba(255,255,255,0)');
g.fillStyle = grd;
g.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(c);
tex.needsUpdate = true;
return tex;
}
function rampContinuous(t, out) {
const hue = (1 - t) * 215 + t * 28;
const sat = 0.62;
const lit = 0.50 + (t - 0.5) * 0.08;
return (out || new THREE.Color()).setHSL(hue / 360, sat, lit);
}
const CATEGORICAL_HEX = [
'#1f4e5f', '#c97b3f', '#8b5a9f', '#5a8560',
'#c74a5e', '#6b7d8f', '#b89f51', '#4a6fa5',
];
const CATEGORICAL = CATEGORICAL_HEX.map(h => new THREE.Color(h));
function buildIdColorsRGB(data) {
const n = data.point_ids.length;
const rgb = new Float32Array(n * 3);
const labels = data.labels || [];
const kind = data.label_kind || null;
const hasRealLabels = kind && labels.some(v => v != null && v !== '');
if (hasRealLabels) {
const tmp = new THREE.Color();
if (kind === 'categorical') {
for (let i = 0; i < n; i++) {
const v = labels[i];
if (v == null) continue;
const c = CATEGORICAL[((v | 0) % CATEGORICAL.length + CATEGORICAL.length) % CATEGORICAL.length];
rgb[i * 3 + 0] = c.r;
rgb[i * 3 + 1] = c.g;
rgb[i * 3 + 2] = c.b;
}
} else {
let lo = Infinity, hi = -Infinity;
for (const v of labels) { if (v == null) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
const range = (hi - lo) || 1;
for (let i = 0; i < n; i++) {
const v = labels[i];
if (v == null) continue;
rampContinuous((v - lo) / range, tmp);
rgb[i * 3 + 0] = tmp.r;
rgb[i * 3 + 1] = tmp.g;
rgb[i * 3 + 2] = tmp.b;
}
}
return rgb;
}
const frame0 = data.frames[0];
if (!frame0) return rgb;
const originalPositions = [];
for (let i = 0; i < n; i++) {
if (frame0.x[i] != null && frame0.y[i] != null
&& !Number.isNaN(frame0.x[i]) && !Number.isNaN(frame0.y[i])) {
originalPositions.push(i);
}
}
originalPositions.sort((a, b) => data.point_ids[a] - data.point_ids[b]);
const nOrig = originalPositions.length;
const tmp = new THREE.Color();
for (let k = 0; k < nOrig; k++) {
const t = nOrig > 1 ? k / (nOrig - 1) : 0.5;
rampContinuous(t, tmp);
const idx = originalPositions[k];
rgb[idx * 3 + 0] = tmp.r;
rgb[idx * 3 + 1] = tmp.g;
rgb[idx * 3 + 2] = tmp.b;
}
return rgb;
}
function readVar(name, fallback) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function readPanelBg(el) {
const v = getComputedStyle(el).getPropertyValue('--picker-panel').trim();
return v || readVar('--panel', '#ffffff');
}
function readPanelColor(el, fallback) {
const v = getComputedStyle(el).getPropertyValue('--panel-color').trim();
return v || fallback;
}
function highlightColor() {
return readVar('--alarm', '#8a3a2a');
}
const PARAM_FIELDS = [
{ key: 'num_points', prefix: 'N' },
{ key: 'num_timesteps', prefix: 'T' },
{ key: 'jitter_scale', prefix: 'J' },
{ key: 'seed', prefix: 's' },
];
function renderHeader(panelEl, data) {
const m = data.meta || {};
panelEl.querySelector('[data-role="embedder"]').textContent = m.embedder || '—';
panelEl.querySelector('[data-role="generator"]').textContent = m.generator || '—';
const host = panelEl.querySelector('[data-role="params"]');
host.textContent = '';
PARAM_FIELDS.forEach(({ key, prefix }, i) => {
if (i > 0) host.appendChild(document.createTextNode(' / '));
const span = document.createElement('span');
span.className = 'param';
span.dataset.key = key;
span.textContent = `${prefix}${m[key] ?? '?'}`;
host.appendChild(span);
});
const stemLink = panelEl.querySelector('[data-role="stem-link"]');
if (stemLink && panelEl.dataset.stem) {
stemLink.href = `/api/runs/${encodeURIComponent(panelEl.dataset.stem)}/frames.json`;
}
}
function renderError(panelEl, stem, msg) {
const statusEl = panelEl.querySelector('[data-role="status"]');
statusEl.style.display = '';
statusEl.classList.add('is-error');
statusEl.textContent = `could not load ${stem}: ${msg}`;
panelEl.querySelector('[data-role="embedder"]').textContent = '—';
panelEl.querySelector('[data-role="generator"]').textContent = '—';
panelEl.querySelector('[data-role="params"]').textContent = '(error)';
}
function markParamDiffs(panels) {
if (panels.length < 2) return;
const metas = panels.map(p => p.data.meta || {});
for (const { key } of PARAM_FIELDS) {
const vals = metas.map(m => m[key]);
const differs = vals.some(v => v !== vals[0]);
for (const p of panels) {
const span = p.panelEl.querySelector(`[data-role="params"] .param[data-key="${key}"]`);
if (span) span.classList.toggle('diff', differs);
}
}
for (const role of ['embedder', 'generator']) {
const vals = metas.map(m => m[role]);
const differs = vals.some(v => v !== vals[0]);
for (const p of panels) {
const span = p.panelEl.querySelector(`[data-role="${role}"]`);
if (span) span.classList.toggle('diff', differs);
}
}
}
// ---- Panel factory ------------------------------------------------------
const DISK_TEX = makeDiskTexture();
function createPanel({ panelEl, data }) {
const canvasEl = panelEl.querySelector('[data-role="canvas"]');
const statusEl = panelEl.querySelector('[data-role="status"]');
const scene = new THREE.Scene();
scene.background = new THREE.Color(readPanelBg(panelEl));
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
canvasEl.appendChild(renderer.domElement);
const maxN = data.point_ids.length;
const positions = new Float32Array(maxN * 3);
const colors = new Float32Array(maxN * 3);
const idColorRGB = buildIdColorsRGB(data);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geo.setDrawRange(0, 0);
const matMono = new THREE.PointsMaterial({
size: 6.0,
sizeAttenuation: false,
map: DISK_TEX,
transparent: true,
alphaTest: 0.2,
depthWrite: false,
color: new THREE.Color(readPanelColor(panelEl, '#26828E')),
});
const matRainbow = new THREE.PointsMaterial({
size: 6.0,
sizeAttenuation: false,
map: DISK_TEX,
transparent: false,
alphaTest: 0.5,
vertexColors: true,
});
let mat = matMono;
const points = new THREE.Points(geo, mat);
scene.add(points);
const hiPos = new Float32Array(3);
const hiGeo = new THREE.BufferGeometry();
hiGeo.setAttribute('position', new THREE.BufferAttribute(hiPos, 3));
hiGeo.setDrawRange(0, 0);
const hiMat = new THREE.PointsMaterial({
size: 14.0,
sizeAttenuation: false,
map: DISK_TEX,
transparent: true,
alphaTest: 0.05,
depthWrite: false,
color: new THREE.Color(highlightColor()),
opacity: 0.9,
});
const hiPoints = new THREE.Points(hiGeo, hiMat);
hiPoints.renderOrder = 1;
scene.add(hiPoints);
let packedN = 0;
const packedX = new Float32Array(maxN);
const packedY = new Float32Array(maxN);
const packedId = new Int32Array(maxN);
const camRect = { xmin: -1, xmax: 1, ymin: -1, ymax: 1 };
function applyCamRect(rect) {
const pad = 0.05;
const dx = rect.xmax - rect.xmin || 1;
const dy = rect.ymax - rect.ymin || 1;
const cx = (rect.xmin + rect.xmax) / 2;
const cy = (rect.ymin + rect.ymax) / 2;
const rx = dx * (0.5 + pad);
const ry = dy * (0.5 + pad);
const rect2 = canvasEl.getBoundingClientRect();
const vw = Math.max(1, rect2.width);
const vh = Math.max(1, rect2.height);
const aspect = vw / vh;
const dataAspect = (rx * 2) / (ry * 2);
let halfW, halfH;
if (aspect > dataAspect) {
halfH = ry;
halfW = ry * aspect;
} else {
halfW = rx;
halfH = rx / aspect;
}
camera.left = cx - halfW;
camera.right = cx + halfW;
camera.top = cy + halfH;
camera.bottom = cy - halfH;
camera.updateProjectionMatrix();
camRect.xmin = rect.xmin;
camRect.xmax = rect.xmax;
camRect.ymin = rect.ymin;
camRect.ymax = rect.ymax;
}
function resize() {
const rect = canvasEl.getBoundingClientRect();
const w = Math.max(1, Math.floor(rect.width));
const h = Math.max(1, Math.floor(rect.height));
renderer.setSize(w, h, false);
applyCamRect(camRect);
}
function setBounds(b) {
applyCamRect({ xmin: b.x[0], xmax: b.x[1], ymin: b.y[0], ymax: b.y[1] });
}
function applyColorsFromTheme() {
matMono.color.set(readPanelColor(panelEl, '#26828E'));
hiMat.color.set(highlightColor());
scene.background = new THREE.Color(readPanelBg(panelEl));
}
function setColorMode(mode) {
const next = mode === 'mono' ? matMono : matRainbow;
if (points.material !== next) {
points.material = next;
mat = next;
}
}
function writePackedColor(j, i) {
colors[j * 3 + 0] = idColorRGB[i * 3 + 0];
colors[j * 3 + 1] = idColorRGB[i * 3 + 1];
colors[j * 3 + 2] = idColorRGB[i * 3 + 2];
}
function setFrame(f) {
const frame = data.frames[f];
if (!frame) return;
const xs = frame.x, ys = frame.y;
const ptIds = data.point_ids;
let j = 0;
for (let i = 0; i < xs.length; i++) {
const x = xs[i], y = ys[i];
if (x === null || y === null || x === undefined || y === undefined
|| Number.isNaN(x) || Number.isNaN(y)) continue;
positions[j * 3 + 0] = x;
positions[j * 3 + 1] = y;
positions[j * 3 + 2] = 0;
writePackedColor(j, i);
packedX[j] = x;
packedY[j] = y;
packedId[j] = ptIds[i];
j++;
}
packedN = j;
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
geo.setDrawRange(0, packedN);
applyHighlightForCurrentFrame();
}
function setFrameInterpolated(uLocal) {
const T = data.frames.length;
if (T === 0) return;
if (uLocal <= 0) return setFrame(0);
if (uLocal >= T - 1) return setFrame(T - 1);
const f0 = Math.floor(uLocal);
const f1 = f0 + 1;
const t = uLocal - f0;
const fr0 = data.frames[f0], fr1 = data.frames[f1];
const x0 = fr0.x, y0 = fr0.y, x1 = fr1.x, y1 = fr1.y;
const ptIds = data.point_ids;
let j = 0;
for (let i = 0; i < x0.length; i++) {
const a = x0[i], b = x1[i], c = y0[i], d = y1[i];
if (a === null || a === undefined || Number.isNaN(a)
|| b === null || b === undefined || Number.isNaN(b)
|| c === null || c === undefined || Number.isNaN(c)
|| d === null || d === undefined || Number.isNaN(d)) continue;
const xi = a + (b - a) * t;
const yi = c + (d - c) * t;
positions[j * 3 + 0] = xi;
positions[j * 3 + 1] = yi;
positions[j * 3 + 2] = 0;
writePackedColor(j, i);
packedX[j] = xi;
packedY[j] = yi;
packedId[j] = ptIds[i];
j++;
}
packedN = j;
geo.attributes.position.needsUpdate = true;
geo.attributes.color.needsUpdate = true;
geo.setDrawRange(0, packedN);
applyHighlightForCurrentFrame();
}
let currentHighlightId = -1;
function applyHighlightForCurrentFrame() {
if (currentHighlightId < 0) {
hiGeo.setDrawRange(0, 0);
return;
}
for (let i = 0; i < packedN; i++) {
if (packedId[i] === currentHighlightId) {
hiPos[0] = packedX[i];
hiPos[1] = packedY[i];
hiPos[2] = 0.01;
hiGeo.attributes.position.needsUpdate = true;
hiGeo.setDrawRange(0, 1);
return;
}
}
hiGeo.setDrawRange(0, 0);
}
function setHighlight(pointId) {
currentHighlightId = (pointId === null || pointId === undefined) ? -1 : pointId;
applyHighlightForCurrentFrame();
}
function clientToWorld(clientX, clientY) {
const rect = canvasEl.getBoundingClientRect();
const u = (clientX - rect.left) / Math.max(1, rect.width);
const v = (clientY - rect.top) / Math.max(1, rect.height);
const wx = camera.left + u * (camera.right - camera.left);
const wy = camera.top - v * (camera.top - camera.bottom);
return { wx, wy };
}
function pickAt(clientX, clientY) {
if (packedN === 0) return -1;
const { wx, wy } = clientToWorld(clientX, clientY);
const rect = canvasEl.getBoundingClientRect();
const worldPerPx = (camera.right - camera.left) / Math.max(1, rect.width);
const pickPx = 14;
const maxR = pickPx * worldPerPx;
const maxR2 = maxR * maxR;
let bestI = -1;
let bestD2 = Infinity;
for (let i = 0; i < packedN; i++) {
const dx = packedX[i] - wx;
const dy = packedY[i] - wy;
const d2 = dx * dx + dy * dy;
if (d2 < bestD2 && d2 < maxR2) {
bestD2 = d2;
bestI = i;
}
}
return bestI < 0 ? -1 : packedId[bestI];
}
function render() {
renderer.render(scene, camera);
}
function dispose() {
geo.dispose();
hiGeo.dispose();
matMono.dispose();
matRainbow.dispose();
hiMat.dispose();
renderer.dispose();
if (renderer.domElement.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement);
}
}
statusEl.style.display = 'none';
return {
canvasEl,
panelEl,
data,
setFrame,
setFrameInterpolated,
setColorMode,
setBounds,
setHighlight,
resize,
render,
pickAt,
applyColorsFromTheme,
dispose,
applyCamRect,
get packedN() { return packedN; },
get camRect() { return camRect; },
};
}
// ---- frames.json fetch --------------------------------------------------
async function fetchFrames(stem) {
const res = await fetch(`/api/runs/${encodeURIComponent(stem)}/frames.json`, { cache: 'no-store' });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
// ---- public API ---------------------------------------------------------
/**
* Mount one three.js panel per stem into `host`, driven by the control strip
* at `controls`. Returns `{ destroy }` for teardown (disposes every WebGL
* context and removes the panel DOM nodes).
*/
export function mountPanels({ host, controls, stems }) {
const tpl = document.getElementById('compare-panel-tpl');
if (!tpl) throw new Error('panel-grid: #compare-panel-tpl not found');
host.innerHTML = '';
const total = stems.length;
const slots = stems.map((stem, i) => {
const frag = tpl.content.cloneNode(true);
const panelEl = frag.querySelector('.compare-panel');
const color = viridisColor(i, total);
panelEl.style.setProperty('--panel-color', color);
panelEl.dataset.stem = stem;
host.appendChild(panelEl);
return { stem, panelEl, color, panel: null };
});
// Build the per-panel time segments inside #cc-times.
const timesHost = controls.querySelector('#cc-times');
if (timesHost) {
timesHost.innerHTML = '';
slots.forEach((s, i) => {
if (i > 0) {
const sep = document.createElement('span');
sep.className = 'cc-time-sep';
sep.textContent = '/';
timesHost.appendChild(sep);
}
const seg = document.createElement('span');
seg.className = 'cc-time-seg';
seg.style.setProperty('--panel-color', s.color);
seg.textContent = '—';
timesHost.appendChild(seg);
s.timeEl = seg;
});
}
const playBtn = controls.querySelector('#cc-play');
const scrub = controls.querySelector('#cc-scrub');
const speedSel = controls.querySelector('#cc-speed');
const syncSel = controls.querySelector('#cc-sync');
const motionSel= controls.querySelector('#cc-motion');
const colorSel = controls.querySelector('#cc-color');
// Reset controls to a clean state (they may be reused across opens).
if (scrub) scrub.value = '0';
if (playBtn) { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'play'); }
// Parallel fetch; each panel's error is independent.
const ready = Promise.allSettled(stems.map(fetchFrames)).then((results) => {
results.forEach((res, i) => {
const slot = slots[i];
if (res.status === 'fulfilled') {
renderHeader(slot.panelEl, res.value);
slot.panel = createPanel({ panelEl: slot.panelEl, data: res.value });
} else {
renderError(slot.panelEl, slot.stem, res.reason?.message || String(res.reason));
}
});
const live = slots.filter(s => s.panel);
markParamDiffs(live.map(s => s.panel));
if (!live.length) return;
for (const s of live) {
s.panel.setBounds(s.panel.data.bounds);
s.panel.resize();
s.panel.setFrame(0);
}
wireDrivers(live);
});
function wireDrivers(live) {
const SCRUB_MAX = 1000;
function framesOf(p) { return p ? p.data.frames.length : 0; }
function maxT() {
let m = 2;
for (const s of live) m = Math.max(m, framesOf(s.panel));
return m;
}
function timeLabelFor(p, uLocal) {
const T = framesOf(p);
if (T <= 0) return '—';
const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal)));
return p.data.times?.[idx] ?? String(idx);
}
function applyU(u) {
u = Math.max(0, Math.min(1, u));
const smooth = motionSel.value === 'smooth';
const tMax = maxT();
const uGlobal = u * (tMax - 1);
for (const s of live) {
const p = s.panel;
const T = framesOf(p);
if (T <= 0) continue;
const uLocal = Math.min(uGlobal, T - 1);
if (smooth) p.setFrameInterpolated(uLocal);
else {
const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal)));
p.setFrame(idx);
}
if (s.timeEl) s.timeEl.textContent = timeLabelFor(p, uLocal);
}
}
function applySync() {
const mode = syncSel ? syncSel.value : 'independent';
if (mode === 'locked' && live.length > 1) {
let xmin = +Infinity, xmax = -Infinity, ymin = +Infinity, ymax = -Infinity;
for (const s of live) {
const b = s.panel.data.bounds;
if (b.x[0] < xmin) xmin = b.x[0];
if (b.x[1] > xmax) xmax = b.x[1];
if (b.y[0] < ymin) ymin = b.y[0];
if (b.y[1] > ymax) ymax = b.y[1];
}
const union = { x: [xmin, xmax], y: [ymin, ymax] };
for (const s of live) s.panel.setBounds(union);
} else {
for (const s of live) s.panel.setBounds(s.panel.data.bounds);
}
}
if (syncSel) onCtl(syncSel, 'change', applySync);
applySync();
let playing = false;
let lastTs = 0;
let u = 0;
function baseMsPerFrame() { return 1600 / parseFloat(speedSel?.value || '1'); }
function tick(ts) {
if (stopped) return;
rafId = requestAnimationFrame(tick);
for (const s of live) s.panel.render();
if (!playing) { lastTs = ts; return; }
if (!lastTs) { lastTs = ts; return; }
const dt = ts - lastTs;
lastTs = ts;
if (dt <= 0) return;
const perFrame = baseMsPerFrame();
const T = maxT();
u += dt / (perFrame * (T - 1));
if (u >= 1) u -= Math.floor(u);
if (scrub) scrub.value = String(Math.round(u * SCRUB_MAX));
applyU(u);
}
rafId = requestAnimationFrame(tick);
function setPlaying(v) {
playing = v;
lastTs = 0;
if (playBtn) {
playBtn.textContent = playing ? '▮▮' : '▶';
playBtn.setAttribute('aria-label', playing ? 'pause' : 'play');
}
}
if (playBtn) onCtl(playBtn, 'click', () => setPlaying(!playing));
if (scrub) onCtl(scrub, 'input', () => {
u = parseFloat(scrub.value) / SCRUB_MAX;
applyU(u);
});
if (speedSel) onCtl(speedSel, 'change', () => { lastTs = 0; });
if (motionSel) onCtl(motionSel, 'change', () => {
applyU(parseFloat(scrub?.value || '0') / SCRUB_MAX);
});
function applyColorMode() {
const mode = colorSel ? colorSel.value : 'original';
for (const s of live) s.panel.setColorMode(mode);
}
if (colorSel) onCtl(colorSel, 'change', applyColorMode);
applyColorMode();
// Linked hover across every panel.
for (const s of live) {
const mm = (ev) => {
const id = s.panel.pickAt(ev.clientX, ev.clientY);
const hid = id >= 0 ? id : null;
for (const t of live) t.panel.setHighlight(hid);
};
const ml = () => { for (const t of live) t.panel.setHighlight(null); };
s.panel.canvasEl.addEventListener('mousemove', mm);
s.panel.canvasEl.addEventListener('mouseleave', ml);
listeners.push({ el: s.panel.canvasEl, ev: 'mousemove', fn: mm });
listeners.push({ el: s.panel.canvasEl, ev: 'mouseleave', fn: ml });
}
// Resize + theme observers.
ro = new ResizeObserver(() => { for (const s of live) s.panel.resize(); });
for (const s of live) ro.observe(s.panel.canvasEl);
themeFn = () => { for (const s of live) s.panel.applyColorsFromTheme(); };
document.addEventListener('themechange', themeFn);
applyU(0);
}
// ---- teardown bookkeeping ---------------------------------------------
const listeners = [];
const ctlListeners = [];
let rafId = 0;
let stopped = false;
let ro = null;
let themeFn = null;
function onCtl(el, ev, fn) {
el.addEventListener(ev, fn);
ctlListeners.push({ el, ev, fn });
}
function destroy() {
stopped = true;
if (rafId) cancelAnimationFrame(rafId);
if (ro) ro.disconnect();
if (themeFn) document.removeEventListener('themechange', themeFn);
for (const { el, ev, fn } of listeners) el.removeEventListener(ev, fn);
for (const { el, ev, fn } of ctlListeners) el.removeEventListener(ev, fn);
for (const s of slots) {
if (s.panel) s.panel.dispose();
if (s.panelEl.parentNode) s.panelEl.parentNode.removeChild(s.panelEl);
}
host.innerHTML = '';
const timesHost2 = controls.querySelector('#cc-times');
if (timesHost2) timesHost2.innerHTML = '';
}
return { destroy, ready };
}

View File

@ -0,0 +1,69 @@
// run-modal.js — homepage click-hijack for embedding links. Opens a
// <dialog id="run-modal"> that renders the run's embedding via panel-grid.js.
import { mountPanels } from './panel-grid.js?v=1';
const dialog = document.getElementById('run-modal');
const host = document.getElementById('modal-panel-host');
const controls = document.getElementById('modal-compare-controls');
const closeBtn = document.getElementById('run-modal-close');
const runsSlot = document.getElementById('runs-slot');
let active = null; // { destroy } returned by mountPanels
function openFor(stem) {
if (!dialog || !host || !controls) return;
if (active) { try { active.destroy(); } catch (_) {} active = null; }
active = mountPanels({ host, controls, stems: [stem] });
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
function closeModal() {
if (!dialog) return;
if (dialog.open) dialog.close();
if (active) { try { active.destroy(); } catch (_) {} active = null; }
}
function onRunsClick(ev) {
if (ev.defaultPrevented) return;
if (ev.button !== undefined && ev.button !== 0) return;
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const a = ev.target.closest('a[data-role="embedding-link"]');
if (!a) return;
if (!runsSlot || !runsSlot.contains(a)) return;
const stem = a.dataset.stem;
if (!stem) return;
ev.preventDefault();
openFor(stem);
}
function wire() {
if (!dialog) return;
if (runsSlot) runsSlot.addEventListener('click', onRunsClick);
if (closeBtn) closeBtn.addEventListener('click', closeModal);
dialog.addEventListener('close', () => {
if (active) { try { active.destroy(); } catch (_) {} active = null; }
});
// Clicking the backdrop (native behavior fires a click on the dialog
// itself, with target === dialog) closes the modal.
dialog.addEventListener('click', (ev) => {
if (ev.target === dialog) closeModal();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wire);
} else {
wire();
}
// htmx re-renders #runs-slot every 3s. Delegation on #runs-slot survives the
// swap (the slot element itself is stable), so we don't need to re-bind —
// but we keep the hook in case a consumer ever replaces the slot wholesale.
document.body.addEventListener('htmx:afterSwap', (ev) => {
if (ev.detail?.target?.id === 'runs-slot' && runsSlot && !runsSlot._runModalWired) {
runsSlot.addEventListener('click', onRunsClick);
runsSlot._runModalWired = true;
}
});

View File

@ -1473,11 +1473,12 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
.compare-grid { .compare-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 1rem; gap: 1rem;
} }
.compare-panel { .compare-panel {
--panel-color: var(--accent);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border: 1px solid var(--rule); border: 1px solid var(--rule);
@ -1505,19 +1506,14 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
min-width: 1.2em; min-width: 1.2em;
padding: 0 0.35rem; padding: 0 0.35rem;
border-radius: 2px; border-radius: 2px;
background: var(--accent-tint); background: color-mix(in srgb, var(--panel-color) 14%, transparent);
color: var(--accent); color: var(--panel-color);
font-weight: 600; font-weight: 600;
text-align: center; text-align: center;
font-size: 0.72rem; font-size: 0.72rem;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.compare-panel[data-slot="b"] .compare-panel-head .panel-tag {
color: var(--warm);
background: color-mix(in srgb, var(--warm) 14%, transparent);
}
.compare-panel-head .panel-embedder { .compare-panel-head .panel-embedder {
color: var(--ink); color: var(--ink);
font-weight: 600; font-weight: 600;
@ -1639,8 +1635,7 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.compare-controls .cc-time-sep { color: var(--faint); } .compare-controls .cc-time-sep { color: var(--faint); }
.compare-controls .cc-time-a { color: var(--accent); } .compare-controls .cc-time-seg { color: var(--panel-color, var(--ink)); }
.compare-controls .cc-time-b { color: var(--warm); }
.compare-controls .cc-speed-wrap, .compare-controls .cc-speed-wrap,
.compare-controls .cc-sync-wrap, .compare-controls .cc-sync-wrap,
@ -1679,5 +1674,60 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
@media (max-width: 900px) { @media (max-width: 900px) {
.compare-layout { --panel-h: 45vh; padding: 1rem 0.9rem 1.2rem; } .compare-layout { --panel-h: 45vh; padding: 1rem 0.9rem 1.2rem; }
.compare-grid { grid-template-columns: 1fr; } }
/* ---------- run modal --------------------------------------------------- */
#run-modal {
width: min(1200px, 94vw);
max-width: 94vw;
height: min(820px, 88vh);
max-height: 88vh;
padding: 0;
border: 1px solid var(--rule);
border-radius: 4px;
background: var(--panel);
color: var(--ink);
}
#run-modal::backdrop {
background: color-mix(in srgb, #000 48%, transparent);
}
.run-modal-body {
display: flex;
flex-direction: column;
gap: 0.8rem;
height: 100%;
padding: 0.8rem 1rem 1rem;
box-sizing: border-box;
--panel-h: 62vh;
}
.run-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.82rem;
color: var(--mute);
}
.run-modal-close {
appearance: none;
border: 1px solid var(--rule-2);
background: var(--panel);
color: var(--ink);
width: 1.9rem;
height: 1.9rem;
border-radius: 2px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.run-modal-close:hover {
background: var(--accent-tint);
border-color: var(--accent);
color: var(--accent);
}
#run-modal .compare-grid {
flex: 1 1 auto;
min-height: 0;
} }

View File

@ -60,7 +60,8 @@
{% if r.emb_file %} {% if r.emb_file %}
{% if r.emb_exists %} {% if r.emb_exists %}
<a href="/figs/{{ r.emb_file }}" target="_blank" rel="noopener">embedding</a> <a href="/figs/{{ r.emb_file }}" data-role="embedding-link"
data-stem="{{ r.emb_file[:-5] }}" target="_blank" rel="noopener">embedding</a>
{% else %} {% else %}
<a aria-disabled="true">embedding</a> <a aria-disabled="true">embedding</a>
{% endif %} {% endif %}

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook &middot; compare</title> <title>embedding notebook &middot; compare</title>
<link rel="stylesheet" href="/static/style.css?v=27" /> <link rel="stylesheet" href="/static/style.css?v=28" />
<script type="importmap"> <script type="importmap">
{ {
"imports": { "imports": {
@ -43,11 +43,7 @@
<input class="cc-scrub" id="cc-scrub" type="range" <input class="cc-scrub" id="cc-scrub" type="range"
min="0" max="1000" step="1" value="0" aria-label="time scrubber" /> min="0" max="1000" step="1" value="0" aria-label="time scrubber" />
<div class="cc-time" id="cc-time"> <div class="cc-time" id="cc-times"></div>
<span class="cc-time-a" data-role="time-a">&mdash;</span>
<span class="cc-time-sep">/</span>
<span class="cc-time-b" data-role="time-b">&mdash;</span>
</div>
<label class="cc-speed-wrap"> <label class="cc-speed-wrap">
<span class="cc-lbl">speed</span> <span class="cc-lbl">speed</span>
@ -84,40 +80,26 @@
</label> </label>
</div> </div>
<div class="compare-grid"> <div class="compare-grid" id="panel-host"></div>
<article class="compare-panel" data-slot="a">
<header class="compare-panel-head">
<span class="panel-tag" data-role="label">A</span>
<span class="panel-embedder" data-role="embedder">&hellip;</span>
<span class="panel-sep">&middot;</span>
<span class="panel-generator" data-role="generator">&hellip;</span>
<a class="panel-stem" data-role="stem-link"
href="/api/runs/{{ stem_a }}/frames.json" target="_blank">&#x2197;</a>
<span class="panel-params" data-role="params">loading&hellip;</span>
</header>
<div class="compare-canvas" data-role="canvas">
<div class="compare-status" data-role="status">loading&hellip;</div>
</div>
</article>
<article class="compare-panel" data-slot="b">
<header class="compare-panel-head">
<span class="panel-tag" data-role="label">B</span>
<span class="panel-embedder" data-role="embedder">&hellip;</span>
<span class="panel-sep">&middot;</span>
<span class="panel-generator" data-role="generator">&hellip;</span>
<a class="panel-stem" data-role="stem-link"
href="/api/runs/{{ stem_b }}/frames.json" target="_blank">&#x2197;</a>
<span class="panel-params" data-role="params">loading&hellip;</span>
</header>
<div class="compare-canvas" data-role="canvas">
<div class="compare-status" data-role="status">loading&hellip;</div>
</div>
</article>
</div>
</section> </section>
<template id="compare-panel-tpl">
<article class="compare-panel">
<header class="compare-panel-head">
<span class="panel-tag" data-role="label">&#9679;</span>
<span class="panel-embedder" data-role="embedder">&hellip;</span>
<span class="panel-sep">&middot;</span>
<span class="panel-generator" data-role="generator">&hellip;</span>
<a class="panel-stem" data-role="stem-link" href="#" target="_blank">&#x2197;</a>
<span class="panel-params" data-role="params">loading&hellip;</span>
</header>
<div class="compare-canvas" data-role="canvas">
<div class="compare-status" data-role="status">loading&hellip;</div>
</div>
</article>
</template>
<script src="/static/theme.js?v=11"></script> <script src="/static/theme.js?v=11"></script>
<script type="module" src="/static/compare.js?v=11"></script> <script type="module" src="/static/compare.js?v=11"></script>
</body> </body>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook</title> <title>embedding notebook</title>
<link rel="stylesheet" href="/static/style.css?v=27" /> <link rel="stylesheet" href="/static/style.css?v=28" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap"> <script type="importmap">
{ {
@ -396,6 +396,70 @@
</div> </div>
</details> </details>
<dialog id="run-modal" aria-label="run embedding">
<div class="run-modal-body">
<div class="run-modal-head">
<span>embedding</span>
<button type="button" class="run-modal-close" id="run-modal-close" aria-label="close">&times;</button>
</div>
<div class="compare-controls" id="modal-compare-controls">
<button type="button" class="cc-play" id="cc-play" aria-label="play / pause">&#9654;</button>
<input class="cc-scrub" id="cc-scrub" type="range"
min="0" max="1000" step="1" value="0" aria-label="time scrubber" />
<div class="cc-time" id="cc-times"></div>
<label class="cc-speed-wrap">
<span class="cc-lbl">speed</span>
<select class="cc-speed" id="cc-speed">
<option value="0.5">0.5&times;</option>
<option value="1" selected>1&times;</option>
<option value="2">2&times;</option>
<option value="4">4&times;</option>
</select>
</label>
<label class="cc-motion-wrap">
<span class="cc-lbl">motion</span>
<select class="cc-motion" id="cc-motion">
<option value="smooth" selected>smooth</option>
<option value="step">step</option>
</select>
</label>
<label class="cc-color-wrap">
<span class="cc-lbl">color</span>
<select class="cc-color" id="cc-color">
<option value="mono">mono</option>
<option value="original" selected>original</option>
</select>
</label>
<label class="cc-sync-wrap">
<span class="cc-lbl">axes</span>
<select class="cc-sync" id="cc-sync">
<option value="independent" selected>independent</option>
<option value="locked">locked</option>
</select>
</label>
</div>
<div class="compare-grid" id="modal-panel-host"></div>
</div>
</dialog>
<template id="compare-panel-tpl">
<article class="compare-panel">
<header class="compare-panel-head">
<span class="panel-tag" data-role="label">&#9679;</span>
<span class="panel-embedder" data-role="embedder">&hellip;</span>
<span class="panel-sep">&middot;</span>
<span class="panel-generator" data-role="generator">&hellip;</span>
<a class="panel-stem" data-role="stem-link" href="#" target="_blank">&#x2197;</a>
<span class="panel-params" data-role="params">loading&hellip;</span>
</header>
<div class="compare-canvas" data-role="canvas">
<div class="compare-status" data-role="status">loading&hellip;</div>
</div>
</article>
</template>
<footer class="colophon"> <footer class="colophon">
<span>&copy; 2026 Mind the Math LLC</span> <span>&copy; 2026 Mind the Math LLC</span>
<span class="prefect-badge"> <span class="prefect-badge">
@ -408,6 +472,7 @@
<script type="module" src="/static/dataset-picker.js?v=11"></script> <script type="module" src="/static/dataset-picker.js?v=11"></script>
<script type="module" src="/static/metrics.js?v=11"></script> <script type="module" src="/static/metrics.js?v=11"></script>
<script src="/static/compare-select.js?v=1"></script> <script src="/static/compare-select.js?v=1"></script>
<script type="module" src="/static/run-modal.js?v=1"></script>
<script> <script>
// Anchor-links alone don't expand <details>; force it. // Anchor-links alone don't expand <details>; force it.
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => { document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {