340 lines
11 KiB
JavaScript
340 lines
11 KiB
JavaScript
// 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 = `
|
|
<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();
|