compare: selection UX on runs list + /compare placeholder page
- per-run checkbox when embedding HTML exists; cap at 2 selected - sticky 'compare selected' button opens /compare?a=&b= in new tab - selection state persists across the 3s htmx poll via a Set keyed by stem - /compare stub validates stems, renders scaffolding (three.js UI next)
This commit is contained in:
parent
b016dbdaee
commit
e680867f8b
@ -833,6 +833,18 @@ async def run_frames(stem: str) -> Response:
|
|||||||
return Response(content=payload, media_type="application/json")
|
return Response(content=payload, media_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
@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():
|
||||||
|
raise HTTPException(404, f"no such run: {stem}")
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "compare.html", {"stem_a": a, "stem_b": b}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> JSONResponse:
|
async def health() -> JSONResponse:
|
||||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
|||||||
64
app/web/static/compare-select.js
Normal file
64
app/web/static/compare-select.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Manages run-comparison selection on the runs list.
|
||||||
|
// HTMX re-renders #runs-slot every 3s, so we keep state in a Set outside
|
||||||
|
// the polled region and re-apply checked state on every afterSwap.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const MAX = 2;
|
||||||
|
const selected = new Set();
|
||||||
|
|
||||||
|
const btn = document.getElementById('compare-btn');
|
||||||
|
const countEl = document.getElementById('compare-count');
|
||||||
|
const slot = document.getElementById('runs-slot');
|
||||||
|
if (!btn || !countEl || !slot) return;
|
||||||
|
|
||||||
|
function refreshButton() {
|
||||||
|
const n = selected.size;
|
||||||
|
countEl.textContent = `(${n}/${MAX})`;
|
||||||
|
btn.disabled = n !== MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToDOM() {
|
||||||
|
const checkboxes = slot.querySelectorAll('.compare-cb');
|
||||||
|
// Drop any selected stems that are no longer in the DOM (run aged out of list)
|
||||||
|
const present = new Set();
|
||||||
|
checkboxes.forEach((cb) => present.add(cb.dataset.stem));
|
||||||
|
for (const s of [...selected]) if (!present.has(s)) selected.delete(s);
|
||||||
|
|
||||||
|
const atCap = selected.size >= MAX;
|
||||||
|
checkboxes.forEach((cb) => {
|
||||||
|
const stem = cb.dataset.stem;
|
||||||
|
cb.checked = selected.has(stem);
|
||||||
|
cb.disabled = atCap && !cb.checked;
|
||||||
|
});
|
||||||
|
refreshButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
slot.addEventListener('change', (e) => {
|
||||||
|
const cb = e.target;
|
||||||
|
if (!cb.matches || !cb.matches('.compare-cb')) return;
|
||||||
|
const stem = cb.dataset.stem;
|
||||||
|
if (cb.checked) {
|
||||||
|
if (selected.size >= MAX) {
|
||||||
|
cb.checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected.add(stem);
|
||||||
|
} else {
|
||||||
|
selected.delete(stem);
|
||||||
|
}
|
||||||
|
applyToDOM();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||||
|
if (e.target && e.target.id === 'runs-slot') applyToDOM();
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
applyToDOM();
|
||||||
|
})();
|
||||||
@ -456,10 +456,63 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
|||||||
border-bottom: 1px solid var(--rule);
|
border-bottom: 1px solid var(--rule);
|
||||||
padding: 0.85rem 0 0.85rem;
|
padding: 0.85rem 0 0.85rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 5.5rem 1fr;
|
grid-template-columns: 1.25rem 5.5rem 1fr;
|
||||||
column-gap: 1rem;
|
column-gap: 0.6rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
.runs li.run > .compare-cb,
|
||||||
|
.runs li.run > .compare-cb-slot {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin: 3px 0 0 0;
|
||||||
|
align-self: start;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.runs li.run > .compare-cb-slot {
|
||||||
|
visibility: hidden;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.runs li.run > .compare-cb:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.compare-bar button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 100ms ease, border-color 100ms ease;
|
||||||
|
}
|
||||||
|
.compare-bar button:not(:disabled):hover {
|
||||||
|
background: var(--accent-tint);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.compare-bar button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.compare-bar #compare-count {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--faint);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
.compare-hint {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
.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;
|
||||||
|
|||||||
@ -8,6 +8,11 @@
|
|||||||
<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 %}">
|
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}">
|
||||||
|
{% if r.emb_exists %}
|
||||||
|
<input type="checkbox" class="compare-cb" data-stem="{{ r.emb_file[:-5] }}" aria-label="select run for comparison" />
|
||||||
|
{% else %}
|
||||||
|
<span class="compare-cb-slot" aria-hidden="true"></span>
|
||||||
|
{% endif %}
|
||||||
<div class="stamp">
|
<div class="stamp">
|
||||||
{% if r.runtime %}<span class="rt">{{ r.runtime }}</span>{% endif %}
|
{% if r.runtime %}<span class="rt">{{ r.runtime }}</span>{% endif %}
|
||||||
{% if r.start %}{{ r.start[:10] }}<br/>{{ r.start[11:19] }}{% else %} {% endif %}
|
{% if r.start %}{{ r.start[:10] }}<br/>{{ r.start[11:19] }}{% else %} {% endif %}
|
||||||
|
|||||||
47
app/web/templates/compare.html
Normal file
47
app/web/templates/compare.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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=20" />
|
||||||
|
<script>
|
||||||
|
(function(){try{
|
||||||
|
var t=localStorage.getItem('theme');
|
||||||
|
if(!t)t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
|
||||||
|
document.documentElement.setAttribute('data-theme',t);
|
||||||
|
}catch(e){}})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">embedding notebook <em>— compare</em></h1>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<a href="/" class="masthead-link">← runs</a>
|
||||||
|
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="toggle theme">◐</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section style="padding: 2rem 1.2rem; max-width: 72ch; margin: 0 auto; font-family: var(--serif);">
|
||||||
|
<p style="color: var(--mute);">
|
||||||
|
Comparison page — scaffolding. Two runs selected:
|
||||||
|
</p>
|
||||||
|
<ul style="font-family: var(--mono); font-size: 0.85rem;">
|
||||||
|
<li><strong>A</strong> · {{ stem_a }} ·
|
||||||
|
<a href="/api/runs/{{ stem_a }}/frames.json" target="_blank">frames.json</a> ·
|
||||||
|
<a href="/figs/{{ stem_a }}.html" target="_blank">original plotly</a></li>
|
||||||
|
<li><strong>B</strong> · {{ stem_b }} ·
|
||||||
|
<a href="/api/runs/{{ stem_b }}/frames.json" target="_blank">frames.json</a> ·
|
||||||
|
<a href="/figs/{{ stem_b }}.html" target="_blank">original plotly</a></li>
|
||||||
|
</ul>
|
||||||
|
<p style="color: var(--faint); font-size: 0.85rem; font-style: italic;">
|
||||||
|
Three.js side-by-side animation UI will render here (next phase).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="/static/theme.js?v=11"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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=19" />
|
<link rel="stylesheet" href="/static/style.css?v=20" />
|
||||||
<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">
|
||||||
{
|
{
|
||||||
@ -301,6 +301,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="compare-bar">
|
||||||
|
<button type="button" id="compare-btn" disabled>
|
||||||
|
compare selected <span id="compare-count">(0/2)</span>
|
||||||
|
</button>
|
||||||
|
<span class="compare-hint muted">pick two embeddings → side-by-side animation in a new tab</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="runs-slot"
|
id="runs-slot"
|
||||||
hx-get="/runs"
|
hx-get="/runs"
|
||||||
@ -400,6 +407,7 @@
|
|||||||
<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>
|
<script>
|
||||||
// Anchor-links alone don't expand <details>; force it.
|
// Anchor-links alone don't expand <details>; force it.
|
||||||
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {
|
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user