metrics: hover tooltip + click-to-select on stability plots
This commit is contained in:
parent
4f6e900c05
commit
25776c12d2
@ -172,34 +172,43 @@ function render() {
|
|||||||
}));
|
}));
|
||||||
const forPlot = state.selected.size === 0 ? colored : colored.filter((c) => c.selected);
|
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(
|
renderLineChart(
|
||||||
document.getElementById("plot-ff"),
|
document.getElementById("plot-ff"),
|
||||||
forPlot.map(({ run, color }) => ({
|
forPlot.map(({ run, color }) => ({
|
||||||
|
key: runKey(run),
|
||||||
label: runLabel(run),
|
label: runLabel(run),
|
||||||
color,
|
color,
|
||||||
points: (run.travel?.frame_to_frame || []).map((row) => [row.t, row[state.stat]]),
|
points: (run.travel?.frame_to_frame || []).map((row) => [row.t, row[state.stat]]),
|
||||||
})),
|
})),
|
||||||
{ yLabel: "distance", xLabel: "t" }
|
{ yLabel: "distance", xLabel: "t", onPick }
|
||||||
);
|
);
|
||||||
|
|
||||||
renderLineChart(
|
renderLineChart(
|
||||||
document.getElementById("plot-vi"),
|
document.getElementById("plot-vi"),
|
||||||
forPlot.map(({ run, color }) => ({
|
forPlot.map(({ run, color }) => ({
|
||||||
|
key: runKey(run),
|
||||||
label: runLabel(run),
|
label: runLabel(run),
|
||||||
color,
|
color,
|
||||||
points: (run.travel?.vs_initial || []).map((row) => [row.t, row[state.stat]]),
|
points: (run.travel?.vs_initial || []).map((row) => [row.t, row[state.stat]]),
|
||||||
})),
|
})),
|
||||||
{ yLabel: "distance", xLabel: "t" }
|
{ yLabel: "distance", xLabel: "t", onPick }
|
||||||
);
|
);
|
||||||
|
|
||||||
renderLineChart(
|
renderLineChart(
|
||||||
document.getElementById("plot-knn"),
|
document.getElementById("plot-knn"),
|
||||||
forPlot.map(({ run, color }) => ({
|
forPlot.map(({ run, color }) => ({
|
||||||
|
key: runKey(run),
|
||||||
label: runLabel(run),
|
label: runLabel(run),
|
||||||
color,
|
color,
|
||||||
points: (run.knn_retention || []).map((row) => [row.t, row.mean]),
|
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);
|
renderLegend(colored);
|
||||||
@ -342,6 +351,7 @@ function renderLineChart(container, series, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lines
|
// lines
|
||||||
|
const drawn = [];
|
||||||
for (const s of series) {
|
for (const s of series) {
|
||||||
const pts = s.points.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
|
const pts = s.points.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
|
||||||
if (!pts.length) continue;
|
if (!pts.length) continue;
|
||||||
@ -354,13 +364,190 @@ function renderLineChart(container, series, opts = {}) {
|
|||||||
path.setAttribute("stroke-linejoin", "round");
|
path.setAttribute("stroke-linejoin", "round");
|
||||||
path.setAttribute("stroke-linecap", "round");
|
path.setAttribute("stroke-linecap", "round");
|
||||||
path.classList.add("series");
|
path.classList.add("series");
|
||||||
const title = document.createElementNS(SVG_NS, "title");
|
const nativeTitle = document.createElementNS(SVG_NS, "title");
|
||||||
title.textContent = s.label;
|
nativeTitle.textContent = s.label;
|
||||||
path.appendChild(title);
|
path.appendChild(nativeTitle);
|
||||||
svg.appendChild(path);
|
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(svg);
|
||||||
|
container.appendChild(tooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(x, y, str, cls) {
|
function text(x, y, str, cls) {
|
||||||
|
|||||||
@ -1418,11 +1418,43 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
fill: var(--faint);
|
fill: var(--faint);
|
||||||
}
|
}
|
||||||
|
.plot-area { position: relative; }
|
||||||
.plot-area svg path.series {
|
.plot-area svg path.series {
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
transition: opacity 120ms ease, stroke-width 120ms ease;
|
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 {
|
.legend {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>embedding notebook · compare</title>
|
<title>embedding notebook · compare</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=36" />
|
<link rel="stylesheet" href="/static/style.css?v=39" />
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>embedding notebook</title>
|
<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 src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
@ -498,7 +498,7 @@
|
|||||||
|
|
||||||
<script src="/static/theme.js?v=11"></script>
|
<script src="/static/theme.js?v=11"></script>
|
||||||
<script type="module" src="/static/dataset-picker.js?v=12"></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/compare-select.js?v=4"></script>
|
||||||
<script src="/static/runs-filter.js?v=7"></script>
|
<script src="/static/runs-filter.js?v=7"></script>
|
||||||
<script type="module" src="/static/run-modal.js?v=3"></script>
|
<script type="module" src="/static/run-modal.js?v=3"></script>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>embedding notebook · metrics</title>
|
<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" />
|
<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>
|
<script>
|
||||||
(function(){try{
|
(function(){try{
|
||||||
@ -97,7 +97,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/theme.js?v=5"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user