From 25776c12d22db19057c9250933b95c4fb1467e27 Mon Sep 17 00:00:00 2001 From: Michael Pilosov Date: Thu, 23 Apr 2026 10:20:41 -0600 Subject: [PATCH] metrics: hover tooltip + click-to-select on stability plots --- app/web/static/metrics.js | 199 ++++++++++++++++++++++++++++++++- app/web/static/style.css | 34 +++++- app/web/templates/compare.html | 2 +- app/web/templates/index.html | 4 +- app/web/templates/metrics.html | 4 +- 5 files changed, 231 insertions(+), 12 deletions(-) diff --git a/app/web/static/metrics.js b/app/web/static/metrics.js index c30d51e..ab7ef50 100644 --- a/app/web/static/metrics.js +++ b/app/web/static/metrics.js @@ -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) { diff --git a/app/web/static/style.css b/app/web/static/style.css index 4d0b315..04d0498 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -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; diff --git a/app/web/templates/compare.html b/app/web/templates/compare.html index 073118c..8f3046c 100644 --- a/app/web/templates/compare.html +++ b/app/web/templates/compare.html @@ -4,7 +4,7 @@ embedding notebook · compare - + - + diff --git a/app/web/templates/metrics.html b/app/web/templates/metrics.html index 7e7130f..f81da9d 100644 --- a/app/web/templates/metrics.html +++ b/app/web/templates/metrics.html @@ -4,7 +4,7 @@ embedding notebook · metrics - + - +