Compare commits

..

2 Commits

Author SHA1 Message Date
Michael Pilosov
b744c48348 stems: fold generator_kwargs into the hash; fix swiss_roll vs hole ambiguity
- run_args_hash now covers (embed_args, generator_kwargs). When gen_kwargs
  is empty we still hash embed_args alone — so plain generators (s_curve,
  plain swiss_roll) keep their stems and no existing plain-gen figs need
  renaming. Kwargs-bearing variants (swiss_roll_hole, blobs,
  gaussian_quantiles, classification) now disambiguate properly.
- Flow persists generator_kwargs into metrics.json meta AND into the
  frames.json sidecar meta, so the label-enrichment path can find it
  without another lookup.
- _enrich_with_labels discovers gen_kwargs in priority: payload meta -->
  sibling metrics.json --> DATASET_META first-match. It matches the
  DATASET_META entry by (path, kwargs) so swiss_roll_hole is no longer
  confused for plain swiss_roll.
- _cached_frames overrides meta.stem with the URL-requested stem before
  enrichment — after a backfill rename the sidecar's baked-in stem is
  stale, and we were then failing to find the sibling metrics.json.
- Submit duplicate-check uses the new hash and keeps the hashless-legacy
  check as a safety net.
- backfill_hashes.py rewritten: queries Prefect for each recent run's
  full params, finds the matching fig under any of (current, legacy,
  hashless) names, renames to the current scheme and patches
  generator_kwargs into metrics.json.
2026-04-22 16:30:42 -06:00
Michael Pilosov
44de8deeeb viz: extract N-panel-agnostic module; homepage modal reuses it for single-run view
- panel-grid.js (new): exports mountPanels({host, controls, stems}) → {destroy}.
  Moved createPanel + shared control wiring + linked-hover + pad-to-match
  time mapping out of compare.js. Stem-count-agnostic; works for 1, 2, or N.
- Panel DOM is cloned from <template id=compare-panel-tpl> on each page.
- compare.js is now a ~10-line shim: parse ?a=&b=, call mountPanels.
- Per-panel color is viridis-sampled by index/N (middle viridis for N=1,
  ends-of-palette for N=2, linear lerp for N≤8, cycle at N≥9). Set as
  --panel-color on the panel element; CSS reads it for tag/time-seg.
- Homepage <dialog id=run-modal> + run-modal.js hijack the 'embedding' link
  (plain click → modal; meta/ctrl/middle-click still opens plotly HTML).
  Dialog close disposes every panel's renderer/geometry/material.
- .compare-grid → repeat(auto-fit, minmax(360px, 1fr)) handles N=1..many,
  replaces the <900px one-column media rule.
- Runs list: relabel Prefect's 'Late' state as 'Queued' — more honest
  description of what the runner is doing at the concurrency cap.
2026-04-22 16:17:01 -06:00
10 changed files with 1215 additions and 872 deletions

View File

@ -450,13 +450,31 @@ def build_embed_args(reducer_key: str, form: Dict[str, str]) -> Dict[str, Any]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def embed_args_hash(embed_args: Optional[Dict[str, Any]]) -> str: def run_args_hash(
"""8-hex digest of an embed_args dict (keys sorted). Stems incorporate embed_args: Optional[Dict[str, Any]],
this so runs that differ only in embed_args get distinct output files.""" generator_kwargs: Optional[Dict[str, Any]] = None,
s = json.dumps(embed_args or {}, sort_keys=True, default=str) ) -> str:
"""8-hex digest of (embed_args, generator_kwargs). When generator_kwargs
is empty/None we hash embed_args alone preserves stems for the plain
generators (s_curve, plain swiss_roll) that never had gen_kwargs. For
kwargs-bearing variants (swiss_roll_hole, blobs, gaussian_quantiles,
classification), the hash now disambiguates them from their kwargs-less
siblings run scripts/backfill_hashes.py to rehash existing figs."""
if generator_kwargs:
payload: Any = {
"embed_args": embed_args or {},
"generator_kwargs": generator_kwargs,
}
else:
payload = embed_args or {}
s = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha1(s.encode()).hexdigest()[:8] return hashlib.sha1(s.encode()).hexdigest()[:8]
# Back-compat alias — some call sites passed only embed_args.
embed_args_hash = run_args_hash
def synthesize_output_paths( def synthesize_output_paths(
generator_path: str, generator_path: str,
embedder: str, embedder: str,
@ -465,6 +483,7 @@ def synthesize_output_paths(
jitter_scale: float, jitter_scale: float,
seed: int, seed: int,
embed_args: Optional[Dict[str, Any]] = None, embed_args: Optional[Dict[str, Any]] = None,
generator_kwargs: Optional[Dict[str, Any]] = None,
) -> Tuple[str, str]: ) -> Tuple[str, str]:
gen = generator_path.split(".")[-1] gen = generator_path.split(".")[-1]
emb = embedder.split(".")[-1] emb = embedder.split(".")[-1]
@ -473,7 +492,7 @@ def synthesize_output_paths(
if embed_args is None: if embed_args is None:
embf = f"{base}.html" embf = f"{base}.html"
else: else:
embf = f"{base}_{embed_args_hash(embed_args)}.html" embf = f"{base}_{run_args_hash(embed_args, generator_kwargs)}.html"
return ref, embf return ref, embf
@ -590,6 +609,11 @@ def _run_view(run: Dict[str, Any]) -> Dict[str, Any]:
rid = run.get("id", "") rid = run.get("id", "")
state_type = (run.get("state_type") or "PENDING").upper() state_type = (run.get("state_type") or "PENDING").upper()
state_name = run.get("state_name") or state_type.title() state_name = run.get("state_name") or state_type.title()
# Prefect labels a scheduled run that's past its start time "Late". The
# runner is simply busy / awaiting its turn at the concurrency cap — show
# "Queued" instead, which is the honest description of the state.
if state_name == "Late":
state_name = "Queued"
start = run.get("start_time") or run.get("expected_start_time") or run.get("created") start = run.get("start_time") or run.get("expected_start_time") or run.get("created")
params = run.get("parameters") or {} params = run.get("parameters") or {}
# estimated_run_time recomputes server-side (ticks up while RUNNING); # estimated_run_time recomputes server-side (ticks up while RUNNING);
@ -615,6 +639,7 @@ def _run_view(run: Dict[str, Any]) -> Dict[str, Any]:
float(params.get("jitter_scale", 0.01)), float(params.get("jitter_scale", 0.01)),
int(params.get("seed", 42)), int(params.get("seed", 42)),
embed_args=params.get("embed_args") or {}, embed_args=params.get("embed_args") or {},
generator_kwargs=params.get("generator_kwargs") or {},
) )
# Older runs may lack the hash suffix; prefer legacy name on disk. # Older runs may lack the hash suffix; prefer legacy name on disk.
emb_file = _resolve_emb_file(emb_file) emb_file = _resolve_emb_file(emb_file)
@ -783,13 +808,12 @@ async def submit(request: Request) -> HTMLResponse:
embed_args = build_embed_args(reducer, data) embed_args = build_embed_args(reducer, data)
# Reject submissions whose output path would overwrite an existing fig. # Reject submissions whose output path would overwrite an existing fig.
# The stem now includes an 8-hex hash of embed_args, so UMAP(n_neighbors=5) # Hash now covers both embed_args and generator_kwargs, so swiss_roll vs
# and UMAP(n_neighbors=15) produce distinct files. Check both the hashed # swiss_roll_hole (and blobs with varying n_features, etc.) no longer
# path (new runs) and the legacy hashless path (pre-hash runs) so users # share a stem. Also check the legacy hashless path for pre-hash figs.
# can't accidentally duplicate against a pre-hash fig either.
_, hashed_emb = synthesize_output_paths( _, hashed_emb = synthesize_output_paths(
generator_path, reducer, num_points, num_timesteps, jitter_scale, seed, generator_path, reducer, num_points, num_timesteps, jitter_scale, seed,
embed_args=embed_args, embed_args=embed_args, generator_kwargs=generator_kwargs,
) )
_, legacy_emb = synthesize_output_paths( _, legacy_emb = synthesize_output_paths(
generator_path, reducer, num_points, num_timesteps, jitter_scale, seed, generator_path, reducer, num_points, num_timesteps, jitter_scale, seed,
@ -833,7 +857,7 @@ async def submit(request: Request) -> HTMLResponse:
ref_file, emb_file = synthesize_output_paths( ref_file, emb_file = synthesize_output_paths(
generator_path, reducer, num_points, num_timesteps, jitter_scale, seed, generator_path, reducer, num_points, num_timesteps, jitter_scale, seed,
embed_args=embed_args, embed_args=embed_args, generator_kwargs=generator_kwargs,
) )
RUN_OUTPUTS[run["id"]] = {"ref": ref_file, "embed": emb_file} RUN_OUTPUTS[run["id"]] = {"ref": ref_file, "embed": emb_file}
@ -890,20 +914,61 @@ for _m in DATASET_META.values():
_GEN_TO_META.setdefault(_m["path"].rsplit(".", 1)[-1], _m) _GEN_TO_META.setdefault(_m["path"].rsplit(".", 1)[-1], _m)
def _lookup_dataset_meta(
generator_short: str, generator_kwargs: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
"""Match DATASET_META by generator short-name AND kwargs when available.
Falls back to first-wins when kwargs are unknown (ambiguous for
swiss_roll vs swiss_roll_hole both share `make_swiss_roll`)."""
candidates = [
m for m in DATASET_META.values()
if m["path"].rsplit(".", 1)[-1] == generator_short
]
if not candidates:
return None
if generator_kwargs is not None:
for m in candidates:
if m["kwargs"] == generator_kwargs:
return m
return candidates[0]
def _enrich_with_labels(d: Dict[str, Any]) -> Dict[str, Any]: def _enrich_with_labels(d: Dict[str, Any]) -> Dict[str, Any]:
"""Attach per-point class/continuous labels by regenerating the dataset """Attach per-point class/continuous labels by regenerating the dataset
with the same (generator, n_samples, kwargs). The stem's `seed` drives with the same (generator, n_samples, kwargs). random_state is fixed at 0
jitter NOT generator so we always use random_state=0 to match the (the flow's _DEFAULT_GENERATOR_KWARGS) — the stem's `seed` drives jitter,
flow's _DEFAULT_GENERATOR_KWARGS. Jitter-added points (id >= num_points) not the generator. Jitter-added points (id >= num_points) get None so
get None so the client renders them as black.""" the client renders them as black.
meta = _GEN_TO_META.get(d["meta"].get("generator") or "")
if not meta: Discovers generator_kwargs in priority order: (1) payload meta (sidecar
return d runs from the updated flow); (2) sibling metrics.json; (3) DATASET_META
by first-match (ambiguous for swiss_roll/swiss_roll_hole need a
backfilled metrics.json to disambiguate)."""
meta = d.get("meta") or {}
gen_short = meta.get("generator") or ""
gk = meta.get("generator_kwargs")
if gk is None:
stem = meta.get("stem")
if stem:
mx = FIGS_DIR / f"{stem}.metrics.json"
if mx.is_file():
try: try:
mod_path, cls_name = meta["path"].rsplit(".", 1) gk = json.loads(mx.read_text(encoding="utf-8")).get(
"meta", {}
).get("generator_kwargs")
except Exception:
gk = None
dm = _lookup_dataset_meta(gen_short, gk)
if not dm:
return d
kwargs_to_use = gk if gk is not None else dm["kwargs"]
try:
mod_path, cls_name = dm["path"].rsplit(".", 1)
fn = getattr(importlib.import_module(mod_path), cls_name) fn = getattr(importlib.import_module(mod_path), cls_name)
N = int(d["meta"]["num_points"]) N = int(meta["num_points"])
_, gen_labels = fn(n_samples=N, random_state=0, **meta["kwargs"]) _, gen_labels = fn(n_samples=N, random_state=0, **kwargs_to_use)
out_labels: List[Optional[float]] = [] out_labels: List[Optional[float]] = []
for pid in d["point_ids"]: for pid in d["point_ids"]:
if isinstance(pid, int) and 0 <= pid < N: if isinstance(pid, int) and 0 <= pid < N:
@ -912,7 +977,7 @@ def _enrich_with_labels(d: Dict[str, Any]) -> Dict[str, Any]:
else: else:
out_labels.append(None) out_labels.append(None)
d["labels"] = out_labels d["labels"] = out_labels
d["label_kind"] = meta["kind"] d["label_kind"] = dm["kind"]
except Exception: except Exception:
pass pass
return d return d
@ -929,6 +994,10 @@ def _cached_frames(stem: str) -> str:
else: else:
html = FIGS_DIR / f"{stem}.html" html = FIGS_DIR / f"{stem}.html"
d = parse_plotly_run(html) d = parse_plotly_run(html)
# Override meta.stem with the URL-requested stem — after a backfill the
# file was renamed but the baked-in meta.stem still points at the old
# name. Enrichment uses this to find the sibling metrics.json.
d.setdefault("meta", {})["stem"] = stem
d = _enrich_with_labels(d) d = _enrich_with_labels(d)
return json.dumps(d, separators=(",", ":")) return json.dumps(d, separators=(",", ":"))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,10 +27,19 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
def _embed_args_hash(ea: Optional[Dict[str, Any]]) -> str: def _run_args_hash(
"""8-hex digest of embed_args (keys sorted) — output stem includes this ea: Optional[Dict[str, Any]],
so runs differing only in embed_args get distinct files.""" gk: Optional[Dict[str, Any]] = None,
s = json.dumps(ea or {}, sort_keys=True, default=str) ) -> str:
"""8-hex digest over (embed_args, generator_kwargs). When gk is empty we
hash embed_args alone keeps stems stable for plain generators that
never had gen_kwargs (s_curve, plain swiss_roll). Must mirror
app.web.main.run_args_hash exactly."""
if gk:
payload: Any = {"embed_args": ea or {}, "generator_kwargs": gk}
else:
payload = ea or {}
s = json.dumps(payload, sort_keys=True, default=str)
return hashlib.sha1(s.encode()).hexdigest()[:8] return hashlib.sha1(s.encode()).hexdigest()[:8]
@ -45,7 +54,7 @@ def _flow_run_name() -> str:
T = p.get("num_timesteps", "?") T = p.get("num_timesteps", "?")
J = p.get("jitter_scale", "?") J = p.get("jitter_scale", "?")
s = p.get("seed", "?") s = p.get("seed", "?")
tag = _embed_args_hash(p.get("embed_args")) tag = _run_args_hash(p.get("embed_args"), p.get("generator_kwargs"))
return f"{gen}_{emb}_N{N}_T{T}_J{J}_s{s}_{tag}" return f"{gen}_{emb}_N{N}_T{T}_J{J}_s{s}_{tag}"
from prefect import flow, runtime, task from prefect import flow, runtime, task
@ -302,7 +311,7 @@ def embedding_flow(
output_ref: str = ( output_ref: str = (
f"{output_dir.strip('/')}/{_generator}_Reference_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}.html" f"{output_dir.strip('/')}/{_generator}_Reference_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}.html"
) )
_args_tag = _embed_args_hash(embed_args) _args_tag = _run_args_hash(embed_args, generator_kwargs)
output_embed: str = ( output_embed: str = (
f"{output_dir.strip('/')}/{_generator}_{embedder.split('.')[-1]}_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}_{_args_tag}.html" f"{output_dir.strip('/')}/{_generator}_{embedder.split('.')[-1]}_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}_{_args_tag}.html"
) )
@ -396,6 +405,7 @@ def embedding_flow(
"jitter_scale": jitter_scale, "jitter_scale": jitter_scale,
"seed": seed, "seed": seed,
"generator_path": generator_path, "generator_path": generator_path,
"generator_kwargs": generator_kwargs or {},
"embedder": embedder, "embedder": embedder,
"embed_args": merged_embed_args, "embed_args": merged_embed_args,
}, },
@ -416,6 +426,9 @@ def embedding_flow(
_sys.path.insert(0, _root) _sys.path.insert(0, _root)
from app.web.plotly_parse import parse_plotly_run from app.web.plotly_parse import parse_plotly_run
frames = parse_plotly_run(emb_path_result) frames = parse_plotly_run(emb_path_result)
# Persist generator_kwargs so the server's label enrichment can
# regenerate the correct dataset variant (swiss_roll vs hole).
frames.setdefault("meta", {})["generator_kwargs"] = generator_kwargs or {}
Path(output_frames).write_text( Path(output_frames).write_text(
json.dumps(frames, separators=(",", ":")), encoding="utf-8" json.dumps(frames, separators=(",", ":")), encoding="utf-8"
) )

View File

@ -1,13 +1,18 @@
"""Rename pre-hash embedder figs to include the embed_args hash suffix. """Rename embedder figs to the current hash scheme (embed_args + generator_kwargs).
Walks figs/ for `.html` files matching the old stem shape (no hash tail) that Two waves of runs may exist on disk:
represent an embedder run (not Reference), reads the sibling (1) pre-hash `<stem>.html`
`<stem>.metrics.json` to recover `meta.embed_args`, computes the hash, and (2) intermediate `<stem>_<sha1(embed_args)>.html` (from the first hash rollout)
renames the .html + .metrics.json in place. (3) current `<stem>_<sha1(embed_args, gen_kwargs)>.html` when gen_kwargs is truthy;
identical to (2) when gen_kwargs is empty.
Default is a dry-run pass `--apply` to actually rename. Reference files are This script queries Prefect for each recent run's full params (so it knows
left alone (they have no embed_args). Missing metrics.json warn and skip. generator_kwargs which the metrics.json sidecar didn't persist before), finds
Target-name collision warn and skip. the matching fig on disk, renames to the current stem, and injects
`meta.generator_kwargs` into the metrics.json so the web server's label
enrichment disambiguates swiss_roll vs swiss_roll_hole etc.
Dry-run by default. Pass --apply to rename.
Usage: Usage:
.venv/bin/python scripts/backfill_hashes.py [--apply] [--figs-dir PATH] .venv/bin/python scripts/backfill_hashes.py [--apply] [--figs-dir PATH]
@ -16,65 +21,91 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio
import hashlib
import json import json
import re
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional
# Reach up to the project root so we can reuse the canonical hash helper.
_ROOT = Path(__file__).resolve().parent.parent _ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_ROOT)) sys.path.insert(0, str(_ROOT))
from app.web.main import embed_args_hash # noqa: E402 from app.web.main import PREFECT, run_args_hash # noqa: E402
_LEGACY_STEM = re.compile(
r"^(?P<base>make_[A-Za-z_]+?_[A-Za-z]+_N\d+_T\d+_J[\d.]+_s\d+)$"
)
def plan_renames(figs_dir: Path): def _legacy_hash(ea: Optional[Dict[str, Any]]) -> str:
for html in sorted(figs_dir.glob("*.html")): s = json.dumps(ea or {}, sort_keys=True, default=str)
stem = html.stem return hashlib.sha1(s.encode()).hexdigest()[:8]
m = _LEGACY_STEM.match(stem)
if not m:
# Either already hashed or doesn't match our scheme at all. def _base_stem(params: Dict[str, Any]) -> Optional[str]:
continue
# Skip Reference runs — they have no embed_args.
if "_Reference_" in stem:
continue
metrics = figs_dir / f"{stem}.metrics.json"
if not metrics.is_file():
yield (html, None, "missing metrics.json — can't compute hash")
continue
try: try:
ea = json.loads(metrics.read_text(encoding="utf-8"))["meta"]["embed_args"] gen = (params.get("generator_path") or "").rsplit(".", 1)[-1]
except (KeyError, json.JSONDecodeError) as e: emb = (params.get("embedder") or "").rsplit(".", 1)[-1]
yield (html, None, f"bad metrics.json: {e}") N = int(params["num_points"])
continue T = int(params.get("num_timesteps", params.get("num_snapshots")))
new_stem = f"{stem}_{embed_args_hash(ea)}" J = float(params["jitter_scale"])
new_html = figs_dir / f"{new_stem}.html" s = int(params["seed"])
if new_html.exists(): except (KeyError, TypeError, ValueError):
yield (html, None, f"target exists: {new_html.name}") return None
continue if not gen or not emb:
yield (html, new_stem, None) return None
return f"{gen}_{emb}_N{N}_T{T}_J{J}_s{s}"
def apply_rename(figs_dir: Path, old_stem: str, new_stem: str) -> list[str]: def _candidate_names(base: str, ea: Dict[str, Any], gk: Dict[str, Any]) -> List[str]:
"""Rename every sidecar sharing the old stem. Returns the renamed files.""" target = f"{base}_{run_args_hash(ea, gk)}.html"
renamed = [] legacy = f"{base}_{_legacy_hash(ea)}.html"
no_hash = f"{base}.html"
# Preserve order: target first so we short-circuit on already-backfilled.
out = [target]
for x in (legacy, no_hash):
if x not in out:
out.append(x)
return out
def _patch_metrics(path: Path, gk: Dict[str, Any]) -> bool:
if not path.is_file():
return False
try:
d = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return False
meta = d.setdefault("meta", {})
if meta.get("generator_kwargs") == gk:
return False
meta["generator_kwargs"] = gk
path.write_text(json.dumps(d, indent=2), encoding="utf-8")
return True
def _rename_bundle(figs_dir: Path, old_stem: str, new_stem: str) -> List[str]:
moved = []
for suffix in (".html", ".metrics.json", ".frames.json"): for suffix in (".html", ".metrics.json", ".frames.json"):
src = figs_dir / f"{old_stem}{suffix}" src = figs_dir / f"{old_stem}{suffix}"
if not src.exists(): if not src.exists():
continue continue
dst = figs_dir / f"{new_stem}{suffix}" dst = figs_dir / f"{new_stem}{suffix}"
if dst.exists():
moved.append(f"SKIP (target exists) {src.name}")
continue
src.rename(dst) src.rename(dst)
renamed.append(f"{src.name} -> {dst.name}") moved.append(f"{src.name} -> {dst.name}")
return renamed return moved
async def _fetch_runs(limit: int = 200) -> List[Dict[str, Any]]:
import httpx
async with httpx.AsyncClient(timeout=10.0) as c:
return await PREFECT.recent_runs(c, limit=limit)
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--apply", action="store_true", help="actually rename (default: dry-run)") ap.add_argument("--apply", action="store_true", help="actually rename + patch (default: dry-run)")
ap.add_argument("--figs-dir", default=str(_ROOT / "figs"), help="path to figs/ directory") ap.add_argument("--figs-dir", default=str(_ROOT / "figs"), help="path to figs/ directory")
ap.add_argument("--limit", type=int, default=200, help="Prefect runs to scan")
args = ap.parse_args() args = ap.parse_args()
figs_dir = Path(args.figs_dir).resolve() figs_dir = Path(args.figs_dir).resolve()
@ -82,36 +113,65 @@ def main() -> int:
print(f"no such directory: {figs_dir}", file=sys.stderr) print(f"no such directory: {figs_dir}", file=sys.stderr)
return 2 return 2
planned, skipped = [], [] try:
for html, new_stem, reason in plan_renames(figs_dir): runs = asyncio.run(_fetch_runs(limit=args.limit))
if new_stem is None: except Exception as e:
skipped.append((html.name, reason)) print(f"could not reach Prefect at {PREFECT.base} ({e})", file=sys.stderr)
return 3
plans = [] # (old_stem, new_stem, gk, found_name)
seen_targets = set()
for r in runs:
params = r.get("parameters") or {}
ea = params.get("embed_args") or {}
gk = params.get("generator_kwargs") or {}
base = _base_stem(params)
if not base:
continue
target = f"{base}_{run_args_hash(ea, gk)}.html"
if target in seen_targets:
continue # later duplicate — the stale-marking logic will handle it
for candidate in _candidate_names(base, ea, gk):
if (figs_dir / candidate).exists():
if candidate == target:
# Already at target; just ensure metrics.json carries gk.
plans.append((Path(candidate).stem, Path(target).stem, gk, candidate, True))
else: else:
planned.append((html.stem, new_stem)) plans.append((Path(candidate).stem, Path(target).stem, gk, candidate, False))
seen_targets.add(target)
break
print(f"scanning {figs_dir}") print(f"scanning {figs_dir} (Prefect runs seen: {len(runs)})")
print(f" {len(planned)} to rename, {len(skipped)} skipped\n") renames = [p for p in plans if not p[4]]
already = [p for p in plans if p[4]]
print(f" {len(renames)} to rename, {len(already)} already at target\n")
for old, new in planned: for old, new, gk, _, _ in renames:
print(f" rename {old} -> {new}") gk_str = json.dumps(gk) if gk else "{}"
if skipped: print(f" rename {old} -> {new} gen_kwargs={gk_str}")
print("\n skipped:")
for name, reason in skipped:
print(f" {name} ({reason})")
if not planned: if already:
print(f"\n at-target (will only patch metrics.json if missing gen_kwargs):")
for old, _, gk, name, _ in already:
print(f" {name} gen_kwargs={json.dumps(gk) if gk else '{}'}")
if not renames and not already:
print("nothing to do")
return 0 return 0
if not args.apply: if not args.apply:
print("\n(dry run — pass --apply to rename)") print("\n(dry run — pass --apply to rename + patch)")
return 0 return 0
print("\napplying...") print("\napplying...")
for old, new in planned: for old, new, gk, _, at_target in plans:
moved = apply_rename(figs_dir, old, new) if not at_target:
for line in moved: for line in _rename_bundle(figs_dir, old, new):
print(f" {line}") print(f" {line}")
print(f"done — renamed {len(planned)} run(s)") patched = _patch_metrics(figs_dir / f"{new}.metrics.json", gk)
if patched:
print(f" patched {new}.metrics.json (generator_kwargs)")
print(f"done — renamed {len(renames)}, patched metrics for {len(plans)} run(s)")
return 0 return 0