Compare commits
No commits in common. "b744c48348c28cab83144e556ae7153d1bf9f4ec" and "47f56b57c85b6b6b6965107b92491d661eab10c7" have entirely different histories.
b744c48348
...
47f56b57c8
111
app/web/main.py
111
app/web/main.py
@ -450,31 +450,13 @@ def build_embed_args(reducer_key: str, form: Dict[str, str]) -> Dict[str, Any]:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def run_args_hash(
|
def embed_args_hash(embed_args: Optional[Dict[str, Any]]) -> str:
|
||||||
embed_args: Optional[Dict[str, Any]],
|
"""8-hex digest of an embed_args dict (keys sorted). Stems incorporate
|
||||||
generator_kwargs: Optional[Dict[str, Any]] = None,
|
this so runs that differ only in embed_args get distinct output files."""
|
||||||
) -> str:
|
s = json.dumps(embed_args or {}, sort_keys=True, default=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,
|
||||||
@ -483,7 +465,6 @@ 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]
|
||||||
@ -492,7 +473,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}_{run_args_hash(embed_args, generator_kwargs)}.html"
|
embf = f"{base}_{embed_args_hash(embed_args)}.html"
|
||||||
return ref, embf
|
return ref, embf
|
||||||
|
|
||||||
|
|
||||||
@ -609,11 +590,6 @@ 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);
|
||||||
@ -639,7 +615,6 @@ 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)
|
||||||
@ -808,12 +783,13 @@ 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.
|
||||||
# Hash now covers both embed_args and generator_kwargs, so swiss_roll vs
|
# The stem now includes an 8-hex hash of embed_args, so UMAP(n_neighbors=5)
|
||||||
# swiss_roll_hole (and blobs with varying n_features, etc.) no longer
|
# and UMAP(n_neighbors=15) produce distinct files. Check both the hashed
|
||||||
# share a stem. Also check the legacy hashless path for pre-hash figs.
|
# path (new runs) and the legacy hashless path (pre-hash runs) so users
|
||||||
|
# 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, generator_kwargs=generator_kwargs,
|
embed_args=embed_args,
|
||||||
)
|
)
|
||||||
_, 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,
|
||||||
@ -857,7 +833,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, generator_kwargs=generator_kwargs,
|
embed_args=embed_args,
|
||||||
)
|
)
|
||||||
RUN_OUTPUTS[run["id"]] = {"ref": ref_file, "embed": emb_file}
|
RUN_OUTPUTS[run["id"]] = {"ref": ref_file, "embed": emb_file}
|
||||||
|
|
||||||
@ -914,61 +890,20 @@ 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). random_state is fixed at 0
|
with the same (generator, n_samples, kwargs). The stem's `seed` drives
|
||||||
(the flow's _DEFAULT_GENERATOR_KWARGS) — the stem's `seed` drives jitter,
|
jitter — NOT generator — so we always use random_state=0 to match the
|
||||||
not the generator. Jitter-added points (id >= num_points) get None so
|
flow's _DEFAULT_GENERATOR_KWARGS. Jitter-added points (id >= num_points)
|
||||||
the client renders them as black.
|
get None so the client renders them as black."""
|
||||||
|
meta = _GEN_TO_META.get(d["meta"].get("generator") or "")
|
||||||
Discovers generator_kwargs in priority order: (1) payload meta (sidecar
|
if not meta:
|
||||||
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:
|
|
||||||
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
|
return d
|
||||||
kwargs_to_use = gk if gk is not None else dm["kwargs"]
|
|
||||||
try:
|
try:
|
||||||
mod_path, cls_name = dm["path"].rsplit(".", 1)
|
mod_path, cls_name = meta["path"].rsplit(".", 1)
|
||||||
fn = getattr(importlib.import_module(mod_path), cls_name)
|
fn = getattr(importlib.import_module(mod_path), cls_name)
|
||||||
N = int(meta["num_points"])
|
N = int(d["meta"]["num_points"])
|
||||||
_, gen_labels = fn(n_samples=N, random_state=0, **kwargs_to_use)
|
_, gen_labels = fn(n_samples=N, random_state=0, **meta["kwargs"])
|
||||||
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:
|
||||||
@ -977,7 +912,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"] = dm["kind"]
|
d["label_kind"] = meta["kind"]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return d
|
return d
|
||||||
@ -994,10 +929,6 @@ 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=(",", ":"))
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,733 @@
|
|||||||
// compare.js — thin shim that parses ?a=&b= and hands off to panel-grid.js.
|
// 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 { mountPanels } from './panel-grid.js?v=1';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// -------- URL / DOM wiring ------------------------------------------------
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const stems = [params.get('a') || '', params.get('b') || ''].filter(Boolean);
|
const STEM_A = params.get('a') || '';
|
||||||
|
const STEM_B = params.get('b') || '';
|
||||||
|
|
||||||
const host = document.getElementById('panel-host');
|
const layout = document.getElementById('compare-layout');
|
||||||
const controls = document.getElementById('compare-controls');
|
const panelElA = layout.querySelector('.compare-panel[data-slot="a"]');
|
||||||
|
const panelElB = layout.querySelector('.compare-panel[data-slot="b"]');
|
||||||
|
|
||||||
mountPanels({ host, controls, stems });
|
const playBtn = document.getElementById('cc-play');
|
||||||
|
const scrub = document.getElementById('cc-scrub');
|
||||||
|
const speedSel = document.getElementById('cc-speed');
|
||||||
|
const syncSel = document.getElementById('cc-sync');
|
||||||
|
const motionSel = document.getElementById('cc-motion');
|
||||||
|
const colorSel = document.getElementById('cc-color');
|
||||||
|
const timeAEl = document.getElementById('cc-time').querySelector('[data-role="time-a"]');
|
||||||
|
const timeBEl = document.getElementById('cc-time').querySelector('[data-role="time-b"]');
|
||||||
|
|
||||||
|
// -------- small helpers ---------------------------------------------------
|
||||||
|
|
||||||
|
// Build a soft-edged circular sprite for THREE.Points. A plain texture with
|
||||||
|
// radial alpha gives anti-aliased round dots without fragment-shader work.
|
||||||
|
function makeDiskTexture(size = 64) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = size;
|
||||||
|
const g = c.getContext('2d');
|
||||||
|
const r = size / 2;
|
||||||
|
const grd = g.createRadialGradient(r, r, 0, r, r, r);
|
||||||
|
grd.addColorStop(0.0, 'rgba(255,255,255,1)');
|
||||||
|
grd.addColorStop(0.55, 'rgba(255,255,255,1)');
|
||||||
|
grd.addColorStop(0.85, 'rgba(255,255,255,0.35)');
|
||||||
|
grd.addColorStop(1.0, 'rgba(255,255,255,0)');
|
||||||
|
g.fillStyle = grd;
|
||||||
|
g.fillRect(0, 0, size, size);
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
tex.needsUpdate = true;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISK_TEX = makeDiskTexture();
|
||||||
|
|
||||||
|
// Continuous ramp (blue → orange), matching dataset-picker.js exactly.
|
||||||
|
function rampContinuous(t, out) {
|
||||||
|
const hue = (1 - t) * 215 + t * 28;
|
||||||
|
const sat = 0.62;
|
||||||
|
const lit = 0.50 + (t - 0.5) * 0.08;
|
||||||
|
return (out || new THREE.Color()).setHSL(hue / 360, sat, lit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8-color categorical palette — same hex list as dataset-picker.js.
|
||||||
|
const CATEGORICAL_HEX = [
|
||||||
|
'#1f4e5f', '#c97b3f', '#8b5a9f', '#5a8560',
|
||||||
|
'#c74a5e', '#6b7d8f', '#b89f51', '#4a6fa5',
|
||||||
|
];
|
||||||
|
const CATEGORICAL = CATEGORICAL_HEX.map(h => new THREE.Color(h));
|
||||||
|
|
||||||
|
// Precompute per-point RGB, indexed by position in data.point_ids.
|
||||||
|
// If the server attached labels + label_kind, color by that (ramp for
|
||||||
|
// continuous, palette for categorical) to match the dataset picker. Points
|
||||||
|
// with null labels (jitter-added, id >= num_points) stay (0,0,0) = black.
|
||||||
|
// Falls back to a frame-0-present ordinal ramp when no labels are present.
|
||||||
|
function buildIdColorsRGB(data) {
|
||||||
|
const n = data.point_ids.length;
|
||||||
|
const rgb = new Float32Array(n * 3);
|
||||||
|
const labels = data.labels || [];
|
||||||
|
const kind = data.label_kind || null;
|
||||||
|
const hasRealLabels = kind && labels.some(v => v != null && v !== '');
|
||||||
|
|
||||||
|
if (hasRealLabels) {
|
||||||
|
const tmp = new THREE.Color();
|
||||||
|
if (kind === 'categorical') {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const v = labels[i];
|
||||||
|
if (v == null) continue;
|
||||||
|
const c = CATEGORICAL[((v | 0) % CATEGORICAL.length + CATEGORICAL.length) % CATEGORICAL.length];
|
||||||
|
rgb[i * 3 + 0] = c.r;
|
||||||
|
rgb[i * 3 + 1] = c.g;
|
||||||
|
rgb[i * 3 + 2] = c.b;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let lo = Infinity, hi = -Infinity;
|
||||||
|
for (const v of labels) { if (v == null) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||||
|
const range = (hi - lo) || 1;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const v = labels[i];
|
||||||
|
if (v == null) continue;
|
||||||
|
rampContinuous((v - lo) / range, tmp);
|
||||||
|
rgb[i * 3 + 0] = tmp.r;
|
||||||
|
rgb[i * 3 + 1] = tmp.g;
|
||||||
|
rgb[i * 3 + 2] = tmp.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: rainbow-by-ordinal over frame-0-present points.
|
||||||
|
const frame0 = data.frames[0];
|
||||||
|
if (!frame0) return rgb;
|
||||||
|
const originalPositions = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (frame0.x[i] != null && frame0.y[i] != null
|
||||||
|
&& !Number.isNaN(frame0.x[i]) && !Number.isNaN(frame0.y[i])) {
|
||||||
|
originalPositions.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
originalPositions.sort((a, b) => data.point_ids[a] - data.point_ids[b]);
|
||||||
|
const nOrig = originalPositions.length;
|
||||||
|
const tmp = new THREE.Color();
|
||||||
|
for (let k = 0; k < nOrig; k++) {
|
||||||
|
const t = nOrig > 1 ? k / (nOrig - 1) : 0.5;
|
||||||
|
rampContinuous(t, tmp);
|
||||||
|
const idx = originalPositions[k];
|
||||||
|
rgb[idx * 3 + 0] = tmp.r;
|
||||||
|
rgb[idx * 3 + 1] = tmp.g;
|
||||||
|
rgb[idx * 3 + 2] = tmp.b;
|
||||||
|
}
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVar(name, fallback) {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return v || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPanelBg(el) {
|
||||||
|
const v = getComputedStyle(el).getPropertyValue('--picker-panel').trim();
|
||||||
|
return v || readVar('--panel', '#ffffff');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel accent colors keyed by slot. Resolved from CSS vars so they flip
|
||||||
|
// with the theme via 'themechange'.
|
||||||
|
function panelAccent(slot) {
|
||||||
|
return slot === 'a' ? readVar('--accent', '#1f4e5f') : readVar('--warm', '#a77a2c');
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightColor() {
|
||||||
|
return readVar('--alarm', '#8a3a2a');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Panel factory ---------------------------------------------------
|
||||||
|
|
||||||
|
// Returns { setFrame, setBounds, setHighlight, onHover, resize, dispose, state }
|
||||||
|
function createPanel({ slotId, panelEl, data }) {
|
||||||
|
const canvasEl = panelEl.querySelector('[data-role="canvas"]');
|
||||||
|
const statusEl = panelEl.querySelector('[data-role="status"]');
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(readPanelBg(panelEl));
|
||||||
|
|
||||||
|
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10, 10);
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
canvasEl.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Pre-allocate a buffer sized for num_points. Each frame we repack the
|
||||||
|
// non-null points into the prefix and call setDrawRange(0, count).
|
||||||
|
const maxN = data.point_ids.length;
|
||||||
|
const positions = new Float32Array(maxN * 3);
|
||||||
|
const colors = new Float32Array(maxN * 3);
|
||||||
|
const ids = new Int32Array(maxN); // packed point_id per drawn vertex
|
||||||
|
const idColorRGB = buildIdColorsRGB(data);
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||||
|
geo.setDrawRange(0, 0);
|
||||||
|
|
||||||
|
const matMono = new THREE.PointsMaterial({
|
||||||
|
size: 6.0,
|
||||||
|
sizeAttenuation: false,
|
||||||
|
map: DISK_TEX,
|
||||||
|
transparent: true,
|
||||||
|
alphaTest: 0.2,
|
||||||
|
depthWrite: false,
|
||||||
|
color: new THREE.Color(panelAccent(slotId)),
|
||||||
|
});
|
||||||
|
const matRainbow = new THREE.PointsMaterial({
|
||||||
|
size: 6.0,
|
||||||
|
sizeAttenuation: false,
|
||||||
|
map: DISK_TEX,
|
||||||
|
// Opaque + alpha cutoff rather than alpha blending: with a ramp, many
|
||||||
|
// overlapping translucent points average out to the middle of the ramp
|
||||||
|
// (blue + orange → green), washing out the whole cloud. Hard edges keep
|
||||||
|
// each point's true color visible.
|
||||||
|
transparent: false,
|
||||||
|
alphaTest: 0.5,
|
||||||
|
vertexColors: true,
|
||||||
|
});
|
||||||
|
let mat = matMono;
|
||||||
|
const points = new THREE.Points(geo, mat);
|
||||||
|
scene.add(points);
|
||||||
|
|
||||||
|
// Highlight overlay — a second 1-vertex Points object drawn on top.
|
||||||
|
const hiPos = new Float32Array(3);
|
||||||
|
const hiGeo = new THREE.BufferGeometry();
|
||||||
|
hiGeo.setAttribute('position', new THREE.BufferAttribute(hiPos, 3));
|
||||||
|
hiGeo.setDrawRange(0, 0);
|
||||||
|
const hiMat = new THREE.PointsMaterial({
|
||||||
|
size: 14.0,
|
||||||
|
sizeAttenuation: false,
|
||||||
|
map: DISK_TEX,
|
||||||
|
transparent: true,
|
||||||
|
alphaTest: 0.05,
|
||||||
|
depthWrite: false,
|
||||||
|
color: new THREE.Color(highlightColor()),
|
||||||
|
opacity: 0.9,
|
||||||
|
});
|
||||||
|
const hiPoints = new THREE.Points(hiGeo, hiMat);
|
||||||
|
// Ensure highlight renders above base points
|
||||||
|
hiPoints.renderOrder = 1;
|
||||||
|
scene.add(hiPoints);
|
||||||
|
|
||||||
|
// ---- current-frame packed state (kept in closure for hover picking) ----
|
||||||
|
// packedX/packedY/packedId of length = current draw count.
|
||||||
|
let packedN = 0;
|
||||||
|
const packedX = new Float32Array(maxN);
|
||||||
|
const packedY = new Float32Array(maxN);
|
||||||
|
const packedId = new Int32Array(maxN);
|
||||||
|
|
||||||
|
// Camera frame rectangle (world coords) currently applied.
|
||||||
|
const camRect = { xmin: -1, xmax: 1, ymin: -1, ymax: 1 };
|
||||||
|
|
||||||
|
function applyCamRect(rect) {
|
||||||
|
const pad = 0.05;
|
||||||
|
const dx = rect.xmax - rect.xmin || 1;
|
||||||
|
const dy = rect.ymax - rect.ymin || 1;
|
||||||
|
const cx = (rect.xmin + rect.xmax) / 2;
|
||||||
|
const cy = (rect.ymin + rect.ymax) / 2;
|
||||||
|
const rx = dx * (0.5 + pad);
|
||||||
|
const ry = dy * (0.5 + pad);
|
||||||
|
// Fit-to-larger-axis so points never get squashed when the panel aspect
|
||||||
|
// doesn't match the bounds aspect. We compute the viewport aspect here
|
||||||
|
// so the ortho frustum covers the data and then some.
|
||||||
|
const rect2 = canvasEl.getBoundingClientRect();
|
||||||
|
const vw = Math.max(1, rect2.width);
|
||||||
|
const vh = Math.max(1, rect2.height);
|
||||||
|
const aspect = vw / vh;
|
||||||
|
const dataAspect = (rx * 2) / (ry * 2);
|
||||||
|
let halfW, halfH;
|
||||||
|
if (aspect > dataAspect) {
|
||||||
|
// viewport wider than data: expand X to fit
|
||||||
|
halfH = ry;
|
||||||
|
halfW = ry * aspect;
|
||||||
|
} else {
|
||||||
|
halfW = rx;
|
||||||
|
halfH = rx / aspect;
|
||||||
|
}
|
||||||
|
camera.left = cx - halfW;
|
||||||
|
camera.right = cx + halfW;
|
||||||
|
camera.top = cy + halfH;
|
||||||
|
camera.bottom = cy - halfH;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
camRect.xmin = rect.xmin;
|
||||||
|
camRect.xmax = rect.xmax;
|
||||||
|
camRect.ymin = rect.ymin;
|
||||||
|
camRect.ymax = rect.ymax;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const rect = canvasEl.getBoundingClientRect();
|
||||||
|
const w = Math.max(1, Math.floor(rect.width));
|
||||||
|
const h = Math.max(1, Math.floor(rect.height));
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
// Re-apply the current cam rect so the aspect fit recomputes.
|
||||||
|
applyCamRect(camRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBounds(b) {
|
||||||
|
applyCamRect({ xmin: b.x[0], xmax: b.x[1], ymin: b.y[0], ymax: b.y[1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColorsFromTheme() {
|
||||||
|
matMono.color.set(panelAccent(slotId));
|
||||||
|
hiMat.color.set(highlightColor());
|
||||||
|
scene.background = new THREE.Color(readPanelBg(panelEl));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setColorMode(mode) {
|
||||||
|
const next = mode === 'mono' ? matMono : matRainbow;
|
||||||
|
if (points.material !== next) {
|
||||||
|
points.material = next;
|
||||||
|
mat = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the precomputed per-id RGB into the packed position `j`.
|
||||||
|
function writePackedColor(j, i) {
|
||||||
|
colors[j * 3 + 0] = idColorRGB[i * 3 + 0];
|
||||||
|
colors[j * 3 + 1] = idColorRGB[i * 3 + 1];
|
||||||
|
colors[j * 3 + 2] = idColorRGB[i * 3 + 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack frame `f` into the geometry buffer, skipping null x/y.
|
||||||
|
function setFrame(f) {
|
||||||
|
const frame = data.frames[f];
|
||||||
|
if (!frame) return;
|
||||||
|
const xs = frame.x, ys = frame.y;
|
||||||
|
const ptIds = data.point_ids;
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < xs.length; i++) {
|
||||||
|
const x = xs[i], y = ys[i];
|
||||||
|
if (x === null || y === null || x === undefined || y === undefined
|
||||||
|
|| Number.isNaN(x) || Number.isNaN(y)) continue;
|
||||||
|
positions[j * 3 + 0] = x;
|
||||||
|
positions[j * 3 + 1] = y;
|
||||||
|
positions[j * 3 + 2] = 0;
|
||||||
|
writePackedColor(j, i);
|
||||||
|
packedX[j] = x;
|
||||||
|
packedY[j] = y;
|
||||||
|
packedId[j] = ptIds[i];
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
packedN = j;
|
||||||
|
geo.attributes.position.needsUpdate = true;
|
||||||
|
geo.attributes.color.needsUpdate = true;
|
||||||
|
geo.setDrawRange(0, packedN);
|
||||||
|
applyHighlightForCurrentFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack an interpolated frame. uLocal is a continuous index in [0, T-1].
|
||||||
|
// Points missing in either adjacent frame are skipped for the duration of
|
||||||
|
// that transition (no connect-back to the last-known position).
|
||||||
|
function setFrameInterpolated(uLocal) {
|
||||||
|
const T = data.frames.length;
|
||||||
|
if (T === 0) return;
|
||||||
|
if (uLocal <= 0) return setFrame(0);
|
||||||
|
if (uLocal >= T - 1) return setFrame(T - 1);
|
||||||
|
const f0 = Math.floor(uLocal);
|
||||||
|
const f1 = f0 + 1;
|
||||||
|
const t = uLocal - f0;
|
||||||
|
const fr0 = data.frames[f0], fr1 = data.frames[f1];
|
||||||
|
const x0 = fr0.x, y0 = fr0.y, x1 = fr1.x, y1 = fr1.y;
|
||||||
|
const ptIds = data.point_ids;
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < x0.length; i++) {
|
||||||
|
const a = x0[i], b = x1[i], c = y0[i], d = y1[i];
|
||||||
|
if (a === null || a === undefined || Number.isNaN(a)
|
||||||
|
|| b === null || b === undefined || Number.isNaN(b)
|
||||||
|
|| c === null || c === undefined || Number.isNaN(c)
|
||||||
|
|| d === null || d === undefined || Number.isNaN(d)) continue;
|
||||||
|
const xi = a + (b - a) * t;
|
||||||
|
const yi = c + (d - c) * t;
|
||||||
|
positions[j * 3 + 0] = xi;
|
||||||
|
positions[j * 3 + 1] = yi;
|
||||||
|
positions[j * 3 + 2] = 0;
|
||||||
|
writePackedColor(j, i);
|
||||||
|
packedX[j] = xi;
|
||||||
|
packedY[j] = yi;
|
||||||
|
packedId[j] = ptIds[i];
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
packedN = j;
|
||||||
|
geo.attributes.position.needsUpdate = true;
|
||||||
|
geo.attributes.color.needsUpdate = true;
|
||||||
|
geo.setDrawRange(0, packedN);
|
||||||
|
applyHighlightForCurrentFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- highlight by point_id ---------------------------------------------
|
||||||
|
let currentHighlightId = -1;
|
||||||
|
|
||||||
|
function applyHighlightForCurrentFrame() {
|
||||||
|
if (currentHighlightId < 0) {
|
||||||
|
hiGeo.setDrawRange(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Linear scan — packedN <= 5000 and this only runs on hover.
|
||||||
|
for (let i = 0; i < packedN; i++) {
|
||||||
|
if (packedId[i] === currentHighlightId) {
|
||||||
|
hiPos[0] = packedX[i];
|
||||||
|
hiPos[1] = packedY[i];
|
||||||
|
hiPos[2] = 0.01;
|
||||||
|
hiGeo.attributes.position.needsUpdate = true;
|
||||||
|
hiGeo.setDrawRange(0, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not present this frame.
|
||||||
|
hiGeo.setDrawRange(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHighlight(pointId) {
|
||||||
|
currentHighlightId = (pointId === null || pointId === undefined) ? -1 : pointId;
|
||||||
|
applyHighlightForCurrentFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert clientX/Y to world coords (ortho, no rotation).
|
||||||
|
function clientToWorld(clientX, clientY) {
|
||||||
|
const rect = canvasEl.getBoundingClientRect();
|
||||||
|
const u = (clientX - rect.left) / Math.max(1, rect.width);
|
||||||
|
const v = (clientY - rect.top) / Math.max(1, rect.height);
|
||||||
|
const wx = camera.left + u * (camera.right - camera.left);
|
||||||
|
const wy = camera.top - v * (camera.top - camera.bottom);
|
||||||
|
return { wx, wy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest-point pick with a screen-space radius cap. Returns point_id or -1.
|
||||||
|
function pickAt(clientX, clientY) {
|
||||||
|
if (packedN === 0) return -1;
|
||||||
|
const { wx, wy } = clientToWorld(clientX, clientY);
|
||||||
|
// Screen-pixel radius -> world radius
|
||||||
|
const rect = canvasEl.getBoundingClientRect();
|
||||||
|
const worldPerPx = (camera.right - camera.left) / Math.max(1, rect.width);
|
||||||
|
const pickPx = 14;
|
||||||
|
const maxR = pickPx * worldPerPx;
|
||||||
|
const maxR2 = maxR * maxR;
|
||||||
|
let bestI = -1;
|
||||||
|
let bestD2 = Infinity;
|
||||||
|
for (let i = 0; i < packedN; i++) {
|
||||||
|
const dx = packedX[i] - wx;
|
||||||
|
const dy = packedY[i] - wy;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < bestD2 && d2 < maxR2) {
|
||||||
|
bestD2 = d2;
|
||||||
|
bestI = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestI < 0 ? -1 : packedId[bestI];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
geo.dispose();
|
||||||
|
hiGeo.dispose();
|
||||||
|
matMono.dispose();
|
||||||
|
matRainbow.dispose();
|
||||||
|
hiMat.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement.parentNode) {
|
||||||
|
renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the status overlay once we have data.
|
||||||
|
statusEl.style.display = 'none';
|
||||||
|
|
||||||
|
return {
|
||||||
|
slotId,
|
||||||
|
canvasEl,
|
||||||
|
panelEl,
|
||||||
|
data,
|
||||||
|
setFrame,
|
||||||
|
setFrameInterpolated,
|
||||||
|
setColorMode,
|
||||||
|
setBounds,
|
||||||
|
setHighlight,
|
||||||
|
resize,
|
||||||
|
render,
|
||||||
|
pickAt,
|
||||||
|
applyColorsFromTheme,
|
||||||
|
dispose,
|
||||||
|
get packedN() { return packedN; },
|
||||||
|
get camRect() { return camRect; },
|
||||||
|
applyCamRect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- error rendering -------------------------------------------------
|
||||||
|
|
||||||
|
function renderError(panelEl, stem, msg) {
|
||||||
|
const statusEl = panelEl.querySelector('[data-role="status"]');
|
||||||
|
statusEl.style.display = '';
|
||||||
|
statusEl.classList.add('is-error');
|
||||||
|
statusEl.textContent = `could not load ${stem}: ${msg}`;
|
||||||
|
// Keep header readable
|
||||||
|
panelEl.querySelector('[data-role="embedder"]').textContent = '—';
|
||||||
|
panelEl.querySelector('[data-role="generator"]').textContent = '—';
|
||||||
|
panelEl.querySelector('[data-role="params"]').textContent = '(error)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARAM_FIELDS = [
|
||||||
|
{ key: 'num_points', prefix: 'N' },
|
||||||
|
{ key: 'num_timesteps', prefix: 'T' },
|
||||||
|
{ key: 'jitter_scale', prefix: 'J' },
|
||||||
|
{ key: 'seed', prefix: 's' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderHeader(panelEl, data) {
|
||||||
|
const m = data.meta || {};
|
||||||
|
panelEl.querySelector('[data-role="embedder"]').textContent = m.embedder || '—';
|
||||||
|
panelEl.querySelector('[data-role="generator"]').textContent = m.generator || '—';
|
||||||
|
const host = panelEl.querySelector('[data-role="params"]');
|
||||||
|
host.textContent = '';
|
||||||
|
PARAM_FIELDS.forEach(({ key, prefix }, i) => {
|
||||||
|
if (i > 0) host.appendChild(document.createTextNode(' / '));
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'param';
|
||||||
|
span.dataset.key = key;
|
||||||
|
span.textContent = `${prefix}${m[key] ?? '?'}`;
|
||||||
|
host.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle .diff on header spans where the two panels disagree.
|
||||||
|
function markParamDiffs(metaA, metaB) {
|
||||||
|
if (!metaA || !metaB) return;
|
||||||
|
for (const { key } of PARAM_FIELDS) {
|
||||||
|
const differs = metaA[key] !== metaB[key];
|
||||||
|
for (const panelEl of [panelElA, panelElB]) {
|
||||||
|
const span = panelEl.querySelector(`[data-role="params"] .param[data-key="${key}"]`);
|
||||||
|
if (span) span.classList.toggle('diff', differs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const role of ['embedder', 'generator']) {
|
||||||
|
const differs = metaA[role] !== metaB[role];
|
||||||
|
for (const panelEl of [panelElA, panelElB]) {
|
||||||
|
const span = panelEl.querySelector(`[data-role="${role}"]`);
|
||||||
|
if (span) span.classList.toggle('diff', differs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- main ------------------------------------------------------------
|
||||||
|
|
||||||
|
async function fetchFrames(stem) {
|
||||||
|
const res = await fetch(`/api/runs/${encodeURIComponent(stem)}/frames.json`, { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!STEM_A || !STEM_B) {
|
||||||
|
renderError(panelElA, STEM_A || '(missing)', 'no stem in ?a=');
|
||||||
|
renderError(panelElB, STEM_B || '(missing)', 'no stem in ?b=');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch in parallel; each panel's failure is independent.
|
||||||
|
const [resA, resB] = await Promise.allSettled([
|
||||||
|
fetchFrames(STEM_A),
|
||||||
|
fetchFrames(STEM_B),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const panels = { a: null, b: null };
|
||||||
|
|
||||||
|
if (resA.status === 'fulfilled') {
|
||||||
|
renderHeader(panelElA, resA.value);
|
||||||
|
panels.a = createPanel({ slotId: 'a', panelEl: panelElA, data: resA.value });
|
||||||
|
} else {
|
||||||
|
renderError(panelElA, STEM_A, resA.reason?.message || String(resA.reason));
|
||||||
|
}
|
||||||
|
if (resB.status === 'fulfilled') {
|
||||||
|
renderHeader(panelElB, resB.value);
|
||||||
|
panels.b = createPanel({ slotId: 'b', panelEl: panelElB, data: resB.value });
|
||||||
|
} else {
|
||||||
|
renderError(panelElB, STEM_B, resB.reason?.message || String(resB.reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panels.a && panels.b) markParamDiffs(panels.a.data.meta, panels.b.data.meta);
|
||||||
|
|
||||||
|
// Nothing loaded — no animation loop to start.
|
||||||
|
if (!panels.a && !panels.b) return;
|
||||||
|
|
||||||
|
// Initial bounds + first frame for each panel.
|
||||||
|
for (const p of Object.values(panels)) {
|
||||||
|
if (!p) continue;
|
||||||
|
p.setBounds(p.data.bounds);
|
||||||
|
p.resize();
|
||||||
|
p.setFrame(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- time mapping -----------------------------------------------------
|
||||||
|
// Scrubber is 0..1000, mapped to a shared global frame index uGlobal in
|
||||||
|
// [0, maxT-1]. Each panel clamps uGlobal to its own (T-1) — so a shorter
|
||||||
|
// timeline pads its last frame until the longer timeline finishes, and
|
||||||
|
// both panels advance at the same wall-clock tempo.
|
||||||
|
const SCRUB_MAX = 1000;
|
||||||
|
|
||||||
|
function framesOf(p) { return p ? p.data.frames.length : 0; }
|
||||||
|
|
||||||
|
function timeLabelForAtLocal(p, uLocal) {
|
||||||
|
if (!p) return '—';
|
||||||
|
const T = framesOf(p);
|
||||||
|
if (T <= 0) return '—';
|
||||||
|
const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal)));
|
||||||
|
return p.data.times?.[idx] ?? String(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyU(u) {
|
||||||
|
u = Math.max(0, Math.min(1, u));
|
||||||
|
const smooth = motionSel.value === 'smooth';
|
||||||
|
const tMax = maxT();
|
||||||
|
const uGlobal = u * (tMax - 1);
|
||||||
|
let uLocalA = 0, uLocalB = 0;
|
||||||
|
for (const [slot, p] of Object.entries(panels)) {
|
||||||
|
if (!p) continue;
|
||||||
|
const T = framesOf(p);
|
||||||
|
if (T <= 0) continue;
|
||||||
|
const uLocal = Math.min(uGlobal, T - 1);
|
||||||
|
if (slot === 'a') uLocalA = uLocal; else uLocalB = uLocal;
|
||||||
|
if (smooth) {
|
||||||
|
p.setFrameInterpolated(uLocal);
|
||||||
|
} else {
|
||||||
|
const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal)));
|
||||||
|
p.setFrame(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timeAEl.textContent = timeLabelForAtLocal(panels.a, uLocalA);
|
||||||
|
timeBEl.textContent = timeLabelForAtLocal(panels.b, uLocalB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- axes sync mode ---------------------------------------------------
|
||||||
|
function applySync() {
|
||||||
|
const mode = syncSel.value;
|
||||||
|
if (mode === 'locked' && panels.a && panels.b) {
|
||||||
|
const ba = panels.a.data.bounds, bb = panels.b.data.bounds;
|
||||||
|
const union = {
|
||||||
|
x: [Math.min(ba.x[0], bb.x[0]), Math.max(ba.x[1], bb.x[1])],
|
||||||
|
y: [Math.min(ba.y[0], bb.y[0]), Math.max(ba.y[1], bb.y[1])],
|
||||||
|
};
|
||||||
|
panels.a.setBounds(union);
|
||||||
|
panels.b.setBounds(union);
|
||||||
|
} else {
|
||||||
|
if (panels.a) panels.a.setBounds(panels.a.data.bounds);
|
||||||
|
if (panels.b) panels.b.setBounds(panels.b.data.bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncSel.addEventListener('change', applySync);
|
||||||
|
applySync();
|
||||||
|
|
||||||
|
// ---- play loop --------------------------------------------------------
|
||||||
|
// Base step: 400ms per frame at 1x, divided by speed. The loop advances
|
||||||
|
// the scrub by (1 / maxT) per step so both panels traverse their whole
|
||||||
|
// timeline in the same wall-clock duration when T differs.
|
||||||
|
let playing = false;
|
||||||
|
let lastTs = 0;
|
||||||
|
function maxT() {
|
||||||
|
const ta = framesOf(panels.a);
|
||||||
|
const tb = framesOf(panels.b);
|
||||||
|
return Math.max(ta, tb, 2);
|
||||||
|
}
|
||||||
|
function baseMsPerFrame() { return 1600 / parseFloat(speedSel.value || '1'); }
|
||||||
|
|
||||||
|
// u is the authoritative playhead (float in [0, 1]). The scrubber mirrors
|
||||||
|
// it for display; reading u back from the scrubber would quantize to the
|
||||||
|
// scrubber's integer step and stall the loop at small du.
|
||||||
|
let u = 0;
|
||||||
|
|
||||||
|
function tick(ts) {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
for (const p of Object.values(panels)) p?.render();
|
||||||
|
if (!playing) { lastTs = ts; return; }
|
||||||
|
if (!lastTs) { lastTs = ts; return; }
|
||||||
|
const dt = ts - lastTs;
|
||||||
|
lastTs = ts;
|
||||||
|
if (dt <= 0) return;
|
||||||
|
const perFrame = baseMsPerFrame();
|
||||||
|
const T = maxT();
|
||||||
|
u += dt / (perFrame * (T - 1));
|
||||||
|
if (u >= 1) u -= Math.floor(u); // wrap 0..1
|
||||||
|
scrub.value = String(Math.round(u * SCRUB_MAX));
|
||||||
|
applyU(u);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
function setPlaying(v) {
|
||||||
|
playing = v;
|
||||||
|
lastTs = 0;
|
||||||
|
playBtn.textContent = playing ? '▮▮' : '▶';
|
||||||
|
playBtn.setAttribute('aria-label', playing ? 'pause' : 'play');
|
||||||
|
}
|
||||||
|
playBtn.addEventListener('click', () => setPlaying(!playing));
|
||||||
|
|
||||||
|
scrub.addEventListener('input', () => {
|
||||||
|
u = parseFloat(scrub.value) / SCRUB_MAX;
|
||||||
|
applyU(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
speedSel.addEventListener('change', () => { lastTs = 0; });
|
||||||
|
|
||||||
|
motionSel.addEventListener('change', () => {
|
||||||
|
applyU(parseFloat(scrub.value) / SCRUB_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyColorMode() {
|
||||||
|
const mode = colorSel.value;
|
||||||
|
for (const p of Object.values(panels)) p?.setColorMode(mode);
|
||||||
|
}
|
||||||
|
colorSel.addEventListener('change', applyColorMode);
|
||||||
|
applyColorMode();
|
||||||
|
|
||||||
|
// ---- linked hover -----------------------------------------------------
|
||||||
|
function wireHover(pA, pB) {
|
||||||
|
if (!pA) return;
|
||||||
|
const el = pA.canvasEl;
|
||||||
|
el.addEventListener('mousemove', (ev) => {
|
||||||
|
const id = pA.pickAt(ev.clientX, ev.clientY);
|
||||||
|
pA.setHighlight(id >= 0 ? id : null);
|
||||||
|
if (pB) pB.setHighlight(id >= 0 ? id : null);
|
||||||
|
});
|
||||||
|
el.addEventListener('mouseleave', () => {
|
||||||
|
pA.setHighlight(null);
|
||||||
|
if (pB) pB.setHighlight(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wireHover(panels.a, panels.b);
|
||||||
|
wireHover(panels.b, panels.a);
|
||||||
|
|
||||||
|
// ---- resize + theme ---------------------------------------------------
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
for (const p of Object.values(panels)) p?.resize();
|
||||||
|
});
|
||||||
|
if (panels.a) ro.observe(panels.a.canvasEl);
|
||||||
|
if (panels.b) ro.observe(panels.b.canvasEl);
|
||||||
|
|
||||||
|
document.addEventListener('themechange', () => {
|
||||||
|
for (const p of Object.values(panels)) p?.applyColorsFromTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialise the label + scrub at 0.
|
||||||
|
applyU(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
renderError(panelElA, STEM_A, 'init failed: ' + err.message);
|
||||||
|
renderError(panelElB, STEM_B, 'init failed: ' + err.message);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,756 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@ -1473,12 +1473,11 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
|||||||
|
|
||||||
.compare-grid {
|
.compare-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
grid-template-columns: 1fr 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);
|
||||||
@ -1506,14 +1505,19 @@ 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: color-mix(in srgb, var(--panel-color) 14%, transparent);
|
background: var(--accent-tint);
|
||||||
color: var(--panel-color);
|
color: var(--accent);
|
||||||
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;
|
||||||
@ -1635,7 +1639,8 @@ 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-seg { color: var(--panel-color, var(--ink)); }
|
.compare-controls .cc-time-a { color: var(--accent); }
|
||||||
|
.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,
|
||||||
@ -1674,60 +1679,5 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,8 +60,7 @@
|
|||||||
|
|
||||||
{% if r.emb_file %}
|
{% if r.emb_file %}
|
||||||
{% if r.emb_exists %}
|
{% if r.emb_exists %}
|
||||||
<a href="/figs/{{ r.emb_file }}" data-role="embedding-link"
|
<a href="/figs/{{ r.emb_file }}" target="_blank" rel="noopener">embedding</a>
|
||||||
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 %}
|
||||||
|
|||||||
@ -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 · compare</title>
|
<title>embedding notebook · compare</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=28" />
|
<link rel="stylesheet" href="/static/style.css?v=27" />
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@ -43,7 +43,11 @@
|
|||||||
<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-times"></div>
|
<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">
|
<label class="cc-speed-wrap">
|
||||||
<span class="cc-lbl">speed</span>
|
<span class="cc-lbl">speed</span>
|
||||||
@ -80,26 +84,40 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="compare-grid" id="panel-host"></div>
|
<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>
|
||||||
|
<a class="panel-stem" data-role="stem-link"
|
||||||
|
href="/api/runs/{{ stem_a }}/frames.json" target="_blank">↗</a>
|
||||||
|
<span class="panel-params" data-role="params">loading…</span>
|
||||||
|
</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>
|
||||||
|
<a class="panel-stem" data-role="stem-link"
|
||||||
|
href="/api/runs/{{ stem_b }}/frames.json" target="_blank">↗</a>
|
||||||
|
<span class="panel-params" data-role="params">loading…</span>
|
||||||
|
</header>
|
||||||
|
<div class="compare-canvas" data-role="canvas">
|
||||||
|
<div class="compare-status" data-role="status">loading…</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">●</span>
|
|
||||||
<span class="panel-embedder" data-role="embedder">…</span>
|
|
||||||
<span class="panel-sep">·</span>
|
|
||||||
<span class="panel-generator" data-role="generator">…</span>
|
|
||||||
<a class="panel-stem" data-role="stem-link" href="#" target="_blank">↗</a>
|
|
||||||
<span class="panel-params" data-role="params">loading…</span>
|
|
||||||
</header>
|
|
||||||
<div class="compare-canvas" data-role="canvas">
|
|
||||||
<div class="compare-status" data-role="status">loading…</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>
|
||||||
|
|||||||
@ -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=28" />
|
<link rel="stylesheet" href="/static/style.css?v=27" />
|
||||||
<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,70 +396,6 @@
|
|||||||
</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">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="compare-controls" id="modal-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-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×</option>
|
|
||||||
<option value="1" selected>1×</option>
|
|
||||||
<option value="2">2×</option>
|
|
||||||
<option value="4">4×</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">●</span>
|
|
||||||
<span class="panel-embedder" data-role="embedder">…</span>
|
|
||||||
<span class="panel-sep">·</span>
|
|
||||||
<span class="panel-generator" data-role="generator">…</span>
|
|
||||||
<a class="panel-stem" data-role="stem-link" href="#" target="_blank">↗</a>
|
|
||||||
<span class="panel-params" data-role="params">loading…</span>
|
|
||||||
</header>
|
|
||||||
<div class="compare-canvas" data-role="canvas">
|
|
||||||
<div class="compare-status" data-role="status">loading…</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<footer class="colophon">
|
<footer class="colophon">
|
||||||
<span>© 2026 Mind the Math LLC</span>
|
<span>© 2026 Mind the Math LLC</span>
|
||||||
<span class="prefect-badge">
|
<span class="prefect-badge">
|
||||||
@ -472,7 +408,6 @@
|
|||||||
<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', () => {
|
||||||
|
|||||||
@ -27,19 +27,10 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
def _run_args_hash(
|
def _embed_args_hash(ea: Optional[Dict[str, Any]]) -> str:
|
||||||
ea: Optional[Dict[str, Any]],
|
"""8-hex digest of embed_args (keys sorted) — output stem includes this
|
||||||
gk: Optional[Dict[str, Any]] = None,
|
so runs differing only in embed_args get distinct files."""
|
||||||
) -> str:
|
s = json.dumps(ea or {}, sort_keys=True, default=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]
|
||||||
|
|
||||||
|
|
||||||
@ -54,7 +45,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 = _run_args_hash(p.get("embed_args"), p.get("generator_kwargs"))
|
tag = _embed_args_hash(p.get("embed_args"))
|
||||||
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
|
||||||
@ -311,7 +302,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 = _run_args_hash(embed_args, generator_kwargs)
|
_args_tag = _embed_args_hash(embed_args)
|
||||||
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"
|
||||||
)
|
)
|
||||||
@ -405,7 +396,6 @@ 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,
|
||||||
},
|
},
|
||||||
@ -426,9 +416,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,18 +1,13 @@
|
|||||||
"""Rename embedder figs to the current hash scheme (embed_args + generator_kwargs).
|
"""Rename pre-hash embedder figs to include the embed_args hash suffix.
|
||||||
|
|
||||||
Two waves of runs may exist on disk:
|
Walks figs/ for `.html` files matching the old stem shape (no hash tail) that
|
||||||
(1) pre-hash — `<stem>.html`
|
represent an embedder run (not Reference), reads the sibling
|
||||||
(2) intermediate — `<stem>_<sha1(embed_args)>.html` (from the first hash rollout)
|
`<stem>.metrics.json` to recover `meta.embed_args`, computes the hash, and
|
||||||
(3) current — `<stem>_<sha1(embed_args, gen_kwargs)>.html` when gen_kwargs is truthy;
|
renames the .html + .metrics.json in place.
|
||||||
identical to (2) when gen_kwargs is empty.
|
|
||||||
|
|
||||||
This script queries Prefect for each recent run's full params (so it knows
|
Default is a dry-run — pass `--apply` to actually rename. Reference files are
|
||||||
generator_kwargs — which the metrics.json sidecar didn't persist before), finds
|
left alone (they have no embed_args). Missing metrics.json → warn and skip.
|
||||||
the matching fig on disk, renames to the current stem, and injects
|
Target-name collision → warn and skip.
|
||||||
`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]
|
||||||
@ -21,91 +16,65 @@ 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 PREFECT, run_args_hash # noqa: E402
|
from app.web.main import embed_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 _legacy_hash(ea: Optional[Dict[str, Any]]) -> str:
|
def plan_renames(figs_dir: Path):
|
||||||
s = json.dumps(ea or {}, sort_keys=True, default=str)
|
for html in sorted(figs_dir.glob("*.html")):
|
||||||
return hashlib.sha1(s.encode()).hexdigest()[:8]
|
stem = html.stem
|
||||||
|
m = _LEGACY_STEM.match(stem)
|
||||||
|
if not m:
|
||||||
|
# Either already hashed or doesn't match our scheme at all.
|
||||||
|
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:
|
||||||
|
ea = json.loads(metrics.read_text(encoding="utf-8"))["meta"]["embed_args"]
|
||||||
|
except (KeyError, json.JSONDecodeError) as e:
|
||||||
|
yield (html, None, f"bad metrics.json: {e}")
|
||||||
|
continue
|
||||||
|
new_stem = f"{stem}_{embed_args_hash(ea)}"
|
||||||
|
new_html = figs_dir / f"{new_stem}.html"
|
||||||
|
if new_html.exists():
|
||||||
|
yield (html, None, f"target exists: {new_html.name}")
|
||||||
|
continue
|
||||||
|
yield (html, new_stem, None)
|
||||||
|
|
||||||
|
|
||||||
def _base_stem(params: Dict[str, Any]) -> Optional[str]:
|
def apply_rename(figs_dir: Path, old_stem: str, new_stem: str) -> list[str]:
|
||||||
try:
|
"""Rename every sidecar sharing the old stem. Returns the renamed files."""
|
||||||
gen = (params.get("generator_path") or "").rsplit(".", 1)[-1]
|
renamed = []
|
||||||
emb = (params.get("embedder") or "").rsplit(".", 1)[-1]
|
|
||||||
N = int(params["num_points"])
|
|
||||||
T = int(params.get("num_timesteps", params.get("num_snapshots")))
|
|
||||||
J = float(params["jitter_scale"])
|
|
||||||
s = int(params["seed"])
|
|
||||||
except (KeyError, TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if not gen or not emb:
|
|
||||||
return None
|
|
||||||
return f"{gen}_{emb}_N{N}_T{T}_J{J}_s{s}"
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate_names(base: str, ea: Dict[str, Any], gk: Dict[str, Any]) -> List[str]:
|
|
||||||
target = f"{base}_{run_args_hash(ea, gk)}.html"
|
|
||||||
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)
|
||||||
moved.append(f"{src.name} -> {dst.name}")
|
renamed.append(f"{src.name} -> {dst.name}")
|
||||||
return moved
|
return renamed
|
||||||
|
|
||||||
|
|
||||||
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 + patch (default: dry-run)")
|
ap.add_argument("--apply", action="store_true", help="actually rename (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()
|
||||||
@ -113,65 +82,36 @@ 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
|
||||||
|
|
||||||
try:
|
planned, skipped = [], []
|
||||||
runs = asyncio.run(_fetch_runs(limit=args.limit))
|
for html, new_stem, reason in plan_renames(figs_dir):
|
||||||
except Exception as e:
|
if new_stem is None:
|
||||||
print(f"could not reach Prefect at {PREFECT.base} ({e})", file=sys.stderr)
|
skipped.append((html.name, reason))
|
||||||
return 3
|
else:
|
||||||
|
planned.append((html.stem, new_stem))
|
||||||
|
|
||||||
plans = [] # (old_stem, new_stem, gk, found_name)
|
print(f"scanning {figs_dir}")
|
||||||
seen_targets = set()
|
print(f" {len(planned)} to rename, {len(skipped)} skipped\n")
|
||||||
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:
|
|
||||||
plans.append((Path(candidate).stem, Path(target).stem, gk, candidate, False))
|
|
||||||
seen_targets.add(target)
|
|
||||||
break
|
|
||||||
|
|
||||||
print(f"scanning {figs_dir} (Prefect runs seen: {len(runs)})")
|
for old, new in planned:
|
||||||
renames = [p for p in plans if not p[4]]
|
print(f" rename {old} -> {new}")
|
||||||
already = [p for p in plans if p[4]]
|
if skipped:
|
||||||
print(f" {len(renames)} to rename, {len(already)} already at target\n")
|
print("\n skipped:")
|
||||||
|
for name, reason in skipped:
|
||||||
|
print(f" {name} ({reason})")
|
||||||
|
|
||||||
for old, new, gk, _, _ in renames:
|
if not planned:
|
||||||
gk_str = json.dumps(gk) if gk else "{}"
|
|
||||||
print(f" rename {old} -> {new} gen_kwargs={gk_str}")
|
|
||||||
|
|
||||||
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 + patch)")
|
print("\n(dry run — pass --apply to rename)")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print("\napplying...")
|
print("\napplying...")
|
||||||
for old, new, gk, _, at_target in plans:
|
for old, new in planned:
|
||||||
if not at_target:
|
moved = apply_rename(figs_dir, old, new)
|
||||||
for line in _rename_bundle(figs_dir, old, new):
|
for line in moved:
|
||||||
print(f" {line}")
|
print(f" {line}")
|
||||||
patched = _patch_metrics(figs_dir / f"{new}.metrics.json", gk)
|
print(f"done — renamed {len(planned)} run(s)")
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user