Compare commits
7 Commits
3280410405
...
a33f8f07cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a33f8f07cb | ||
|
|
1807b3ac4e | ||
|
|
158f3fdefa | ||
|
|
d385337a36 | ||
|
|
61e9221b3a | ||
|
|
ca59516f26 | ||
|
|
b2be3d0835 |
@ -261,6 +261,24 @@ REDUCERS: Dict[str, Dict[str, Any]] = {
|
||||
("apply_pca", "bool", True, None, None),
|
||||
],
|
||||
},
|
||||
"pacmap.LocalMAP": {
|
||||
"pkg": "pacmap",
|
||||
"label": "LocalMAP",
|
||||
"blurb": "PaCMAP variant with a low-distance threshold; sharper local structure.",
|
||||
"key": [
|
||||
("n_components", "int", 2, None, "Locked."),
|
||||
("n_neighbors", "int", 10, None, None),
|
||||
("MN_ratio", "float", 0.5, None, None),
|
||||
("FP_ratio", "float", 2.0, None, None),
|
||||
("random_state", "int", 42, None, None),
|
||||
],
|
||||
"advanced": [
|
||||
("lr", "float", 1.0, None, None),
|
||||
("num_iters", "int", 450, None, None),
|
||||
("apply_pca", "bool", True, None, None),
|
||||
("low_dist_thres", "float", 10.0, None, None),
|
||||
],
|
||||
},
|
||||
"trimap.TRIMAP": {
|
||||
"pkg": "trimap",
|
||||
"label": "TriMap",
|
||||
@ -594,14 +612,32 @@ async def submit(request: Request) -> HTMLResponse:
|
||||
)
|
||||
|
||||
# Dataset came from the picker via dataset_id; fall back to explicit
|
||||
# generator_path / generator_kwargs if a client posts those directly.
|
||||
dataset_id = data.get("dataset_id") or ""
|
||||
if dataset_id and dataset_id in DATASET_META:
|
||||
# generator_path / generator_kwargs only when dataset_id is absent entirely
|
||||
# (API consumers). UI form posts always carry the key, so an empty value
|
||||
# means the user hit submit without picking — reject rather than silently
|
||||
# defaulting to s_curve.
|
||||
if "dataset_id" in data:
|
||||
dataset_id = data.get("dataset_id") or ""
|
||||
if not dataset_id:
|
||||
return HTMLResponse(
|
||||
"<div class='flash err'>pick a dataset first (§ 1 above)</div>",
|
||||
status_code=400,
|
||||
)
|
||||
if dataset_id not in DATASET_META:
|
||||
return HTMLResponse(
|
||||
f"<div class='flash err'>unknown dataset: {dataset_id}</div>",
|
||||
status_code=400,
|
||||
)
|
||||
meta = DATASET_META[dataset_id]
|
||||
generator_path = meta["path"]
|
||||
generator_kwargs = dict(meta["kwargs"])
|
||||
else:
|
||||
generator_path = data.get("generator_path") or "sklearn.datasets.make_s_curve"
|
||||
generator_path = data.get("generator_path") or ""
|
||||
if not generator_path:
|
||||
return HTMLResponse(
|
||||
"<div class='flash err'>missing dataset_id or generator_path</div>",
|
||||
status_code=400,
|
||||
)
|
||||
raw_kwargs = data.get("generator_kwargs") or ""
|
||||
try:
|
||||
generator_kwargs = json.loads(raw_kwargs) if raw_kwargs else {}
|
||||
@ -667,6 +703,34 @@ async def submit(request: Request) -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
def _scan_metrics() -> List[Dict[str, Any]]:
|
||||
"""Read every `*.metrics.json` in FIGS_DIR and return them as a list."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for p in sorted(FIGS_DIR.glob("*.metrics.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
data = json.loads(p.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
data["filename"] = p.name
|
||||
data["embedding_file"] = p.name.replace(".metrics.json", ".html")
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/metrics", response_class=HTMLResponse)
|
||||
async def metrics_page(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"metrics.html",
|
||||
{"prefect_api": PREFECT_API},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/metrics.json")
|
||||
async def metrics_json() -> JSONResponse:
|
||||
return JSONResponse(_scan_metrics())
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
|
||||
@ -72,6 +72,11 @@ function buildColors(labels, kind) {
|
||||
return colors;
|
||||
}
|
||||
|
||||
function applyPickerBackground(scene, el) {
|
||||
const css = getComputedStyle(el).getPropertyValue('--picker-panel').trim();
|
||||
scene.background = new THREE.Color(css || '#f2eee4');
|
||||
}
|
||||
|
||||
function createScene(container, dataset) {
|
||||
const { positions: basePositions } = normalize(dataset.points);
|
||||
const colors = buildColors(dataset.labels, dataset.kind);
|
||||
@ -90,7 +95,7 @@ function createScene(container, dataset) {
|
||||
});
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xf2eee4);
|
||||
applyPickerBackground(scene, container);
|
||||
scene.add(new THREE.Points(geometry, material));
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
||||
@ -231,10 +236,20 @@ async function main() {
|
||||
updateContinue();
|
||||
}
|
||||
|
||||
document.addEventListener('themechange', () => {
|
||||
for (const s of scenes) applyPickerBackground(s.scene, s.container);
|
||||
});
|
||||
|
||||
const formSubmitBtn = document.querySelector('#run-form button.submit');
|
||||
function updateContinue() {
|
||||
continueBtn.disabled = !selectedId;
|
||||
continueBtn.title = selectedId ? '' : 'pick a dataset first';
|
||||
if (formSubmitBtn) {
|
||||
formSubmitBtn.disabled = !selectedId;
|
||||
formSubmitBtn.title = selectedId ? '' : 'pick a dataset first';
|
||||
}
|
||||
}
|
||||
updateContinue();
|
||||
|
||||
const nInputs = document.querySelectorAll('input[name="n"]');
|
||||
function applyN(n) {
|
||||
|
||||
385
app/web/static/metrics.js
Normal file
385
app/web/static/metrics.js
Normal file
@ -0,0 +1,385 @@
|
||||
// metrics.js — client-side filtering + hand-rolled SVG line charts for the
|
||||
// metrics page. No deps. Reads /metrics.json (list of sidecar-JSON contents),
|
||||
// filters by dataset + algorithm, and overlays every matching run on three
|
||||
// plots: frame-to-frame travel, vs-initial travel, kNN retention.
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
// Qualitative palette — defined as CSS custom properties on :root (and
|
||||
// overridden under [data-theme="dark"]) so the theme toggle automatically
|
||||
// swaps plot colours. Read fresh on every render.
|
||||
function getPalette() {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const out = [];
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const v = cs.getPropertyValue(`--plot-${i}`).trim();
|
||||
if (v) out.push(v);
|
||||
}
|
||||
return out.length ? out : ["#1f4e5f"];
|
||||
}
|
||||
|
||||
const state = {
|
||||
raw: [],
|
||||
// Multi-select: membership = "this value is currently enabled". Empty
|
||||
// set = nothing shows (matches "none" toggle). Both default to all
|
||||
// values on load so the initial view matches what a user expects.
|
||||
datasets: new Set(),
|
||||
algorithms: new Set(),
|
||||
datasetsAll: [],
|
||||
algorithmsAll: [],
|
||||
stat: "mean",
|
||||
// Runs the user has clicked in the legend to isolate. Empty = show all.
|
||||
selected: new Set(),
|
||||
};
|
||||
|
||||
const shortGen = (p) => (p || "").split(".").pop();
|
||||
const shortEmb = (p) => (p || "").split(".").pop();
|
||||
|
||||
function runLabel(r) {
|
||||
const m = r.meta || {};
|
||||
return `${shortEmb(m.embedder)} · ${shortGen(m.generator_path)} · N${m.num_points} T${m.num_timesteps} J${m.jitter_scale} s${m.seed}`;
|
||||
}
|
||||
|
||||
function runKey(r) {
|
||||
return r.filename;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const resp = await fetch("/metrics.json");
|
||||
state.raw = await resp.json();
|
||||
|
||||
populateFilters();
|
||||
wireEvents();
|
||||
document.addEventListener("themechange", render);
|
||||
render();
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
const datasets = new Set();
|
||||
const algos = new Set();
|
||||
for (const r of state.raw) {
|
||||
datasets.add(shortGen(r.meta?.generator_path));
|
||||
algos.add(shortEmb(r.meta?.embedder));
|
||||
}
|
||||
state.datasetsAll = [...datasets].filter(Boolean).sort();
|
||||
state.algorithmsAll = [...algos].filter(Boolean).sort();
|
||||
// Default to everything enabled.
|
||||
state.datasets = new Set(state.datasetsAll);
|
||||
state.algorithms = new Set(state.algorithmsAll);
|
||||
renderChips();
|
||||
document.getElementById("total-count").textContent = state.raw.length;
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
paintChips("flt-dataset", state.datasetsAll, state.datasets);
|
||||
paintChips("flt-algo", state.algorithmsAll, state.algorithms);
|
||||
}
|
||||
|
||||
function paintChips(containerId, values, selectedSet) {
|
||||
const el = document.getElementById(containerId);
|
||||
el.innerHTML = "";
|
||||
|
||||
for (const v of values) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "chip" + (selectedSet.has(v) ? " is-on" : "");
|
||||
b.setAttribute("aria-pressed", selectedSet.has(v) ? "true" : "false");
|
||||
b.dataset.value = v;
|
||||
b.dataset.role = "value";
|
||||
b.textContent = v;
|
||||
el.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;
|
||||
el.appendChild(b);
|
||||
}
|
||||
}
|
||||
|
||||
function wireEvents() {
|
||||
const bindChipRow = (containerId, selectedSet, allList) => {
|
||||
document.getElementById(containerId).addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".chip");
|
||||
if (!btn) return;
|
||||
const role = btn.dataset.role;
|
||||
if (role === "value") {
|
||||
const v = btn.dataset.value;
|
||||
if (selectedSet.has(v)) selectedSet.delete(v);
|
||||
else selectedSet.add(v);
|
||||
} else if (role === "all") {
|
||||
for (const v of allList) selectedSet.add(v);
|
||||
} else if (role === "none") {
|
||||
selectedSet.clear();
|
||||
}
|
||||
renderChips();
|
||||
render();
|
||||
});
|
||||
};
|
||||
bindChipRow("flt-dataset", state.datasets, state.datasetsAll);
|
||||
bindChipRow("flt-algo", state.algorithms, state.algorithmsAll);
|
||||
|
||||
document.querySelectorAll('input[name="stat"]').forEach((el) => {
|
||||
el.addEventListener("change", (e) => {
|
||||
if (e.target.checked) { state.stat = e.target.value; render(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function matchesFilters(r) {
|
||||
const m = r.meta || {};
|
||||
if (!state.datasets.has(shortGen(m.generator_path))) return false;
|
||||
if (!state.algorithms.has(shortEmb(m.embedder))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
// Legend lists every run passing the dropdown filters. If the user has
|
||||
// clicked any legend rows, plots restrict to those; legend still shows
|
||||
// the full filtered set so you can toggle more in/out.
|
||||
return state.raw.filter(matchesFilters);
|
||||
}
|
||||
|
||||
function plotted(runs) {
|
||||
if (state.selected.size === 0) return runs;
|
||||
return runs.filter((r) => state.selected.has(runKey(r)));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const runs = filtered();
|
||||
document.getElementById("match-count").textContent = runs.length;
|
||||
|
||||
const empty = document.getElementById("empty");
|
||||
empty.hidden = state.raw.length !== 0;
|
||||
|
||||
// Prune selections that no longer match the filters so the plots don't
|
||||
// stay empty when the user narrows the dataset/algo dropdowns.
|
||||
const visibleKeys = new Set(runs.map(runKey));
|
||||
for (const k of state.selected) {
|
||||
if (!visibleKeys.has(k)) state.selected.delete(k);
|
||||
}
|
||||
|
||||
// Stable colour assignment per legend row (across the full filtered set),
|
||||
// so toggling selection doesn't shuffle colours.
|
||||
const palette = getPalette();
|
||||
const colored = runs.map((r, i) => ({
|
||||
run: r,
|
||||
color: palette[i % palette.length],
|
||||
selected: state.selected.has(runKey(r)),
|
||||
}));
|
||||
const forPlot = state.selected.size === 0 ? colored : colored.filter((c) => c.selected);
|
||||
|
||||
renderLineChart(
|
||||
document.getElementById("plot-ff"),
|
||||
forPlot.map(({ run, color }) => ({
|
||||
label: runLabel(run),
|
||||
color,
|
||||
points: (run.travel?.frame_to_frame || []).map((row) => [row.t, row[state.stat]]),
|
||||
})),
|
||||
{ yLabel: "distance", xLabel: "t" }
|
||||
);
|
||||
|
||||
renderLineChart(
|
||||
document.getElementById("plot-vi"),
|
||||
forPlot.map(({ run, color }) => ({
|
||||
label: runLabel(run),
|
||||
color,
|
||||
points: (run.travel?.vs_initial || []).map((row) => [row.t, row[state.stat]]),
|
||||
})),
|
||||
{ yLabel: "distance", xLabel: "t" }
|
||||
);
|
||||
|
||||
renderLineChart(
|
||||
document.getElementById("plot-knn"),
|
||||
forPlot.map(({ run, color }) => ({
|
||||
label: runLabel(run),
|
||||
color,
|
||||
points: (run.knn_retention || []).map((row) => [row.t, row.mean]),
|
||||
})),
|
||||
{ yLabel: "retention", xLabel: "t", yMin: 0, yMax: 1 }
|
||||
);
|
||||
|
||||
renderLegend(colored);
|
||||
}
|
||||
|
||||
function renderLegend(colored) {
|
||||
const el = document.getElementById("legend");
|
||||
el.innerHTML = "";
|
||||
if (!colored.length) return;
|
||||
|
||||
const hasSelection = state.selected.size > 0;
|
||||
|
||||
if (hasSelection) {
|
||||
const clear = document.createElement("button");
|
||||
clear.type = "button";
|
||||
clear.className = "legend-clear";
|
||||
clear.textContent = `clear selection (${state.selected.size})`;
|
||||
clear.addEventListener("click", () => {
|
||||
state.selected.clear();
|
||||
render();
|
||||
});
|
||||
el.appendChild(clear);
|
||||
}
|
||||
|
||||
for (const { run, color, selected } of colored) {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "legend-row" + (selected ? " is-selected" : "") +
|
||||
(hasSelection && !selected ? " is-dim" : "");
|
||||
row.setAttribute("aria-pressed", selected ? "true" : "false");
|
||||
row.innerHTML = `
|
||||
<span class="swatch" style="background:${color}"></span>
|
||||
<span class="lbl">${runLabel(run)}</span>
|
||||
<span class="fn">${run.filename}</span>
|
||||
`;
|
||||
row.addEventListener("click", () => {
|
||||
const k = runKey(run);
|
||||
if (state.selected.has(k)) state.selected.delete(k);
|
||||
else state.selected.add(k);
|
||||
render();
|
||||
});
|
||||
el.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SVG line chart -------------------------------------------------
|
||||
|
||||
function renderLineChart(container, series, opts = {}) {
|
||||
container.innerHTML = "";
|
||||
|
||||
const W = 720, H = 240;
|
||||
// left gutter fits (from outer to inner): y-label column (~16px) +
|
||||
// breathing (~10px) + widest tick label (~30px) + tick gap (6px).
|
||||
const PAD = { top: 12, right: 16, bottom: 28, left: 62 };
|
||||
|
||||
const allPts = series.flatMap((s) => s.points).filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
|
||||
const hasData = allPts.length > 0;
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
svg.setAttribute("role", "img");
|
||||
svg.classList.add("chart");
|
||||
|
||||
if (!hasData) {
|
||||
const t = text(W / 2, H / 2, "no data", "empty");
|
||||
t.setAttribute("text-anchor", "middle");
|
||||
svg.appendChild(t);
|
||||
container.appendChild(svg);
|
||||
return;
|
||||
}
|
||||
|
||||
const xs = allPts.map((p) => p[0]);
|
||||
const ys = allPts.map((p) => p[1]);
|
||||
const xMin = Math.min(...xs), xMax = Math.max(...xs);
|
||||
let yMin = opts.yMin !== undefined ? opts.yMin : Math.min(0, Math.min(...ys));
|
||||
let yMax = opts.yMax !== undefined ? opts.yMax : Math.max(...ys);
|
||||
if (yMax === yMin) yMax = yMin + 1;
|
||||
|
||||
const xPx = (x) => PAD.left + ((x - xMin) / Math.max(1, xMax - xMin)) * (W - PAD.left - PAD.right);
|
||||
const yPx = (y) => H - PAD.bottom - ((y - yMin) / (yMax - yMin)) * (H - PAD.top - PAD.bottom);
|
||||
|
||||
// gridlines (4 horizontal ticks)
|
||||
const nTicks = 4;
|
||||
for (let i = 0; i <= nTicks; i++) {
|
||||
const yVal = yMin + ((yMax - yMin) * i) / nTicks;
|
||||
const y = yPx(yVal);
|
||||
const gl = document.createElementNS(SVG_NS, "line");
|
||||
gl.setAttribute("x1", PAD.left);
|
||||
gl.setAttribute("x2", W - PAD.right);
|
||||
gl.setAttribute("y1", y);
|
||||
gl.setAttribute("y2", y);
|
||||
gl.classList.add("grid");
|
||||
svg.appendChild(gl);
|
||||
|
||||
const lbl = text(PAD.left - 6, y + 3.5, fmtTick(yVal), "axis");
|
||||
lbl.setAttribute("text-anchor", "end");
|
||||
svg.appendChild(lbl);
|
||||
}
|
||||
|
||||
// x-axis ticks (at integer t, max ~8 labels)
|
||||
const xSpan = Math.max(1, xMax - xMin);
|
||||
const everyX = Math.max(1, Math.ceil(xSpan / 8));
|
||||
for (let x = Math.ceil(xMin); x <= xMax; x += everyX) {
|
||||
const px = xPx(x);
|
||||
const tick = document.createElementNS(SVG_NS, "line");
|
||||
tick.setAttribute("x1", px); tick.setAttribute("x2", px);
|
||||
tick.setAttribute("y1", H - PAD.bottom); tick.setAttribute("y2", H - PAD.bottom + 3);
|
||||
tick.classList.add("axis-tick");
|
||||
svg.appendChild(tick);
|
||||
|
||||
const lbl = text(px, H - PAD.bottom + 14, String(x), "axis");
|
||||
lbl.setAttribute("text-anchor", "middle");
|
||||
svg.appendChild(lbl);
|
||||
}
|
||||
|
||||
// axis lines
|
||||
const xAxis = document.createElementNS(SVG_NS, "line");
|
||||
xAxis.setAttribute("x1", PAD.left); xAxis.setAttribute("x2", W - PAD.right);
|
||||
xAxis.setAttribute("y1", H - PAD.bottom); xAxis.setAttribute("y2", H - PAD.bottom);
|
||||
xAxis.classList.add("axis"); svg.appendChild(xAxis);
|
||||
|
||||
// y/x labels
|
||||
if (opts.yLabel) {
|
||||
const cx = 10;
|
||||
const cy = PAD.top + (H - PAD.top - PAD.bottom) / 2;
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
g.setAttribute("transform", `translate(${cx} ${cy}) rotate(-90)`);
|
||||
const lbl = document.createElementNS(SVG_NS, "text");
|
||||
lbl.setAttribute("text-anchor", "middle");
|
||||
lbl.setAttribute("dominant-baseline", "middle");
|
||||
lbl.classList.add("axis-label");
|
||||
lbl.textContent = opts.yLabel;
|
||||
g.appendChild(lbl);
|
||||
svg.appendChild(g);
|
||||
}
|
||||
if (opts.xLabel) {
|
||||
const lbl = text(W - PAD.right, H - 6, opts.xLabel, "axis-label");
|
||||
lbl.setAttribute("text-anchor", "end");
|
||||
svg.appendChild(lbl);
|
||||
}
|
||||
|
||||
// lines
|
||||
for (const s of series) {
|
||||
const pts = s.points.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
|
||||
if (!pts.length) continue;
|
||||
const d = pts.map((p, i) => `${i === 0 ? "M" : "L"}${xPx(p[0]).toFixed(2)},${yPx(p[1]).toFixed(2)}`).join(" ");
|
||||
const path = document.createElementNS(SVG_NS, "path");
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("stroke", s.color);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke-width", "1.5");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.classList.add("series");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = s.label;
|
||||
path.appendChild(title);
|
||||
svg.appendChild(path);
|
||||
}
|
||||
|
||||
container.appendChild(svg);
|
||||
}
|
||||
|
||||
function text(x, y, str, cls) {
|
||||
const el = document.createElementNS(SVG_NS, "text");
|
||||
el.setAttribute("x", x);
|
||||
el.setAttribute("y", y);
|
||||
if (cls) el.classList.add(cls);
|
||||
el.textContent = str;
|
||||
return el;
|
||||
}
|
||||
|
||||
function fmtTick(v) {
|
||||
const abs = Math.abs(v);
|
||||
if (abs === 0) return "0";
|
||||
if (abs < 0.01) return v.toExponential(1);
|
||||
if (abs < 1) return v.toFixed(3);
|
||||
if (abs < 10) return v.toFixed(2);
|
||||
if (abs < 100) return v.toFixed(1);
|
||||
return v.toFixed(0);
|
||||
}
|
||||
|
||||
init();
|
||||
@ -25,6 +25,17 @@
|
||||
--warm: #a77a2c;
|
||||
--good: #3a6f3f;
|
||||
|
||||
/* Qualitative palette for plot lines. JS reads these via getComputedStyle
|
||||
so the palette flips with the theme. */
|
||||
--plot-1: #1f4e5f;
|
||||
--plot-2: #8a3a2a;
|
||||
--plot-3: #a77a2c;
|
||||
--plot-4: #3a6f3f;
|
||||
--plot-5: #5d4a7b;
|
||||
--plot-6: #7a5c4b;
|
||||
--plot-7: #2b5d7a;
|
||||
--plot-8: #6b6b3e;
|
||||
|
||||
--serif: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
|
||||
"Inter", "Arial", sans-serif;
|
||||
@ -36,6 +47,57 @@
|
||||
--space: 1rem;
|
||||
}
|
||||
|
||||
/* Dark mode — warm near-black, preserving the scientific-notebook feel.
|
||||
Toggled by setting [data-theme="dark"] on <html>. */
|
||||
[data-theme="dark"] {
|
||||
--page: #1a1917;
|
||||
--panel: #1f1e1c;
|
||||
--ink: #e8e4da;
|
||||
--mute: #9a968c;
|
||||
--faint: #5f5b52;
|
||||
--rule: #2b2925;
|
||||
--rule-2: #3a3834;
|
||||
--accent: #84bcc9;
|
||||
--accent-tint: #1c2930;
|
||||
--alarm: #d18774;
|
||||
--warm: #cba368;
|
||||
--good: #8fb695;
|
||||
|
||||
--plot-1: #84bcc9;
|
||||
--plot-2: #d18774;
|
||||
--plot-3: #cba368;
|
||||
--plot-4: #8fb695;
|
||||
--plot-5: #a695c4;
|
||||
--plot-6: #bc9f8a;
|
||||
--plot-7: #8fa7c5;
|
||||
--plot-8: #adad80;
|
||||
}
|
||||
|
||||
html { color-scheme: light dark; }
|
||||
[data-theme="light"] { color-scheme: light; }
|
||||
[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
/* Hardcoded light-mode colour patches. These predate the theme system;
|
||||
overriding them here keeps the original selectors untouched. */
|
||||
[data-theme="dark"] button.submit:hover,
|
||||
[data-theme="dark"] .picker-footer .continue:not(:disabled):hover {
|
||||
background: #a5d0da;
|
||||
}
|
||||
[data-theme="dark"] .flash.err,
|
||||
[data-theme="dark"] .badge.cancelled {
|
||||
background: #3a2520;
|
||||
}
|
||||
[data-theme="dark"] .badge.completed {
|
||||
background: #1f2e21;
|
||||
}
|
||||
[data-theme="dark"] .dataset-picker {
|
||||
--picker-panel: #252420;
|
||||
--picker-hair: #3a3834;
|
||||
}
|
||||
[data-theme="dark"] .dataset-picker .card-desc {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
@ -870,3 +932,310 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
||||
.dataset-picker > summary { padding: 0.9rem 1.2rem; }
|
||||
.dataset-picker .picker-body { padding: 0.4rem 1.2rem 1.4rem; }
|
||||
}
|
||||
|
||||
/* ---------- metrics page ----------------------------------------------- */
|
||||
|
||||
.masthead-link {
|
||||
font-family: var(--mono);
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.masthead-link:hover { border-bottom-color: var(--accent); }
|
||||
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--mute);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 0.55rem;
|
||||
vertical-align: middle;
|
||||
transition: color 120ms ease, transform 240ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
.theme-toggle:hover { color: var(--accent); }
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
[data-theme="dark"] .theme-toggle { transform: rotate(180deg); }
|
||||
|
||||
.masthead .nav-link {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid transparent;
|
||||
margin-left: 0.8rem;
|
||||
}
|
||||
.masthead .nav-link:hover { border-bottom-color: var(--accent); }
|
||||
|
||||
.metrics-page {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 1.4rem 2.2rem 2rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 1.1rem 2rem;
|
||||
padding: 0.9rem 0 1rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
.filter-group { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
|
||||
.filter-group .ctl-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mute);
|
||||
}
|
||||
.filter-group.stat-group { min-width: 18rem; }
|
||||
|
||||
.filter-group .chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--mute);
|
||||
background: transparent;
|
||||
border: 1px solid var(--rule-2);
|
||||
border-radius: 2px;
|
||||
padding: 0.22rem 0.55rem;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
user-select: none;
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chip:hover { color: var(--ink); }
|
||||
.chip.is-on {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.chip:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }
|
||||
.chip-meta {
|
||||
color: var(--faint);
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0;
|
||||
border-style: dashed;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.chip-meta:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
.filter-group .segmented.count-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--rule-2);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.filter-group .segmented label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--mute);
|
||||
cursor: pointer;
|
||||
padding: 3px 2px 4px;
|
||||
transition: color 120ms ease;
|
||||
user-select: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
.filter-group .segmented label > span {
|
||||
display: inline-block;
|
||||
padding: 0 2px 1px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.filter-group .segmented label:hover { color: var(--ink); }
|
||||
.filter-group .segmented label:has(input:checked) { color: var(--accent); }
|
||||
.filter-group .segmented label:has(input:checked) > span {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.filter-group .segmented input[type="radio"] {
|
||||
position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0;
|
||||
}
|
||||
.filter-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: auto;
|
||||
align-self: center;
|
||||
padding-top: 0.3rem;
|
||||
}
|
||||
.filter-count .muted { color: var(--mute); }
|
||||
|
||||
.plots {
|
||||
display: grid;
|
||||
gap: 1.6rem;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
.plots { grid-template-columns: 1fr 1fr; }
|
||||
.plots .plot:nth-child(3) { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
.plot { margin: 0; }
|
||||
.plot figcaption {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.plot .plot-title {
|
||||
font-family: var(--sans);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.plot .plot-sub {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 0.76rem;
|
||||
color: var(--mute);
|
||||
}
|
||||
.plot-area svg.chart {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
background: var(--panel);
|
||||
}
|
||||
.plot-area svg .grid { stroke: var(--rule); stroke-width: 1; }
|
||||
.plot-area svg .axis { stroke: var(--rule-2); stroke-width: 1; }
|
||||
.plot-area svg .axis-tick { stroke: var(--rule-2); stroke-width: 1; }
|
||||
.plot-area svg text.axis {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
fill: var(--mute);
|
||||
}
|
||||
.plot-area svg text.axis-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
fill: var(--faint);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.plot-area svg text.empty {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
fill: var(--faint);
|
||||
}
|
||||
.plot-area svg path.series {
|
||||
opacity: 0.88;
|
||||
transition: opacity 120ms ease, stroke-width 120ms ease;
|
||||
}
|
||||
.plot-area svg path.series:hover { opacity: 1; stroke-width: 2.25; }
|
||||
|
||||
.legend {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.8rem 0 0.4rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.legend .legend-row {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
padding: 0.22rem 0.4rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
border-radius: 2px;
|
||||
transition: background 100ms ease, opacity 120ms ease;
|
||||
}
|
||||
.legend .legend-row:hover { background: var(--accent-tint); }
|
||||
.legend .legend-row.is-selected {
|
||||
background: var(--accent-tint);
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
.legend .legend-row.is-selected .lbl { color: var(--accent); font-weight: 600; }
|
||||
.legend .legend-row.is-dim { opacity: 0.42; }
|
||||
.legend .legend-row.is-dim:hover { opacity: 0.85; }
|
||||
.legend .legend-row:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.legend-clear {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: 1px solid var(--rule-2);
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
justify-self: start;
|
||||
margin-bottom: 0.4rem;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.legend-clear:hover { background: var(--accent-tint); border-color: var(--accent); }
|
||||
.legend .swatch {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
align-self: center;
|
||||
}
|
||||
.legend .lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.legend .fn {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--faint);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
.metrics-page .empty {
|
||||
border: 1px dashed var(--rule-2);
|
||||
padding: 1.4rem;
|
||||
text-align: center;
|
||||
color: var(--mute);
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 0.92rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.metrics-page { padding: 1rem 1.1rem 1.4rem; }
|
||||
.filter-bar { gap: 0.9rem 1.2rem; }
|
||||
.filter-group.stat-group { width: 100%; min-width: 0; }
|
||||
.filter-count { margin-left: 0; text-align: left; padding-top: 0; }
|
||||
.legend .legend-row { grid-template-columns: 10px 1fr; }
|
||||
.legend .fn { display: none; }
|
||||
}
|
||||
|
||||
29
app/web/static/theme.js
Normal file
29
app/web/static/theme.js
Normal file
@ -0,0 +1,29 @@
|
||||
// theme.js — toggle UI wire-up. The initial theme is set by a tiny inline
|
||||
// script in the <head> so there's no flash of the wrong palette on load;
|
||||
// this module only handles click-to-toggle + broadcasting the change so
|
||||
// anything that cached a CSS-var value (e.g. the metrics page's plot
|
||||
// palette) can re-render.
|
||||
|
||||
(function () {
|
||||
function current() {
|
||||
return document.documentElement.getAttribute("data-theme") || "light";
|
||||
}
|
||||
function apply(theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
try { localStorage.setItem("theme", theme); } catch (e) {}
|
||||
document.dispatchEvent(new CustomEvent("themechange", { detail: { theme } }));
|
||||
const btn = document.getElementById("theme-toggle");
|
||||
if (btn) btn.setAttribute("aria-label", theme === "dark" ? "switch to light mode" : "switch to dark mode");
|
||||
}
|
||||
function wire() {
|
||||
const btn = document.getElementById("theme-toggle");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => apply(current() === "dark" ? "light" : "dark"));
|
||||
apply(current());
|
||||
}
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", wire);
|
||||
} else {
|
||||
wire();
|
||||
}
|
||||
})();
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>embedding notebook — web1</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=6" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
@ -15,6 +15,13 @@
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3' fill='%231f4e5f'/%3E%3C/svg%3E" />
|
||||
<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>
|
||||
|
||||
@ -24,7 +31,9 @@
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="dot {% if not deployment_id %}bad{% endif %}"></span>
|
||||
{% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %}<br/>
|
||||
{% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %}
|
||||
<a href="/metrics" class="nav-link">metrics →</a>
|
||||
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="toggle theme">◐</button><br/>
|
||||
<span style="color:var(--faint)">{{ prefect_api }}</span>
|
||||
</div>
|
||||
</header>
|
||||
@ -182,10 +191,10 @@
|
||||
|
||||
<footer class="colophon">
|
||||
<span><span class="k">web</span> · scientific instrument · port 8001</span>
|
||||
<span>fastapi · htmx · no build step</span>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/static/dataset-picker.js"></script>
|
||||
<script src="/static/theme.js?v=7"></script>
|
||||
<script type="module" src="/static/dataset-picker.js?v=7"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
100
app/web/templates/metrics.html
Normal file
100
app/web/templates/metrics.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>metrics — embedding notebook</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=5" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3' fill='%231f4e5f'/%3E%3C/svg%3E" />
|
||||
<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>— stability metrics</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><br/>
|
||||
<span style="color:var(--faint)">{{ prefect_api }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metrics-page">
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="filter-group">
|
||||
<span class="ctl-label">dataset</span>
|
||||
<div class="chips" id="flt-dataset" aria-label="filter by dataset"></div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span class="ctl-label">algorithm</span>
|
||||
<div class="chips" id="flt-algo" aria-label="filter by algorithm"></div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group stat-group">
|
||||
<span class="ctl-label">travel stat</span>
|
||||
<div class="segmented count-4" role="radiogroup" aria-label="travel stat">
|
||||
<label><input type="radio" name="stat" value="mean" checked><span>mean</span></label>
|
||||
<label><input type="radio" name="stat" value="median"><span>median</span></label>
|
||||
<label><input type="radio" name="stat" value="p95"><span>p95</span></label>
|
||||
<label><input type="radio" name="stat" value="max"><span>max</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-count">
|
||||
<span id="match-count">0</span> / <span id="total-count">0</span> <span class="muted">runs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plots">
|
||||
<figure class="plot">
|
||||
<figcaption>
|
||||
<span class="plot-title">frame-to-frame travel</span>
|
||||
<span class="plot-sub">‖ y(t) − y(t−1) ‖ · output 2-D space</span>
|
||||
</figcaption>
|
||||
<div id="plot-ff" class="plot-area"></div>
|
||||
</figure>
|
||||
|
||||
<figure class="plot">
|
||||
<figcaption>
|
||||
<span class="plot-title">vs-initial travel</span>
|
||||
<span class="plot-sub">‖ y(t) − y(0) ‖ · drift from first timestep</span>
|
||||
</figcaption>
|
||||
<div id="plot-vi" class="plot-area"></div>
|
||||
</figure>
|
||||
|
||||
<figure class="plot">
|
||||
<figcaption>
|
||||
<span class="plot-title">kNN retention</span>
|
||||
<span class="plot-sub">fraction of input-space k-NN preserved in 2-D (higher = more faithful)</span>
|
||||
</figcaption>
|
||||
<div id="plot-knn" class="plot-area"></div>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="legend" id="legend"></div>
|
||||
|
||||
<div id="empty" class="empty" hidden>
|
||||
No metrics to show. Dispatch a run from <a href="/">the form</a> — sidecar JSONs appear in <code>figs/</code> after the flow completes.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<footer class="colophon">
|
||||
<span><span class="k">web</span> · metrics · port 8001</span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/theme.js?v=5"></script>
|
||||
<script type="module" src="/static/metrics.js?v=5"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -382,5 +382,5 @@ def embedding_flow(
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
embedding_flow.serve()
|
||||
embedding_flow.serve(limit=1)
|
||||
# embedding_flow()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user