dr-sandbox/app/web/static/metrics.js

573 lines
19 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 — 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 = `
<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
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();