// 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: [], // 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(); 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 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();