// 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); const onPick = (key) => { if (state.selected.has(key)) state.selected.delete(key); else state.selected.add(key); render(); }; renderLineChart( document.getElementById("plot-ff"), forPlot.map(({ run, color }) => ({ key: runKey(run), label: runLabel(run), color, points: (run.travel?.frame_to_frame || []).map((row) => [row.t, row[state.stat]]), })), { yLabel: "distance", xLabel: "t", onPick } ); renderLineChart( document.getElementById("plot-vi"), forPlot.map(({ run, color }) => ({ key: runKey(run), label: runLabel(run), color, points: (run.travel?.vs_initial || []).map((row) => [row.t, row[state.stat]]), })), { yLabel: "distance", xLabel: "t", onPick } ); renderLineChart( document.getElementById("plot-knn"), forPlot.map(({ run, color }) => ({ key: runKey(run), label: runLabel(run), color, points: (run.knn_retention || []).map((row) => [row.t, row.mean]), })), { yLabel: "retention", xLabel: "t", yMin: 0, yMax: 1, onPick } ); 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 const drawn = []; 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 nativeTitle = document.createElementNS(SVG_NS, "title"); nativeTitle.textContent = s.label; path.appendChild(nativeTitle); svg.appendChild(path); drawn.push({ key: s.key, label: s.label, color: s.color, pts, d, path }); } // Hover affordances. A single transparent overlay rect captures all // pointer events over the plot area; we find the nearest line // mathematically. More robust than per-line stroke hit-testing. All // styling is applied inline so it doesn't depend on stylesheet caching. if (getComputedStyle(container).position === "static") { container.style.position = "relative"; } const marker = document.createElementNS(SVG_NS, "circle"); marker.setAttribute("r", "3.5"); marker.setAttribute("stroke-width", "2"); marker.setAttribute("fill", "#ffffff"); marker.setAttribute("pointer-events", "none"); marker.style.display = "none"; svg.appendChild(marker); const tooltip = document.createElement("div"); Object.assign(tooltip.style, { position: "absolute", pointerEvents: "none", zIndex: "4", display: "none", minWidth: "180px", maxWidth: "320px", padding: "6px 9px", background: "var(--panel, #ffffff)", color: "var(--ink, #111)", border: "1px solid var(--rule-2, #bbb)", borderLeft: "3px solid var(--ink, #111)", boxShadow: "0 4px 14px rgba(0, 0, 0, 0.14)", fontSize: "12px", lineHeight: "1.3", left: "0", top: "0", }); const ttLabel = document.createElement("div"); Object.assign(ttLabel.style, { fontWeight: "600", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", }); const ttValues = document.createElement("div"); Object.assign(ttValues.style, { fontFamily: "var(--mono, ui-monospace, monospace)", opacity: "0.75", marginTop: "2px", }); tooltip.appendChild(ttLabel); tooltip.appendChild(ttValues); const dimOthers = (active) => { for (const o of drawn) { if (o === active) { o.path.style.opacity = "1"; o.path.style.strokeWidth = "2.25"; } else { o.path.style.opacity = "0.18"; o.path.style.strokeWidth = ""; } } }; const clearDim = () => { for (const o of drawn) { o.path.style.opacity = ""; o.path.style.strokeWidth = ""; } }; // Interpolate a series' y at a given data-x; also return the nearest // actual data point (for marker placement + tooltip value readout). const seriesYAt = (pts, x) => { if (!pts.length) return null; if (x <= pts[0][0]) return { y: pts[0][1], pt: pts[0] }; if (x >= pts[pts.length - 1][0]) { const last = pts[pts.length - 1]; return { y: last[1], pt: last }; } for (let i = 1; i < pts.length; i++) { const a = pts[i - 1], b = pts[i]; if (x >= a[0] && x <= b[0]) { const span = b[0] - a[0]; const t = span === 0 ? 0 : (x - a[0]) / span; const y = a[1] + t * (b[1] - a[1]); const pt = Math.abs(x - a[0]) < Math.abs(x - b[0]) ? a : b; return { y, pt }; } } return { y: pts[pts.length - 1][1], pt: pts[pts.length - 1] }; }; const overlay = document.createElementNS(SVG_NS, "rect"); overlay.setAttribute("x", "0"); overlay.setAttribute("y", "0"); overlay.setAttribute("width", W); overlay.setAttribute("height", H); overlay.setAttribute("fill", "transparent"); overlay.setAttribute("pointer-events", "all"); overlay.style.cursor = "crosshair"; let activeDs = null; const hideHover = () => { if (!activeDs) return; clearDim(); marker.style.display = "none"; tooltip.style.display = "none"; overlay.style.cursor = "crosshair"; activeDs = null; }; overlay.addEventListener("pointermove", (e) => { const svgRect = svg.getBoundingClientRect(); const vbX = ((e.clientX - svgRect.left) / svgRect.width) * W; const vbY = ((e.clientY - svgRect.top) / svgRect.height) * H; // Outside the inner plot area? Release. if (vbX < PAD.left || vbX > W - PAD.right || vbY < PAD.top || vbY > H - PAD.bottom) { hideHover(); return; } const dataX = xMin + ((vbX - PAD.left) / (W - PAD.left - PAD.right)) * (xMax - xMin); // Nearest line to the cursor, measured in *pixels* on the y axis at the // cursor's x. A proximity threshold keeps the tooltip quiet in empty // regions of the chart. let best = null; let bestDist = Infinity; for (const ds of drawn) { const hit = seriesYAt(ds.pts, dataX); if (!hit) continue; const distPx = Math.abs(yPx(hit.y) - vbY); if (distPx < bestDist) { bestDist = distPx; best = { ds, pt: hit.pt }; } } if (!best || bestDist > 40) { hideHover(); return; } const { ds, pt } = best; if (activeDs !== ds) { activeDs = ds; dimOthers(ds); marker.setAttribute("stroke", ds.color); marker.style.display = ""; tooltip.style.display = "block"; tooltip.style.borderLeftColor = ds.color; overlay.style.cursor = typeof opts.onPick === "function" ? "pointer" : "crosshair"; } marker.setAttribute("cx", xPx(pt[0]).toFixed(2)); marker.setAttribute("cy", yPx(pt[1]).toFixed(2)); ttLabel.textContent = ds.label; ttValues.textContent = `t=${pt[0]} · ${fmtTick(pt[1])}`; const cRect = container.getBoundingClientRect(); const ttW = tooltip.offsetWidth || 200; const ttH = tooltip.offsetHeight || 44; let left = e.clientX - cRect.left + 12; let top = e.clientY - cRect.top + 12; if (left + ttW > cRect.width - 4) left = e.clientX - cRect.left - ttW - 12; if (top + ttH > cRect.height - 4) top = e.clientY - cRect.top - ttH - 12; if (left < 4) left = 4; if (top < 4) top = 4; tooltip.style.left = left + "px"; tooltip.style.top = top + "px"; }); overlay.addEventListener("pointerleave", hideHover); overlay.addEventListener("click", () => { if (activeDs && typeof opts.onPick === "function" && activeDs.key != null) { opts.onPick(activeDs.key); } }); svg.appendChild(overlay); container.appendChild(svg); container.appendChild(tooltip); } 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();