Compare commits

...

4 Commits

Author SHA1 Message Date
Michael Pilosov
c12d2cda6c flow: hash user-supplied generator_kwargs, not the merged dict
The flow previously merged _DEFAULT_GENERATOR_KWARGS={random_state:0} and
n_samples=num_points into generator_kwargs BEFORE hashing. Prefect only
records the user-supplied form, so the web app's synth_output_paths
disagreed with the flow's output name — a plain swiss_roll run showed
'embedding: n/a' in the runs list despite completing, because the web
looked for the hash that excluded those defaults.

Now we keep the user-supplied generator_kwargs around for hashing +
metadata, and use the merged dict only for the actual generator call.
n_samples is already captured in the stem as 'N<n>', and random_state=0
is a flow constant — neither belongs in the semantic identity.
2026-04-22 17:04:50 -06:00
Michael Pilosov
bdbebaa7e8 compare: click to pin a point's highlight; hover temporarily overrides
Click a point in any panel to pin its id — highlight persists after the
cursor leaves, across all linked panels. Click the same pinned point (or
empty space) to unpin. Hover still shows the point under the cursor,
briefly overriding the pinned display. Canvas cursor is now crosshair to
hint at the interaction.
2026-04-22 17:00:29 -06:00
Michael Pilosov
a4fc36352d compare: axes dropdown combines sync (scaled/locked) × aspect (1:1/3:2)
Canvas height now derives from column width via aspect-ratio (CSS custom
prop --canvas-aspect set by JS on the grid host), with --panel-h as a
ceiling. Dropdown options: scaled/locked × 1:1/3:2. Default scaled 3:2.
Legacy 'independent'/'locked' values still parse. Canvas resizes after
aspect changes via requestAnimationFrame.
2026-04-22 16:57:15 -06:00
Michael Pilosov
9b178dad38 runs: filter chips + compare selection up to 8
- /compare accepts ?stem=…&stem=… (repeated) for 2-8 runs; legacy ?a=&b=
  still works. compare.js parses multi-stem; template drops stem_a/_b
  data attrs that were unused.
- compare-select.js: MAX bumped to 8, button enables at 2-8 selected.
  URL emitted as ?stem=… per selection.
- runs list gets a dataset/algorithm chip filter bar above #runs-slot
  (pattern ported from metrics.js). Chips reflect the union of values in
  the current list; selection state persists across htmx swaps. Non-
  matching rows get .filtered-out (display:none).
- _runs.html li now carries data-embedder/data-generator so the filter
  can key on them.
2026-04-22 16:41:06 -06:00
11 changed files with 263 additions and 45 deletions

View File

@ -1020,14 +1020,23 @@ async def run_frames(stem: str) -> Response:
@app.get("/compare", response_class=HTMLResponse)
async def compare_page(request: Request, a: str = "", b: str = "") -> HTMLResponse:
for label, stem in (("a", a), ("b", b)):
if not stem or not _STEM_RE.match(stem):
raise HTTPException(400, f"missing or malformed stem for {label!r}")
if not (FIGS_DIR / f"{stem}.html").is_file():
async def compare_page(request: Request) -> HTMLResponse:
q = request.query_params
stems = [s for s in q.getlist("stem") if s]
if not stems:
# Legacy two-stem form: ?a=&b=
stems = [s for s in (q.get("a", ""), q.get("b", "")) if s]
if not (2 <= len(stems) <= 8):
raise HTTPException(400, f"need 2..8 stems, got {len(stems)}")
for stem in stems:
if not _STEM_RE.match(stem):
raise HTTPException(400, f"malformed stem: {stem!r}")
has_sidecar = (FIGS_DIR / f"{stem}.frames.json").is_file()
has_html = (FIGS_DIR / f"{stem}.html").is_file()
if not (has_sidecar or has_html):
raise HTTPException(404, f"no such run: {stem}")
return templates.TemplateResponse(
request, "compare.html", {"stem_a": a, "stem_b": b}
request, "compare.html", {"stems": stems}
)

View File

@ -3,7 +3,8 @@
// the polled region and re-apply checked state on every afterSwap.
(function () {
const MAX = 2;
const MIN = 2;
const MAX = 8;
const selected = new Set();
const btn = document.getElementById('compare-btn');
@ -14,7 +15,7 @@
function refreshButton() {
const n = selected.size;
countEl.textContent = `(${n}/${MAX})`;
btn.disabled = n !== MAX;
btn.disabled = n < MIN || n > MAX;
}
function applyToDOM() {
@ -54,10 +55,10 @@
});
btn.addEventListener('click', () => {
if (selected.size !== MAX) return;
const [a, b] = [...selected];
const url = `/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`;
window.open(url, '_blank', 'noopener');
const n = selected.size;
if (n < MIN || n > MAX) return;
const qs = [...selected].map((s) => `stem=${encodeURIComponent(s)}`).join('&');
window.open(`/compare?${qs}`, '_blank', 'noopener');
});
applyToDOM();

View File

@ -1,9 +1,13 @@
// compare.js — thin shim that parses ?a=&b= and hands off to panel-grid.js.
// compare.js — thin shim that parses ?stem=…&stem=… (legacy ?a=&b=) and
// hands off to panel-grid.js.
import { mountPanels } from './panel-grid.js?v=1';
import { mountPanels } from './panel-grid.js?v=5';
const params = new URLSearchParams(window.location.search);
const stems = [params.get('a') || '', params.get('b') || ''].filter(Boolean);
let stems = params.getAll('stem').filter(Boolean);
if (stems.length === 0) {
stems = [params.get('a') || '', params.get('b') || ''].filter(Boolean);
}
const host = document.getElementById('panel-host');
const controls = document.getElementById('compare-controls');

View File

@ -628,9 +628,21 @@ export function mountPanels({ host, controls, stems }) {
}
}
function applySync() {
const mode = syncSel ? syncSel.value : 'independent';
if (mode === 'locked' && live.length > 1) {
function parseAxes(value) {
// Accepted: "scaled-1x1", "scaled-3x2", "locked-1x1", "locked-3x2",
// plus legacy "independent" / "locked" (assume 1:1).
const v = value || 'scaled-1x1';
if (v === 'independent') return { sync: 'scaled', aspect: '1 / 1' };
if (v === 'locked') return { sync: 'locked', aspect: '1 / 1' };
const [sync, ratio] = v.split('-');
const aspect = ratio === '3x2' ? '3 / 2' : '1 / 1';
return { sync: sync === 'locked' ? 'locked' : 'scaled', aspect };
}
function applyAxes() {
const { sync, aspect } = parseAxes(syncSel ? syncSel.value : 'scaled-3x2');
host.style.setProperty('--canvas-aspect', aspect);
if (sync === 'locked' && live.length > 1) {
let xmin = +Infinity, xmax = -Infinity, ymin = +Infinity, ymax = -Infinity;
for (const s of live) {
const b = s.panel.data.bounds;
@ -644,9 +656,11 @@ export function mountPanels({ host, controls, stems }) {
} else {
for (const s of live) s.panel.setBounds(s.panel.data.bounds);
}
// Canvas size changes with aspect — let the panels re-fit next frame.
requestAnimationFrame(() => { for (const s of live) s.panel.resize(); });
}
if (syncSel) onCtl(syncSel, 'change', applySync);
applySync();
if (syncSel) onCtl(syncSel, 'change', applyAxes);
applyAxes();
let playing = false;
let lastTs = 0;
@ -699,18 +713,33 @@ export function mountPanels({ host, controls, stems }) {
if (colorSel) onCtl(colorSel, 'change', applyColorMode);
applyColorMode();
// Linked hover across every panel.
// Linked hover + click-to-pin. Hover shows the point under the cursor
// across all panels. Click toggles a "pinned" id that stays highlighted
// once the cursor leaves; hover temporarily overrides the display.
let pinnedId = null;
let hoveredId = null;
function paintHighlight() {
const id = hoveredId !== null ? hoveredId : pinnedId;
for (const t of live) t.panel.setHighlight(id);
}
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);
hoveredId = id >= 0 ? id : null;
paintHighlight();
};
const ml = () => { hoveredId = null; paintHighlight(); };
const mc = (ev) => {
const id = s.panel.pickAt(ev.clientX, ev.clientY);
pinnedId = (id < 0 || id === pinnedId) ? null : id;
paintHighlight();
};
const ml = () => { for (const t of live) t.panel.setHighlight(null); };
s.panel.canvasEl.addEventListener('mousemove', mm);
s.panel.canvasEl.addEventListener('mouseleave', ml);
s.panel.canvasEl.addEventListener('click', mc);
listeners.push({ el: s.panel.canvasEl, ev: 'mousemove', fn: mm });
listeners.push({ el: s.panel.canvasEl, ev: 'mouseleave', fn: ml });
listeners.push({ el: s.panel.canvasEl, ev: 'click', fn: mc });
}
// Resize + theme observers.

View File

@ -1,7 +1,7 @@
// 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';
import { mountPanels } from './panel-grid.js?v=5';
const dialog = document.getElementById('run-modal');
const host = document.getElementById('modal-panel-host');

View File

@ -0,0 +1,114 @@
// Filter the recent-runs list by dataset + algorithm chips.
// State lives outside #runs-slot so it survives the 3s htmx poll. After
// each swap we repopulate chip options from whatever runs came back, then
// re-apply the current selection to hide non-matching rows.
(function () {
const slot = document.getElementById('runs-slot');
const dsEl = document.getElementById('runs-flt-dataset');
const algEl = document.getElementById('runs-flt-algo');
if (!slot || !dsEl || !algEl) return;
// null = "all selected" (no filtering on this axis). Populated Sets
// override that. Sticky across htmx swaps.
let datasets = null;
let algorithms = null;
function scanValues() {
const ds = new Set();
const alg = new Set();
slot.querySelectorAll('li.run').forEach((li) => {
const d = li.dataset.generator; if (d) ds.add(d);
const a = li.dataset.embedder; if (a) alg.add(a);
});
return {
datasets: [...ds].sort(),
algorithms: [...alg].sort(),
};
}
function paint(container, values, selected) {
container.innerHTML = '';
for (const v of values) {
const b = document.createElement('button');
b.type = 'button';
const on = selected == null || selected.has(v);
b.className = 'chip' + (on ? ' is-on' : '');
b.dataset.value = v;
b.dataset.role = 'value';
b.setAttribute('aria-pressed', on ? 'true' : 'false');
b.textContent = v;
container.appendChild(b);
}
for (const [role, label] of [['all', 'all'], ['none', 'none']]) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'chip chip-meta';
b.dataset.role = role;
b.textContent = label;
container.appendChild(b);
}
}
function repaint() {
const { datasets: allDs, algorithms: allAlg } = scanValues();
paint(dsEl, allDs, datasets);
paint(algEl, allAlg, algorithms);
}
function apply() {
slot.querySelectorAll('li.run').forEach((li) => {
const ds = li.dataset.generator || '';
const al = li.dataset.embedder || '';
const passDs = datasets == null || datasets.has(ds);
const passAl = algorithms == null || algorithms.has(al);
li.classList.toggle('filtered-out', !(passDs && passAl));
});
}
function bind(container, getSet, setSet, allGetter) {
container.addEventListener('click', (e) => {
const btn = e.target.closest('.chip');
if (!btn) return;
const role = btn.dataset.role;
const all = allGetter();
let cur = getSet();
if (cur == null) cur = new Set(all);
if (role === 'value') {
const v = btn.dataset.value;
if (cur.has(v)) cur.delete(v);
else cur.add(v);
} else if (role === 'all') {
cur = new Set(all);
} else if (role === 'none') {
cur = new Set();
}
setSet(cur);
repaint();
apply();
});
}
bind(
dsEl,
() => datasets,
(s) => { datasets = s; },
() => scanValues().datasets,
);
bind(
algEl,
() => algorithms,
(s) => { algorithms = s; },
() => scanValues().algorithms,
);
document.body.addEventListener('htmx:afterSwap', (e) => {
if (e.target && e.target.id === 'runs-slot') {
repaint();
apply();
}
});
repaint();
apply();
})();

View File

@ -513,6 +513,37 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
font-size: 0.76rem;
font-style: italic;
}
.runs-filter {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1.4rem;
padding: 0.25rem 0 0.7rem;
margin-bottom: 0.3rem;
border-bottom: 1px dashed var(--rule);
}
.runs-filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1 1 auto;
}
.runs-filter-group .ctl-label {
font-family: var(--mono);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.runs-filter-group .chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.3rem;
align-items: center;
}
.runs li.run.filtered-out { display: none; }
.runs li.run.just-submitted {
background: linear-gradient(to right, var(--accent-tint), transparent 60%);
padding-left: 0.55rem;
@ -1556,10 +1587,14 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
.compare-canvas {
position: relative;
flex: 1 1 auto;
flex: 0 0 auto;
min-height: 0;
background: var(--picker-panel, var(--panel));
height: var(--panel-h);
/* Height derives from column width × aspect ratio. Controlled by the
axes dropdown via --canvas-aspect on the grid. --panel-h is the cap. */
aspect-ratio: var(--canvas-aspect, 1 / 1);
max-height: var(--panel-h);
cursor: crosshair;
}
.compare-canvas canvas {

View File

@ -7,7 +7,9 @@
{% else %}
<ul class="runs">
{% for r in runs %}
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}{% if r.stale %} stale{% endif %}">
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}{% if r.stale %} stale{% endif %}"
data-embedder="{{ r.embedder_short or '' }}"
data-generator="{{ r.generator_short or '' }}">
{% if r.emb_exists and not r.stale %}
<input type="checkbox" class="compare-cb" data-stem="{{ r.emb_file[:-5] }}" aria-label="select run for comparison" />
{% else %}

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook &middot; compare</title>
<link rel="stylesheet" href="/static/style.css?v=28" />
<link rel="stylesheet" href="/static/style.css?v=32" />
<script type="importmap">
{
"imports": {
@ -34,8 +34,7 @@
</div>
</header>
<section class="compare-layout" id="compare-layout"
data-stem-a="{{ stem_a }}" data-stem-b="{{ stem_b }}">
<section class="compare-layout" id="compare-layout">
<div class="compare-controls" id="compare-controls">
<button type="button" class="cc-play" id="cc-play" aria-label="play / pause">&#9654;</button>
@ -74,8 +73,10 @@
<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>
<option value="scaled-1x1">scaled &middot; 1:1</option>
<option value="scaled-3x2" selected>scaled &middot; 3:2</option>
<option value="locked-1x1">locked &middot; 1:1</option>
<option value="locked-3x2">locked &middot; 3:2</option>
</select>
</label>
</div>
@ -101,6 +102,8 @@
</template>
<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=17"></script>
<!-- panel-grid.js is imported by compare.js (module); versioned via compare.js cache-bust -->
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook</title>
<link rel="stylesheet" href="/static/style.css?v=28" />
<link rel="stylesheet" href="/static/style.css?v=32" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap">
{
@ -303,9 +303,20 @@
<div class="compare-bar">
<button type="button" id="compare-btn" disabled>
compare selected <span id="compare-count">(0/2)</span>
compare selected <span id="compare-count">(0/8)</span>
</button>
<span class="compare-hint muted">pick two embeddings &rarr; side-by-side animation in a new tab</span>
<span class="compare-hint muted">pick 2&ndash;8 embeddings &rarr; side-by-side animation in a new tab</span>
</div>
<div class="runs-filter" id="runs-filter">
<div class="runs-filter-group">
<span class="ctl-label">dataset</span>
<div class="chips" id="runs-flt-dataset" aria-label="filter by dataset"></div>
</div>
<div class="runs-filter-group">
<span class="ctl-label">algorithm</span>
<div class="chips" id="runs-flt-algo" aria-label="filter by algorithm"></div>
</div>
</div>
<div
@ -434,8 +445,10 @@
<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>
<option value="scaled-1x1">scaled &middot; 1:1</option>
<option value="scaled-3x2" selected>scaled &middot; 3:2</option>
<option value="locked-1x1">locked &middot; 1:1</option>
<option value="locked-3x2">locked &middot; 3:2</option>
</select>
</label>
</div>
@ -471,8 +484,9 @@
<script src="/static/theme.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 src="/static/compare-select.js?v=1"></script>
<script type="module" src="/static/run-modal.js?v=1"></script>
<script src="/static/compare-select.js?v=2"></script>
<script src="/static/runs-filter.js?v=1"></script>
<script type="module" src="/static/run-modal.js?v=2"></script>
<script>
// Anchor-links alone don't expand <details>; force it.
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {

View File

@ -296,9 +296,16 @@ def embedding_flow(
reference_speedup: float = 10.0,
samples: int = 10_000,
):
# Preserve the user-supplied generator_kwargs for hashing / metadata —
# the merged dict (with random_state defaults + n_samples) goes to the
# generator itself but those aren't part of the run's semantic identity
# (random_state=0 is a flow constant; n_samples is captured as `N` in
# the stem). If the merged dict were hashed, the web app would disagree
# with the flow because Prefect only records the user-supplied form.
user_generator_kwargs = dict(generator_kwargs or {})
generator_kwargs = {
**_DEFAULT_GENERATOR_KWARGS,
**(generator_kwargs or {}),
**user_generator_kwargs,
"n_samples": num_points,
}
embed_columns = (
@ -311,7 +318,7 @@ def embedding_flow(
output_ref: str = (
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 = _run_args_hash(embed_args, user_generator_kwargs)
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"
)
@ -405,7 +412,7 @@ def embedding_flow(
"jitter_scale": jitter_scale,
"seed": seed,
"generator_path": generator_path,
"generator_kwargs": generator_kwargs or {},
"generator_kwargs": user_generator_kwargs,
"embedder": embedder,
"embed_args": merged_embed_args,
},
@ -428,7 +435,7 @@ def embedding_flow(
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 {}
frames.setdefault("meta", {})["generator_kwargs"] = user_generator_kwargs
Path(output_frames).write_text(
json.dumps(frames, separators=(",", ":")), encoding="utf-8"
)