diff --git a/app/web/main.py b/app/web/main.py index eb68739..8836d72 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -667,6 +667,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: diff --git a/app/web/static/metrics.js b/app/web/static/metrics.js new file mode 100644 index 0000000..ae5dd77 --- /dev/null +++ b/app/web/static/metrics.js @@ -0,0 +1,339 @@ +// 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 — muted, matches the notebook aesthetic. Cycles if +// there are more runs than colours. +const PALETTE = [ + "#1f4e5f", // accent teal + "#8a3a2a", // rust + "#a77a2c", // warm amber + "#3a6f3f", // olive + "#5d4a7b", // slate purple + "#7a5c4b", // brown + "#2b5d7a", // deeper blue + "#6b6b3e", // moss +]; + +const state = { + raw: [], + dataset: "all", + algorithm: "all", + 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(); + 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)); + } + const dsSel = document.getElementById("flt-dataset"); + const algoSel = document.getElementById("flt-algo"); + for (const d of [...datasets].filter(Boolean).sort()) { + const o = document.createElement("option"); + o.value = d; o.textContent = d; + dsSel.appendChild(o); + } + for (const a of [...algos].filter(Boolean).sort()) { + const o = document.createElement("option"); + o.value = a; o.textContent = a; + algoSel.appendChild(o); + } + document.getElementById("total-count").textContent = state.raw.length; +} + +function wireEvents() { + document.getElementById("flt-dataset").addEventListener("change", (e) => { + state.dataset = e.target.value; render(); + }); + document.getElementById("flt-algo").addEventListener("change", (e) => { + state.algorithm = e.target.value; render(); + }); + 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.dataset !== "all" && shortGen(m.generator_path) !== state.dataset) return false; + if (state.algorithm !== "all" && shortEmb(m.embedder) !== state.algorithm) 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 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 = ` + + ${runLabel(run)} + ${run.filename} + `; + 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(); diff --git a/app/web/static/style.css b/app/web/static/style.css index af040ee..1357461 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -870,3 +870,266 @@ 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); } + +.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: grid; + grid-template-columns: auto auto 1fr auto; + align-items: end; + gap: 1.4rem 1.8rem; + padding: 0.9rem 0 1rem; + border-bottom: 1px solid var(--rule); + margin-bottom: 1.4rem; +} +.filter-group { display: flex; flex-direction: column; gap: 0.25rem; 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 select { + font-family: var(--mono); + font-size: 0.85rem; + color: var(--ink); + background: transparent; + border: 0; + border-bottom: 1px solid var(--rule-2); + padding: 3px 18px 4px 2px; + outline: none; + min-width: 12ch; +} +.filter-group select:focus { + border-bottom-color: var(--accent); + background: var(--accent-tint); +} +.filter-group.stat-group { min-width: 18rem; } +.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); + text-align: right; + font-variant-numeric: tabular-nums; +} +.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 { + grid-template-columns: 1fr 1fr; + gap: 0.9rem 1.1rem; + } + .filter-group.stat-group { grid-column: 1 / -1; min-width: 0; } + .filter-count { grid-column: 1 / -1; text-align: left; } + .legend .legend-row { + grid-template-columns: 10px 1fr; + } + .legend .fn { display: none; } +} diff --git a/app/web/templates/index.html b/app/web/templates/index.html index 81fe9bc..b9ab784 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -24,7 +24,8 @@
diff --git a/app/web/templates/metrics.html b/app/web/templates/metrics.html new file mode 100644 index 0000000..91ce8b4 --- /dev/null +++ b/app/web/templates/metrics.html @@ -0,0 +1,95 @@ + + + + + +figs/ after the flow completes.
+