diff --git a/examples/demo-advance.ipynb b/examples/demo-advance.ipynb
index c2663f17..56f66194 100644
--- a/examples/demo-advance.ipynb
+++ b/examples/demo-advance.ipynb
@@ -274,6 +274,38 @@
"chart"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "repeated-box-zoom-precision",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Regression demo for #87: open Zoom controls, choose Box zoom, then\n",
+ "# box-zoom into the cloud three times and zoom back out. The point cloud\n",
+ "# should keep its two-dimensional spread instead of collapsing to a line.\n",
+ "precision_rng = np.random.default_rng(42)\n",
+ "precision_x = precision_rng.normal(0, 1, 20_000)\n",
+ "precision_y = 0.5 * precision_x + precision_rng.normal(0, 0.6, precision_x.size)\n",
+ "precision_color = precision_x**2 + precision_y**2\n",
+ "precision_size = np.abs(precision_rng.normal(size=precision_x.size))\n",
+ "\n",
+ "xy.scatter_chart(\n",
+ " xy.scatter(\n",
+ " x=precision_x,\n",
+ " y=precision_y,\n",
+ " color=precision_color,\n",
+ " size=precision_size,\n",
+ " colormap=\"viridis\",\n",
+ " size_range=(3, 18),\n",
+ " opacity=0.7,\n",
+ " ),\n",
+ " title=\"Repeated box zoom — axes must not overshoot home\",\n",
+ " width=900,\n",
+ " height=500,\n",
+ ")"
+ ]
+ },
{
"cell_type": "code",
"execution_count": null,
diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js
index 1c6c320a..2ebd99a3 100644
--- a/js/src/50_chartview.js
+++ b/js/src/50_chartview.js
@@ -2733,6 +2733,8 @@ class ChartView {
markOf(g.trace.kind).draw(this, g, x0, x1, y0, y1);
}
this._drawHoverState();
+ // Keep a visible tooltip anchored through pan, zoom, and linked views.
+ this._repositionTooltip();
// Hover-only frames leave the pick snapshot valid (see draw()); direct
// _drawNow() callers never set the flag, so they invalidate as before.
if (!this._rafKeepPick) this._pickDirty = true;
@@ -4316,7 +4318,7 @@ class ChartView {
this._hoverTarget = null;
this._lastHoverXY = null;
this._pickSeq = (this._pickSeq || 0) + 1;
- this.tooltip.style.display = "none";
+ this._hideTooltip();
if (hadHover) this.draw();
return;
}
@@ -4330,14 +4332,15 @@ class ChartView {
this._hoverTarget = null;
this._lastHoverXY = null;
this._pickSeq = (this._pickSeq || 0) + 1;
- this.tooltip.style.display = "none";
+ this._hideTooltip();
if (hadHover) this._drawKeepPick();
return;
}
const id = hit.trace * 1e9 + hit.index;
this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY };
if (id === this._hoverId) {
- this._renderTooltip(this._lastRow, e.clientX, e.clientY);
+ // Anchored tooltips do not need a per-pointermove DOM rebuild.
+ if (!this._tooltipAnchor) this._renderTooltip(this._lastRow, e.clientX, e.clientY);
return;
}
this._hoverId = id;
diff --git a/js/src/52_tooltip.js b/js/src/52_tooltip.js
index fa85a8f7..90e796a6 100644
--- a/js/src/52_tooltip.js
+++ b/js/src/52_tooltip.js
@@ -6,6 +6,7 @@ Object.assign(ChartView.prototype, {
_showTooltip(hit, clientX, clientY) {
const row = this._localRow(hit);
this._lastRow = row;
+ this._setTooltipAnchor(hit, row, clientX, clientY);
this._renderTooltip(row, clientX, clientY);
if (this._interactionFlag("hover")) {
this._dispatchChartEvent("hover", {
@@ -220,14 +221,75 @@ Object.assign(ChartView.prototype, {
return lines.length ? lines : this._defaultTooltipLines(row);
},
+ // Anchor in data space so view changes carry the tooltip with its point.
+ _setTooltipAnchor(hit, row, clientX, clientY) {
+ const g = hit.g;
+ if (!g) { this._tooltipAnchor = null; return; }
+ const xAxis = g.xAxis || "x";
+ const yAxis = g.yAxis || "y";
+ let x = row.x;
+ let y = row.y;
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
+ // Category rows carry labels, so derive their numeric anchor from the pick.
+ const rect = this.canvas.getBoundingClientRect();
+ [x, y] = this._dataFromCanvas(clientX - rect.left, clientY - rect.top, xAxis, yAxis);
+ }
+ this._tooltipAnchor = Number.isFinite(x) && Number.isFinite(y)
+ ? { xAxis, yAxis, x, y }
+ : null;
+ // Keyboard traversal can reach an off-screen point; keep its clamped placement.
+ if (this._tooltipAnchor && !this._tooltipAnchorPx()) this._tooltipAnchor = null;
+ },
+
+ _tooltipAnchorPx() {
+ const a = this._tooltipAnchor;
+ if (!a) return null;
+ const lx = this._dataPx(a.xAxis, a.x);
+ const ly = this._dataPx(a.yAxis, a.y);
+ const p = this.plot;
+ if (!Number.isFinite(lx) || !Number.isFinite(ly)
+ || lx < p.x || lx > p.x + p.w || ly < p.y || ly > p.y + p.h) {
+ return null;
+ }
+ return { lx, ly };
+ },
+
+ _hideTooltip() {
+ this.tooltip.style.display = "none";
+ this._tooltipAnchor = null;
+ },
+
+ // A hidden retained anchor is off-screen and may return after another draw.
+ _repositionTooltip() {
+ if (!this._tooltipAnchor) return;
+ const pos = this._tooltipAnchorPx();
+ if (!pos) {
+ this.tooltip.style.display = "none";
+ return;
+ }
+ this.tooltip.style.display = "block";
+ this._placeTooltip(pos.lx, pos.ly);
+ },
+
+ _placeTooltip(lx, ly) {
+ const tw = this.tooltip.offsetWidth;
+ const th = this.tooltip.offsetHeight;
+ const edge = 4;
+ const gap = 12;
+ const maxLeft = Math.max(edge, this.size.w - tw - edge);
+ const left = Math.max(edge, Math.min(lx + gap, maxLeft));
+ const below = ly + gap;
+ const above = ly - th - gap;
+ const top = below + th <= this.size.h - edge ? below : Math.max(edge, above);
+ this.tooltip.style.left = left + "px";
+ this.tooltip.style.top = top + "px";
+ },
+
_renderTooltip(row, clientX, clientY, options = {}) {
if (!row || this.spec.show_tooltip === false) {
- this.tooltip.style.display = "none";
+ this._hideTooltip();
return;
}
- const rect = this.root.getBoundingClientRect();
- const lx = clientX - rect.left;
- const ly = clientY - rect.top;
const lines = this._tooltipLines(row);
// Text nodes, not innerHTML: category labels are user data and must never
// be parsed as markup (a category named "
" is just a label).
@@ -245,16 +307,15 @@ Object.assign(ChartView.prototype, {
if (this.a11yLive.textContent !== announcement) this.a11yLive.textContent = announcement;
}
this.tooltip.style.display = "block";
- const tw = this.tooltip.offsetWidth;
- const th = this.tooltip.offsetHeight;
- const edge = 4;
- const gap = 12;
- const maxLeft = Math.max(edge, this.size.w - tw - edge);
- const left = Math.max(edge, Math.min(lx + gap, maxLeft));
- const below = ly + gap;
- const above = ly - th - gap;
- const top = below + th <= this.size.h - edge ? below : Math.max(edge, above);
- this.tooltip.style.left = left + "px";
- this.tooltip.style.top = top + "px";
+ const pos = this._tooltipAnchorPx();
+ if (pos) {
+ this._placeTooltip(pos.lx, pos.ly);
+ } else if (this._tooltipAnchor) {
+ // Keep the content and anchor so zooming back can reveal the tooltip.
+ this.tooltip.style.display = "none";
+ } else {
+ const rect = this.root.getBoundingClientRect();
+ this._placeTooltip(clientX - rect.left, clientY - rect.top);
+ }
},
});
diff --git a/js/src/53_interaction.js b/js/src/53_interaction.js
index 0233576c..9fcca002 100644
--- a/js/src/53_interaction.js
+++ b/js/src/53_interaction.js
@@ -52,7 +52,7 @@ Object.assign(ChartView.prototype, {
handle,
};
handle.dataset.xyActive = "";
- this.tooltip.style.display = "none";
+ this._hideTooltip();
try { this.selLasso.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ }
e.preventDefault();
e.stopPropagation();
@@ -131,7 +131,7 @@ Object.assign(ChartView.prototype, {
previousLasso,
};
try { c.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ }
- this.tooltip.style.display = "none";
+ this._hideTooltip();
return;
}
if (this.dragMode === "pan" && canNavigate && canPan) {
@@ -151,7 +151,7 @@ Object.assign(ChartView.prototype, {
changedAxes: [],
};
try { c.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ }
- this.tooltip.style.display = "none";
+ this._hideTooltip();
}
});
this._listen(c, "pointermove", (e) => {
@@ -242,7 +242,7 @@ Object.assign(ChartView.prototype, {
interactionId: drag.interactionId,
});
}
- if (drag && !drag.moved) this.tooltip.style.display = "none";
+ if (drag && !drag.moved) this._hideTooltip();
drag = null;
};
this._listen(c, "pointerup", end);
@@ -263,7 +263,7 @@ Object.assign(ChartView.prototype, {
this._lastHoverXY = null;
this._a11yKeyboardReadout = null;
this._pickSeq = (this._pickSeq || 0) + 1;
- this.tooltip.style.display = "none";
+ this._hideTooltip();
this._hideCrosshair();
if (this._interactionFlag("hover")) {
this._dispatchChartEvent("leave", { view: this._eventView("leave") });
@@ -317,7 +317,7 @@ Object.assign(ChartView.prototype, {
if (e.key === "Escape") {
e.preventDefault();
const hadHover = this._hoverId !== -1;
- this.tooltip.style.display = "none";
+ this._hideTooltip();
this._hoverId = -1;
this._hoverTarget = null;
this._lastHoverXY = null;
@@ -1607,9 +1607,33 @@ Object.assign(ChartView.prototype, {
const minSpan = Math.max(Math.abs(ca), 1e-30) * 1e-12;
if (Math.abs((c1 - c0) * f) < minSpan) return null;
}
+ const next0 = ca - (ca - c0) * f;
+ const next1 = ca + (c1 - ca) * f;
+ if (f > 1 && this.view0) {
+ // A box zoom can narrow X and Y by very different factors. Cap each
+ // axis's span at its home span while zooming out; otherwise the
+ // less-zoomed axis expands far beyond home and flattens the point cloud
+ // while the other axis is still zoomed in. Preserve the cursor anchor
+ // instead of snapping to the home *range*: a free (pan-enabled) axis may
+ // still sit off-center after the cap, and positional containment (the
+ // shared clamp, applied later) is what pins a locked axis to home.
+ const home = this._axisRange(axisId, this.view0);
+ const home0 = this._axisCoord(axis, home[0]);
+ const home1 = this._axisCoord(axis, home[1]);
+ const homeSpan = Math.abs(home1 - home0);
+ if ([home0, home1].every(Number.isFinite)
+ && homeSpan > 0
+ && Math.abs(next1 - next0) > homeSpan) {
+ const signedHome = homeSpan * Math.sign(next1 - next0);
+ return [
+ this._axisValue(axis, ca - anchorFrac * signedHome),
+ this._axisValue(axis, ca + (1 - anchorFrac) * signedHome),
+ ];
+ }
+ }
return [
- this._axisValue(axis, ca - (ca - c0) * f),
- this._axisValue(axis, ca + (c1 - ca) * f),
+ this._axisValue(axis, next0),
+ this._axisValue(axis, next1),
];
},
diff --git a/js/src/54_kernel.js b/js/src/54_kernel.js
index 5b80e4af..3cb222f3 100644
--- a/js/src/54_kernel.js
+++ b/js/src/54_kernel.js
@@ -320,7 +320,7 @@ Object.assign(ChartView.prototype, {
this._applyAppend(msg, buffers);
} else if (msg.type === "pick_result") {
if (msg.seq !== undefined && msg.seq !== this._pickSeq) return;
- if (!msg.row) { this.tooltip.style.display = "none"; return; }
+ if (!msg.row) { this._hideTooltip(); return; }
// The kernel returns exact values for the picked trace only. Rehydrate
// tooltip fields sourced from sibling traces before replacing the local
// approximate row, otherwise a rich layered tooltip visibly collapses.
@@ -336,6 +336,12 @@ Object.assign(ChartView.prototype, {
}
this._applySharedTooltipFields(msg.row);
this._lastRow = msg.row;
+ // Replace the approximate f32 anchor with the exact f64 point (§16).
+ if (this._tooltipAnchor
+ && Number.isFinite(msg.row.x) && Number.isFinite(msg.row.y)) {
+ this._tooltipAnchor.x = msg.row.x;
+ this._tooltipAnchor.y = msg.row.y;
+ }
const xy = this._lastHoverXY;
// Exact values replace the visible approximate tooltip. A keyboard
// readout already announced its position and approximate values, so do
diff --git a/python/xy/_chromium.py b/python/xy/_chromium.py
index 14468982..b8bd067c 100644
--- a/python/xy/_chromium.py
+++ b/python/xy/_chromium.py
@@ -144,6 +144,10 @@ def __init__(
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage",
+ # No crashpad handler: it is a detached process that writes into
+ # the profile dir on its own schedule, past the browser's exit.
+ "--disable-breakpad",
+ "--disable-crash-reporter",
"--hide-scrollbars",
*gl_flags,
]
@@ -383,14 +387,24 @@ def render_pdf(
page_path.unlink(missing_ok=True)
def close(self) -> None:
+ # Orderly shutdown via CDP: on Browser.close Chromium flushes profile
+ # state and reaps its helper processes before the main process exits,
+ # so waiting on it leaves the profile dir quiescent for cleanup().
+ # SIGTERM instead kills the browser out from under its helpers, which
+ # keep writing into Default/ while rmtree walks it (ENOTEMPTY races).
+ with contextlib.suppress(Exception):
+ self._call("Browser.close", timeout_s=10.0)
with contextlib.suppress(Exception):
self._ws.close()
- self._proc.terminate()
try:
- self._proc.wait(timeout=10)
+ self._proc.wait(timeout=15)
except subprocess.TimeoutExpired:
- self._proc.kill()
- self._proc.wait()
+ self._proc.terminate()
+ try:
+ self._proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ self._proc.kill()
+ self._proc.wait()
self._stderr_file.close()
self._tmp.cleanup()
diff --git a/python/xy/static/index.js b/python/xy/static/index.js
index f03df618..91d68703 100644
--- a/python/xy/static/index.js
+++ b/python/xy/static/index.js
@@ -4092,6 +4092,7 @@ continue;
markOf(g.trace.kind).draw(this, g, x0, x1, y0, y1);
}
this._drawHoverState();
+this._repositionTooltip();
if (!this._rafKeepPick) this._pickDirty = true;
this._rafKeepPick = false;
this._drawChrome();
@@ -5492,7 +5493,7 @@ this._hoverId = -1;
this._hoverTarget = null;
this._lastHoverXY = null;
this._pickSeq = (this._pickSeq || 0) + 1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
if (hadHover) this.draw();
return;
}
@@ -5506,14 +5507,14 @@ this._hoverId = -1;
this._hoverTarget = null;
this._lastHoverXY = null;
this._pickSeq = (this._pickSeq || 0) + 1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
if (hadHover) this._drawKeepPick();
return;
}
const id = hit.trace * 1e9 + hit.index;
this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY };
if (id === this._hoverId) {
-this._renderTooltip(this._lastRow, e.clientX, e.clientY);
+if (!this._tooltipAnchor) this._renderTooltip(this._lastRow, e.clientX, e.clientY);
return;
}
this._hoverId = id;
@@ -6187,6 +6188,7 @@ Object.assign(ChartView.prototype, {
_showTooltip(hit, clientX, clientY) {
const row = this._localRow(hit);
this._lastRow = row;
+this._setTooltipAnchor(hit, row, clientX, clientY);
this._renderTooltip(row, clientX, clientY);
if (this._interactionFlag("hover")) {
this._dispatchChartEvent("hover", {
@@ -6380,14 +6382,66 @@ lines.push(`${field}: ${this._formatTooltipValue(value, kind, formats[field])}`)
}
return lines.length ? lines : this._defaultTooltipLines(row);
},
+_setTooltipAnchor(hit, row, clientX, clientY) {
+const g = hit.g;
+if (!g) { this._tooltipAnchor = null; return; }
+const xAxis = g.xAxis || "x";
+const yAxis = g.yAxis || "y";
+let x = row.x;
+let y = row.y;
+if (!Number.isFinite(x) || !Number.isFinite(y)) {
+const rect = this.canvas.getBoundingClientRect();
+[x, y] = this._dataFromCanvas(clientX - rect.left, clientY - rect.top, xAxis, yAxis);
+}
+this._tooltipAnchor = Number.isFinite(x) && Number.isFinite(y)
+? { xAxis, yAxis, x, y }
+: null;
+if (this._tooltipAnchor && !this._tooltipAnchorPx()) this._tooltipAnchor = null;
+},
+_tooltipAnchorPx() {
+const a = this._tooltipAnchor;
+if (!a) return null;
+const lx = this._dataPx(a.xAxis, a.x);
+const ly = this._dataPx(a.yAxis, a.y);
+const p = this.plot;
+if (!Number.isFinite(lx) || !Number.isFinite(ly)
+|| lx < p.x || lx > p.x + p.w || ly < p.y || ly > p.y + p.h) {
+return null;
+}
+return { lx, ly };
+},
+_hideTooltip() {
+this.tooltip.style.display = "none";
+this._tooltipAnchor = null;
+},
+_repositionTooltip() {
+if (!this._tooltipAnchor) return;
+const pos = this._tooltipAnchorPx();
+if (!pos) {
+this.tooltip.style.display = "none";
+return;
+}
+this.tooltip.style.display = "block";
+this._placeTooltip(pos.lx, pos.ly);
+},
+_placeTooltip(lx, ly) {
+const tw = this.tooltip.offsetWidth;
+const th = this.tooltip.offsetHeight;
+const edge = 4;
+const gap = 12;
+const maxLeft = Math.max(edge, this.size.w - tw - edge);
+const left = Math.max(edge, Math.min(lx + gap, maxLeft));
+const below = ly + gap;
+const above = ly - th - gap;
+const top = below + th <= this.size.h - edge ? below : Math.max(edge, above);
+this.tooltip.style.left = left + "px";
+this.tooltip.style.top = top + "px";
+},
_renderTooltip(row, clientX, clientY, options = {}) {
if (!row || this.spec.show_tooltip === false) {
-this.tooltip.style.display = "none";
+this._hideTooltip();
return;
}
-const rect = this.root.getBoundingClientRect();
-const lx = clientX - rect.left;
-const ly = clientY - rect.top;
const lines = this._tooltipLines(row);
this.tooltip.textContent = "";
lines.forEach((ln, i) => {
@@ -6403,17 +6457,15 @@ const announcement = prefix
if (this.a11yLive.textContent !== announcement) this.a11yLive.textContent = announcement;
}
this.tooltip.style.display = "block";
-const tw = this.tooltip.offsetWidth;
-const th = this.tooltip.offsetHeight;
-const edge = 4;
-const gap = 12;
-const maxLeft = Math.max(edge, this.size.w - tw - edge);
-const left = Math.max(edge, Math.min(lx + gap, maxLeft));
-const below = ly + gap;
-const above = ly - th - gap;
-const top = below + th <= this.size.h - edge ? below : Math.max(edge, above);
-this.tooltip.style.left = left + "px";
-this.tooltip.style.top = top + "px";
+const pos = this._tooltipAnchorPx();
+if (pos) {
+this._placeTooltip(pos.lx, pos.ly);
+} else if (this._tooltipAnchor) {
+this.tooltip.style.display = "none";
+} else {
+const rect = this.root.getBoundingClientRect();
+this._placeTooltip(clientX - rect.left, clientY - rect.top);
+}
},
});
Object.assign(ChartView.prototype, {
@@ -6461,7 +6513,7 @@ original: [...this._lassoPolygon[index]],
handle,
};
handle.dataset.xyActive = "";
-this.tooltip.style.display = "none";
+this._hideTooltip();
try { this.selLasso.setPointerCapture(e.pointerId); } catch (_err) { }
e.preventDefault();
e.stopPropagation();
@@ -6535,7 +6587,7 @@ points: firstLassoPoint ? [firstLassoPoint] : null,
previousLasso,
};
try { c.setPointerCapture(e.pointerId); } catch (_err) { }
-this.tooltip.style.display = "none";
+this._hideTooltip();
return;
}
if (this.dragMode === "pan" && canNavigate && canPan) {
@@ -6552,7 +6604,7 @@ axes: [...new Set([
changedAxes: [],
};
try { c.setPointerCapture(e.pointerId); } catch (_err) { }
-this.tooltip.style.display = "none";
+this._hideTooltip();
}
});
this._listen(c, "pointermove", (e) => {
@@ -6639,7 +6691,7 @@ phase: "end",
interactionId: drag.interactionId,
});
}
-if (drag && !drag.moved) this.tooltip.style.display = "none";
+if (drag && !drag.moved) this._hideTooltip();
drag = null;
};
this._listen(c, "pointerup", end);
@@ -6660,7 +6712,7 @@ this._hoverTarget = null;
this._lastHoverXY = null;
this._a11yKeyboardReadout = null;
this._pickSeq = (this._pickSeq || 0) + 1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
this._hideCrosshair();
if (this._interactionFlag("hover")) {
this._dispatchChartEvent("leave", { view: this._eventView("leave") });
@@ -6709,7 +6761,7 @@ return;
if (e.key === "Escape") {
e.preventDefault();
const hadHover = this._hoverId !== -1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
this._hoverId = -1;
this._hoverTarget = null;
this._lastHoverXY = null;
@@ -7901,9 +7953,26 @@ if (f < 1) {
const minSpan = Math.max(Math.abs(ca), 1e-30) * 1e-12;
if (Math.abs((c1 - c0) * f) < minSpan) return null;
}
+const next0 = ca - (ca - c0) * f;
+const next1 = ca + (c1 - ca) * f;
+if (f > 1 && this.view0) {
+const home = this._axisRange(axisId, this.view0);
+const home0 = this._axisCoord(axis, home[0]);
+const home1 = this._axisCoord(axis, home[1]);
+const homeSpan = Math.abs(home1 - home0);
+if ([home0, home1].every(Number.isFinite)
+&& homeSpan > 0
+&& Math.abs(next1 - next0) > homeSpan) {
+const signedHome = homeSpan * Math.sign(next1 - next0);
return [
-this._axisValue(axis, ca - (ca - c0) * f),
-this._axisValue(axis, ca + (c1 - ca) * f),
+this._axisValue(axis, ca - anchorFrac * signedHome),
+this._axisValue(axis, ca + (1 - anchorFrac) * signedHome),
+];
+}
+}
+return [
+this._axisValue(axis, next0),
+this._axisValue(axis, next1),
];
},
_zoomAt(f, fx, fy, animate = false, duration = 120, opts = {}) {
@@ -8570,7 +8639,7 @@ this.draw();
this._applyAppend(msg, buffers);
} else if (msg.type === "pick_result") {
if (msg.seq !== undefined && msg.seq !== this._pickSeq) return;
-if (!msg.row) { this.tooltip.style.display = "none"; return; }
+if (!msg.row) { this._hideTooltip(); return; }
const local = this._lastRow;
if (local && local.trace === msg.row.trace && local.index === msg.row.index) {
for (const [key, value] of Object.entries(local)) {
@@ -8579,6 +8648,11 @@ if (msg.row[key] === undefined) msg.row[key] = value;
}
this._applySharedTooltipFields(msg.row);
this._lastRow = msg.row;
+if (this._tooltipAnchor
+&& Number.isFinite(msg.row.x) && Number.isFinite(msg.row.y)) {
+this._tooltipAnchor.x = msg.row.x;
+this._tooltipAnchor.y = msg.row.y;
+}
const xy = this._lastHoverXY;
if (xy) this._renderTooltip(msg.row, xy.clientX, xy.clientY, {
announce: !this._a11yKeyboardReadout,
diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js
index 85f607f2..494899eb 100644
--- a/python/xy/static/standalone.js
+++ b/python/xy/static/standalone.js
@@ -4093,6 +4093,7 @@ continue;
markOf(g.trace.kind).draw(this, g, x0, x1, y0, y1);
}
this._drawHoverState();
+this._repositionTooltip();
if (!this._rafKeepPick) this._pickDirty = true;
this._rafKeepPick = false;
this._drawChrome();
@@ -5493,7 +5494,7 @@ this._hoverId = -1;
this._hoverTarget = null;
this._lastHoverXY = null;
this._pickSeq = (this._pickSeq || 0) + 1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
if (hadHover) this.draw();
return;
}
@@ -5507,14 +5508,14 @@ this._hoverId = -1;
this._hoverTarget = null;
this._lastHoverXY = null;
this._pickSeq = (this._pickSeq || 0) + 1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
if (hadHover) this._drawKeepPick();
return;
}
const id = hit.trace * 1e9 + hit.index;
this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY };
if (id === this._hoverId) {
-this._renderTooltip(this._lastRow, e.clientX, e.clientY);
+if (!this._tooltipAnchor) this._renderTooltip(this._lastRow, e.clientX, e.clientY);
return;
}
this._hoverId = id;
@@ -6188,6 +6189,7 @@ Object.assign(ChartView.prototype, {
_showTooltip(hit, clientX, clientY) {
const row = this._localRow(hit);
this._lastRow = row;
+this._setTooltipAnchor(hit, row, clientX, clientY);
this._renderTooltip(row, clientX, clientY);
if (this._interactionFlag("hover")) {
this._dispatchChartEvent("hover", {
@@ -6381,14 +6383,66 @@ lines.push(`${field}: ${this._formatTooltipValue(value, kind, formats[field])}`)
}
return lines.length ? lines : this._defaultTooltipLines(row);
},
+_setTooltipAnchor(hit, row, clientX, clientY) {
+const g = hit.g;
+if (!g) { this._tooltipAnchor = null; return; }
+const xAxis = g.xAxis || "x";
+const yAxis = g.yAxis || "y";
+let x = row.x;
+let y = row.y;
+if (!Number.isFinite(x) || !Number.isFinite(y)) {
+const rect = this.canvas.getBoundingClientRect();
+[x, y] = this._dataFromCanvas(clientX - rect.left, clientY - rect.top, xAxis, yAxis);
+}
+this._tooltipAnchor = Number.isFinite(x) && Number.isFinite(y)
+? { xAxis, yAxis, x, y }
+: null;
+if (this._tooltipAnchor && !this._tooltipAnchorPx()) this._tooltipAnchor = null;
+},
+_tooltipAnchorPx() {
+const a = this._tooltipAnchor;
+if (!a) return null;
+const lx = this._dataPx(a.xAxis, a.x);
+const ly = this._dataPx(a.yAxis, a.y);
+const p = this.plot;
+if (!Number.isFinite(lx) || !Number.isFinite(ly)
+|| lx < p.x || lx > p.x + p.w || ly < p.y || ly > p.y + p.h) {
+return null;
+}
+return { lx, ly };
+},
+_hideTooltip() {
+this.tooltip.style.display = "none";
+this._tooltipAnchor = null;
+},
+_repositionTooltip() {
+if (!this._tooltipAnchor) return;
+const pos = this._tooltipAnchorPx();
+if (!pos) {
+this.tooltip.style.display = "none";
+return;
+}
+this.tooltip.style.display = "block";
+this._placeTooltip(pos.lx, pos.ly);
+},
+_placeTooltip(lx, ly) {
+const tw = this.tooltip.offsetWidth;
+const th = this.tooltip.offsetHeight;
+const edge = 4;
+const gap = 12;
+const maxLeft = Math.max(edge, this.size.w - tw - edge);
+const left = Math.max(edge, Math.min(lx + gap, maxLeft));
+const below = ly + gap;
+const above = ly - th - gap;
+const top = below + th <= this.size.h - edge ? below : Math.max(edge, above);
+this.tooltip.style.left = left + "px";
+this.tooltip.style.top = top + "px";
+},
_renderTooltip(row, clientX, clientY, options = {}) {
if (!row || this.spec.show_tooltip === false) {
-this.tooltip.style.display = "none";
+this._hideTooltip();
return;
}
-const rect = this.root.getBoundingClientRect();
-const lx = clientX - rect.left;
-const ly = clientY - rect.top;
const lines = this._tooltipLines(row);
this.tooltip.textContent = "";
lines.forEach((ln, i) => {
@@ -6404,17 +6458,15 @@ const announcement = prefix
if (this.a11yLive.textContent !== announcement) this.a11yLive.textContent = announcement;
}
this.tooltip.style.display = "block";
-const tw = this.tooltip.offsetWidth;
-const th = this.tooltip.offsetHeight;
-const edge = 4;
-const gap = 12;
-const maxLeft = Math.max(edge, this.size.w - tw - edge);
-const left = Math.max(edge, Math.min(lx + gap, maxLeft));
-const below = ly + gap;
-const above = ly - th - gap;
-const top = below + th <= this.size.h - edge ? below : Math.max(edge, above);
-this.tooltip.style.left = left + "px";
-this.tooltip.style.top = top + "px";
+const pos = this._tooltipAnchorPx();
+if (pos) {
+this._placeTooltip(pos.lx, pos.ly);
+} else if (this._tooltipAnchor) {
+this.tooltip.style.display = "none";
+} else {
+const rect = this.root.getBoundingClientRect();
+this._placeTooltip(clientX - rect.left, clientY - rect.top);
+}
},
});
Object.assign(ChartView.prototype, {
@@ -6462,7 +6514,7 @@ original: [...this._lassoPolygon[index]],
handle,
};
handle.dataset.xyActive = "";
-this.tooltip.style.display = "none";
+this._hideTooltip();
try { this.selLasso.setPointerCapture(e.pointerId); } catch (_err) { }
e.preventDefault();
e.stopPropagation();
@@ -6536,7 +6588,7 @@ points: firstLassoPoint ? [firstLassoPoint] : null,
previousLasso,
};
try { c.setPointerCapture(e.pointerId); } catch (_err) { }
-this.tooltip.style.display = "none";
+this._hideTooltip();
return;
}
if (this.dragMode === "pan" && canNavigate && canPan) {
@@ -6553,7 +6605,7 @@ axes: [...new Set([
changedAxes: [],
};
try { c.setPointerCapture(e.pointerId); } catch (_err) { }
-this.tooltip.style.display = "none";
+this._hideTooltip();
}
});
this._listen(c, "pointermove", (e) => {
@@ -6640,7 +6692,7 @@ phase: "end",
interactionId: drag.interactionId,
});
}
-if (drag && !drag.moved) this.tooltip.style.display = "none";
+if (drag && !drag.moved) this._hideTooltip();
drag = null;
};
this._listen(c, "pointerup", end);
@@ -6661,7 +6713,7 @@ this._hoverTarget = null;
this._lastHoverXY = null;
this._a11yKeyboardReadout = null;
this._pickSeq = (this._pickSeq || 0) + 1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
this._hideCrosshair();
if (this._interactionFlag("hover")) {
this._dispatchChartEvent("leave", { view: this._eventView("leave") });
@@ -6710,7 +6762,7 @@ return;
if (e.key === "Escape") {
e.preventDefault();
const hadHover = this._hoverId !== -1;
-this.tooltip.style.display = "none";
+this._hideTooltip();
this._hoverId = -1;
this._hoverTarget = null;
this._lastHoverXY = null;
@@ -7902,9 +7954,26 @@ if (f < 1) {
const minSpan = Math.max(Math.abs(ca), 1e-30) * 1e-12;
if (Math.abs((c1 - c0) * f) < minSpan) return null;
}
+const next0 = ca - (ca - c0) * f;
+const next1 = ca + (c1 - ca) * f;
+if (f > 1 && this.view0) {
+const home = this._axisRange(axisId, this.view0);
+const home0 = this._axisCoord(axis, home[0]);
+const home1 = this._axisCoord(axis, home[1]);
+const homeSpan = Math.abs(home1 - home0);
+if ([home0, home1].every(Number.isFinite)
+&& homeSpan > 0
+&& Math.abs(next1 - next0) > homeSpan) {
+const signedHome = homeSpan * Math.sign(next1 - next0);
return [
-this._axisValue(axis, ca - (ca - c0) * f),
-this._axisValue(axis, ca + (c1 - ca) * f),
+this._axisValue(axis, ca - anchorFrac * signedHome),
+this._axisValue(axis, ca + (1 - anchorFrac) * signedHome),
+];
+}
+}
+return [
+this._axisValue(axis, next0),
+this._axisValue(axis, next1),
];
},
_zoomAt(f, fx, fy, animate = false, duration = 120, opts = {}) {
@@ -8571,7 +8640,7 @@ this.draw();
this._applyAppend(msg, buffers);
} else if (msg.type === "pick_result") {
if (msg.seq !== undefined && msg.seq !== this._pickSeq) return;
-if (!msg.row) { this.tooltip.style.display = "none"; return; }
+if (!msg.row) { this._hideTooltip(); return; }
const local = this._lastRow;
if (local && local.trace === msg.row.trace && local.index === msg.row.index) {
for (const [key, value] of Object.entries(local)) {
@@ -8580,6 +8649,11 @@ if (msg.row[key] === undefined) msg.row[key] = value;
}
this._applySharedTooltipFields(msg.row);
this._lastRow = msg.row;
+if (this._tooltipAnchor
+&& Number.isFinite(msg.row.x) && Number.isFinite(msg.row.y)) {
+this._tooltipAnchor.x = msg.row.x;
+this._tooltipAnchor.y = msg.row.y;
+}
const xy = this._lastHoverXY;
if (xy) this._renderTooltip(msg.row, xy.clientX, xy.clientY, {
announce: !this._a11yKeyboardReadout,
diff --git a/spec/api/interaction.md b/spec/api/interaction.md
index a5d0aca2..0a0e7b3c 100644
--- a/spec/api/interaction.md
+++ b/spec/api/interaction.md
@@ -247,6 +247,25 @@ least 3 sampled vertices, sampled at 3 px spacing and capped at 2048. In
`select-x` the y bounds are replaced with the full current view and in
`select-y` the x bounds are, so those modes brush one axis.
+**Zoom limits.** Every factor zoom — wheel, modebar Zoom In/Out — goes
+through `_zoomAxisRange` (`53_interaction.js:1439`) and stops at two
+boundaries:
+
+- *Zooming in* stops at the dossier §16 precision floor: if either axis's
+ next span would fall below ~1 part in 10¹² of the anchor's magnitude, the
+ whole step is ignored — neither axis moves.
+- *Zooming out* stops at the home view, per axis: when a factor-out step
+ would grow an axis's span to or past its home (`view0`) span, that axis
+ snaps exactly to home. The two axes clamp independently, so a view that
+ was box-zoomed anisotropically (X and Y narrowed by very different
+ factors) recovers the home aspect on zoom-out instead of the less-zoomed
+ axis overshooting far past home and flattening the point cloud. Factor
+ zoom-out therefore never takes the view beyond home; regions outside the
+ home view are reached by panning or box-zooming, subject to axis bounds.
+
+Box zoom is bounded separately: a drag whose data rectangle would collapse a
+span below f32 resolution is ignored as degenerate.
+
The modebar exposes the same actions: Pan; a zoom menu with Zoom In (×0.5),
Zoom Out (×2), Box Zoom, and Reset View; and a selection menu with Box
Select, Lasso Select, X Range, and Y Range. Each menu is built only when its
@@ -271,7 +290,34 @@ to exactly `linked_dims`, so `link_select=True` with `link=None` produces an
empty `link_axes` — panels share selections and nothing else. `link_select`
is written straight into each panel's interaction dict.
-## 7. Unconditional behavior
+## 7. Tooltip anchoring
+
+The hover tooltip is anchored in data space, not at the cursor
+(matplotlib's data-coordinate-annotation contract;
+`js/src/52_tooltip.js`, `_setTooltipAnchor` / `_repositionTooltip`):
+
+- At pick time the hovered point's data coordinates are recorded against the
+ trace's own axis pair. Category rows carry labels rather than numbers, so
+ their numeric anchor is derived from the pick position instead. The
+ kernel's exact-pick reply sharpens the f32-decoded anchor to full f64
+ (dossier §16).
+- Every draw reprojects the anchor, so the tooltip rides its point through
+ pans, zooms, and reset animations — including view changes that happen
+ without a pointermove: modebar Reset View, dblclick home, wheel zoom, and
+ views applied from link-group peers. It never floats at a stale screen
+ position describing a point that is no longer there.
+- When the anchor's projection leaves the plot rect the tooltip hides,
+ rather than clamping to an edge that would misrepresent where the point
+ is. The retained anchor may bring it back if a later view change returns
+ the point to view — but every explicit hide path clears the anchor, so a
+ dismissed tooltip cannot resurrect on a subsequent draw.
+- Placement: 12 px gap beside the anchor, flipped above when below does not
+ fit, clamped to the canvas with a 4 px edge margin.
+- Keyboard traversal can focus a point outside the current view; its readout
+ keeps the edge-clamped placement (the anchor is dropped when its
+ projection starts outside the plot rect).
+
+## 8. Unconditional behavior
Not configurable through any switch: tooltip rendering and the kernel `pick`
round-trip on hover; keyboard point traversal and the live-region readout;
diff --git a/spec/design-dossier.md b/spec/design-dossier.md
index 6c42e507..e7721547 100644
--- a/spec/design-dossier.md
+++ b/spec/design-dossier.md
@@ -608,6 +608,14 @@ detail inside a decade of millisecond timestamps).
values, and hover readouts are computed CPU-side in f64/i64 from source columns —
the GPU path only ever positions pixels, so display precision is exact even when
geometry is quantized to sub-pixel f32.
+- **Linear axes stay offset-encoded through the vertex transform.** The shader's
+ affine view mapping is composed directly onto the encoded values (`xyMap`,
+ `js/src/40_gl.js`); the CPU folds the offset into the affine constants in f64
+ (`_map`, `js/src/50_chartview.js`). Decoding to absolute coordinates in-shader
+ first would discard the low bits whenever a deeply zoomed window is far smaller
+ than the offset — after which zooming back out could never recover the point
+ spread. Only log axes decode before mapping, because log10 is not affine.
+ *Augments §4.*
- **Time is i64 end-to-end** (Arrow timestamp columns), with calendar-aware tick
generation (months are not 30×86400s). Plotly gets this right; matching it is
table stakes and it must not be routed through any float path.
diff --git a/spec/design/renderer-architecture.md b/spec/design/renderer-architecture.md
index 53e365ef..97dea39d 100644
--- a/spec/design/renderer-architecture.md
+++ b/spec/design/renderer-architecture.md
@@ -28,11 +28,11 @@ relative mass, not as a budget (see §3 on why a line count failed as a metric).
| `40_gl.js` | 829 | WebGL2 primitives: shader compile/link, `makeProgram` with its per-program uniform-location memo (R1), the fixed `ATTR_SLOTS` attribute-slot table bound at link time, and the shader inventory itself. The only module that is GPU-API-specific by design (§4). |
| `45_lod.js` | 567 | View-dependent level-of-detail orchestration, deliberately chart-agnostic: tier selection, drill enter/exit hysteresis (`LOD_DRILL_EXIT_FACTOR`), cross-tier fades, and the retained tier caches. Calls back into `view._draw*` rather than drawing itself, which is the seam tests intercept. |
| `46_worker.js` | 59 | The standalone density re-bin worker: a worker source string carried inside the bundle and booted from a Blob URL. Re-bins the retained sample off the main thread so kernel-less (`to_html`) density charts refine on zoom instead of stretching the overview texture; absence of workers falls back to stretching. |
-| `50_chartview.js` | 4167 | The `ChartView` class: the four drawing surfaces, scale/view state, chrome (background, grid, axes, legend, colorbar), GL buffer and VAO management (R2), and pick orchestration. Modules 51–54 extend this same class. |
+| `50_chartview.js` | 4175 | The `ChartView` class: the four drawing surfaces, scale/view state, chrome (background, grid, axes, legend, colorbar), GL buffer and VAO management (R2), and pick orchestration. Modules 51–54 extend this same class. |
| `51_annotations.js` | 591 | The 2D overlay canvas above the marks canvas: annotation markers, arrows, shape fills, and collision-nudged labels. Separates canvas shape style keys from label CSS so annotation styling never leaks into the DOM label. |
-| `52_tooltip.js` | 260 | Hit → source row → tooltip DOM. Renders the local f32-decoded row immediately, then replaces it with the kernel's exact f64 row when that reply arrives (sequence- and `drill_seq`-guarded); composes text nodes, never HTML. |
-| `53_interaction.js` | 1803 | The entire user-facing interaction surface: pointer/drag/wheel wiring, crosshair, box select, box zoom, lasso, the modebar and its export menu, and the animated pan/zoom view state machine. The gesture→action mapping, the modebar tool inventory and the `interaction_config` switches are specified in [interaction.md](../api/interaction.md) §2 and §5. |
-| `54_kernel.js` | 408 | The client half of the kernel channel: debounced density/decimated view-requests, streaming `append` handling, the inbound message dispatcher, and the deep-zoom drill lifecycle (§16). Degrades to `46_worker.js` when there is no comm. The message catalog it consumes is specified in [wire-protocol.md](wire-protocol.md). |
+| `52_tooltip.js` | 321 | Hit → source row → tooltip DOM. Anchors the tooltip at the picked point's data coordinates and reprojects it every draw ([interaction.md](../api/interaction.md) §7). Renders the local f32-decoded row immediately, then replaces it with the kernel's exact f64 row when that reply arrives (sequence- and `drill_seq`-guarded); composes text nodes, never HTML. |
+| `53_interaction.js` | 1820 | The entire user-facing interaction surface: pointer/drag/wheel wiring, crosshair, box select, box zoom, lasso, the modebar and its export menu, and the animated pan/zoom view state machine. The gesture→action mapping, the modebar tool inventory and the `interaction_config` switches are specified in [interaction.md](../api/interaction.md) §2 and §5. |
+| `54_kernel.js` | 437 | The client half of the kernel channel: debounced density/decimated view-requests, streaming `append` handling, the inbound message dispatcher, and the deep-zoom drill lifecycle (§16). Degrades to `46_worker.js` when there is no comm. The message catalog it consumes is specified in [wire-protocol.md](wire-protocol.md). |
| `55_marks.js` | 220 | The `MARK_KINDS` registry — `build`/`draw` per kind plus the `pointPick`/`retainCpu`/`refreshColor` capability flags — mirroring the kernel's `_emit_` dispatch so adding a 2D chart is an entry here, not a branch in `ChartView`. |
| `60_entries.js` | 76 | Mount/unmount entry points for both hosts (`render` for anywidget, `renderStandalone` for exported HTML) and `payloadBuffers`, which materializes first-paint columns in whichever layout the spec declares. Keeps aligned views zero-copy; a spec/transport disagreement throws. |
diff --git a/tests/test_zoom_precision.py b/tests/test_zoom_precision.py
new file mode 100644
index 00000000..68d98d7b
--- /dev/null
+++ b/tests/test_zoom_precision.py
@@ -0,0 +1,104 @@
+"""Browser regression for anisotropic box-zoom followed by zoom-out."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+from conftest import run_browser_probe
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "python"))
+
+import xy # noqa: E402
+from xy.export import find_chromium # noqa: E402
+
+_RENDER_CALLS = (
+ 'xy.renderStandalone(document.getElementById("chart"), spec, bytes.buffer);',
+ 'xy.renderStandalone(document.getElementById("chart"), spec, buf);',
+)
+
+_PROBE = """
+
+"""
+
+
+def test_zoom_out_does_not_expand_less_zoomed_axis_past_home(tmp_path: Path) -> None:
+ chromium = find_chromium()
+ if chromium is None:
+ pytest.skip("no chromium available for the zoom precision probe")
+
+ chart = xy.scatter_chart(
+ xy.scatter(
+ x=[-2.0, -1.0, 0.0, 1.0, 2.0],
+ y=[-1.0, 0.5, 0.0, -0.5, 1.0],
+ ),
+ width=640,
+ height=420,
+ )
+ document = chart.to_html()
+ render_call = next((call for call in _RENDER_CALLS if call in document), None)
+ assert render_call is not None
+ document = document.replace(
+ render_call,
+ render_call.replace(
+ "xy.renderStandalone(", "window.__xyZoomProbeView = xy.renderStandalone(", 1
+ ),
+ 1,
+ )
+ document = document.replace("