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.
This commit is contained in:
Michael Pilosov 2026-04-22 16:41:06 -06:00
parent b744c48348
commit 9b178dad38
8 changed files with 194 additions and 22 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';
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

@ -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;

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=29" />
<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>

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=29" />
<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
@ -471,7 +482,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=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=1"></script>
<script>
// Anchor-links alone don't expand <details>; force it.