From 9b178dad383dd28559d49e123aa3ceae0b4454c9 Mon Sep 17 00:00:00 2001 From: Michael Pilosov Date: Wed, 22 Apr 2026 16:41:06 -0600 Subject: [PATCH] runs: filter chips + compare selection up to 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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. --- app/web/main.py | 21 ++++-- app/web/static/compare-select.js | 13 ++-- app/web/static/compare.js | 8 ++- app/web/static/runs-filter.js | 114 +++++++++++++++++++++++++++++++ app/web/static/style.css | 31 +++++++++ app/web/templates/_runs.html | 4 +- app/web/templates/compare.html | 5 +- app/web/templates/index.html | 20 ++++-- 8 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 app/web/static/runs-filter.js diff --git a/app/web/main.py b/app/web/main.py index 9e156da..c6e3c33 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -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} ) diff --git a/app/web/static/compare-select.js b/app/web/static/compare-select.js index 31ea4b0..70a31c1 100644 --- a/app/web/static/compare-select.js +++ b/app/web/static/compare-select.js @@ -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(); diff --git a/app/web/static/compare.js b/app/web/static/compare.js index ceb1814..3f89aee 100644 --- a/app/web/static/compare.js +++ b/app/web/static/compare.js @@ -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'); diff --git a/app/web/static/runs-filter.js b/app/web/static/runs-filter.js new file mode 100644 index 0000000..8e99583 --- /dev/null +++ b/app/web/static/runs-filter.js @@ -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(); +})(); diff --git a/app/web/static/style.css b/app/web/static/style.css index 62dca48..be5859f 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -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; diff --git a/app/web/templates/_runs.html b/app/web/templates/_runs.html index 91712c4..ebffc90 100644 --- a/app/web/templates/_runs.html +++ b/app/web/templates/_runs.html @@ -7,7 +7,9 @@ {% else %}