runs filter: N + T chip rows; group all/none meta chips; explicit row layout

- Add N and T axes alongside dataset/algorithm; chips populated from runs
  in the list, axis group hidden when there's a single unique value.
- Dataset+algorithm on row 1, N+T on row 2 via two explicit
  .runs-filter-row flex containers (cleaner than a sentinel break elem
  that double-counted the row-gap).
- 'all' and 'none' meta-chips now wrap as a unit inside .chip-meta-wrap
  so one doesn't orphan to the next line.
- Row is hidden entirely when every axis in it collapses to a single
  value (:has selector on .runs-filter-row).
This commit is contained in:
Michael Pilosov 2026-04-22 17:20:08 -06:00
parent 4576088c73
commit d70eff3704
5 changed files with 98 additions and 62 deletions

View File

@ -1,30 +1,40 @@
// 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.
// Filter the recent-runs list by chip-groups. State lives outside
// #runs-slot so it survives the 3s htmx poll. After each swap we repopulate
// chips from whatever came back, then re-apply selection to hide 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;
if (!slot) return;
// null = "all selected" (no filtering on this axis). Populated Sets
// override that. Sticky across htmx swaps.
let datasets = null;
let algorithms = null;
const AXES = [
{ prop: 'generator', chipsId: 'runs-flt-dataset', numeric: false },
{ prop: 'embedder', chipsId: 'runs-flt-algo', numeric: false },
{ prop: 'n', chipsId: 'runs-flt-n', numeric: true },
{ prop: 't', chipsId: 'runs-flt-t', numeric: true },
];
function scanValues() {
const ds = new Set();
const alg = new Set();
for (const ax of AXES) {
ax.el = document.getElementById(ax.chipsId);
ax.group = ax.el ? ax.el.closest('.runs-filter-group') : null;
ax.selected = null; // null = all
}
function scanAll() {
const out = new Map(AXES.map(a => [a.prop, 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);
for (const ax of AXES) {
const v = li.dataset[ax.prop];
if (v) out.get(ax.prop).add(v);
}
});
return {
datasets: [...ds].sort(),
algorithms: [...alg].sort(),
};
return out;
}
function sortValues(vals, numeric) {
const arr = [...vals];
if (numeric) arr.sort((a, b) => Number(a) - Number(b));
else arr.sort();
return arr;
}
function paint(container, values, selected) {
@ -40,68 +50,65 @@
b.textContent = v;
container.appendChild(b);
}
// Keep all/none atomic so they wrap together rather than orphaning.
const metaWrap = document.createElement('span');
metaWrap.className = 'chip-meta-wrap';
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);
metaWrap.appendChild(b);
}
container.appendChild(metaWrap);
}
function repaint() {
const { datasets: allDs, algorithms: allAlg } = scanValues();
paint(dsEl, allDs, datasets);
paint(algEl, allAlg, algorithms);
const scanned = scanAll();
for (const ax of AXES) {
if (!ax.el) continue;
const values = sortValues(scanned.get(ax.prop), ax.numeric);
// Hide the whole group when there's nothing to filter by.
if (ax.group) ax.group.style.display = values.length <= 1 ? 'none' : '';
paint(ax.el, values, ax.selected);
}
}
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));
let pass = true;
for (const ax of AXES) {
if (ax.selected == null) continue;
const v = li.dataset[ax.prop] || '';
if (!ax.selected.has(v)) { pass = false; break; }
}
li.classList.toggle('filtered-out', !pass);
});
}
function bind(container, getSet, setSet, allGetter) {
container.addEventListener('click', (e) => {
for (const ax of AXES) {
if (!ax.el) continue;
ax.el.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);
const all = sortValues(scanAll().get(ax.prop), ax.numeric);
let cur = ax.selected == null ? new Set(all) : ax.selected;
if (role === 'value') {
const v = btn.dataset.value;
if (cur.has(v)) cur.delete(v);
else cur.add(v);
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);
ax.selected = 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();

View File

@ -516,12 +516,22 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
.runs-filter {
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1.4rem;
flex-direction: column;
gap: 0.4rem;
padding: 0.25rem 0 0.7rem;
margin-bottom: 0.3rem;
border-bottom: 1px dashed var(--rule);
}
.runs-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.4rem;
}
/* Hide a row entirely when every child group is display:none (all axes
in it have a single value). :has is supported in all modern evergreens. */
.runs-filter-row:not(:has(.runs-filter-group:not([style*="display: none"]))) {
display: none;
}
.runs-filter-group {
display: flex;
align-items: center;
@ -542,6 +552,11 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
gap: 0.25rem 0.3rem;
align-items: center;
}
.runs-filter-group .chips .chip-meta-wrap {
display: inline-flex;
gap: 0.25rem 0.3rem;
flex-shrink: 0;
}
.runs li.run.filtered-out { display: none; }
.runs li.run.just-submitted {

View File

@ -9,7 +9,9 @@
{% 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 '' }}">
data-generator="{{ r.generator_short or '' }}"
data-n="{{ r.params.get('num_points', '') if r.params else '' }}"
data-t="{{ r.params.get('num_timesteps', r.params.get('num_snapshots', '')) if r.params else '' }}">
{% 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=36" />
<script type="importmap">
{
"imports": {

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=36" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap">
{
@ -309,13 +309,25 @@
</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 class="runs-filter-row">
<div class="runs-filter-group" data-axis="dataset">
<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" data-axis="algorithm">
<span class="ctl-label">algorithm</span>
<div class="chips" id="runs-flt-algo" aria-label="filter by algorithm"></div>
</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 class="runs-filter-row">
<div class="runs-filter-group" data-axis="n">
<span class="ctl-label">N</span>
<div class="chips" id="runs-flt-n" aria-label="filter by N"></div>
</div>
<div class="runs-filter-group" data-axis="t">
<span class="ctl-label">T</span>
<div class="chips" id="runs-flt-t" aria-label="filter by T"></div>
</div>
</div>
</div>
@ -485,7 +497,7 @@
<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 src="/static/runs-filter.js?v=3"></script>
<script type="module" src="/static/run-modal.js?v=2"></script>
<script>
// Anchor-links alone don't expand <details>; force it.