diff --git a/app/web/static/compare.js b/app/web/static/compare.js new file mode 100644 index 0000000..b4f54f0 --- /dev/null +++ b/app/web/static/compare.js @@ -0,0 +1,515 @@ +// 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. + +import * as THREE from 'three'; + +// -------- URL / DOM wiring ------------------------------------------------ + +const params = new URLSearchParams(window.location.search); +const STEM_A = params.get('a') || ''; +const STEM_B = params.get('b') || ''; + +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 playBtn = document.getElementById('cc-play'); +const scrub = document.getElementById('cc-scrub'); +const speedSel = document.getElementById('cc-speed'); +const syncSel = document.getElementById('cc-sync'); +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(); + +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 ids = new Int32Array(maxN); // packed point_id per drawn vertex + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setDrawRange(0, 0); + + const mat = 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 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() { + mat.color.set(panelAccent(slotId)); + hiMat.color.set(highlightColor()); + scene.background = new THREE.Color(readPanelBg(panelEl)); + } + + // 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; + packedX[j] = x; + packedY[j] = y; + packedId[j] = ptIds[i]; + j++; + } + packedN = j; + geo.attributes.position.needsUpdate = true; + geo.setDrawRange(0, packedN); + // If there was a highlighted id, reapply it so the overlay follows. + 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(); + mat.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, + 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)'; +} + +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 terse = `N${m.num_points ?? '?'} / T${m.num_timesteps ?? '?'} / J${m.jitter_scale ?? '?'} / s${m.seed ?? '?'}`; + panelEl.querySelector('[data-role="params"]').textContent = terse; +} + +// -------- main ------------------------------------------------------------ + +async function fetchFrames(stem) { + const res = await fetch(`/api/runs/${encodeURIComponent(stem)}/frames.json`); + 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)); + } + + // 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. Each panel picks round(u * (T-1)) as its frame idx. + const SCRUB_MAX = 1000; + + function framesOf(p) { return p ? p.data.frames.length : 0; } + + function timeLabelFor(p, u) { + if (!p) return '—'; + const T = framesOf(p); + if (T <= 0) return '—'; + const idx = Math.max(0, Math.min(T - 1, Math.round(u * (T - 1)))); + return p.data.times?.[idx] ?? String(idx); + } + + function applyU(u) { + u = Math.max(0, Math.min(1, u)); + for (const p of Object.values(panels)) { + if (!p) continue; + const T = framesOf(p); + if (T <= 0) continue; + const idx = Math.max(0, Math.min(T - 1, Math.round(u * (T - 1)))); + p.setFrame(idx); + } + timeAEl.textContent = timeLabelFor(panels.a, u); + timeBEl.textContent = timeLabelFor(panels.b, u); + } + + // ---- 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 400 / parseFloat(speedSel.value || '1'); } + + function tick(ts) { + requestAnimationFrame(tick); + for (const p of Object.values(panels)) p?.render(); + if (!playing) { lastTs = ts; return; } + if (!lastTs) lastTs = ts; + const dt = ts - lastTs; + const perFrame = baseMsPerFrame(); + const T = maxT(); + const du = dt / (perFrame * (T - 1)); + if (du > 0) { + let u = parseFloat(scrub.value) / SCRUB_MAX + du; + if (u >= 1) u -= Math.floor(u); // wrap 0..1 + scrub.value = String(Math.round(u * SCRUB_MAX)); + applyU(u); + lastTs = ts; + } + } + 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', () => { + applyU(parseFloat(scrub.value) / SCRUB_MAX); + }); + + speedSel.addEventListener('change', () => { lastTs = 0; }); + + // ---- 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); +}); diff --git a/app/web/static/style.css b/app/web/static/style.css index 46226ae..265b35e 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -1451,3 +1451,215 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c .legend .legend-row { grid-template-columns: 10px 1fr; } .legend .fn { display: none; } } + +/* ---------- compare page ------------------------------------------------ */ + +.compare-layout { + --panel-h: 62vh; + padding: 1.2rem 1.6rem 1.6rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.compare-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.compare-panel { + display: flex; + flex-direction: column; + border: 1px solid var(--rule); + background: var(--panel); + border-radius: 2px; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.compare-panel-head { + display: flex; + align-items: baseline; + gap: 0.55rem; + padding: 0.55rem 0.85rem; + border-bottom: 1px solid var(--rule); + font-family: var(--mono); + font-size: 0.78rem; + color: var(--mute); + flex-wrap: wrap; +} + +.compare-panel-head .panel-tag { + display: inline-block; + min-width: 1.2em; + padding: 0 0.35rem; + border-radius: 2px; + background: var(--accent-tint); + color: var(--accent); + 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; +} + +.compare-panel-head .panel-generator { + color: var(--ink); +} + +.compare-panel-head .panel-sep { + color: var(--faint); +} + +.compare-panel-head .panel-params { + color: var(--mute); + font-size: 0.74rem; +} + +.compare-panel-head .panel-stem { + margin-left: auto; + color: var(--faint); + font-size: 0.72rem; + border-bottom: 1px solid transparent; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.compare-panel-head .panel-stem:hover { + color: var(--mute); + border-bottom-color: var(--faint); +} + +.compare-canvas { + position: relative; + flex: 1 1 auto; + min-height: 0; + background: var(--picker-panel, var(--panel)); + height: var(--panel-h); +} + +.compare-canvas canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.compare-status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + text-align: center; + color: var(--mute); + font-family: var(--mono); + font-size: 0.82rem; + pointer-events: none; +} +.compare-status.is-error { + color: var(--alarm); +} + +.compare-controls { + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.7rem 0.9rem; + border: 1px solid var(--rule); + border-radius: 2px; + background: var(--panel); + font-family: var(--mono); + font-size: 0.8rem; + color: var(--mute); + flex-wrap: wrap; +} + +.compare-controls .cc-play { + appearance: none; + border: 1px solid var(--rule-2); + background: var(--panel); + color: var(--ink); + width: 2.1rem; + height: 2.1rem; + border-radius: 2px; + cursor: pointer; + font-size: 0.9rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 120ms ease, border-color 120ms ease; +} +.compare-controls .cc-play:hover { + background: var(--accent-tint); + border-color: var(--accent); + color: var(--accent); +} + +.compare-controls .cc-scrub { + flex: 1 1 auto; + min-width: 10rem; + accent-color: var(--accent); +} + +.compare-controls .cc-time { + display: inline-flex; + gap: 0.35rem; + white-space: nowrap; + color: var(--ink); + 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-speed-wrap, +.compare-controls .cc-sync-wrap { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} +.compare-controls .cc-lbl { + color: var(--faint); + text-transform: lowercase; + font-size: 0.74rem; +} +.compare-controls select { + appearance: none; + -webkit-appearance: none; + border: 1px solid var(--rule-2); + background: var(--panel); + color: var(--ink); + font-family: var(--mono); + font-size: 0.78rem; + padding: 0.25rem 1.4rem 0.25rem 0.5rem; + border-radius: 2px; + cursor: pointer; + background-image: linear-gradient(45deg, transparent 50%, var(--mute) 50%), + linear-gradient(-45deg, transparent 50%, var(--mute) 50%); + background-position: calc(100% - 12px) 50%, calc(100% - 7px) 50%; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} +.compare-controls select:focus { + outline: none; + border-color: var(--accent); +} + +@media (max-width: 900px) { + .compare-layout { --panel-h: 45vh; padding: 1rem 0.9rem 1.2rem; } + .compare-grid { grid-template-columns: 1fr; } +} diff --git a/app/web/templates/compare.html b/app/web/templates/compare.html index 7eaff9b..ab2bad5 100644 --- a/app/web/templates/compare.html +++ b/app/web/templates/compare.html @@ -4,7 +4,16 @@ embedding notebook · compare - + + + + diff --git a/app/web/templates/index.html b/app/web/templates/index.html index d9cf475..6dfd542 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -4,7 +4,7 @@ embedding notebook - +