Compare commits

..

No commits in common. "c12d2cda6c043a3f40e23dcbf770ba054ae16f92" and "b744c48348c28cab83144e556ae7153d1bf9f4ec" have entirely different histories.

11 changed files with 45 additions and 263 deletions

View File

@ -1020,23 +1020,14 @@ async def run_frames(stem: str) -> Response:
@app.get("/compare", response_class=HTMLResponse)
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):
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():
raise HTTPException(404, f"no such run: {stem}")
return templates.TemplateResponse(
request, "compare.html", {"stems": stems}
request, "compare.html", {"stem_a": a, "stem_b": b}
)

View File

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

View File

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

View File

@ -628,21 +628,9 @@ export function mountPanels({ host, controls, stems }) {
}
}
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) {
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;
@ -656,11 +644,9 @@ 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', applyAxes);
applyAxes();
if (syncSel) onCtl(syncSel, 'change', applySync);
applySync();
let playing = false;
let lastTs = 0;
@ -713,33 +699,18 @@ export function mountPanels({ host, controls, stems }) {
if (colorSel) onCtl(colorSel, 'change', applyColorMode);
applyColorMode();
// 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);
}
// Linked hover across every panel.
for (const s of live) {
const mm = (ev) => {
const id = s.panel.pickAt(ev.clientX, ev.clientY);
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 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);
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=5';
import { mountPanels } from './panel-grid.js?v=1';
const dialog = document.getElementById('run-modal');
const host = document.getElementById('modal-panel-host');

View File

@ -1,114 +0,0 @@
// 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,37 +513,6 @@ 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;
@ -1587,14 +1556,10 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
.compare-canvas {
position: relative;
flex: 0 0 auto;
flex: 1 1 auto;
min-height: 0;
background: var(--picker-panel, var(--panel));
/* 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;
height: var(--panel-h);
}
.compare-canvas canvas {

View File

@ -7,9 +7,7 @@
{% 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 %}"
data-embedder="{{ r.embedder_short or '' }}"
data-generator="{{ r.generator_short or '' }}">
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}{% if r.stale %} stale{% endif %}">
{% 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=32" />
<link rel="stylesheet" href="/static/style.css?v=28" />
<script type="importmap">
{
"imports": {
@ -34,7 +34,8 @@
</div>
</header>
<section class="compare-layout" id="compare-layout">
<section class="compare-layout" id="compare-layout"
data-stem-a="{{ stem_a }}" data-stem-b="{{ stem_b }}">
<div class="compare-controls" id="compare-controls">
<button type="button" class="cc-play" id="cc-play" aria-label="play / pause">&#9654;</button>
@ -73,10 +74,8 @@
<label class="cc-sync-wrap">
<span class="cc-lbl">axes</span>
<select class="cc-sync" id="cc-sync">
<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>
<option value="independent" selected>independent</option>
<option value="locked">locked</option>
</select>
</label>
</div>
@ -102,8 +101,6 @@
</template>
<script src="/static/theme.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 -->
<script type="module" src="/static/compare.js?v=11"></script>
</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=32" />
<link rel="stylesheet" href="/static/style.css?v=28" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap">
{
@ -303,20 +303,9 @@
<div class="compare-bar">
<button type="button" id="compare-btn" disabled>
compare selected <span id="compare-count">(0/8)</span>
compare selected <span id="compare-count">(0/2)</span>
</button>
<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>
<span class="compare-hint muted">pick two embeddings &rarr; side-by-side animation in a new tab</span>
</div>
<div
@ -445,10 +434,8 @@
<label class="cc-sync-wrap">
<span class="cc-lbl">axes</span>
<select class="cc-sync" id="cc-sync">
<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>
<option value="independent" selected>independent</option>
<option value="locked">locked</option>
</select>
</label>
</div>
@ -484,9 +471,8 @@
<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=2"></script>
<script src="/static/runs-filter.js?v=1"></script>
<script type="module" src="/static/run-modal.js?v=2"></script>
<script src="/static/compare-select.js?v=1"></script>
<script type="module" src="/static/run-modal.js?v=1"></script>
<script>
// Anchor-links alone don't expand <details>; force it.
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {

View File

@ -296,16 +296,9 @@ 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,
**user_generator_kwargs,
**(generator_kwargs or {}),
"n_samples": num_points,
}
embed_columns = (
@ -318,7 +311,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, user_generator_kwargs)
_args_tag = _run_args_hash(embed_args, 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"
)
@ -412,7 +405,7 @@ def embedding_flow(
"jitter_scale": jitter_scale,
"seed": seed,
"generator_path": generator_path,
"generator_kwargs": user_generator_kwargs,
"generator_kwargs": generator_kwargs or {},
"embedder": embedder,
"embed_args": merged_embed_args,
},
@ -435,7 +428,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"] = user_generator_kwargs
frames.setdefault("meta", {})["generator_kwargs"] = generator_kwargs or {}
Path(output_frames).write_text(
json.dumps(frames, separators=(",", ":")), encoding="utf-8"
)