metrics: hover tooltip + click-to-select on stability plots

This commit is contained in:
Michael Pilosov 2026-04-23 10:20:41 -06:00
parent 4f6e900c05
commit 25776c12d2
5 changed files with 231 additions and 12 deletions

View File

@ -172,34 +172,43 @@ function render() {
}));
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" }
{ 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" }
{ 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 }
{ yLabel: "retention", xLabel: "t", yMin: 0, yMax: 1, onPick }
);
renderLegend(colored);
@ -342,6 +351,7 @@ function renderLineChart(container, series, opts = {}) {
}
// 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;
@ -354,13 +364,190 @@ function renderLineChart(container, series, opts = {}) {
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);
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) {

View File

@ -1418,11 +1418,43 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
font-size: 13px;
fill: var(--faint);
}
.plot-area { position: relative; }
.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; }
.plot-area svg.has-hover path.series { opacity: 0.22; }
.plot-area svg.has-hover path.series.is-hover { opacity: 1; stroke-width: 2.25; }
.plot-area svg .chart-marker { fill: var(--panel); }
.chart-tooltip {
position: absolute;
pointer-events: none;
z-index: 4;
min-width: 180px;
max-width: 320px;
padding: 0.4rem 0.55rem;
background: var(--panel);
border: 1px solid var(--rule-2);
border-left: 3px solid var(--tt-accent, var(--ink));
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
font-size: 0.74rem;
line-height: 1.25;
color: var(--ink);
}
.chart-tooltip[hidden] { display: none; }
.chart-tooltip .tt-label {
font-family: var(--sans);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-tooltip .tt-values {
font-family: var(--mono);
color: var(--mute);
margin-top: 0.15rem;
}
.legend {
display: grid;

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook &middot; compare</title>
<link rel="stylesheet" href="/static/style.css?v=36" />
<link rel="stylesheet" href="/static/style.css?v=39" />
<script type="importmap">
{
"imports": {

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook</title>
<link rel="stylesheet" href="/static/style.css?v=38" />
<link rel="stylesheet" href="/static/style.css?v=39" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script type="importmap">
{
@ -498,7 +498,7 @@
<script src="/static/theme.js?v=11"></script>
<script type="module" src="/static/dataset-picker.js?v=12"></script>
<script type="module" src="/static/metrics.js?v=11"></script>
<script type="module" src="/static/metrics.js?v=13"></script>
<script src="/static/compare-select.js?v=4"></script>
<script src="/static/runs-filter.js?v=7"></script>
<script type="module" src="/static/run-modal.js?v=3"></script>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook · metrics</title>
<link rel="stylesheet" href="/static/style.css?v=16" />
<link rel="stylesheet" href="/static/style.css?v=39" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3' fill='%231f4e5f'/%3E%3C/svg%3E" />
<script>
(function(){try{
@ -97,7 +97,7 @@
</footer>
<script src="/static/theme.js?v=5"></script>
<script type="module" src="/static/metrics.js?v=5"></script>
<script type="module" src="/static/metrics.js?v=13"></script>
</body>
</html>