compare: side-by-side three.js animation with linked scrub, hover, and theme
- /compare page renders two ortho-camera panels fed by /api/runs/{stem}/frames.json
- shared controls: play/pause, scrubber, speed (0.5-4x), axes sync (independent/locked)
- linked hover: picks nearest point in one panel, highlights matching point_id in other
- add/remove-jitter nulls handled via per-frame packed positions + setDrawRange
- independent error states per panel; theme-aware colors via themechange event
This commit is contained in:
parent
e680867f8b
commit
fc6aad5516
515
app/web/static/compare.js
Normal file
515
app/web/static/compare.js
Normal file
@ -0,0 +1,515 @@
|
||||
// compare.js — side-by-side animated scatter for two embedding runs.
|
||||
//
|
||||
// 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';
|
||||
|
||||
// -------- 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);
|
||||
});
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -4,7 +4,16 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>embedding notebook · compare</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=20" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=21" />
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3' fill='%231f4e5f'/%3E%3C/svg%3E" />
|
||||
<script>
|
||||
(function(){try{
|
||||
var t=localStorage.getItem('theme');
|
||||
@ -25,23 +34,75 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section style="padding: 2rem 1.2rem; max-width: 72ch; margin: 0 auto; font-family: var(--serif);">
|
||||
<p style="color: var(--mute);">
|
||||
Comparison page — scaffolding. Two runs selected:
|
||||
</p>
|
||||
<ul style="font-family: var(--mono); font-size: 0.85rem;">
|
||||
<li><strong>A</strong> · {{ stem_a }} ·
|
||||
<a href="/api/runs/{{ stem_a }}/frames.json" target="_blank">frames.json</a> ·
|
||||
<a href="/figs/{{ stem_a }}.html" target="_blank">original plotly</a></li>
|
||||
<li><strong>B</strong> · {{ stem_b }} ·
|
||||
<a href="/api/runs/{{ stem_b }}/frames.json" target="_blank">frames.json</a> ·
|
||||
<a href="/figs/{{ stem_b }}.html" target="_blank">original plotly</a></li>
|
||||
</ul>
|
||||
<p style="color: var(--faint); font-size: 0.85rem; font-style: italic;">
|
||||
Three.js side-by-side animation UI will render here (next phase).
|
||||
</p>
|
||||
<section class="compare-layout" id="compare-layout"
|
||||
data-stem-a="{{ stem_a }}" data-stem-b="{{ stem_b }}">
|
||||
|
||||
<div class="compare-grid">
|
||||
<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">…</span>
|
||||
<span class="panel-sep">·</span>
|
||||
<span class="panel-generator" data-role="generator">…</span>
|
||||
<span class="panel-params" data-role="params">loading…</span>
|
||||
<a class="panel-stem" data-role="stem-link"
|
||||
href="/api/runs/{{ stem_a }}/frames.json" target="_blank">{{ stem_a }}</a>
|
||||
</header>
|
||||
<div class="compare-canvas" data-role="canvas">
|
||||
<div class="compare-status" data-role="status">loading…</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">…</span>
|
||||
<span class="panel-sep">·</span>
|
||||
<span class="panel-generator" data-role="generator">…</span>
|
||||
<span class="panel-params" data-role="params">loading…</span>
|
||||
<a class="panel-stem" data-role="stem-link"
|
||||
href="/api/runs/{{ stem_b }}/frames.json" target="_blank">{{ stem_b }}</a>
|
||||
</header>
|
||||
<div class="compare-canvas" data-role="canvas">
|
||||
<div class="compare-status" data-role="status">loading…</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="compare-controls" id="compare-controls">
|
||||
<button type="button" class="cc-play" id="cc-play" aria-label="play / pause">▶</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-time">
|
||||
<span class="cc-time-a" data-role="time-a">—</span>
|
||||
<span class="cc-time-sep">/</span>
|
||||
<span class="cc-time-b" data-role="time-b">—</span>
|
||||
</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×</option>
|
||||
<option value="1" selected>1×</option>
|
||||
<option value="2">2×</option>
|
||||
<option value="4">4×</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>
|
||||
|
||||
</section>
|
||||
|
||||
<script src="/static/theme.js?v=11"></script>
|
||||
<script type="module" src="/static/compare.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>embedding notebook</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=20" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=21" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user