Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions examples/demo-advance.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions js/src/50_chartview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
91 changes: 76 additions & 15 deletions js/src/52_tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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 "<img onerror=…>" is just a label).
Expand All @@ -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);
}
},
});
40 changes: 32 additions & 8 deletions js/src/53_interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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") });
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
];
},

Expand Down
8 changes: 7 additions & 1 deletion js/src/54_kernel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
22 changes: 18 additions & 4 deletions python/xy/_chromium.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading