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)
|
@app.get("/compare", response_class=HTMLResponse)
|
||||||
async def compare_page(request: Request, a: str = "", b: str = "") -> HTMLResponse:
|
async def compare_page(request: Request) -> HTMLResponse:
|
||||||
for label, stem in (("a", a), ("b", b)):
|
q = request.query_params
|
||||||
if not stem or not _STEM_RE.match(stem):
|
stems = [s for s in q.getlist("stem") if s]
|
||||||
raise HTTPException(400, f"missing or malformed stem for {label!r}")
|
if not stems:
|
||||||
if not (FIGS_DIR / f"{stem}.html").is_file():
|
# 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}")
|
raise HTTPException(404, f"no such run: {stem}")
|
||||||
return templates.TemplateResponse(
|
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.
|
// the polled region and re-apply checked state on every afterSwap.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const MAX = 2;
|
const MIN = 2;
|
||||||
|
const MAX = 8;
|
||||||
const selected = new Set();
|
const selected = new Set();
|
||||||
|
|
||||||
const btn = document.getElementById('compare-btn');
|
const btn = document.getElementById('compare-btn');
|
||||||
@ -14,7 +15,7 @@
|
|||||||
function refreshButton() {
|
function refreshButton() {
|
||||||
const n = selected.size;
|
const n = selected.size;
|
||||||
countEl.textContent = `(${n}/${MAX})`;
|
countEl.textContent = `(${n}/${MAX})`;
|
||||||
btn.disabled = n !== MAX;
|
btn.disabled = n < MIN || n > MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToDOM() {
|
function applyToDOM() {
|
||||||
@ -54,10 +55,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (selected.size !== MAX) return;
|
const n = selected.size;
|
||||||
const [a, b] = [...selected];
|
if (n < MIN || n > MAX) return;
|
||||||
const url = `/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`;
|
const qs = [...selected].map((s) => `stem=${encodeURIComponent(s)}`).join('&');
|
||||||
window.open(url, '_blank', 'noopener');
|
window.open(`/compare?${qs}`, '_blank', 'noopener');
|
||||||
});
|
});
|
||||||
|
|
||||||
applyToDOM();
|
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';
|
import { mountPanels } from './panel-grid.js?v=1';
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
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 host = document.getElementById('panel-host');
|
||||||
const controls = document.getElementById('compare-controls');
|
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-size: 0.76rem;
|
||||||
font-style: italic;
|
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 {
|
.runs li.run.just-submitted {
|
||||||
background: linear-gradient(to right, var(--accent-tint), transparent 60%);
|
background: linear-gradient(to right, var(--accent-tint), transparent 60%);
|
||||||
padding-left: 0.55rem;
|
padding-left: 0.55rem;
|
||||||
|
|||||||
@ -7,7 +7,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<ul class="runs">
|
<ul class="runs">
|
||||||
{% for r in 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 %}
|
{% 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" />
|
<input type="checkbox" class="compare-cb" data-stem="{{ r.emb_file[:-5] }}" aria-label="select run for comparison" />
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>embedding notebook · compare</title>
|
<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">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@ -34,8 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div class="compare-controls" id="compare-controls">
|
||||||
<button type="button" class="cc-play" id="cc-play" aria-label="play / pause">▶</button>
|
<button type="button" class="cc-play" id="cc-play" aria-label="play / pause">▶</button>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>embedding notebook</title>
|
<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 src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
@ -303,9 +303,20 @@
|
|||||||
|
|
||||||
<div class="compare-bar">
|
<div class="compare-bar">
|
||||||
<button type="button" id="compare-btn" disabled>
|
<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>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -471,7 +482,8 @@
|
|||||||
<script src="/static/theme.js?v=11"></script>
|
<script src="/static/theme.js?v=11"></script>
|
||||||
<script type="module" src="/static/dataset-picker.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 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 type="module" src="/static/run-modal.js?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
// Anchor-links alone don't expand <details>; force it.
|
// Anchor-links alone don't expand <details>; force it.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user