From c15ddd5f31e1b027f6b8358d28db4b1337867cf7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:09:50 +0500 Subject: [PATCH 1/9] Fix scatter precision after repeated box zoom --- js/src/40_gl.js | 8 +++++++- js/src/50_chartview.js | 5 ++++- python/xy/static/index.js | 10 ++++++++-- python/xy/static/standalone.js | 10 ++++++++-- tests/test_static_client_security.py | 13 +++++++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/js/src/40_gl.js b/js/src/40_gl.js index 751e9686..0a81834e 100644 --- a/js/src/40_gl.js +++ b/js/src/40_gl.js @@ -78,7 +78,13 @@ float xyAxisCoord(float encoded, vec2 meta, int mode) { return value; } float xyMap(float encoded, vec2 map, vec2 meta, int mode) { - return xyAxisCoord(encoded, meta, mode) * map.x + map.y; + // Linear columns stay offset-encoded through the affine transform. Decoding + // them first loses the low bits when a deeply zoomed window is far smaller + // than meta.offset; a later zoom-out then cannot recover the point spread. + // Log axes must decode because log10 is not affine. + return mode == 1 + ? xyAxisCoord(encoded, meta, mode) * map.x + map.y + : encoded * map.x + map.y; } float xyViewCoord(float value, int mode) { if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index e1f44290..e2c72549 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -2130,7 +2130,10 @@ class ChartView { // -- drawing -------------------------------------------------------------- _map(meta, lo, hi, axisId = null) { - if (!axisId) { + // Linear shader mapping operates directly on offset-encoded f32 values. + // Besides matching the legacy axis-less path, this avoids reconstructing + // large absolute coordinates in f32 before subtracting a deep-zoom view. + if (!axisId || this._axisMode(axisId) === 0) { const mul = 2 / ((hi - lo) * meta.scale); const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; return [mul, add]; diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 2fef1684..0532f303 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -563,7 +563,13 @@ float xyAxisCoord(float encoded, vec2 meta, int mode) { return value; } float xyMap(float encoded, vec2 map, vec2 meta, int mode) { - return xyAxisCoord(encoded, meta, mode) * map.x + map.y; + // Linear columns stay offset-encoded through the affine transform. Decoding + // them first loses the low bits when a deeply zoomed window is far smaller + // than meta.offset; a later zoom-out then cannot recover the point spread. + // Log axes must decode because log10 is not affine. + return mode == 1 + ? xyAxisCoord(encoded, meta, mode) * map.x + map.y + : encoded * map.x + map.y; } float xyViewCoord(float value, int mode) { if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; @@ -3491,7 +3497,7 @@ this._pickW = this.canvas.width; this._pickH = this.canvas.height; } _map(meta, lo, hi, axisId = null) { -if (!axisId) { +if (!axisId || this._axisMode(axisId) === 0) { const mul = 2 / ((hi - lo) * meta.scale); const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; return [mul, add]; diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 1db67384..44b258a7 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -564,7 +564,13 @@ float xyAxisCoord(float encoded, vec2 meta, int mode) { return value; } float xyMap(float encoded, vec2 map, vec2 meta, int mode) { - return xyAxisCoord(encoded, meta, mode) * map.x + map.y; + // Linear columns stay offset-encoded through the affine transform. Decoding + // them first loses the low bits when a deeply zoomed window is far smaller + // than meta.offset; a later zoom-out then cannot recover the point spread. + // Log axes must decode because log10 is not affine. + return mode == 1 + ? xyAxisCoord(encoded, meta, mode) * map.x + map.y + : encoded * map.x + map.y; } float xyViewCoord(float value, int mode) { if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; @@ -3492,7 +3498,7 @@ this._pickW = this.canvas.width; this._pickH = this.canvas.height; } _map(meta, lo, hi, axisId = null) { -if (!axisId) { +if (!axisId || this._axisMode(axisId) === 0) { const mul = 2 / ((hi - lo) * meta.scale); const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; return [mul, add]; diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 60fe0567..62985957 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -32,6 +32,19 @@ def _read(path: Path) -> str: THEME_FILES = (("js/src/20_theme.js", _read(ROOT / "js/src/20_theme.js")), _INDEX, _STANDALONE) +def test_linear_mapping_preserves_offset_encoding_during_deep_zoom() -> None: + """Linear points must not be decoded to large absolute f32 coordinates + before applying the view transform; repeated box zooms would discard their + point-to-point deltas and flatten the cloud on the subsequent zoom-out.""" + markers = ( + "if (!axisId || this._axisMode(axisId) === 0) {", + ": encoded * map.x + map.y;", + ) + for path, text in CLIENT_FILES: + for marker in markers: + assert marker in text, f"{path} no longer keeps linear mapping offset-relative" + + def test_chrome_visual_defaults_are_a_defeatable_where_stylesheet() -> None: """Chrome styling lives in a layered, zero-specificity :where() stylesheet so user class_names / styles always win (the CSS+Tailwind contract). From d046d58bba972f9310f4ade1450be44cb133a2f5 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:17:04 +0500 Subject: [PATCH 2/9] Fix anisotropic scatter zoom-out collapse --- examples/demo-advance.ipynb | 32 ++++++++++ js/src/53_interaction.js | 21 ++++++- python/xy/static/index.js | 17 +++++- python/xy/static/standalone.js | 17 +++++- tests/test_zoom_precision.py | 104 +++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 tests/test_zoom_precision.py diff --git a/examples/demo-advance.ipynb b/examples/demo-advance.ipynb index 211d2fe6..9febcbd3 100644 --- a/examples/demo-advance.ipynb +++ b/examples/demo-advance.ipynb @@ -270,6 +270,38 @@ ")\n", "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 precision — cloud must not collapse\",\n", + " width=900,\n", + " height=500,\n", + ")" + ] } ], "metadata": { diff --git a/js/src/53_interaction.js b/js/src/53_interaction.js index dc6d53a9..7dae5400 100644 --- a/js/src/53_interaction.js +++ b/js/src/53_interaction.js @@ -1359,9 +1359,26 @@ 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. Stop each + // axis independently 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. + const home = axisId === "x" + ? [this.view0.x0, this.view0.x1] + : [this.view0.y0, this.view0.y1]; + const home0 = this._axisCoord(axis, home[0]); + const home1 = this._axisCoord(axis, home[1]); + if ([home0, home1].every(Number.isFinite) + && Math.abs(next1 - next0) >= Math.abs(home1 - home0)) { + return home; + } + } 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/python/xy/static/index.js b/python/xy/static/index.js index 0532f303..59ca7306 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -7060,9 +7060,22 @@ 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 = axisId === "x" +? [this.view0.x0, this.view0.x1] +: [this.view0.y0, this.view0.y1]; +const home0 = this._axisCoord(axis, home[0]); +const home1 = this._axisCoord(axis, home[1]); +if ([home0, home1].every(Number.isFinite) +&& Math.abs(next1 - next0) >= Math.abs(home1 - home0)) { +return home; +} +} return [ -this._axisValue(axis, ca - (ca - c0) * f), -this._axisValue(axis, ca + (c1 - ca) * f), +this._axisValue(axis, next0), +this._axisValue(axis, next1), ]; }, _zoomAt(f, fx, fy, animate = false, duration = 120) { diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 44b258a7..6a2effe2 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -7061,9 +7061,22 @@ 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 = axisId === "x" +? [this.view0.x0, this.view0.x1] +: [this.view0.y0, this.view0.y1]; +const home0 = this._axisCoord(axis, home[0]); +const home1 = this._axisCoord(axis, home[1]); +if ([home0, home1].every(Number.isFinite) +&& Math.abs(next1 - next0) >= Math.abs(home1 - home0)) { +return home; +} +} return [ -this._axisValue(axis, ca - (ca - c0) * f), -this._axisValue(axis, ca + (c1 - ca) * f), +this._axisValue(axis, next0), +this._axisValue(axis, next1), ]; }, _zoomAt(f, fx, fy, animate = false, duration = 120) { diff --git a/tests/test_zoom_precision.py b/tests/test_zoom_precision.py new file mode 100644 index 00000000..126147bb --- /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 From 83d816ccb11a3db076114f1f418612e79e1d64f1 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:23:36 +0500 Subject: [PATCH 3/9] Remove unrelated scatter precision workaround --- examples/demo-advance.ipynb | 2 +- js/src/40_gl.js | 8 +------- js/src/50_chartview.js | 5 +---- python/xy/static/index.js | 10 ++-------- python/xy/static/standalone.js | 10 ++-------- tests/test_static_client_security.py | 13 ------------- 6 files changed, 7 insertions(+), 41 deletions(-) diff --git a/examples/demo-advance.ipynb b/examples/demo-advance.ipynb index 9febcbd3..be77527b 100644 --- a/examples/demo-advance.ipynb +++ b/examples/demo-advance.ipynb @@ -297,7 +297,7 @@ " size_range=(3, 18),\n", " opacity=0.7,\n", " ),\n", - " title=\"Repeated box-zoom precision — cloud must not collapse\",\n", + " title=\"Repeated box zoom — axes must not overshoot home\",\n", " width=900,\n", " height=500,\n", ")" diff --git a/js/src/40_gl.js b/js/src/40_gl.js index 0a81834e..751e9686 100644 --- a/js/src/40_gl.js +++ b/js/src/40_gl.js @@ -78,13 +78,7 @@ float xyAxisCoord(float encoded, vec2 meta, int mode) { return value; } float xyMap(float encoded, vec2 map, vec2 meta, int mode) { - // Linear columns stay offset-encoded through the affine transform. Decoding - // them first loses the low bits when a deeply zoomed window is far smaller - // than meta.offset; a later zoom-out then cannot recover the point spread. - // Log axes must decode because log10 is not affine. - return mode == 1 - ? xyAxisCoord(encoded, meta, mode) * map.x + map.y - : encoded * map.x + map.y; + return xyAxisCoord(encoded, meta, mode) * map.x + map.y; } float xyViewCoord(float value, int mode) { if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index e2c72549..e1f44290 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -2130,10 +2130,7 @@ class ChartView { // -- drawing -------------------------------------------------------------- _map(meta, lo, hi, axisId = null) { - // Linear shader mapping operates directly on offset-encoded f32 values. - // Besides matching the legacy axis-less path, this avoids reconstructing - // large absolute coordinates in f32 before subtracting a deep-zoom view. - if (!axisId || this._axisMode(axisId) === 0) { + if (!axisId) { const mul = 2 / ((hi - lo) * meta.scale); const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; return [mul, add]; diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 59ca7306..3c5f97ec 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -563,13 +563,7 @@ float xyAxisCoord(float encoded, vec2 meta, int mode) { return value; } float xyMap(float encoded, vec2 map, vec2 meta, int mode) { - // Linear columns stay offset-encoded through the affine transform. Decoding - // them first loses the low bits when a deeply zoomed window is far smaller - // than meta.offset; a later zoom-out then cannot recover the point spread. - // Log axes must decode because log10 is not affine. - return mode == 1 - ? xyAxisCoord(encoded, meta, mode) * map.x + map.y - : encoded * map.x + map.y; + return xyAxisCoord(encoded, meta, mode) * map.x + map.y; } float xyViewCoord(float value, int mode) { if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; @@ -3497,7 +3491,7 @@ this._pickW = this.canvas.width; this._pickH = this.canvas.height; } _map(meta, lo, hi, axisId = null) { -if (!axisId || this._axisMode(axisId) === 0) { +if (!axisId) { const mul = 2 / ((hi - lo) * meta.scale); const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; return [mul, add]; diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 6a2effe2..d8af5760 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -564,13 +564,7 @@ float xyAxisCoord(float encoded, vec2 meta, int mode) { return value; } float xyMap(float encoded, vec2 map, vec2 meta, int mode) { - // Linear columns stay offset-encoded through the affine transform. Decoding - // them first loses the low bits when a deeply zoomed window is far smaller - // than meta.offset; a later zoom-out then cannot recover the point spread. - // Log axes must decode because log10 is not affine. - return mode == 1 - ? xyAxisCoord(encoded, meta, mode) * map.x + map.y - : encoded * map.x + map.y; + return xyAxisCoord(encoded, meta, mode) * map.x + map.y; } float xyViewCoord(float value, int mode) { if (mode == 1) return value > 0.0 ? log(value) / log(10.0) : -1e30; @@ -3498,7 +3492,7 @@ this._pickW = this.canvas.width; this._pickH = this.canvas.height; } _map(meta, lo, hi, axisId = null) { -if (!axisId || this._axisMode(axisId) === 0) { +if (!axisId) { const mul = 2 / ((hi - lo) * meta.scale); const add = ((meta.offset - lo) / (hi - lo)) * 2 - 1; return [mul, add]; diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 62985957..60fe0567 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -32,19 +32,6 @@ def _read(path: Path) -> str: THEME_FILES = (("js/src/20_theme.js", _read(ROOT / "js/src/20_theme.js")), _INDEX, _STANDALONE) -def test_linear_mapping_preserves_offset_encoding_during_deep_zoom() -> None: - """Linear points must not be decoded to large absolute f32 coordinates - before applying the view transform; repeated box zooms would discard their - point-to-point deltas and flatten the cloud on the subsequent zoom-out.""" - markers = ( - "if (!axisId || this._axisMode(axisId) === 0) {", - ": encoded * map.x + map.y;", - ) - for path, text in CLIENT_FILES: - for marker in markers: - assert marker in text, f"{path} no longer keeps linear mapping offset-relative" - - def test_chrome_visual_defaults_are_a_defeatable_where_stylesheet() -> None: """Chrome styling lives in a layered, zero-specificity :where() stylesheet so user class_names / styles always win (the CSS+Tailwind contract). From d8e36ce9a471fee0a802df184624ebfce62765ae Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:36:08 +0500 Subject: [PATCH 4/9] fix(client): anchor hover tooltip to its point across view changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tooltip stored only the cursor's screen position, so any view change that happened without a pointermove — modebar Reset View, dblclick home, wheel zoom, linked views — left it floating at a stale location showing values for a point that was no longer there. Anchor it in data space instead (matplotlib's data-coordinate-annotation contract): record the hovered point's coordinates at pick time, reproject them on every draw so the tooltip rides its point through pans, zooms, and reset animations, and clip it when the anchor leaves the plot rect rather than clamping to an edge that misrepresents where the point is. The kernel's exact pick reply sharpens the f32-decoded anchor to f64 (§16), and every hide path clears the anchor so a later draw cannot resurrect a dismissed tooltip. --- js/src/50_chartview.js | 9 ++- js/src/52_tooltip.js | 91 +++++++++++++++++++++++----- js/src/53_interaction.js | 12 ++-- js/src/54_kernel.js | 8 ++- python/xy/static/index.js | 107 +++++++++++++++++++++++++-------- python/xy/static/standalone.js | 107 +++++++++++++++++++++++++-------- 6 files changed, 259 insertions(+), 75 deletions(-) diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index e1f44290..c5b06fab 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -2218,6 +2218,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; @@ -3719,7 +3721,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; } @@ -3733,14 +3735,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 20b0c6cb..31dec99d 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", { @@ -212,14 +213,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). @@ -237,16 +299,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 7dae5400..bea6dc01 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(); @@ -127,12 +127,12 @@ Object.assign(ChartView.prototype, { previousLasso, }; try { c.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ } - this.tooltip.style.display = "none"; + this._hideTooltip(); return; } drag = { px: e.clientX, py: e.clientY, view: { ...this.view }, moved: false }; try { c.setPointerCapture(e.pointerId); } catch (_err) { /* synthetic event */ } - this.tooltip.style.display = "none"; + this._hideTooltip(); }); this._listen(c, "pointermove", (e) => { if (band) { this._updateBand(band, e); return; } @@ -202,7 +202,7 @@ Object.assign(ChartView.prototype, { return; } if (drag && drag.moved) this._ignoreNextClick = true; - if (drag && !drag.moved) this.tooltip.style.display = "none"; + if (drag && !drag.moved) this._hideTooltip(); drag = null; }; this._listen(c, "pointerup", end); @@ -223,7 +223,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") }); @@ -263,7 +263,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; diff --git a/js/src/54_kernel.js b/js/src/54_kernel.js index ffa5c6d8..4bc6f7e1 100644 --- a/js/src/54_kernel.js +++ b/js/src/54_kernel.js @@ -306,7 +306,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. @@ -322,6 +322,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/static/index.js b/python/xy/static/index.js index 3c5f97ec..7d33d8b6 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -3562,6 +3562,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(); @@ -4888,7 +4889,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; } @@ -4902,14 +4903,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; @@ -5572,6 +5573,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", { @@ -5757,14 +5759,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) => { @@ -5780,17 +5834,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, { @@ -5838,7 +5890,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(); @@ -5908,12 +5960,12 @@ points: firstLassoPoint ? [firstLassoPoint] : null, previousLasso, }; try { c.setPointerCapture(e.pointerId); } catch (_err) { } -this.tooltip.style.display = "none"; +this._hideTooltip(); return; } drag = { px: e.clientX, py: e.clientY, view: { ...this.view }, moved: false }; try { c.setPointerCapture(e.pointerId); } catch (_err) { } -this.tooltip.style.display = "none"; +this._hideTooltip(); }); this._listen(c, "pointermove", (e) => { if (band) { this._updateBand(band, e); return; } @@ -5979,7 +6031,7 @@ band = null; return; } if (drag && drag.moved) this._ignoreNextClick = true; -if (drag && !drag.moved) this.tooltip.style.display = "none"; +if (drag && !drag.moved) this._hideTooltip(); drag = null; }; this._listen(c, "pointerup", end); @@ -6000,7 +6052,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") }); @@ -6035,7 +6087,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; @@ -7617,7 +7669,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)) { @@ -7626,6 +7678,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 d8af5760..c6ef62cd 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -3563,6 +3563,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(); @@ -4889,7 +4890,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; } @@ -4903,14 +4904,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; @@ -5573,6 +5574,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", { @@ -5758,14 +5760,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) => { @@ -5781,17 +5835,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, { @@ -5839,7 +5891,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(); @@ -5909,12 +5961,12 @@ points: firstLassoPoint ? [firstLassoPoint] : null, previousLasso, }; try { c.setPointerCapture(e.pointerId); } catch (_err) { } -this.tooltip.style.display = "none"; +this._hideTooltip(); return; } drag = { px: e.clientX, py: e.clientY, view: { ...this.view }, moved: false }; try { c.setPointerCapture(e.pointerId); } catch (_err) { } -this.tooltip.style.display = "none"; +this._hideTooltip(); }); this._listen(c, "pointermove", (e) => { if (band) { this._updateBand(band, e); return; } @@ -5980,7 +6032,7 @@ band = null; return; } if (drag && drag.moved) this._ignoreNextClick = true; -if (drag && !drag.moved) this.tooltip.style.display = "none"; +if (drag && !drag.moved) this._hideTooltip(); drag = null; }; this._listen(c, "pointerup", end); @@ -6001,7 +6053,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") }); @@ -6036,7 +6088,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; @@ -7618,7 +7670,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)) { @@ -7627,6 +7679,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, From ccb7b5d5f790bc0bf892ddb7ce97c0fcdcc8ed38 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:40:07 +0500 Subject: [PATCH 5/9] spec: document zoom limits, tooltip data-space anchoring, and offset-encoded linear mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interaction.md §5 gains the zoom-limit rules this branch introduced (the §16 precision floor on zoom-in and the per-axis home-span stop on zoom-out); a new §7 specifies the tooltip's data-coordinate anchoring contract. design-dossier.md §16 records that linear axes stay offset-encoded through the vertex transform (only log axes decode). renderer-architecture.md module inventory resynced with the code. --- spec/api/interaction.md | 48 +++++++++++++++++++++++++++- spec/design-dossier.md | 8 +++++ spec/design/renderer-architecture.md | 8 ++--- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 0bd904a3..5f6df794 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -161,6 +161,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 @@ -185,7 +204,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 16074281..07e68514 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 a11145ed..e7ac5b5a 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. | From e89ba6ec0cd55571ec0c73e64b874b806e654a03 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 21 Jul 2026 12:24:26 -0700 Subject: [PATCH 6/9] fix(client): cap zoom-out at home span without discarding the cursor anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _zoomAxisRange's zoom-out guard snapped an axis to its home *range* once it reached home span, discarding the translation from a cursor-anchored zoom chain. A zoom-in-high then zoom-out-low pans a free (pan-enabled) axis; the snap yanked it back to home. Cap the span at home while preserving the anchor instead, matching the spec (zoom-out stops at home span, §7.5). Positional containment remains the mechanism that pins locked axes to home. Also fix the zoom-precision probe's finite check: view.view now nests a `ranges` object (per-axis pan/zoom, #117), so Object.values(current) included a non-number. Check the numeric view coordinates directly. --- js/src/53_interaction.js | 23 +++++++++++++++-------- python/xy/static/index.js | 14 +++++++++----- python/xy/static/standalone.js | 14 +++++++++----- tests/test_zoom_precision.py | 2 +- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/js/src/53_interaction.js b/js/src/53_interaction.js index 630ae7a7..9fcca002 100644 --- a/js/src/53_interaction.js +++ b/js/src/53_interaction.js @@ -1610,18 +1610,25 @@ Object.assign(ChartView.prototype, { 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. Stop each - // axis independently at its home span while zooming out; otherwise the + // 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. - const home = axisId === "x" - ? [this.view0.x0, this.view0.x1] - : [this.view0.y0, this.view0.y1]; + // 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) - && Math.abs(next1 - next0) >= Math.abs(home1 - home0)) { - return home; + && 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 [ diff --git a/python/xy/static/index.js b/python/xy/static/index.js index b348b331..91d68703 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -7956,14 +7956,18 @@ 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 = axisId === "x" -? [this.view0.x0, this.view0.x1] -: [this.view0.y0, this.view0.y1]; +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) -&& Math.abs(next1 - next0) >= Math.abs(home1 - home0)) { -return home; +&& 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 [ diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 06bcb35c..494899eb 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -7957,14 +7957,18 @@ 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 = axisId === "x" -? [this.view0.x0, this.view0.x1] -: [this.view0.y0, this.view0.y1]; +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) -&& Math.abs(next1 - next0) >= Math.abs(home1 - home0)) { -return home; +&& 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 [ diff --git a/tests/test_zoom_precision.py b/tests/test_zoom_precision.py index 126147bb..68d98d7b 100644 --- a/tests/test_zoom_precision.py +++ b/tests/test_zoom_precision.py @@ -51,7 +51,7 @@ current, xZoom, yZoom, - finite: Object.values(current).every(Number.isFinite), + finite: [current.x0, current.x1, current.y0, current.y1].every(Number.isFinite), })); } catch (err) { document.body.setAttribute( From e9552a6e9f7f70ea43bd6fd31860777e4c079de8 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:21:44 +0500 Subject: [PATCH 7/9] fix(export): tolerate Chromium profile-dir cleanup races on session close Chromium helper processes can outlive the terminated main process and write into the temp profile while rmtree walks it, failing CI teardown with ENOTEMPTY. Retry the cleanup, then abandon leftovers. --- python/xy/_chromium.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/python/xy/_chromium.py b/python/xy/_chromium.py index 14468982..dde8d0e7 100644 --- a/python/xy/_chromium.py +++ b/python/xy/_chromium.py @@ -16,6 +16,7 @@ import json import re import secrets +import shutil import socket import subprocess import tempfile @@ -392,7 +393,16 @@ def close(self) -> None: self._proc.kill() self._proc.wait() self._stderr_file.close() - self._tmp.cleanup() + # Chromium helpers (crashpad handler, GPU process) can outlive the + # main process briefly and write into the profile while rmtree walks + # it, racing cleanup into ENOTEMPTY. Retry, then abandon leftovers. + for _ in range(20): + try: + self._tmp.cleanup() + return + except OSError: + time.sleep(0.25) + shutil.rmtree(self._tmp.name, ignore_errors=True) def __enter__(self) -> "ChromiumSession": return self From ef45d86cb4fe143ae5a7375d87a772d84bd9bed6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:23:04 +0500 Subject: [PATCH 8/9] Revert "fix(export): tolerate Chromium profile-dir cleanup races on session close" This reverts commit e9552a6e9f7f70ea43bd6fd31860777e4c079de8. --- python/xy/_chromium.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/python/xy/_chromium.py b/python/xy/_chromium.py index dde8d0e7..14468982 100644 --- a/python/xy/_chromium.py +++ b/python/xy/_chromium.py @@ -16,7 +16,6 @@ import json import re import secrets -import shutil import socket import subprocess import tempfile @@ -393,16 +392,7 @@ def close(self) -> None: self._proc.kill() self._proc.wait() self._stderr_file.close() - # Chromium helpers (crashpad handler, GPU process) can outlive the - # main process briefly and write into the profile while rmtree walks - # it, racing cleanup into ENOTEMPTY. Retry, then abandon leftovers. - for _ in range(20): - try: - self._tmp.cleanup() - return - except OSError: - time.sleep(0.25) - shutil.rmtree(self._tmp.name, ignore_errors=True) + self._tmp.cleanup() def __enter__(self) -> "ChromiumSession": return self From 1dfaeda42926ec0e0ea0deae463cd9fd77c06ef2 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:24:32 +0500 Subject: [PATCH 9/9] fix(export): shut Chromium down via CDP Browser.close before profile cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIGTERM killed the browser process out from under its helper processes, which kept flushing into the profile's Default/ while TemporaryDirectory cleanup walked it, failing CI teardown with ENOTEMPTY. Browser.close asks Chromium for an orderly exit — it flushes profile state and reaps its helpers before the main process exits — so waiting on the process leaves the profile quiescent. Also drop the crashpad handler, a detached process that writes into the profile on its own schedule. Signals remain only as escalation if the browser fails to exit. --- python/xy/_chromium.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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()