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:
parent
b744c48348
commit
9b178dad38
@ -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}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
|
||||
114
app/web/static/runs-filter.js
Normal file
114
app/web/static/runs-filter.js
Normal 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();
|
||||
})();
|
||||
@ -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;
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>embedding notebook · 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">▶</button>
|
||||
|
||||
@ -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 → side-by-side animation in a new tab</span>
|
||||
<span class="compare-hint muted">pick 2–8 embeddings → 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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user