Compare commits
No commits in common. "c12d2cda6c043a3f40e23dcbf770ba054ae16f92" and "b744c48348c28cab83144e556ae7153d1bf9f4ec" have entirely different histories.
c12d2cda6c
...
b744c48348
@ -1020,23 +1020,14 @@ async def run_frames(stem: str) -> Response:
|
||||
|
||||
|
||||
@app.get("/compare", response_class=HTMLResponse)
|
||||
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):
|
||||
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", {"stems": stems}
|
||||
request, "compare.html", {"stem_a": a, "stem_b": b}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -3,8 +3,7 @@
|
||||
// the polled region and re-apply checked state on every afterSwap.
|
||||
|
||||
(function () {
|
||||
const MIN = 2;
|
||||
const MAX = 8;
|
||||
const MAX = 2;
|
||||
const selected = new Set();
|
||||
|
||||
const btn = document.getElementById('compare-btn');
|
||||
@ -15,7 +14,7 @@
|
||||
function refreshButton() {
|
||||
const n = selected.size;
|
||||
countEl.textContent = `(${n}/${MAX})`;
|
||||
btn.disabled = n < MIN || n > MAX;
|
||||
btn.disabled = n !== MAX;
|
||||
}
|
||||
|
||||
function applyToDOM() {
|
||||
@ -55,10 +54,10 @@
|
||||
});
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
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');
|
||||
if (selected.size !== MAX) return;
|
||||
const [a, b] = [...selected];
|
||||
const url = `/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
});
|
||||
|
||||
applyToDOM();
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
// compare.js — thin shim that parses ?stem=…&stem=… (legacy ?a=&b=) and
|
||||
// hands off to panel-grid.js.
|
||||
// compare.js — thin shim that parses ?a=&b= and hands off to panel-grid.js.
|
||||
|
||||
import { mountPanels } from './panel-grid.js?v=5';
|
||||
import { mountPanels } from './panel-grid.js?v=1';
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let stems = params.getAll('stem').filter(Boolean);
|
||||
if (stems.length === 0) {
|
||||
stems = [params.get('a') || '', params.get('b') || ''].filter(Boolean);
|
||||
}
|
||||
const stems = [params.get('a') || '', params.get('b') || ''].filter(Boolean);
|
||||
|
||||
const host = document.getElementById('panel-host');
|
||||
const controls = document.getElementById('compare-controls');
|
||||
|
||||
@ -628,21 +628,9 @@ export function mountPanels({ host, controls, stems }) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAxes(value) {
|
||||
// Accepted: "scaled-1x1", "scaled-3x2", "locked-1x1", "locked-3x2",
|
||||
// plus legacy "independent" / "locked" (assume 1:1).
|
||||
const v = value || 'scaled-1x1';
|
||||
if (v === 'independent') return { sync: 'scaled', aspect: '1 / 1' };
|
||||
if (v === 'locked') return { sync: 'locked', aspect: '1 / 1' };
|
||||
const [sync, ratio] = v.split('-');
|
||||
const aspect = ratio === '3x2' ? '3 / 2' : '1 / 1';
|
||||
return { sync: sync === 'locked' ? 'locked' : 'scaled', aspect };
|
||||
}
|
||||
|
||||
function applyAxes() {
|
||||
const { sync, aspect } = parseAxes(syncSel ? syncSel.value : 'scaled-3x2');
|
||||
host.style.setProperty('--canvas-aspect', aspect);
|
||||
if (sync === 'locked' && live.length > 1) {
|
||||
function applySync() {
|
||||
const mode = syncSel ? syncSel.value : 'independent';
|
||||
if (mode === 'locked' && live.length > 1) {
|
||||
let xmin = +Infinity, xmax = -Infinity, ymin = +Infinity, ymax = -Infinity;
|
||||
for (const s of live) {
|
||||
const b = s.panel.data.bounds;
|
||||
@ -656,11 +644,9 @@ export function mountPanels({ host, controls, stems }) {
|
||||
} else {
|
||||
for (const s of live) s.panel.setBounds(s.panel.data.bounds);
|
||||
}
|
||||
// Canvas size changes with aspect — let the panels re-fit next frame.
|
||||
requestAnimationFrame(() => { for (const s of live) s.panel.resize(); });
|
||||
}
|
||||
if (syncSel) onCtl(syncSel, 'change', applyAxes);
|
||||
applyAxes();
|
||||
if (syncSel) onCtl(syncSel, 'change', applySync);
|
||||
applySync();
|
||||
|
||||
let playing = false;
|
||||
let lastTs = 0;
|
||||
@ -713,33 +699,18 @@ export function mountPanels({ host, controls, stems }) {
|
||||
if (colorSel) onCtl(colorSel, 'change', applyColorMode);
|
||||
applyColorMode();
|
||||
|
||||
// Linked hover + click-to-pin. Hover shows the point under the cursor
|
||||
// across all panels. Click toggles a "pinned" id that stays highlighted
|
||||
// once the cursor leaves; hover temporarily overrides the display.
|
||||
let pinnedId = null;
|
||||
let hoveredId = null;
|
||||
function paintHighlight() {
|
||||
const id = hoveredId !== null ? hoveredId : pinnedId;
|
||||
for (const t of live) t.panel.setHighlight(id);
|
||||
}
|
||||
// Linked hover across every panel.
|
||||
for (const s of live) {
|
||||
const mm = (ev) => {
|
||||
const id = s.panel.pickAt(ev.clientX, ev.clientY);
|
||||
hoveredId = id >= 0 ? id : null;
|
||||
paintHighlight();
|
||||
};
|
||||
const ml = () => { hoveredId = null; paintHighlight(); };
|
||||
const mc = (ev) => {
|
||||
const id = s.panel.pickAt(ev.clientX, ev.clientY);
|
||||
pinnedId = (id < 0 || id === pinnedId) ? null : id;
|
||||
paintHighlight();
|
||||
const hid = id >= 0 ? id : null;
|
||||
for (const t of live) t.panel.setHighlight(hid);
|
||||
};
|
||||
const ml = () => { for (const t of live) t.panel.setHighlight(null); };
|
||||
s.panel.canvasEl.addEventListener('mousemove', mm);
|
||||
s.panel.canvasEl.addEventListener('mouseleave', ml);
|
||||
s.panel.canvasEl.addEventListener('click', mc);
|
||||
listeners.push({ el: s.panel.canvasEl, ev: 'mousemove', fn: mm });
|
||||
listeners.push({ el: s.panel.canvasEl, ev: 'mouseleave', fn: ml });
|
||||
listeners.push({ el: s.panel.canvasEl, ev: 'click', fn: mc });
|
||||
}
|
||||
|
||||
// Resize + theme observers.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// run-modal.js — homepage click-hijack for embedding links. Opens a
|
||||
// <dialog id="run-modal"> that renders the run's embedding via panel-grid.js.
|
||||
|
||||
import { mountPanels } from './panel-grid.js?v=5';
|
||||
import { mountPanels } from './panel-grid.js?v=1';
|
||||
|
||||
const dialog = document.getElementById('run-modal');
|
||||
const host = document.getElementById('modal-panel-host');
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
// 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,37 +513,6 @@ 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;
|
||||
@ -1587,14 +1556,10 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
||||
|
||||
.compare-canvas {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--picker-panel, var(--panel));
|
||||
/* Height derives from column width × aspect ratio. Controlled by the
|
||||
axes dropdown via --canvas-aspect on the grid. --panel-h is the cap. */
|
||||
aspect-ratio: var(--canvas-aspect, 1 / 1);
|
||||
max-height: var(--panel-h);
|
||||
cursor: crosshair;
|
||||
height: var(--panel-h);
|
||||
}
|
||||
|
||||
.compare-canvas canvas {
|
||||
|
||||
@ -7,9 +7,7 @@
|
||||
{% 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 %}"
|
||||
data-embedder="{{ r.embedder_short or '' }}"
|
||||
data-generator="{{ r.generator_short or '' }}">
|
||||
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}{% if r.stale %} stale{% endif %}">
|
||||
{% 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=32" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=28" />
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
@ -34,7 +34,8 @@
|
||||
</div>
|
||||
</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">
|
||||
<button type="button" class="cc-play" id="cc-play" aria-label="play / pause">▶</button>
|
||||
@ -73,10 +74,8 @@
|
||||
<label class="cc-sync-wrap">
|
||||
<span class="cc-lbl">axes</span>
|
||||
<select class="cc-sync" id="cc-sync">
|
||||
<option value="scaled-1x1">scaled · 1:1</option>
|
||||
<option value="scaled-3x2" selected>scaled · 3:2</option>
|
||||
<option value="locked-1x1">locked · 1:1</option>
|
||||
<option value="locked-3x2">locked · 3:2</option>
|
||||
<option value="independent" selected>independent</option>
|
||||
<option value="locked">locked</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@ -102,8 +101,6 @@
|
||||
</template>
|
||||
|
||||
<script src="/static/theme.js?v=11"></script>
|
||||
<script type="module" src="/static/compare.js?v=17"></script>
|
||||
<!-- panel-grid.js is imported by compare.js (module); versioned via compare.js cache-bust -->
|
||||
|
||||
<script type="module" src="/static/compare.js?v=11"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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=28" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
@ -303,20 +303,9 @@
|
||||
|
||||
<div class="compare-bar">
|
||||
<button type="button" id="compare-btn" disabled>
|
||||
compare selected <span id="compare-count">(0/8)</span>
|
||||
compare selected <span id="compare-count">(0/2)</span>
|
||||
</button>
|
||||
<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>
|
||||
<span class="compare-hint muted">pick two embeddings → side-by-side animation in a new tab</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -445,10 +434,8 @@
|
||||
<label class="cc-sync-wrap">
|
||||
<span class="cc-lbl">axes</span>
|
||||
<select class="cc-sync" id="cc-sync">
|
||||
<option value="scaled-1x1">scaled · 1:1</option>
|
||||
<option value="scaled-3x2" selected>scaled · 3:2</option>
|
||||
<option value="locked-1x1">locked · 1:1</option>
|
||||
<option value="locked-3x2">locked · 3:2</option>
|
||||
<option value="independent" selected>independent</option>
|
||||
<option value="locked">locked</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@ -484,9 +471,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=2"></script>
|
||||
<script src="/static/runs-filter.js?v=1"></script>
|
||||
<script type="module" src="/static/run-modal.js?v=2"></script>
|
||||
<script src="/static/compare-select.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.
|
||||
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {
|
||||
|
||||
@ -296,16 +296,9 @@ def embedding_flow(
|
||||
reference_speedup: float = 10.0,
|
||||
samples: int = 10_000,
|
||||
):
|
||||
# Preserve the user-supplied generator_kwargs for hashing / metadata —
|
||||
# the merged dict (with random_state defaults + n_samples) goes to the
|
||||
# generator itself but those aren't part of the run's semantic identity
|
||||
# (random_state=0 is a flow constant; n_samples is captured as `N` in
|
||||
# the stem). If the merged dict were hashed, the web app would disagree
|
||||
# with the flow because Prefect only records the user-supplied form.
|
||||
user_generator_kwargs = dict(generator_kwargs or {})
|
||||
generator_kwargs = {
|
||||
**_DEFAULT_GENERATOR_KWARGS,
|
||||
**user_generator_kwargs,
|
||||
**(generator_kwargs or {}),
|
||||
"n_samples": num_points,
|
||||
}
|
||||
embed_columns = (
|
||||
@ -318,7 +311,7 @@ def embedding_flow(
|
||||
output_ref: str = (
|
||||
f"{output_dir.strip('/')}/{_generator}_Reference_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}.html"
|
||||
)
|
||||
_args_tag = _run_args_hash(embed_args, user_generator_kwargs)
|
||||
_args_tag = _run_args_hash(embed_args, generator_kwargs)
|
||||
output_embed: str = (
|
||||
f"{output_dir.strip('/')}/{_generator}_{embedder.split('.')[-1]}_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}_{_args_tag}.html"
|
||||
)
|
||||
@ -412,7 +405,7 @@ def embedding_flow(
|
||||
"jitter_scale": jitter_scale,
|
||||
"seed": seed,
|
||||
"generator_path": generator_path,
|
||||
"generator_kwargs": user_generator_kwargs,
|
||||
"generator_kwargs": generator_kwargs or {},
|
||||
"embedder": embedder,
|
||||
"embed_args": merged_embed_args,
|
||||
},
|
||||
@ -435,7 +428,7 @@ def embedding_flow(
|
||||
frames = parse_plotly_run(emb_path_result)
|
||||
# Persist generator_kwargs so the server's label enrichment can
|
||||
# regenerate the correct dataset variant (swiss_roll vs hole).
|
||||
frames.setdefault("meta", {})["generator_kwargs"] = user_generator_kwargs
|
||||
frames.setdefault("meta", {})["generator_kwargs"] = generator_kwargs or {}
|
||||
Path(output_frames).write_text(
|
||||
json.dumps(frames, separators=(",", ":")), encoding="utf-8"
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user