diff --git a/app/web/main.py b/app/web/main.py index 77d38d5..e04b25e 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -590,6 +590,11 @@ def _run_view(run: Dict[str, Any]) -> Dict[str, Any]: rid = run.get("id", "") state_type = (run.get("state_type") or "PENDING").upper() 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") params = run.get("parameters") or {} # estimated_run_time recomputes server-side (ticks up while RUNNING); diff --git a/app/web/static/compare.js b/app/web/static/compare.js index b5fdbdf..ceb1814 100644 --- a/app/web/static/compare.js +++ b/app/web/static/compare.js @@ -1,733 +1,11 @@ -// compare.js — side-by-side animated scatter for two embedding runs. -// -// Reads ?a=&b= from the URL, fetches /api/runs//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. +// compare.js — thin shim that parses ?a=&b= and hands off to panel-grid.js. -import * as THREE from 'three'; - -// -------- URL / DOM wiring ------------------------------------------------ +import { mountPanels } from './panel-grid.js?v=1'; const params = new URLSearchParams(window.location.search); -const STEM_A = params.get('a') || ''; -const STEM_B = params.get('b') || ''; +const stems = [params.get('a') || '', params.get('b') || ''].filter(Boolean); -const layout = document.getElementById('compare-layout'); -const panelElA = layout.querySelector('.compare-panel[data-slot="a"]'); -const panelElB = layout.querySelector('.compare-panel[data-slot="b"]'); +const host = document.getElementById('panel-host'); +const controls = document.getElementById('compare-controls'); -const playBtn = document.getElementById('cc-play'); -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); -}); +mountPanels({ host, controls, stems }); diff --git a/app/web/static/panel-grid.js b/app/web/static/panel-grid.js new file mode 100644 index 0000000..22e5124 --- /dev/null +++ b/app/web/static/panel-grid.js @@ -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 }; +} diff --git a/app/web/static/run-modal.js b/app/web/static/run-modal.js new file mode 100644 index 0000000..4764c88 --- /dev/null +++ b/app/web/static/run-modal.js @@ -0,0 +1,69 @@ +// run-modal.js — homepage click-hijack for embedding links. Opens a +// 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; + } +}); diff --git a/app/web/static/style.css b/app/web/static/style.css index f6072ef..62dca48 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -1473,11 +1473,12 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c .compare-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 1rem; } .compare-panel { + --panel-color: var(--accent); display: flex; flex-direction: column; border: 1px solid var(--rule); @@ -1505,19 +1506,14 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c min-width: 1.2em; padding: 0 0.35rem; border-radius: 2px; - background: var(--accent-tint); - color: var(--accent); + background: color-mix(in srgb, var(--panel-color) 14%, transparent); + color: var(--panel-color); font-weight: 600; text-align: center; font-size: 0.72rem; 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 { color: var(--ink); font-weight: 600; @@ -1639,8 +1635,7 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c font-variant-numeric: tabular-nums; } .compare-controls .cc-time-sep { color: var(--faint); } -.compare-controls .cc-time-a { color: var(--accent); } -.compare-controls .cc-time-b { color: var(--warm); } +.compare-controls .cc-time-seg { color: var(--panel-color, var(--ink)); } .compare-controls .cc-speed-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) { .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; } diff --git a/app/web/templates/_runs.html b/app/web/templates/_runs.html index 2254f6e..91712c4 100644 --- a/app/web/templates/_runs.html +++ b/app/web/templates/_runs.html @@ -60,7 +60,8 @@ {% if r.emb_file %} {% if r.emb_exists %} - embedding + embedding {% else %} embedding {% endif %} diff --git a/app/web/templates/compare.html b/app/web/templates/compare.html index 2df62da..6414340 100644 --- a/app/web/templates/compare.html +++ b/app/web/templates/compare.html @@ -4,7 +4,7 @@ embedding notebook · compare - + diff --git a/app/web/templates/index.html b/app/web/templates/index.html index a04e1db..0de1435 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -4,7 +4,7 @@ embedding notebook - + +