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("", _PROBE + "\n", 1) + + result = run_browser_probe( + chromium, + document, + tmp_path / "zoom_precision.html", + "data-xy-zoom-precision", + label="zoom precision probe", + ) + + assert result["finite"] is True, result + assert result["xZoom"] == pytest.approx(7.8125), result + assert result["yZoom"] == pytest.approx(1.0), result + assert result["current"]["y0"] == pytest.approx(result["home"]["y0"]), result + assert result["current"]["y1"] == pytest.approx(result["home"]["y1"]), result