From e789a1ed605f1a90f964e7e23df15b924284062a Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Wed, 2 Sep 2026 12:00:15 -0700 Subject: [PATCH 1/7] Add xy.tooltip(mode="x"|"y"): a shared-axis tooltip with cursor and active dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recharts' axis tooltip and Plotly's hovermode="x unified": with mode="x" the pointer only has to be inside the plot, its horizontal position snaps to the nearest x value, and every series' point at that x is listed at once while the vertical position is ignored. The plot divides into full-height bands whose boundaries fall halfway between adjacent points; a cursor line marks the snapped x; each series shows an active dot; the tooltip follows the pointer. mode="y" mirrors it along the y axis. The default, mode="nearest", is unchanged and ships nothing new on the wire. Client: `_hover` routes to `_hoverBand` when the mode is set. `_bandHits` snaps every eligible series (point and line marks with retained CPU columns; density tiers, bars, rectangles, ribbons, funnels, heatmaps and segments keep their own hover geometry, legend-hidden series are out, polar falls back to nearest) to its nearest point along the band axis via the new `_nearestCpuIndexAlong` (which `_nearestCpuIndex` now delegates to), takes the candidate closest to the pointer as the band, and admits every series whose snapped point projects to the same coordinate within 0.5 CSS px. The band tooltip renders a title for the coordinate (or the authored template) and one row per series with the name painted in the series colour, honoring fields/format; it follows the pointer rather than anchoring, and a new `tooltip_cursor` DOM slot draws the line across the plot, reprojected on every draw. Active dots draw from the CPU columns through a scratch VAO rather than each trace's vertex buffer (a smoothed or stepped line's vertex index is not its data index), in the series colour. `xy:hover` carries one `points[]` entry per series; one exact pick per series goes to the kernel and each reply replaces its own row, matched by seq ahead of the single-pick sequence check. Fixed on the way: the existing nearest-mode hover highlight dot had stopped rendering. The full point program multiplies fill alpha by the per-item `a_style.x` factor; the regular scatter draw moved to the simpler point program that never sets that constant attribute, so `_drawHoverPoint` inherited its default of 0. It now sets every constant attribute and stroke uniform the program reads, and a probe pins the highlight paint landing on the presented canvas. Spec: interaction.md §7.3 (new) and the §3 xy:hover row; the slot joins styling.md, chrome-slots.md and the regenerated capability matrix (48 -> 49 slots; counts updated in export.md and the dossier); a live capture under spec/assets. Docs gain a "Shared Tooltip Along an Axis" section with a demo. Tests: wire option (opt-in only, validation, dataclass positional order, re-validation of a mutated node), browser probes for the x band (far-above selection, same-band re-placement without new picks, midpoint boundary in both directions, outside-plot hide, legend-hidden exclusion, cursor geometry, series-coloured labels and active dots measured on the presented canvas, one pick per series, xy:hover points), the y band, the unchanged default, the restored nearest-mode highlight, and byte-identical static exports. --- docs/components/tooltips.md | 36 +++ docs/styling/capabilities.md | 3 +- docs/styling/chrome-slots.md | 5 +- js/src/20_theme.ts | 1 + js/src/50_chartview.ts | 234 ++++++++++++++-- js/src/52_tooltip.ts | 184 ++++++++++++- js/src/54_kernel.ts | 6 + js/src/57_viewstate.ts | 25 +- python/xy/components.py | 24 ++ python/xy/dom.py | 1 + spec/api/capability-matrix.md | 3 +- spec/api/export.md | 4 +- spec/api/interaction.md | 52 +++- spec/api/styling.md | 1 + spec/assets/tooltip-x-band.png | Bin 0 -> 38527 bytes spec/design-dossier.md | 2 +- tests/test_static_client_security.py | 1 + tests/test_tooltip_band.py | 396 +++++++++++++++++++++++++++ tests/test_type_surface.py | 1 + 19 files changed, 937 insertions(+), 42 deletions(-) create mode 100644 spec/assets/tooltip-x-band.png create mode 100644 tests/test_tooltip_band.py diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index 74803b17b..c80970b96 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -193,6 +193,42 @@ See [Events and callbacks](/docs/xy/api-reference/events-and-callbacks/) for hover payloads and [Marks and components reference](/docs/xy/api-reference/marks-and-components/) for the exact tooltip signature. +## Shared Tooltip Along an Axis + +`xy.tooltip(mode="x")` turns the tooltip into an axis tooltip, the model +Recharts uses by default and Plotly calls `hovermode="x unified"`. The pointer +only has to be inside the plot: its horizontal position snaps to the nearest x +value and every series' point at that x is listed at once, while the vertical +position is ignored. The plot divides into full-height bands with boundaries +halfway between adjacent points, a cursor line marks the selected x, each series +shows an active dot, and the tooltip follows the pointer. `mode="y"` does the +same along the y axis for horizontal layouts. The default, `mode="nearest"`, +keeps the 12 px nearest-point behavior. + +~~~python demo exec +import reflex_xy +import xy + +pages = ["Page A", "Page B", "Page C", "Page D", "Page E", "Page F", "Page G"] +shared_tooltip_chart = xy.line_chart( + xy.line(pages, [2400, 1398, 9800, 3908, 4800, 3800, 4300], name="pv", color="#8884d8", width=2), + xy.line(pages, [4000, 3000, 2000, 2780, 1890, 2390, 3490], name="uv", color="#82ca9d", width=2), + xy.tooltip(mode="x"), + xy.legend(loc="upper right"), + title="Hover anywhere above a page", +) + + +def shared_tooltip_demo(): + return reflex_xy.chart(shared_tooltip_chart, height="320px") +~~~ + +`fields=`, `format=`, and `title=` keep their meaning: the title template +resolves against the first series' row, and each series row shows the +requested fields (minus the band axis, which is already the title). Style the +cursor line through the `tooltip_cursor` slot or the `--chart-crosshair` +token it shares with the crosshair. + ## FAQ ### How do I show values on hover in a Python chart? diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 88ad0b0d2..21dc45d10 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -12,7 +12,7 @@ and *does the change survive where I need it*. This page answers both from the registry the implementation is checked against. - **11** mark style properties across **22** mark kinds, drawn by all three renderers. -- **48** stable chrome slots for CSS and Tailwind in the browser. +- **49** stable chrome slots for CSS and Tailwind in the browser. - **1** way to add a mark kind XY does not ship, without forking it. ## Mark style properties @@ -79,6 +79,7 @@ token bag or in mark and axis `style=`, which every renderer reads. | `tooltip_row` | full | none | none | | `tooltip_label` | full | none | none | | `tooltip_value` | full | none | none | +| `tooltip_cursor` | full | none | none | | `modebar` | full | none | none | | `modebar_drag_handle` | full | none | none | | `modebar_control_group` | full | none | none | diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 526d659ee..6a5dcde99 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -38,6 +38,7 @@ primitive or structural descendant is a separate DOM element. | `tooltip_row` | One tooltip field row | | `tooltip_label` | One tooltip field label | | `tooltip_value` | One formatted tooltip value | +| `tooltip_cursor` | Line across the plot at the shared-tooltip band coordinate (`xy.tooltip(mode="x")`) | | `modebar` | Mode/tool bar container | | `modebar_drag_handle` | Draggable grip revealed beside the toolbar | | `modebar_control_group` | Selection, pan, and export control group | @@ -358,8 +359,8 @@ apply it with. Rather than leave that to be discovered, it is a contract: | --- | --- | --- | --- | | mark / axis `style=` | yes | yes | yes | | chart-level `style=` (design tokens) | yes | yes | yes | -| `styles={slot: {...}}` | yes, all 48 slots | text subset, 9 slots | text subset, 9 slots | -| `class_names={slot: "..."}` | yes, all 48 slots | dropped | dropped | +| `styles={slot: {...}}` | yes, all 49 slots | text subset, 9 slots | text subset, 9 slots | +| `class_names={slot: "..."}` | yes, all 49 slots | dropped | dropped | | `custom_css=` | yes | raises | raises | | `xy.legend(style=...)` | yes | 6 keys | 6 keys | | `xy.colorbar(style=...)` | yes | dropped | dropped | diff --git a/js/src/20_theme.ts b/js/src/20_theme.ts index 4803d1273..ceeb0f822 100644 --- a/js/src/20_theme.ts +++ b/js/src/20_theme.ts @@ -186,6 +186,7 @@ export const XY_CHROME_CSS = ` :where(.xy [data-xy-selection-lasso-handle]){fill:var(--chart-bg,#fff);stroke:var(--chart-selection,var(--xy-selection));stroke-width:1.5;cursor:grab;pointer-events:all} :where(.xy [data-xy-selection-lasso-handle][data-xy-active]){cursor:grabbing;fill:var(--chart-selection,var(--xy-selection))} :where(.xy [data-xy-slot="crosshair_x"],.xy [data-xy-slot="crosshair_y"]){background:var(--chart-crosshair,rgba(15,23,42,.42))} +:where(.xy [data-xy-slot="tooltip_cursor"]){background:var(--chart-crosshair,rgba(15,23,42,.42))} :where(.xy [data-xy-slot="axis_band"]){cursor:var(--xy-axis-band-cursor)} :where(.xy [data-xy-slot="axis_line"],.xy [data-xy-slot="tick_mark"]){width:var(--xy-axis-rule-width);height:var(--xy-axis-rule-height);background:var(--xy-axis-rule-paint)} :where(.xy [data-xy-slot="tick_label"]){color:var(--chart-text,inherit)} diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 2c6df776f..7a9e7db40 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -6561,8 +6561,10 @@ export class ChartView { } // Presentation now owns the GL pixels. Do DOM/2D overlay work afterward // so the shared default framebuffer is copied immediately after GPU work. - // Keep a visible tooltip anchored through pan, zoom, and linked views. + // Keep a visible tooltip anchored through pan, zoom, and linked views — + // and a band cursor on its snapped coordinate (§7.3). this._repositionTooltip(); + if (this._bandCursor) this._positionTooltipCursor(); // 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; @@ -6805,6 +6807,28 @@ export class ChartView { } _drawHoverState() { + if (this._hoverTargets && this._hoverTargets.length) { + // A band's dots come from the retained CPU columns, not the vertex + // buffers: a smoothed or stepped line's vertex index is not its data + // index (§7.3). + for (const hit of this._hoverTargets) { + const g = hit.g; + if (!g || g.tier === "density" || g._legendHidden || !g._cpu) continue; + const cpu = g._cpu; + const xMeta = cpu.xMeta || g.xMeta; + const yMeta = cpu.yMeta || g.yMeta; + const [x0, x1] = this._axisRange(g.xAxis); + const [y0, y1] = this._axisRange(g.yAxis); + this._drawHoverPoint( + g, + 0, + this._map(xMeta, x0, x1, g.xAxis), + this._map(yMeta, y0, y1, g.yAxis), + { x: cpu.x[hit.index], y: cpu.y[hit.index], xMeta, yMeta, color: g.color }, + ); + } + return; + } const hit = this._hoverTarget; if (!hit || !hit.g) return; const g = hit.g; @@ -6822,15 +6846,18 @@ export class ChartView { ); } - _drawHoverPoint(g, index, xm, ym) { + // `encoded` draws one dot from explicit encoded coordinates (and metas) + // through a scratch buffer instead of `g`'s vertex buffers at `index`; + // its optional `color` (unit RGBA) replaces the hover-state paint. + _drawHoverPoint(g, index, xm, ym, encoded: any = null) { const gl = this.gl; const prog = this.pointProg; gl.useProgram(prog); const u = (n) => uniformOf(gl, prog, n); gl.uniform2f(u("u_xmap"), xm[0], xm[1]); gl.uniform2f(u("u_ymap"), ym[0], ym[1]); - this._setAxisUniforms(prog, "u_x", g.xMeta, g.xAxis); - this._setAxisUniforms(prog, "u_y", g.yMeta, g.yAxis); + this._setAxisUniforms(prog, "u_x", encoded ? encoded.xMeta : g.xMeta, g.xAxis); + this._setAxisUniforms(prog, "u_y", encoded ? encoded.yMeta : g.yMeta, g.yAxis); this._setPolarUniforms(prog); // Size-channel points hover at their encoded size, not the scalar default // (sample traces keep no CPU copy of the size column; they fall back). @@ -6842,7 +6869,10 @@ export class ChartView { const defaultSize = Math.max(adjustedSize * 1.75, adjustedSize + 5); const size = Math.max(0, this._markStateNumber("hover", "size", defaultSize)); const opacity = Math.max(0, Math.min(1, this._markStateNumber("hover", "opacity", 0.95))); - const color = parseColor( + const seriesColor = encoded && Array.isArray(encoded.color) && encoded.color.length >= 3 + ? encoded.color + : null; + const color = seriesColor || parseColor( this.root, this._markStatePaint("hover", "color", "rgba(15,23,42,.92)"), [0.06, 0.09, 0.16, 0.92] @@ -6858,16 +6888,60 @@ export class ChartView { gl.uniform4f(u("u_color"), color[0], color[1], color[2], 1); gl.uniform1i(u("u_selActive"), 0); gl.uniform1f(u("u_dblend"), 0); - - this._bindVao(g, "hover", [g.xBuf._fcId, g.yBuf._fcId], () => { - this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); - this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); - }); + // The full point program reads per-item style/paint through constant + // vertex attributes and stroke uniforms that the regular scatter draw sets + // for every trace. Constant attributes are global GL state, not program + // state, and the dominant scatter path now renders through the simpler + // point program that never touches `a_style` — so without setting them + // here the hover dot inherited `a_style = (0, 0, 0, 1)`, and its + // `.x` per-item opacity factor of 0 made the highlight invisible. + gl.uniform1i(u("u_symbol"), 0); + gl.uniform1f(u("u_ptStrokeWidth"), 0); + gl.uniform1i(u("u_ptStrokeFace"), 0); + gl.uniform1i(u("u_strokeMode"), 0); + gl.uniform1f(u("u_strokeOpacity"), 1); + gl.uniform1i(u("u_transitionActive"), 0); + gl.uniform1f(u("u_transitionProgress"), 1); + gl.vertexAttrib4f(ATTR_SLOTS.a_rgba, color[0], color[1], color[2], 1); + gl.vertexAttrib4f(ATTR_SLOTS.a_style, 1, -1, -1, -1); + gl.vertexAttrib4f(ATTR_SLOTS.a_stroke, color[0], color[1], color[2], 1); + + if (encoded) { + // A dedicated VAO: on the default one, attribute arrays other traces + // enabled (size, selection) would still be on and override the + // per-vertex constants below — the point then draws at size zero. + if (!this._bandDotVao) { + this._bandDotBufX = gl.createBuffer(); + this._bandDotBufY = gl.createBuffer(); + this._bandDotVao = gl.createVertexArray(); + gl.bindVertexArray(this._bandDotVao); + gl.bindBuffer(gl.ARRAY_BUFFER, this._bandDotBufX); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(1), gl.DYNAMIC_DRAW); + this._vaoAttr(ATTR_SLOTS.ax, this._bandDotBufX, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, this._bandDotBufY); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(1), gl.DYNAMIC_DRAW); + this._vaoAttr(ATTR_SLOTS.ay, this._bandDotBufY, 0, 0); + } else { + gl.bindVertexArray(this._bandDotVao); + } + // bufferSubData keeps the VAO's pointers valid; only the bytes change. + gl.bindBuffer(gl.ARRAY_BUFFER, this._bandDotBufX); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array([encoded.x])); + gl.bindBuffer(gl.ARRAY_BUFFER, this._bandDotBufY); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, new Float32Array([encoded.y])); + index = 0; + } else { + this._bindVao(g, "hover", [g.xBuf._fcId, g.yBuf._fcId], () => { + this._vaoAttr(ATTR_SLOTS.ax, g.xBuf, 0, 0); + this._vaoAttr(ATTR_SLOTS.ay, g.yBuf, 0, 0); + }); + } gl.vertexAttrib1f(ATTR_SLOTS.a_cval, 0); gl.vertexAttrib1f(ATTR_SLOTS.a_sval, 0.5); gl.vertexAttrib1f(ATTR_SLOTS.a_sel, 1); gl.vertexAttrib1f(ATTR_SLOTS.a_dval, 0); gl.drawArrays(gl.POINTS, index, 1); + if (encoded) gl.bindVertexArray(null); } _drawDensity(g, density, opacityScale = 1) { @@ -8723,22 +8797,31 @@ export class ChartView { } _nearestCpuIndex(g, dataX) { + return this._nearestCpuIndexAlong(g, "x", dataX); + } + + // Nearest retained row along ONE axis, in that axis's own coordinate space + // (so a log axis measures decades, not values). The x form is the point + // tooltip's fallback when the GPU pick misses; the y form serves + // `xy.tooltip(mode="y")` bands (interaction spec §7.3). + _nearestCpuIndexAlong(g, dim, target) { const cpu = g && g._cpu; - if (!cpu || !cpu.x || !cpu.x.length) return -1; - const xMeta = cpu.xMeta || g.xMeta; - const axis = this._axis(g.xAxis); - const target = this._axisCoord(axis, dataX); + const column = cpu && (dim === "x" ? cpu.x : cpu.y); + if (!column || !column.length) return -1; + const meta = dim === "x" ? cpu.xMeta || g.xMeta : cpu.yMeta || g.yMeta; + const axis = this._axis(dim === "x" ? g.xAxis : g.yAxis); + const starts = dim === "x" ? g._transitionPrevXValues : g._transitionPrevYValues; + const progress = g._transitionPositionProgress; + const coord = this._axisCoord(axis, target); let best = -1; let bestDist = Infinity; - const limit = Math.min(cpu.x.length, g.n || cpu.x.length); + const limit = Math.min(column.length, g.n || column.length); for (let i = 0; i < limit; i++) { - const starts = g._transitionPrevXValues; - const progress = g._transitionPositionProgress; - const xEncoded = starts && Number.isFinite(progress) - ? starts[i] + (cpu.x[i] - starts[i]) * progress - : cpu.x[i]; - const x = xEncoded / (xMeta.scale || 1) + xMeta.offset; - const d = Math.abs(this._axisCoord(axis, x) - target); + const encoded = starts && Number.isFinite(progress) + ? starts[i] + (column[i] - starts[i]) * progress + : column[i]; + const value = encoded / (meta.scale || 1) + meta.offset; + const d = Math.abs(this._axisCoord(axis, value) - coord); if (d < bestDist) { bestDist = d; best = i; @@ -8747,6 +8830,108 @@ export class ChartView { return best; } + // The retained row's data-space (x, y), transition-interpolated like the + // draw is, so a band computed mid-animation lands where the dot draws. + _cpuPointValue(g, idx) { + const cpu = g._cpu; + const xMeta = cpu.xMeta || g.xMeta; + const yMeta = cpu.yMeta || g.yMeta; + const progress = g._transitionPositionProgress; + const xEncoded = g._transitionPrevXValues && Number.isFinite(progress) + ? g._transitionPrevXValues[idx] + (cpu.x[idx] - g._transitionPrevXValues[idx]) * progress + : cpu.x[idx]; + const yEncoded = g._transitionPrevYValues && Number.isFinite(progress) + ? g._transitionPrevYValues[idx] + (cpu.y[idx] - g._transitionPrevYValues[idx]) * progress + : cpu.y[idx]; + return [xEncoded / (xMeta.scale || 1) + xMeta.offset, yEncoded / (yMeta.scale || 1) + yMeta.offset]; + } + + // `xy.tooltip(mode="x"|"y")`, or null for the nearest-point default. Polar + // has no band axis to snap along and keeps nearest-point hover. + _tooltipBandMode() { + const mode = this.spec && this.spec.tooltip && this.spec.tooltip.mode; + if (mode !== "x" && mode !== "y") return null; + if (this._polarGeometry()) return null; + return mode; + } + + // Shared-axis hover (interaction spec §7.3): the pointer's coordinate along + // the band axis alone picks the data. Every eligible series snaps to its + // point nearest along that axis; the one closest to the pointer sets the + // band, and every series whose snapped point projects to the same + // coordinate joins it — index-aligned series read as one band with + // boundaries halfway between adjacent points, while a series with no point + // at that coordinate is left out rather than guessed. The perpendicular + // coordinate is ignored entirely. + _bandHits(cssX, cssY, dim) { + const candidates = []; + for (const g of this.gpuTraces) { + if (g.tier === "density" || g._legendHidden) continue; + // Marks with their own hover geometry never join a band (§7.3). + if (g.heatmap || g.trace.bar || g._cpuRibbon || g._cpuFunnel || g._cpuRect || g._segmentCpu) continue; + if (!g._cpu || !g._cpu.x || !g._cpu.y) continue; + const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); + const target = dim === "x" ? dataX : dataY; + if (!Number.isFinite(target)) continue; + const idx = this._nearestCpuIndexAlong(g, dim, target); + if (idx < 0) continue; + const [x, y] = this._cpuPointValue(g, idx); + const [chartX, chartY] = this._projectDataPoint(g.xAxis, g.yAxis, x, y); + const px = dim === "x" ? chartX - this.plot.x : chartY - this.plot.y; + if (!Number.isFinite(px)) continue; + candidates.push({ trace: g.trace.id, index: idx, g, px, x, y, dist: 0, synthetic: true }); + } + if (!candidates.length) return null; + const cursor = dim === "x" ? cssX : cssY; + let anchor = candidates[0]; + for (const c of candidates) { + if (Math.abs(c.px - cursor) < Math.abs(anchor.px - cursor)) anchor = c; + } + // Same projected coordinate within half a CSS pixel: f32 decode noise, + // not a different value. + const hits = candidates.filter((c) => Math.abs(c.px - anchor.px) <= 0.5); + return { hits, anchor, dim }; + } + + _hoverBand(e, cssX, cssY, dim) { + const p = this.plot; + const inside = cssX >= 0 && cssX <= p.w && cssY >= 0 && cssY <= p.h; + const band = inside ? this._bandHits(cssX, cssY, dim) : null; + if (!band || !band.hits.length) { + const had = this._hoverId !== -1 || !!(this._hoverTargets && this._hoverTargets.length); + this._hoverId = -1; + this._hoverTarget = null; + this._lastHoverXY = null; + this._pickSeq = (this._pickSeq || 0) + 1; + this._hideTooltip(); + if (had) this._drawKeepPick(); + return; + } + this._lastHoverXY = { clientX: e.clientX, clientY: e.clientY }; + const key = band.hits.map((h) => `${h.trace}:${h.index}`).join("|"); + if (key === this._bandKey) { + // Same band: the content and the cursor stay; only the tooltip follows + // the pointer (Recharts' cursor model, §7.3). + const rect = this.root.getBoundingClientRect(); + this._placeTooltip(e.clientX - rect.left, e.clientY - rect.top); + return; + } + this._bandKey = key; + this._hoverTargets = band.hits; + this._hoverTarget = band.hits[0]; + this._hoverId = band.hits[0].trace * 1e9 + band.hits[0].index; + this._bandRows = band.hits.map((h) => this._localRow(h)); + this._lastRow = this._bandRows[0]; + this._tooltipAnchor = null; + const ag = band.anchor.g; + this._bandCursor = { dim, xAxis: ag.xAxis, yAxis: ag.yAxis, x: band.anchor.x, y: band.anchor.y }; + this._renderBandTooltip(e.clientX, e.clientY); + this._positionTooltipCursor(); + this._dispatchBandHover(e.clientX, e.clientY, false); + this._requestBandPicks(); + this._drawKeepPick(); + } + _nearestPolarCpuIndex(g, cssX, cssY) { const cpu = g && g._cpu; if (!cpu || !cpu.x || !cpu.y) return -1; @@ -9035,6 +9220,11 @@ export class ChartView { const rect = this.canvas.getBoundingClientRect(); const cssX = e.clientX - rect.left; const cssY = e.clientY - rect.top; + const bandMode = this._tooltipBandMode(); + if (bandMode) { + this._hoverBand(e, cssX, cssY, bandMode); + return; + } const hit = this._pickAt(cssX, cssY) || this._hoverAt(cssX, cssY); if (!hit) { const hadHover = this._hoverId !== -1; diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index 73e26d097..4ff303131 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -7,6 +7,7 @@ import { ChartView } from "./50_chartview"; Object.assign(ChartView.prototype, { _showTooltip(hit, clientX, clientY) { + this._clearBandHover(); const row = this._localRow(hit); this._lastRow = row; this._setTooltipAnchor(hit, row, clientX, clientY); @@ -479,7 +480,7 @@ Object.assign(ChartView.prototype, { _tooltipLines(items) { return items.map((item) => ( - item.kind === "field" ? `${item.label}: ${item.value}` : item.value + item.kind === "field" || item.kind === "series" ? `${item.label}: ${item.value}` : item.value )); }, @@ -492,10 +493,13 @@ Object.assign(ChartView.prototype, { row.textContent = item.value; } else { this._applySlot(row, "tooltip_row"); - if (item.kind === "field") { + if (item.kind === "field" || item.kind === "series") { const label = document.createElement("span"); this._applySlot(label, "tooltip_label"); label.textContent = item.label; + // A band row names a series; painting the name in the series + // colour is the swatch (renderer-owned state, inline like hover). + if (item.kind === "series" && item.color) label.style.color = item.color; row.appendChild(label); } const value = document.createElement("span"); @@ -545,6 +549,182 @@ Object.assign(ChartView.prototype, { _hideTooltip() { this.tooltip.style.display = "none"; this._tooltipAnchor = null; + this._clearBandHover(); + }, + + // -- shared-axis bands (interaction spec §7.3) ------------------------------ + + _clearBandHover() { + this._bandKey = null; + this._hoverTargets = null; + this._bandRows = null; + this._bandCursor = null; + if (this._bandPicks) this._bandPicks.clear(); + this._hideTooltipCursor(); + }, + + _ensureTooltipCursor() { + if (this._tooltipCursor) return this._tooltipCursor; + const el = document.createElement("div"); + el.style.cssText = "position:absolute;display:none;pointer-events:none;z-index:3;"; + this._applySlot(el, "tooltip_cursor"); + this.root.appendChild(el); + this._tooltipCursor = el; + return el; + }, + + // The cursor is anchored in data space like a point tooltip: reprojected on + // every draw, hidden when its coordinate leaves the plot. + _positionTooltipCursor() { + const a = this._bandCursor; + if (!a) { this._hideTooltipCursor(); return; } + const el = this._ensureTooltipCursor(); + const [lx, ly] = this._projectDataPoint(a.xAxis, a.yAxis, a.x, a.y); + const p = this.plot; + const pos = a.dim === "x" ? lx : ly; + const lo = a.dim === "x" ? p.x : p.y; + const hi = a.dim === "x" ? p.x + p.w : p.y + p.h; + if (!Number.isFinite(pos) || pos < lo || pos > hi) { el.style.display = "none"; return; } + el.style.display = "block"; + if (a.dim === "x") { + el.style.left = `${lx}px`; + el.style.top = `${p.y}px`; + el.style.width = "1px"; + el.style.height = `${p.h}px`; + } else { + el.style.left = `${p.x}px`; + el.style.top = `${ly}px`; + el.style.width = `${p.w}px`; + el.style.height = "1px"; + } + }, + + _hideTooltipCursor() { + if (this._tooltipCursor) this._tooltipCursor.style.display = "none"; + }, + + // One title for the band coordinate (or the authored template against the + // anchor series' row), then one row per series: its name in its colour and + // its value along the other axis — or the authored fields minus the band + // field — through the same format grammar as a point tooltip. + _bandTooltipItems(rows, hits) { + const tooltip = this.spec.tooltip || {}; + const formats = tooltip.format || {}; + const along = this._tooltipBandMode() || "x"; + const across = along === "x" ? "y" : "x"; + const first = rows[0]; + const items: any[] = []; + let title; + if (typeof tooltip.title === "string") { + title = tooltip.title.replace(/\{([^}]+)\}/g, (_, field) => { + const [value, kind] = this._tooltipLookup(first, field); + return value === undefined ? "" : this._formatTooltipValue(value, kind, formats[field]); + }); + } else if (first[along] !== undefined) { + title = this._formatTooltipValue(first[along], first[`${along}_kind`], formats[along]); + } + if (title) items.push({ kind: "title", value: title }); + const fields = Array.isArray(tooltip.fields) + ? tooltip.fields.filter((f) => typeof f === "string" && f !== along) + : null; + rows.forEach((row, i) => { + const g = hits[i] && hits[i].g; + const name = this._tooltipSeriesName(row) || `series ${i + 1}`; + let value; + if (fields && fields.length) { + value = fields + .map((f) => { + const [v, k] = this._tooltipLookup(row, f); + return v === undefined ? null : this._formatTooltipValue(v, k, formats[f]); + }) + .filter((v) => v !== null) + .join(" "); + } else { + const v = row[across]; + value = v === undefined ? "" : this._formatTooltipValue(v, row[`${across}_kind`], formats[across]); + } + items.push({ kind: "series", label: name, value, color: this._seriesColorCss(g) }); + }); + return items; + }, + + // The band tooltip follows the pointer (§7.3): a band has several points, + // and the cursor line already marks where it is. + _renderBandTooltip(clientX, clientY, options: any = {}) { + const rows = this._bandRows; + const hits = this._hoverTargets; + if (!rows || !rows.length || !hits || this.spec.show_tooltip === false) { + this._hideTooltip(); + return; + } + const items = this._bandTooltipItems(rows, hits); + if (!this._customTooltip) this._renderBuiltinTooltip(items); + if (this.a11yLive && options.announce !== false) { + const announcement = this._tooltipLines(items).join(", "); + if (this.a11yLive.textContent !== announcement) this.a11yLive.textContent = announcement; + } + this.tooltip.style.display = "block"; + const rect = this.root.getBoundingClientRect(); + this._placeTooltip(clientX - rect.left, clientY - rect.top); + }, + + _dispatchBandHover(clientX, clientY, exact) { + if (!this._interactionFlag("hover")) return; + const rows = this._bandRows; + const hits = this._hoverTargets; + if (!rows || !hits || !rows.length) return; + const points = hits.map((h, i) => this._hoverPoint(rows[i], h)); + this._dispatchChartEvent("hover", { + row: rows[0], + trace: hits[0].trace, + index: hits[0].index, + ...(exact ? { exact: true } : {}), + view: this._eventView("hover"), + ...this._hoverPayload(rows[0], hits[0], clientX, clientY, exact, points), + }); + }, + + // One exact pick per series in the band; replies are matched by seq to + // their row (`_applyBandPickResult`), never to the single-pick `_pickSeq`. + _requestBandPicks() { + if (!this.comm || !this._hoverTargets) return; + if (!this._bandPicks) this._bandPicks = new Map(); + this._bandPicks.clear(); + this._hoverTargets.forEach((h, i) => { + this._pickSeq = (this._pickSeq || 0) + 1; + this._bandPicks.set(this._pickSeq, i); + this.comm.send({ type: "pick", seq: this._pickSeq, trace: h.trace, index: h.index }); + }); + }, + + _applyBandPickResult(msg) { + const slot = this._bandPicks.get(msg.seq); + this._bandPicks.delete(msg.seq); + const rows = this._bandRows; + if (!msg.row || !rows || slot === undefined || slot >= rows.length) return; + const rowG = this.gpuTraces.find((t) => t.trace.id === msg.row.trace); + if (!rowG) return; + for (const channel of ["x", "y"]) { + if (typeof msg.row[channel] !== "number") continue; + const [value, kind] = this._sourceDisplayValue( + rowG, channel, msg.row[channel], msg.row[`${channel}_kind`], + ); + msg.row[channel] = value; + if (kind === undefined) delete msg.row[`${channel}_kind`]; + } + const local = rows[slot]; + if (local && local.trace === msg.row.trace && local.index === msg.row.index) { + for (const [key, value] of Object.entries(local)) { + if (msg.row[key] === undefined) msg.row[key] = value; + } + } + rows[slot] = msg.row; + // The primary row mirrors the single-pick path's `_lastRow` contract. + if (slot === 0) this._lastRow = rows[0]; + const xy = this._lastHoverXY; + if (!xy) return; + this._renderBandTooltip(xy.clientX, xy.clientY, { announce: false }); + if (this._bandPicks.size === 0) this._dispatchBandHover(xy.clientX, xy.clientY, true); }, // A hidden retained anchor is off-screen and may return after another draw. diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index e7df9f40c..f91b9fba0 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -848,6 +848,12 @@ Object.assign(ChartView.prototype, { } else if (msg.type === "append") { this._applyAppend(msg, buffers); } else if (msg.type === "pick_result") { + // A shared-axis band sends one pick per series; each reply belongs to + // its own row (§7.3), not to the single-pick sequence below. + if (this._bandPicks && this._bandPicks.has(msg.seq)) { + this._applyBandPickResult(msg); + return; + } if (msg.seq !== undefined && msg.seq !== this._pickSeq) return; if (!msg.row) { this._hideTooltip(); return; } // The kernel returns exact values for the picked trace only. Rehydrate diff --git a/js/src/57_viewstate.ts b/js/src/57_viewstate.ts index 8d3b65f7c..bedf7a395 100644 --- a/js/src/57_viewstate.ts +++ b/js/src/57_viewstate.ts @@ -548,7 +548,20 @@ Object.assign(ChartView.prototype, { // by exact axis ID (one entry per declared axis — a chart-root pixel maps // to a different value on every axis, so a bare {x, y} would be ambiguous // with a y2 declared). - _hoverPayload(row, hit, clientX, clientY, exact = false) { + _hoverPoint(row, hit) { + const g = hit && hit.g; + return { + trace: (g && g.trace && g.trace.name) || row.trace, + index: row.index, + row, + x_axis: (g && g.xAxis) || "x", + y_axis: (g && g.yAxis) || "y", + color: this._seriesColorCss(g), + }; + }, + + // `points` lets a shared-axis band (§7.3) supply one entry per series. + _hoverPayload(row, hit, clientX, clientY, exact = false, points = null) { const rootRect = this.root.getBoundingClientRect(); const canvasRect = this.canvas.getBoundingClientRect(); const cssX = Math.max(0, Math.min(canvasRect.width, clientX - canvasRect.left)); @@ -563,15 +576,7 @@ Object.assign(ChartView.prototype, { ); data[axisId] = dim === "x" ? x : y; } - const g = hit && hit.g; - const points = row ? [{ - trace: (g && g.trace && g.trace.name) || row.trace, - index: row.index, - row, - x_axis: (g && g.xAxis) || "x", - y_axis: (g && g.yAxis) || "y", - color: this._seriesColorCss(g), - }] : []; + if (!points) points = row ? [this._hoverPoint(row, hit)] : []; const payload: any = { active: true, cursor: { diff --git a/python/xy/components.py b/python/xy/components.py index f365432da..d4b1fa038 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -294,6 +294,7 @@ class Tooltip(Component): # New fields append after ``render``: Tooltip is public and positional # construction over the released field order must keep binding. labels: dict[str, str] = field(default_factory=dict) + mode: str = "nearest" @dataclass @@ -3181,6 +3182,7 @@ def tooltip( title: Optional[str] = None, format: Optional[dict[str, str]] = None, labels: Optional[dict[str, str]] = None, + mode: str = "nearest", class_name: Optional[str] = None, style: Optional[dict[str, StyleValue]] = None, ) -> Tooltip: @@ -3196,6 +3198,13 @@ def tooltip( labels: Display labels keyed by source field. Without ``fields``, they rename the matching default x/y/color/size rows. Formatting and title placeholders continue to use the source field names. + mode: How the pointer selects data (live client only). ``"nearest"`` + shows the mark within 12 px of the pointer. ``"x"`` is a shared + axis tooltip: only the pointer's horizontal position matters, it + snaps to the nearest x value, and every series' point at that x + is listed together with a cursor line — the vertical position is + ignored, so the whole plot height is the hit target. ``"y"`` does + the same along the y axis. class_name: DOM class name applied to the tooltip. style: Tooltip style overrides. """ @@ -3206,6 +3215,7 @@ def tooltip( title=_optional_string(title, "tooltip title"), format=_string_dict(format, "tooltip format"), labels=_string_dict(labels, "tooltip labels"), + mode=_tooltip_mode(mode), class_name=_optional_string(class_name, "tooltip class_name"), style=_style_dict(style, "tooltip style"), render=render, @@ -4071,6 +4081,7 @@ def figure(self) -> Figure: title=node.title, format=node.format, labels=node.labels, + mode=node.mode, class_name=node.class_name, style=node.style, ) @@ -5222,12 +5233,25 @@ def _apply_chrome_node( fig.chrome_styles[slot] = {**fig.chrome_styles.get(slot, {}), **style} +_TOOLTIP_MODES = ("nearest", "x", "y") + + +def _tooltip_mode(value: Any) -> str: + if not isinstance(value, str) or value not in _TOOLTIP_MODES: + raise ValueError(f"tooltip mode must be one of {list(_TOOLTIP_MODES)}, got {value!r}") + return value + + def _tooltip_spec( node: Tooltip, aliases: dict[str, str], sources: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: spec: dict[str, Any] = {} + if node.mode != "nearest": + # Default-on/opt-in only on the wire, so existing specs stay + # byte-identical. + spec["mode"] = node.mode if node.fields: spec["fields"] = list(node.fields) if node.title is not None: diff --git a/python/xy/dom.py b/python/xy/dom.py index 08a513479..d087c06b4 100644 --- a/python/xy/dom.py +++ b/python/xy/dom.py @@ -28,6 +28,7 @@ "tooltip_row", "tooltip_label", "tooltip_value", + "tooltip_cursor", "modebar", "modebar_drag_handle", "modebar_control_group", diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 069fc2189..8465db13f 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -14,7 +14,7 @@ which is sometimes deliberate, and the notes say which. ## In one line - **11** mark style properties across **22** mark kinds, drawn by all three renderers. -- **48** stable chrome slots, CSS- and Tailwind-addressable in the browser; **10** of them reach the native writers — nine through `styles={slot: ...}` itself, and `root` through the chart-level `style=` token bag. +- **49** stable chrome slots, CSS- and Tailwind-addressable in the browser; **10** of them reach the native writers — nine through `styles={slot: ...}` itself, and `root` through the chart-level `style=` token bag. - **1** shipped extension point. - **1** known default divergence between renderers, listed below rather than left to be discovered. @@ -87,6 +87,7 @@ contracted in [export.md](export.md) §9 and pinned by | `tooltip_row` | full | none | none | | `tooltip_label` | full | none | none | | `tooltip_value` | full | none | none | +| `tooltip_cursor` | full | none | none | | `modebar` | full | none | none | | `modebar_drag_handle` | full | none | none | | `modebar_control_group` | full | none | none | diff --git a/spec/api/export.md b/spec/api/export.md index 259e403d5..3e7f8fbcd 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -248,8 +248,8 @@ vector** (`_svg.to_svg`, and `_pdf.svg_to_pdf` on top of it). | `style={...}` on a mark | yes | yes | yes | validated CSS subset, `styles.compile_mark_style` | | `style={...}` on an axis | yes | yes | yes | validated vocabulary, `styles.compile_axis_style` | | `style={...}` on the chart (token bag) | yes | yes | yes | `spec["dom"]["style"]`, read at `_svg.py:767,1481` and `_raster.py:662` | -| `styles={slot: {...}}` (per-slot inline) | yes, all 48 slots | text subset, 9 slots | text subset, 9 slots | `_svg.STATIC_STYLED_SLOTS`; the rest is live-only chrome | -| `class_names={slot: "..."}` | yes, all 48 slots | **dropped** | **dropped** | silent — the SVG writer emits no `class` at all | +| `styles={slot: {...}}` (per-slot inline) | yes, all 49 slots | text subset, 9 slots | text subset, 9 slots | `_svg.STATIC_STYLED_SLOTS`; the rest is live-only chrome | +| `class_names={slot: "..."}` | yes, all 49 slots | **dropped** | **dropped** | silent — the SVG writer emits no `class` at all | | `custom_css="..."` | yes (HTML + Chromium capture) | **raises** | **raises** | `_resolve_image_engine`, `export.py:812` | | `xy.legend(style=...)` | yes | 6 keys | 6 keys | merged with the slot and the theme token before the writers see it | | `xy.colorbar(style=...)` | yes | **dropped** | **dropped** | no native channel; use `styles={"colorbar_title"/"colorbar_tick": ...}` | diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 33870d820..4ab3f7a3c 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -163,7 +163,7 @@ aliases for `ranges.x`/`ranges.y` (`50_chartview.ts`, `_eventView`). | Event | Detail | | --- | --- | -| `xy:hover` | `{row, trace, index, view}` plus the structured payload `{active: true, cursor: {px, data}, points}` (view-state.md §7.1) — genuinely additive; the kernel's exact-value reply re-dispatches with `exact: true` and a refreshed payload. `cursor.px` is chart-root-relative pixels; `cursor.data` is keyed by **exact axis ID** with one entry per declared axis; each `points[]` entry carries `trace` (series name), `index`, `row`, its `x_axis`/`y_axis` bindings, and the series `color`. | +| `xy:hover` | `{row, trace, index, view}` plus the structured payload `{active: true, cursor: {px, data}, points}` (view-state.md §7.1) — genuinely additive; the kernel's exact-value reply re-dispatches with `exact: true` and a refreshed payload. `cursor.px` is chart-root-relative pixels; `cursor.data` is keyed by **exact axis ID** with one entry per declared axis; each `points[]` entry carries `trace` (series name), `index`, `row`, its `x_axis`/`y_axis` bindings, and the series `color`. Under `xy.tooltip(mode="x"\|"y")` (§7.3) `points[]` holds one entry per series in the band and `row`/`trace`/`index` describe the first. | | `xy:leave` | `{view, active: false}` with `source: "leave"`. Dispatched by canvas pointer exit and by a document-level missed-leave backstop: browsers skip boundary events when the element under a stationary cursor changes (page scroll, hit-test churn), so while a pointer-owned readout is live, a `pointerover` whose target left the chart root runs the same exit path (`53_interaction.ts` `_pointerHoverExit`). Keyboard readouts are exempt — they survive mouse movement elsewhere and are dismissed by `Escape`. | | `xy:click` | `{x, y, view, row, trace, index}`; `row`/`trace`/`index` are `null` when the click hit no mark. | | `xy:brush` | `{range: {x0, x1, y0, y1}, view}` for box/axis-range drags, or `{polygon: [[x, y], …], view}` for lasso. | @@ -398,6 +398,56 @@ The hover tooltip is anchored in data space, not at the cursor keeps the edge-clamped placement (the anchor is dropped when its projection starts outside the plot rect). +### 7.3 Shared-axis bands (`xy.tooltip(mode="x")`) + +`mode="nearest"` (the default) is the behaviour above: the pointer must land +within 12 px of a mark. `mode="x"` (and `mode="y"`) trades that for Recharts' +axis tooltip and Plotly's `hovermode="x unified"`: only the pointer's +coordinate along the band axis picks the data, the perpendicular coordinate +is ignored entirely, and the whole plot height (width) is the hit target. +Wire: `tooltip.mode`, shipped only when not `"nearest"`. + +- Every eligible series snaps to its point nearest the pointer along the + band axis (`_nearestCpuIndexAlong`, `50_chartview.ts`); the closest of + those to the pointer sets the band, and every series whose snapped point + projects to the same coordinate (within 0.5 CSS px — f32 decode noise, not + a different x) joins it (`_bandHits`). Index-aligned series therefore read + as one band whose boundaries fall halfway between adjacent points; a + series with no point at that coordinate is omitted rather than guessed. +- Eligible series are point and line marks with retained CPU columns. + Density tiers, bars, rectangles, ribbons, funnels, heatmaps and segments + keep their own hover geometry and never join a band; legend-hidden series + are out (§10). Polar charts have no band axis and fall back to nearest. +- The tooltip shows the band coordinate as its title (or the authored + `title` template resolved against the anchor series' row), then one row per + series in band order: the series name painted in the series colour, then + its value along the other axis — or the authored `fields`, minus the band + field — through the same `format` grammar. It **follows the pointer**: the + one exception to the data-space anchoring above, because a band has several + points and the cursor already marks it. The `tooltip_cursor` DOM slot draws + that line across the plot at the snapped coordinate, reprojected on every + draw exactly as an anchor would be, hidden when the coordinate leaves the + plot. +- Every series in the band gets an active dot, drawn from its CPU columns + rather than its vertex buffer (a smoothed or stepped line's vertex index is + not its data index) in the series colour. Adding it exposed that the + nearest-mode highlight dot had stopped rendering at all: the full point + program multiplies fill alpha by the per-item `a_style.x` factor, the + regular scatter draw moved to the simpler point program that never sets + that constant attribute, and `_drawHoverPoint` inherited its default of 0. + It now sets every constant attribute and stroke uniform the program reads + (`tests/test_tooltip_band.py::test_browser_nearest_hover_highlight_is_visible`). +- `xy:hover` fires once per band change with `points[]` carrying one entry + per series in band order; `row`/`trace`/`index` describe the first. One + `pick` goes to the kernel per series; each exact reply replaces its own row + and re-renders, and the last one re-dispatches `xy:hover` with + `exact: true`. +- Keyboard traversal is unchanged: it walks single points, and starting it + clears the band. Static exports are unaffected (tooltips are live-only). + +Live capture: `spec/assets/tooltip-x-band.png` — the pointer (red ring) far +above Page B, the band tooltip, the cursor line, and both active dots. + ## 8. Unconditional behavior Not configurable through any switch: tooltip rendering and the kernel `pick` diff --git a/spec/api/styling.md b/spec/api/styling.md index 2bbf64c88..ca669639f 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -716,6 +716,7 @@ raises before it reaches the client. | `tooltip_row` | One tooltip field row | | `tooltip_label` | One tooltip field label | | `tooltip_value` | One formatted tooltip value | +| `tooltip_cursor` | Line across the plot at the shared-tooltip band coordinate (`xy.tooltip(mode="x"\|"y")`, interaction spec §7.3) | | `modebar` | Mode/tool bar container | | `modebar_drag_handle` | Draggable grip that reveals and moves the modebar | | `modebar_control_group` | Main top-level control group | diff --git a/spec/assets/tooltip-x-band.png b/spec/assets/tooltip-x-band.png new file mode 100644 index 0000000000000000000000000000000000000000..c4dc92175a57a95edc63b96e20fc747acff7e01d GIT binary patch literal 38527 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 literal 0 HcmV?d00001 diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 6e0068147..d6f90efd9 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -1403,7 +1403,7 @@ changes presentation text without changing source-field lookup, formatting keys, title placeholders, or event payloads; without an explicit `fields=` list it renames the matching default x/y/color/size rows. User-provided chrome text is assigned through `textContent` / text nodes, never parsed as HTML. -The canonical 48-slot tuple also reaches Cartesian axis spines/ticks/gesture +The canonical 49-slot tuple also reaches Cartesian axis spines/ticks/gesture bands, colorbar extensions/contour lines/minor ticks, the whole annotation canvas, and every visible modebar subpart (including its draggable grip and popover contents). Visual defaults live in the zero-specificity base layer so diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 4806e0c28..85ba17950 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -329,6 +329,7 @@ def test_client_applies_every_public_dom_slot() -> None: "tooltip_row": '_applySlot(row, "tooltip_row")', "tooltip_label": '_applySlot(label, "tooltip_label")', "tooltip_value": '_applySlot(value, "tooltip_value")', + "tooltip_cursor": '_applySlot(el, "tooltip_cursor")', "modebar": '_applySlot(bar, "modebar")', "modebar_drag_handle": '_applySlot(dragPeek, "modebar_drag_handle")', "modebar_control_group": '_applySlot(toolGroup, "modebar_control_group")', diff --git a/tests/test_tooltip_band.py b/tests/test_tooltip_band.py new file mode 100644 index 000000000..e25978d16 --- /dev/null +++ b/tests/test_tooltip_band.py @@ -0,0 +1,396 @@ +"""Shared-axis tooltip (`xy.tooltip(mode="x")`, interaction spec §7.3). + +Recharts' axis tooltip and Plotly's `hovermode="x unified"`: the pointer's +position along the band axis alone selects the data, the perpendicular +position is ignored, every series' point at that coordinate is listed with a +cursor line and an active dot, and the band boundary is halfway between +adjacent points. Browser probes drive the real client; they skip (never +fail) without Chromium, like the repo's others. +""" + +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest + +from conftest import probe_document, 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 + +PAGES = ["Page A", "Page B", "Page C", "Page D", "Page E", "Page F", "Page G"] +PV = [2400.0, 1398.0, 9800.0, 3908.0, 4800.0, 3800.0, 4300.0] +UV = [4000.0, 3000.0, 2000.0, 2780.0, 1890.0, 2390.0, 3490.0] + + +def test_tooltip_mode_option() -> None: + """`mode` rides the wire only when it is not the default, like the other + opt-in chrome switches, so existing specs stay byte-identical.""" + chart = xy.line_chart(xy.line(PAGES, PV, name="pv"), xy.tooltip(mode="x")) + assert chart.figure().build_payload()[0]["tooltip"]["mode"] == "x" + chart = xy.line_chart(xy.line(PAGES, PV, name="pv"), xy.tooltip(mode="y")) + assert chart.figure().build_payload()[0]["tooltip"]["mode"] == "y" + default = xy.line_chart(xy.line(PAGES, PV, name="pv"), xy.tooltip()) + assert "mode" not in default.figure().build_payload()[0].get("tooltip", {}) + with pytest.raises(ValueError, match="tooltip mode must be one of"): + xy.tooltip(mode="unified") + with pytest.raises(ValueError, match="tooltip mode must be one of"): + xy.tooltip(mode=None) # type: ignore[arg-type] + # Public dataclass: the new field appends after the released order. + assert xy.Tooltip(True, None, None, {}, None, {}, None, {}).mode == "nearest" + # A direct dataclass edit is re-validated at build. + node = xy.tooltip() + node.mode = "diagonal" + with pytest.raises(ValueError, match="tooltip mode must be one of"): + xy.line_chart(xy.line(PAGES, PV, name="pv"), node).figure() + + +def test_tooltip_cursor_is_a_public_dom_slot() -> None: + assert "tooltip_cursor" in xy.CHART_DOM_SLOTS + + +_BAND_PROBE = """ + +""" + + +def _recharts_chart(**tooltip): + return xy.line_chart( + xy.line(PAGES, PV, name="pv", color="#8884d8", width=2), + xy.line(PAGES, UV, name="uv", color="#82ca9d", width=2), + xy.tooltip(**tooltip), + xy.legend(), + # DOM hover events are opt-in (interaction spec §2); the probe asserts + # their payload. + xy.interaction_config(hover=True), + width=640, + height=360, + ) + + +def test_browser_x_band_selects_by_horizontal_position_only() -> None: + chromium = find_chromium() + if not chromium: + pytest.skip("no chromium available for the band tooltip probe") + document = probe_document(_recharts_chart(mode="x"), _BAND_PROBE) + with tempfile.TemporaryDirectory() as td: + payload = run_browser_probe( + chromium, document, Path(td) / "band.html", "data-xy-bandtip", label="x band" + ) + + s = payload["aboveB"] + assert s["shown"] is True and s["title"] == "Page B", s + assert s["rows"] == ["pv1398", "uv3000"], s # label + value text nodes + assert s["targets"] == 2, s + # Series names carry their series colour as the swatch. + assert s["labelColors"] == ["rgb(136, 132, 216)", "rgb(130, 202, 157)"], s + # The cursor spans the plot at Page B's projected x. + assert s["cursorShown"] is True, s + assert abs(s["cursorLeft"] - s["anchorLeft"]) <= 0.5, s + assert abs(s["cursorHeight"] - s["plotH"]) <= 0.5, s + # Every series in the band gets an active dot in its own colour: the + # series-coloured pixel count around each Page B point grows well past + # what the 2 px line alone contributes. + for before, after in zip(s["dotsBefore"], s["dotsAfter"], strict=True): + # Threshold sized for a DPR-1 headless run (a 9 px dot over a 2 px line). + assert after > before + 30, (s["dotsBefore"], s["dotsAfter"]) + # One exact pick per series in the band. + assert sorted(payload["picksAfterB"]) == [[0, 1], [1, 1]], payload["picksAfterB"] + + # Moving inside the same band re-places the tooltip and sends nothing new. + t = payload["sameBand"] + assert t["title"] == "Page B" and t["rows"] == s["rows"], t + assert t["tipLeft"] != s["tipLeft"], (t["tipLeft"], s["tipLeft"]) + assert payload["picksAfterSame"] == 2, payload["picksAfterSame"] + + # The boundary is halfway between adjacent points. + assert payload["pastMid"]["title"] == "Page C", payload["pastMid"] + assert payload["pastMid"]["rows"] == ["pv9800", "uv2000"], payload["pastMid"] + assert payload["beforeMid"]["title"] == "Page B", payload["beforeMid"] + + o = payload["outside"] + assert o["shown"] is False and o["cursorShown"] is False and o["targets"] == 0, o + + h = payload["uvHidden"] + assert h["rows"] == ["pv1398"] and h["targets"] == 1, h + + # xy:hover carries every series in the band; the first is primary. + assert payload["hoverPoints"][0] == [2, 0, 1, "Page B"], payload["hoverPoints"] + assert payload["hoverPoints"][-1] == [1, 0, 1, "Page B"], payload["hoverPoints"] + + +_NEAREST_PROBE = """ + +""" + + +def test_browser_default_mode_still_needs_the_pointer_near_a_point() -> None: + """The default is unchanged: far above the points there is no tooltip and + no cursor element is ever created.""" + chromium = find_chromium() + if not chromium: + pytest.skip("no chromium available for the nearest tooltip probe") + document = probe_document(_recharts_chart(), _NEAREST_PROBE) + with tempfile.TemporaryDirectory() as td: + payload = run_browser_probe( + chromium, document, Path(td) / "nearest.html", "data-xy-nearest", label="nearest" + ) + assert payload == {"shown": False, "cursor": False, "targets": 0}, payload + + +_Y_BAND_PROBE = """ + +""" + + +def test_browser_y_band_selects_by_vertical_position_only() -> None: + chromium = find_chromium() + if not chromium: + pytest.skip("no chromium available for the y band probe") + ys = [10.0, 20.0, 30.0] + chart = xy.scatter_chart( + xy.scatter([1.0, 2.0, 3.0], ys, name="left", size=8), + xy.scatter([4.0, 5.0, 6.0], ys, name="right", size=8), + xy.tooltip(mode="y"), + width=640, + height=360, + ) + document = probe_document(chart, _Y_BAND_PROBE) + with tempfile.TemporaryDirectory() as td: + payload = run_browser_probe( + chromium, document, Path(td) / "yband.html", "data-xy-yband", label="y band" + ) + assert payload["title"] == "20", payload + assert payload["rows"] == ["left2", "right5"], payload + assert abs(payload["cursorWidth"] - payload["plotW"]) <= 0.5, payload + assert abs(payload["cursorTop"] - payload["anchorTop"]) <= 0.5, payload + + +def test_band_mode_leaves_static_exports_alone() -> None: + """Tooltips are live-only; the option must not change a byte of the SVG.""" + plain = _recharts_chart().to_svg() + banded = _recharts_chart(mode="x").to_svg() + assert plain == banded + assert np.array_equal( + np.frombuffer(_recharts_chart().to_png(), dtype=np.uint8), + np.frombuffer(_recharts_chart(mode="x").to_png(), dtype=np.uint8), + ) + + +_HOVER_DOT_PROBE = """ + +""" + + +def test_browser_nearest_hover_highlight_is_visible() -> None: + """The hover highlight dot had silently stopped rendering: the full point + program multiplies fill alpha by the per-item `a_style.x` factor, the + regular scatter draw now runs through the simpler program that never sets + that constant attribute, and `_drawHoverPoint` inherited its default of 0. + The dark highlight paint must actually land on the canvas.""" + chromium = find_chromium() + if not chromium: + pytest.skip("no chromium available for the hover dot probe") + chart = xy.scatter_chart( + xy.scatter([0.0, 1.0, 2.0, 3.0], [1.0, 4.0, 2.0, 3.0], name="a", color="#8884d8", size=7), + xy.scatter([0.0, 1.0, 2.0, 3.0], [3.0, 1.0, 4.0, 2.0], name="b", color="#82ca9d", size=7), + xy.tooltip(), + width=640, + height=360, + ) + document = probe_document(chart, _HOVER_DOT_PROBE) + with tempfile.TemporaryDirectory() as td: + payload = run_browser_probe( + chromium, document, Path(td) / "dot.html", "data-xy-hoverdot", label="hover dot" + ) + assert payload["target"] == [0, 1], payload + assert payload["before"] < 10, payload + assert payload["after"] > payload["before"] + 60, payload diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index f5e12505a..bf057121c 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -216,6 +216,7 @@ def test_chart_dom_slots_are_public_styling_contract() -> None: "tooltip_row", "tooltip_label", "tooltip_value", + "tooltip_cursor", "modebar", "modebar_drag_handle", "modebar_control_group", From 26669882b2571bb88129f363a16dfea94d20618b Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Wed, 2 Sep 2026 12:28:41 -0700 Subject: [PATCH 2/7] Add news fragment for the shared-axis tooltip (#509) --- news/509.feature.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 news/509.feature.md diff --git a/news/509.feature.md b/news/509.feature.md new file mode 100644 index 000000000..a6a492527 --- /dev/null +++ b/news/509.feature.md @@ -0,0 +1,12 @@ +`xy.tooltip(mode="x")` turns the tooltip into a shared-axis tooltip, the model +Recharts uses by default and Plotly calls `hovermode="x unified"`: the pointer +only has to be inside the plot, its horizontal position snaps to the nearest x +value, and every series' point at that x is listed at once while the vertical +position is ignored. The plot divides into full-height bands whose boundaries +fall halfway between adjacent points; a cursor line (new `tooltip_cursor` DOM +slot) marks the snapped x, each series shows an active dot in its own colour, +and the tooltip follows the pointer. `mode="y"` mirrors it along the y axis; +the default `mode="nearest"` is unchanged. `xy:hover` carries one `points[]` +entry per series in the band. Also fixed on the way: the nearest-mode hover +highlight dot had silently stopped rendering (its fill alpha inherited a +per-item factor of zero); it draws again. From f34cd6d59bf3a92f675d71eadce7cb69c6a967da Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Wed, 2 Sep 2026 15:03:59 -0700 Subject: [PATCH 3/7] Band tooltips: bars join by footprint, survive context loss, keep hover under show=False Edge-case pass over xy.tooltip(mode="x"|"y"). Four breaks, all fixed with browser regression tests: - Bars were excluded from bands, so a bar chart in band mode showed no tooltip at all. A bar's footprint (pos +/- width/2 in plot px) is now its band extent; touching footprints chain into one band, so grouped slots read as one category with the cursor and title on the category centre. Bar series snap to the chain, not the pointer: from the gap after a category the pointer is nearer the previous category's slot of the far series, which listed one series instead of all. - The band-dot scratch VAO outlived its GL context. After a context loss the recovery frame bound the dead handle, the frame-ready check saw the error, and the restore retried forever. _initGl forgets the scratch objects before rebuilding; destroy() deletes them (they leaked into the shared host). - show=False dropped xy:hover in band mode; nearest mode keeps it. Only the tooltip element and cursor are hidden now. - Bar-band titles showed the category index instead of its label. Probed and unchanged: interleaved x grids, log-axis boundaries, time titles, NaN rows, y2 series, all-hidden legend, zoom off-plot, density + line mix, decimated 1M-point line, 30 series, duplicate x, single point, keyboard after band, polar fallback. --- docs/components/tooltips.md | 6 +- js/src/50_chartview.ts | 158 +++++++++++++--- js/src/52_tooltip.ts | 9 + news/509.feature.md | 3 +- spec/api/interaction.md | 28 ++- spec/assets/tooltip-x-band-bars.png | Bin 0 -> 18832 bytes tests/test_tooltip_band.py | 270 ++++++++++++++++++++++++++++ 7 files changed, 445 insertions(+), 29 deletions(-) create mode 100644 spec/assets/tooltip-x-band-bars.png diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index c80970b96..50aa1fb5a 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -201,8 +201,10 @@ only has to be inside the plot: its horizontal position snaps to the nearest x value and every series' point at that x is listed at once, while the vertical position is ignored. The plot divides into full-height bands with boundaries halfway between adjacent points, a cursor line marks the selected x, each series -shows an active dot, and the tooltip follows the pointer. `mode="y"` does the -same along the y axis for horizontal layouts. The default, `mode="nearest"`, +shows an active dot, and the tooltip follows the pointer. Bars join by their +footprint: a grouped bar chart lists every series of the category under the +pointer, with one cursor on the category centre. `mode="y"` does the same along +the y axis for horizontal layouts. The default, `mode="nearest"`, keeps the 12 px nearest-point behavior. ~~~python demo exec diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 7a9e7db40..07bb14b0e 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -4385,6 +4385,10 @@ export class ChartView { } _initGl(buffer) { + // The band-dot scratch VAO belongs to the context being (re)built: a + // handle from a lost context binds with INVALID_OPERATION, and the + // recovery frame's error check then rejects every restore attempt. + this._releaseBandDotResources(); const dpr = window.devicePixelRatio || 1; this.dpr = dpr; // A canvas backing-store write clears the canvas even at an unchanged @@ -6813,7 +6817,7 @@ export class ChartView { // index (§7.3). for (const hit of this._hoverTargets) { const g = hit.g; - if (!g || g.tier === "density" || g._legendHidden || !g._cpu) continue; + if (!g || g.tier === "density" || g._legendHidden || !g._cpu || g.trace.bar) continue; const cpu = g._cpu; const xMeta = cpu.xMeta || g.xMeta; const yMeta = cpu.yMeta || g.yMeta; @@ -6849,6 +6853,26 @@ export class ChartView { // `encoded` draws one dot from explicit encoded coordinates (and metas) // through a scratch buffer instead of `g`'s vertex buffers at `index`; // its optional `color` (unit RGBA) replaces the hover-state paint. + // Drop the band-dot scratch objects: deleted when their context is still + // live (destroy, host rebuild), merely forgotten when it died with them. + _releaseBandDotResources() { + const gl = this.gl; + if (gl && this._bandDotVao) { + try { + if (!gl.isContextLost()) { + gl.deleteVertexArray(this._bandDotVao); + gl.deleteBuffer(this._bandDotBufX); + gl.deleteBuffer(this._bandDotBufY); + } + } catch (_err) { + // A context torn down under us: the handles are already gone. + } + } + this._bandDotVao = null; + this._bandDotBufX = null; + this._bandDotBufY = null; + } + _drawHoverPoint(g, index, xm, ym, encoded: any = null) { const gl = this.gl; const prog = this.pointProg; @@ -8863,34 +8887,112 @@ export class ChartView { // boundaries halfway between adjacent points, while a series with no point // at that coordinate is left out rather than guessed. The perpendicular // coordinate is ignored entirely. + // One band candidate for a series: its point nearest `target` along the + // band axis, projected, with a bar's footprint along its position axis + // (`pos ± width/2`) as the extent — a point's extent is its coordinate. + _bandCandidate(g, dim, target) { + const idx = this._nearestCpuIndexAlong(g, dim, target); + if (idx < 0) return null; + const [x, y] = this._cpuPointValue(g, idx); + const [chartX, chartY] = this._projectDataPoint(g.xAxis, g.yAxis, x, y); + const px = dim === "x" ? chartX - this.plot.x : chartY - this.plot.y; + if (!Number.isFinite(px)) return null; + let lo = px; + let hi = px; + const bar = !!g.trace.bar; + if (bar && (g.orientation === 1 ? "y" : "x") === dim && g.width > 0) { + const half = g.width / 2; + const pos = dim === "x" ? x : y; + const [ax, ay] = this._projectDataPoint( + g.xAxis, g.yAxis, dim === "x" ? pos - half : x, dim === "y" ? pos - half : y, + ); + const [bx, by] = this._projectDataPoint( + g.xAxis, g.yAxis, dim === "x" ? pos + half : x, dim === "y" ? pos + half : y, + ); + const a = dim === "x" ? ax - this.plot.x : ay - this.plot.y; + const b = dim === "x" ? bx - this.plot.x : by - this.plot.y; + if (Number.isFinite(a) && Number.isFinite(b)) { + lo = Math.min(a, b); + hi = Math.max(a, b); + } + } + return { trace: g.trace.id, index: idx, g, px, lo, hi, bar, x, y, dist: 0, synthetic: true }; + } + + _bandTarget(g, dim, cssX, cssY) { + const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); + return dim === "x" ? dataX : dataY; + } + _bandHits(cssX, cssY, dim) { const candidates = []; for (const g of this.gpuTraces) { if (g.tier === "density" || g._legendHidden) continue; - // Marks with their own hover geometry never join a band (§7.3). - if (g.heatmap || g.trace.bar || g._cpuRibbon || g._cpuFunnel || g._cpuRect || g._segmentCpu) continue; + // Marks with their own hover geometry never join a band (§7.3); bars + // do, by their footprint along the position axis. + if (g.heatmap || g._cpuRibbon || g._cpuFunnel || g._cpuRect || g._segmentCpu) continue; if (!g._cpu || !g._cpu.x || !g._cpu.y) continue; - const [dataX, dataY] = this._dataFromCanvas(cssX, cssY, g.xAxis, g.yAxis); - const target = dim === "x" ? dataX : dataY; + const target = this._bandTarget(g, dim, cssX, cssY); if (!Number.isFinite(target)) continue; - const idx = this._nearestCpuIndexAlong(g, dim, target); - if (idx < 0) continue; - const [x, y] = this._cpuPointValue(g, idx); - const [chartX, chartY] = this._projectDataPoint(g.xAxis, g.yAxis, x, y); - const px = dim === "x" ? chartX - this.plot.x : chartY - this.plot.y; - if (!Number.isFinite(px)) continue; - candidates.push({ trace: g.trace.id, index: idx, g, px, x, y, dist: 0, synthetic: true }); + const c = this._bandCandidate(g, dim, target); + if (c) candidates.push(c); } if (!candidates.length) return null; const cursor = dim === "x" ? cssX : cssY; + // The anchor is the candidate whose footprint is nearest the pointer; + // ties go to the nearer centre. + const gap = (c) => (cursor < c.lo ? c.lo - cursor : cursor > c.hi ? cursor - c.hi : 0); let anchor = candidates[0]; for (const c of candidates) { - if (Math.abs(c.px - cursor) < Math.abs(anchor.px - cursor)) anchor = c; + const d = gap(c) - gap(anchor); + if (d < 0 || (d === 0 && Math.abs(c.px - cursor) < Math.abs(anchor.px - cursor))) anchor = c; + } + // Half a CSS pixel of slack: f32 decode noise, not a different value. + let lo = anchor.lo - 0.5; + let hi = anchor.hi + 0.5; + if (anchor.bar) { + // Grouped slots of one category touch: chain bars whose footprints + // touch the band. A bar series snaps to the *chain*, not the pointer — + // from the gap past a category, the pointer is nearer the previous + // category's slot of the next series than to this category's — so + // re-snap each bar series to the chain centre and widen until stable. + for (let pass = 0; pass <= candidates.length; pass++) { + const mid = (lo + hi) / 2; + let changed = false; + for (let k = 0; k < candidates.length; k++) { + const c = candidates[k]; + if (!c.bar) continue; + const target = this._bandTarget( + c.g, dim, dim === "x" ? mid : cssX, dim === "y" ? mid : cssY, + ); + if (!Number.isFinite(target)) continue; + const next = this._bandCandidate(c.g, dim, target); + if (next && next.index !== c.index) { + candidates[k] = next; + changed = true; + } + } + let grew = false; + for (const c of candidates) { + if (!c.bar || c.hi < lo || c.lo > hi) continue; + if (c.lo - 0.5 < lo) { lo = c.lo - 0.5; grew = true; } + if (c.hi + 0.5 > hi) { hi = c.hi + 0.5; grew = true; } + } + if (!changed && !grew) break; + } + } + const hits = candidates.filter((c) => (c.bar ? c.hi >= lo && c.lo <= hi : c.px >= lo && c.px <= hi)); + let center = null; + if (hi - lo > 1.5) { + // A footprint band: the cursor and title sit on its centre (the + // category), not on the anchor slot. + const mid = (lo + hi) / 2; + const [cx, cy] = this._dataFromCanvas( + dim === "x" ? mid : cssX, dim === "y" ? mid : cssY, anchor.g.xAxis, anchor.g.yAxis, + ); + center = { x: dim === "x" ? cx : anchor.x, y: dim === "y" ? cy : anchor.y }; } - // Same projected coordinate within half a CSS pixel: f32 decode noise, - // not a different value. - const hits = candidates.filter((c) => Math.abs(c.px - anchor.px) <= 0.5); - return { hits, anchor, dim }; + return { hits, anchor, dim, center }; } _hoverBand(e, cssX, cssY, dim) { @@ -8912,8 +9014,10 @@ export class ChartView { if (key === this._bandKey) { // Same band: the content and the cursor stay; only the tooltip follows // the pointer (Recharts' cursor model, §7.3). - const rect = this.root.getBoundingClientRect(); - this._placeTooltip(e.clientX - rect.left, e.clientY - rect.top); + if (this.spec.show_tooltip !== false) { + const rect = this.root.getBoundingClientRect(); + this._placeTooltip(e.clientX - rect.left, e.clientY - rect.top); + } return; } this._bandKey = key; @@ -8924,9 +9028,18 @@ export class ChartView { this._lastRow = this._bandRows[0]; this._tooltipAnchor = null; const ag = band.anchor.g; - this._bandCursor = { dim, xAxis: ag.xAxis, yAxis: ag.yAxis, x: band.anchor.x, y: band.anchor.y }; - this._renderBandTooltip(e.clientX, e.clientY); - this._positionTooltipCursor(); + const at = band.center || band.anchor; + this._bandTitleValue = band.center ? (dim === "x" ? at.x : at.y) : undefined; + // `show=False` keeps the hover contract (events, picks, active dots) and + // drops only the tooltip chrome, as the nearest mode does. + if (this.spec.show_tooltip === false) { + this._bandCursor = null; + this._hideTooltipCursor(); + } else { + this._bandCursor = { dim, xAxis: ag.xAxis, yAxis: ag.yAxis, x: at.x, y: at.y }; + this._renderBandTooltip(e.clientX, e.clientY); + this._positionTooltipCursor(); + } this._dispatchBandHover(e.clientX, e.clientY, false); this._requestBandPicks(); this._drawKeepPick(); @@ -9473,6 +9586,7 @@ export class ChartView { this.quad = null; if (this.quadVao && !this._glHost) gl.deleteVertexArray(this.quadVao); this.quadVao = null; + this._releaseBandDotResources(); for (const p of this._progCache ? this._progCache.values() : []) { if (p) gl.deleteProgram(p); } diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index 4ff303131..b3a5ed93e 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -559,6 +559,7 @@ Object.assign(ChartView.prototype, { this._hoverTargets = null; this._bandRows = null; this._bandCursor = null; + this._bandTitleValue = undefined; if (this._bandPicks) this._bandPicks.clear(); this._hideTooltipCursor(); }, @@ -620,6 +621,14 @@ Object.assign(ChartView.prototype, { const [value, kind] = this._tooltipLookup(first, field); return value === undefined ? "" : this._formatTooltipValue(value, kind, formats[field]); }); + } else if (this._bandTitleValue !== undefined && this._bandTitleValue !== null) { + // A bar band is titled by its category centre, not the anchor slot; the + // centre is an axis coordinate, so it takes the same label lookup as a + // row value. + const [value, kind] = this._sourceDisplayValue( + hits[0] && hits[0].g, along, this._bandTitleValue, first[`${along}_kind`], + ); + title = this._formatTooltipValue(value, kind, formats[along]); } else if (first[along] !== undefined) { title = this._formatTooltipValue(first[along], first[`${along}_kind`], formats[along]); } diff --git a/news/509.feature.md b/news/509.feature.md index a6a492527..5656c2169 100644 --- a/news/509.feature.md +++ b/news/509.feature.md @@ -5,7 +5,8 @@ value, and every series' point at that x is listed at once while the vertical position is ignored. The plot divides into full-height bands whose boundaries fall halfway between adjacent points; a cursor line (new `tooltip_cursor` DOM slot) marks the snapped x, each series shows an active dot in its own colour, -and the tooltip follows the pointer. `mode="y"` mirrors it along the y axis; +and the tooltip follows the pointer. Bars join by footprint: a grouped bar chart +lists every series of the category under the pointer. `mode="y"` mirrors it along the y axis; the default `mode="nearest"` is unchanged. `xy:hover` carries one `points[]` entry per series in the band. Also fixed on the way: the nearest-mode hover highlight dot had silently stopped rendering (its fill alpha inherited a diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 4ab3f7a3c..08dd184ff 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -414,10 +414,19 @@ Wire: `tooltip.mode`, shipped only when not `"nearest"`. a different x) joins it (`_bandHits`). Index-aligned series therefore read as one band whose boundaries fall halfway between adjacent points; a series with no point at that coordinate is omitted rather than guessed. -- Eligible series are point and line marks with retained CPU columns. - Density tiers, bars, rectangles, ribbons, funnels, heatmaps and segments - keep their own hover geometry and never join a band; legend-hidden series - are out (§10). Polar charts have no band axis and fall back to nearest. +- Eligible series are point, line and area marks with retained CPU columns, + and bars. A bar's footprint along its position axis (`pos ± width/2`, in + plot pixels) is its band extent: the pointer anywhere over the bar selects + it, and bars whose footprints touch chain into one band — so the slots of + a grouped category read as one band listing every series, a stacked + category as one band of cumulative tops (the same rows nearest mode + shows), and two bar calls with a gap between them as two bands. The + cursor and the title then sit on the chain's centre — the category — not + on the anchor slot, and the title takes the category label lookup. Bars + get no active dot: the bar is the mark. Density tiers, rectangles, + ribbons, funnels, heatmaps and segments keep their own hover geometry and + never join a band; legend-hidden series are out (§10). Polar charts have + no band axis and fall back to nearest. - The tooltip shows the band coordinate as its title (or the authored `title` template resolved against the anchor series' row), then one row per series in band order: the series name painted in the series colour, then @@ -442,6 +451,17 @@ Wire: `tooltip.mode`, shipped only when not `"nearest"`. `pick` goes to the kernel per series; each exact reply replaces its own row and re-renders, and the last one re-dispatches `xy:hover` with `exact: true`. +- `xy.tooltip(show=False, mode="x")` keeps the hover contract — `xy:hover` + with `points[]`, the kernel picks, the active dots — and drops only the + tooltip element and the cursor line, as nearest mode drops only the + element. +- The active dots draw through a scratch VAO owned by the view's current GL + context. `_initGl` forgets it before rebuilding (a handle from a lost + context binds with `INVALID_OPERATION`, and the recovery frame's error + check would then reject every restore while a band was up — §18) and + `destroy()` deletes it, so a band tooltip neither strands a context + restore nor leaks into the shared host + (`tests/test_tooltip_band.py::test_browser_band_survives_context_loss_and_destroy`). - Keyboard traversal is unchanged: it walks single points, and starting it clears the band. Static exports are unaffected (tooltips are live-only). diff --git a/spec/assets/tooltip-x-band-bars.png b/spec/assets/tooltip-x-band-bars.png new file mode 100644 index 0000000000000000000000000000000000000000..cb699a846ab9bba67d123b625144d1676e98edd8 GIT binary patch literal 18832 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
  • )xjb}8CXtO!=d?ES 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

    nvjc!bvQ`(E~2YbdBl84dEt%7Y&mP z+)qRAXg%|dI@OS_qM1!>&60Q9BQc_Up3tn@AUCP36;8mE+Vp|4Pvs1(y&hezb001- zd>w%vj-K?k*C>-S&rizA;moWWS~+T#jxO^RGi~{(zvW}=3F%y>Uvdn5lNW+!;)IU9&ls+B?x^Fj)guftCdN1y4fGK! zpb`?dONRs;t{zupp|LB}O^W}*_vLP$fs;(q*-A&EZoUS*Nk!{Tb&Kt6@drhbRPpF* zo(a`H-^3PYmwk==gbaJ?c&_`cG>9WR#m`w}WSAEy94ZfK6JE$zmE#lPT zJS8S$tBim({I1%!-4=@Kui7ekwtVfPgvvlJzL<+_MiUVQk(`_gR_CXsFE@p(FZ=dj z3qrEncXs;u*7dW}p~)Jgv(0LKIBV=|PH~0W%C|wR>U%f+-W>MiloO=870#Kf<$XEP z?Kt?@`a->l8-0JR3GtDA^=g?k?N?aRs4m22A~ISGT+c?bkdiDgg4x3N-aW_nP`DSHCiDl=TBfCm` zswVQi-tq#_w#~vfi9rPSErdeCS;kGYW_q=~r_?+<`>}ZSoa0TaWhI5bdg*dCbY(s1 z4Cyy*zQJw^{KH1ONNpj49{9M{;;n{cbTqX}Qc8xl72=FP#6PcUay^}qb<@xQOZucK zS&dz{4O594o96#e`&Q9gT4qO(2weoZ4wZzd&o!atW1Vyf%h3wQ1Jp);^=y9cQs8DI ziX*IpP&)7IS-`#yWY<%0RKI_3MStdU8H-o#A>WHT`duu1p*fg(u8x`0Ge|#IN$A!0 z+Cz{{1l-YbbLmP{sje3Y*gB6N7&b4(WD3~x$-STJ;y9ZXazuC@F6!YM+p{{`yShEB zGkTp-LaVZA%5Bt&Shtj)fl-izHvzF~@Y~`$^#+_*if(mr0**1M<*Aoq9PYlQkx%)j zC4ZWX$;9i|dX!A%kML(1cOQmX&BzwqQ0yxZlUI-d7jLdO2W50s<7zwoQ%AUjVc1wl zgXaaepb5zy`6i-LDFs{R9(E-_l)6sB?)o0eGL03h4l_RGj=kq8KA}H}Xr8;gj##d(}Tl_-RV@u_tsN+>!ZBq9Ek*AunPdc15g-WuzsK8#r>L}6y# z?F~+i0+q>|qweRSo5i@+npwHET$`a3(Xhy>n=*`P*w!XvYTq4yD}WN@Wr+$^g~rax#A+5FT2HRWu03&wyMo9=fTEljoy^UheqDbGHAM*(h}$|XVP_xslF+j@={Dc~KIi=Z grPoD|=y!OPe%Wfg))@HTeGb5`YF_!Egn0bF08j*e&j0`b literal 0 HcmV?d00001 diff --git a/tests/test_tooltip_band.py b/tests/test_tooltip_band.py index e25978d16..7e6b1a9b4 100644 --- a/tests/test_tooltip_band.py +++ b/tests/test_tooltip_band.py @@ -394,3 +394,273 @@ def test_browser_nearest_hover_highlight_is_visible() -> None: assert payload["target"] == [0, 1], payload assert payload["before"] < 10, payload assert payload["after"] > payload["before"] + 60, payload + + +# --- Edge cases (§7.3): the probes below share one helper prelude; each +# reports through `data-xy-bandedge` and drives `view._hover` like the ones +# above. + +_EDGE_HELPERS = """ + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const view = window.__fcProbeView; + if (!view) throw new Error("no probe view captured"); + view._drawNow(); view._raf = null; + const sent = []; view.comm = { send: (m) => sent.push(m) }; + const hovers = []; document.addEventListener("xy:hover", (e) => hovers.push(e.detail)); + for (let i = 0; i < 200 && !view.gpuTraces[0]._cpu; i++) await sleep(25); + const rect = view.canvas.getBoundingClientRect(); + const g0 = view.gpuTraces[0]; + const proj = (x, y, g = g0) => { + const [px, py] = view._projectDataPoint(g.xAxis, g.yAxis, x, y); + return [px - view.plot.x, py - view.plot.y]; + }; + const hover = (x, y) => view._hover({ clientX: rect.left + x, clientY: rect.top + y }); + const tip = view.tooltip; + const cursor = () => view.root.querySelector('[data-xy-slot="tooltip_cursor"]'); + const state = () => ({ + shown: tip.style.display === "block", + title: tip.querySelector('[data-xy-slot="tooltip_title"]')?.textContent ?? null, + rows: [...tip.querySelectorAll('[data-xy-slot="tooltip_row"]')].map((r) => r.textContent), + targets: (view._hoverTargets || []).length, + cursorShown: !!cursor() && cursor().style.display === "block", + cursorLeft: cursor() ? parseFloat(cursor().style.left) : null, + cursorTop: cursor() ? parseFloat(cursor().style.top) : null, + }); + const done = (obj) => document.body.setAttribute("data-xy-bandedge", JSON.stringify(obj)); +""" + + +def _edge_probe(body: str) -> str: + return ( + """" + ) + + +def _run_edge(chart, body: str, label: str) -> dict: + chromium = find_chromium() + if chromium is None: + pytest.skip("headless chromium not found") + document = probe_document(chart, _edge_probe(body)) + with tempfile.TemporaryDirectory() as td: + return run_browser_probe( + chromium, document, Path(td) / "edge.html", "data-xy-bandedge", label=label + ) + + +_CATS = ["A", "B", "C", "D", "E"] +_PV5 = [4.0, 3.0, 5.0, 2.0, 6.0] +_UV5 = [2.0, 5.0, 1.0, 4.0, 3.0] + + +def test_browser_grouped_bars_form_one_band_per_category() -> None: + """Recharts' classic case: a grouped BarChart lists every series of the + category the pointer is over, from any x inside the group, with one cursor + on the category centre and the category label as the title.""" + chart = xy.bar_chart( + xy.bar(_CATS, [_PV5, _UV5], series=["pv", "uv"]), + xy.tooltip(mode="x"), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [bx] = proj(1, 0); + const slots = view.gpuTraces.map((g) => g._cpu.x[1] / (g._cpu.xMeta.scale || 1) + g._cpu.xMeta.offset); + hover(bx, 8); const centre = state(); + hover(bx - 25, 8); const leftSlot = state(); + hover(bx + 25, 8); const rightSlot = state(); + const [cx] = proj(2, 0); + hover((bx + cx) / 2 + 2, 8); const pastGap = state(); + done({ slots, centre, leftSlot, rightSlot, pastGap, bx, plotX: view.plot.x, hovers: hovers.length, + picks: sent.filter((m) => m.type === "pick").length }); +""", + "grouped bars band", + ) + slots = payload["slots"] + assert slots[0] < 1.0 < slots[1], slots # the two slots straddle the category + for key in ("centre", "leftSlot", "rightSlot"): + s = payload[key] + assert s["shown"] is True and s["title"] == "B", (key, s) + assert s["rows"] == ["pv3", "uv5"], (key, s) + assert s["cursorShown"] is True, (key, s) + # One cursor on the category centre (root coordinates), whichever slot + # the pointer is over. + assert abs(s["cursorLeft"] - (payload["bx"] + payload["plotX"])) < 1.0, (key, s, payload) + assert payload["pastGap"]["title"] == "C", payload["pastGap"] + # Three pointer positions in one band: one hover event, one pick per series. + assert payload["hovers"] == 2 and payload["picks"] == 4, payload + + +def test_browser_horizontal_bars_band_along_y() -> None: + chart = xy.bar_chart( + xy.bar(_CATS, [_PV5, _UV5], series=["pv", "uv"], orientation="horizontal"), + xy.tooltip(mode="y"), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [, by] = proj(0, 1); hover(view.plot.w - 8, by); done({ atB: state(), plotY: view.plot.y, by }); +""", + "horizontal bars band", + ) + s = payload["atB"] + assert s["shown"] is True and s["title"] == "B" and s["rows"] == ["pv3", "uv5"], s + assert s["cursorShown"] is True, s + assert abs(s["cursorTop"] - (payload["by"] + payload["plotY"])) < 1.0, (s, payload) + + +def test_browser_band_respects_each_series_own_x_grid() -> None: + """A series with no point at the band coordinate is omitted, not guessed: + two series on interleaved grids alternate bands at the midpoints.""" + chart = xy.line_chart( + xy.line([0, 1, 2, 3, 4, 5, 6], [1, 2, 3, 2, 1, 2, 3], name="whole"), + xy.line([0.5, 1.5, 2.5, 3.5, 4.5, 5.5], [3, 2, 1, 2, 3, 2], name="half"), + xy.tooltip(mode="x"), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [x2] = proj(2, 0), [x25] = proj(2.5, 0); + hover(x2 + 1, 8); const at2 = state(); + hover((x2 + x25) / 2 - 0.6, 8); const beforeMid = state(); + hover((x2 + x25) / 2 + 0.6, 8); const pastMid = state(); + done({ at2, beforeMid, pastMid }); +""", + "interleaved grids", + ) + assert payload["at2"]["rows"] == ["whole3"] and payload["at2"]["title"] == "2", payload + assert payload["beforeMid"]["rows"] == ["whole3"], payload + assert payload["pastMid"]["rows"] == ["half1"] and payload["pastMid"]["title"] == "2.5", payload + + +def test_browser_band_boundary_is_the_axis_midpoint_on_log_scales() -> None: + chart = xy.line_chart( + xy.line([1, 10, 100, 1000], [1, 2, 3, 4], name="s"), + xy.x_axis(type_="log"), + xy.tooltip(mode="x"), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [logMid] = proj(Math.sqrt(1000), 0), [linMid] = proj(55, 0); + hover(logMid - 2, 8); const under = state(); + hover(logMid + 2, 8); const over = state(); + hover(linMid, 8); const linear = state(); + done({ under, over, linear }); +""", + "log band boundary", + ) + assert payload["under"]["title"] == "10", payload + assert payload["over"]["title"] == "100", payload + # The linear midpoint (55) is well past the log midpoint: still 100. + assert payload["linear"]["title"] == "100", payload + + +def test_browser_hidden_band_tooltip_keeps_the_hover_contract() -> None: + chart = xy.line_chart( + xy.line([0, 1, 2], [1, 2, 3], name="a"), + xy.tooltip(show=False, mode="x"), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [x1] = proj(1, 0); hover(x1, 8); + done({ s: state(), hovers: hovers.map((h) => [(h.points || []).length, h.row && h.row.x]), + picks: sent.filter((m) => m.type === "pick").length }); +""", + "show=False band", + ) + s = payload["s"] + assert s["shown"] is False and s["cursorShown"] is False, s + assert s["targets"] == 1, s # active dot state stays, like nearest mode + assert payload["hovers"] == [[1, 1]] and payload["picks"] == 1, payload + + +def test_browser_band_survives_context_loss_and_destroy() -> None: + """The band-dot scratch VAO belongs to one GL context: a restore must not + bind the dead handle (which made the recovery frame fail its error check + and stranded the chart), and destroy must delete it.""" + chart = xy.line_chart( + xy.line([0, 1, 2, 3], [1, 2, 3, 4], name="a"), + xy.line([0, 1, 2, 3], [4, 3, 2, 1], name="b"), + xy.tooltip(mode="x"), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [x1] = proj(1, 0); hover(x1, 8); view._drawNow(); + const vao = view._bandDotVao; + const host = view._glHost; + const ext = (host ? host.gl : view.gl).getExtension("WEBGL_lose_context"); + if (!ext) throw new Error("WEBGL_lose_context unavailable"); + const waitUntil = async (pred, label) => { + const deadline = performance.now() + 5000; + while (!pred()) { if (performance.now() > deadline) throw new Error("timeout " + label); await sleep(20); } + }; + const lc = view._contextLossCount, rc = view._contextRestoreCount; + ext.loseContext(); await waitUntil(() => view._contextLossCount >= lc + 1, "loss"); + ext.restoreContext(); + await waitUntil(() => view._contextRestoreCount >= rc + 1 && view.canvas.dataset.xyCtx === "live", "restore"); + const restored = { vaoReplaced: !!view._bandDotVao && view._bandDotVao !== vao, targets: (view._hoverTargets || []).length }; + hover(x1 + 1, 8); view._drawNow(); + const after = { ...state(), glError: view.gl.getError() }; + view.destroy(); + done({ hadVao: !!vao, restored, after, vaoAfterDestroy: view._bandDotVao }); +""", + "band context loss", + ) + assert payload["hadVao"] is True, payload + assert payload["restored"]["vaoReplaced"] is True, payload + assert payload["after"]["shown"] is True and payload["after"]["glError"] == 0, payload + assert payload["after"]["rows"] == ["a2", "b3"], payload + assert payload["vaoAfterDestroy"] is None, payload + + +def test_browser_three_grouped_series_snap_to_the_chain_not_the_pointer() -> None: + """From the gap after a category the pointer is nearer the *previous* + category's slot of the far series than to this category's; bar series + snap to the band chain, so all three slots read as one band.""" + chart = xy.bar_chart( + xy.bar(_CATS, [_PV5, _UV5, [1.0, 2.0, 3.0, 4.0, 5.0]], series=["pv", "uv", "amt"]), + xy.tooltip(mode="x"), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [bx] = proj(1, 0), [cx] = proj(2, 0); + hover(bx - 22, 8); const leftSlotB = state(); + hover((bx + cx) / 2 + 2, 8); const gapIntoC = state(); + hover((bx + cx) / 2 - 2, 8); const gapIntoB = state(); + done({ leftSlotB, gapIntoC, gapIntoB, bx, cx, plotX: view.plot.x }); +""", + "three grouped series", + ) + assert payload["leftSlotB"]["rows"] == ["pv3", "uv5", "amt2"], payload["leftSlotB"] + assert payload["gapIntoC"]["title"] == "C", payload["gapIntoC"] + assert payload["gapIntoC"]["rows"] == ["pv5", "uv1", "amt3"], payload["gapIntoC"] + assert payload["gapIntoB"]["title"] == "B", payload["gapIntoB"] + assert payload["gapIntoB"]["rows"] == ["pv3", "uv5", "amt2"], payload["gapIntoB"] + assert abs(payload["gapIntoC"]["cursorLeft"] - (payload["cx"] + payload["plotX"])) < 1.0, ( + payload + ) From 9cf6b2f4f23df757e0daa711e00302640390b5b3 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 15:23:30 +0000 Subject: [PATCH 4/7] Fix the band tooltip's hidden-row scan, stale picks, dot size and bar chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review findings on this PR, five of them reachable from supported charts: - A category-filtered trace draws a subset, so `_visMap` maps each drawn instance to its shipped row while `g.n` counts the drawn ones. Treating `g.n` as a prefix of the CPU column made the band scan rows the legend had hidden and drop visible rows past the prefix. Walk the map's source indices instead. - A band pick reply the kernel could not resolve, or one whose trace has since gone, returned silently and left the band's rows and cursor on screen describing points that are not there. Exact or nothing (§16), as in nearest mode: hide. - A reply could also replace a row it was never requested for, because `seq` alone does not prove the slot still holds that series — the map is rebuilt per band. Verify the trace and index against `_hoverTargets[slot]`. - Every band active dot read row 0's size, since the scratch buffer holds one vertex and its index was used for the size-channel lookup too. Pass the hovered row for that lookup and keep drawing vertex 0. - The dots uploaded raw CPU columns while the cursor and title used transition-interpolated coordinates, so mid-transition the dot sat at the final position. Interpolate in encoded space, which is what the buffer takes and avoids a decode/re-encode round trip. - The grouped-bar chain expansion ran only when the ANCHOR was a bar, so a line or area point anchoring a category that also holds bars listed the line and dropped every bar beside it. Run it whenever any candidate is a bar; the passes already ignore non-bar candidates. Docs: `mode="y"` is a y-axis mode on any Cartesian chart, not a horizontal-layout-only one, and the `tooltip_cursor` slot serves both modes. --- docs/components/tooltips.md | 4 ++-- docs/styling/chrome-slots.md | 2 +- js/src/50_chartview.ts | 42 +++++++++++++++++++++++++++++++----- js/src/52_tooltip.ts | 18 +++++++++++++--- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index 50aa1fb5a..2c67a698a 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -204,8 +204,8 @@ halfway between adjacent points, a cursor line marks the selected x, each series shows an active dot, and the tooltip follows the pointer. Bars join by their footprint: a grouped bar chart lists every series of the category under the pointer, with one cursor on the category centre. `mode="y"` does the same along -the y axis for horizontal layouts. The default, `mode="nearest"`, -keeps the 12 px nearest-point behavior. +the y axis — useful for horizontal layouts, but available on any Cartesian +chart. The default, `mode="nearest"`, keeps the 12 px nearest-point behavior. ~~~python demo exec import reflex_xy diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 6a5dcde99..622db7c86 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -38,7 +38,7 @@ primitive or structural descendant is a separate DOM element. | `tooltip_row` | One tooltip field row | | `tooltip_label` | One tooltip field label | | `tooltip_value` | One formatted tooltip value | -| `tooltip_cursor` | Line across the plot at the shared-tooltip band coordinate (`xy.tooltip(mode="x")`) | +| `tooltip_cursor` | Line across the plot at the shared-tooltip band coordinate — vertical for `xy.tooltip(mode="x")`, horizontal for `mode="y"` | | `modebar` | Mode/tool bar container | | `modebar_drag_handle` | Draggable grip revealed beside the toolbar | | `modebar_control_group` | Selection, pan, and export control group | diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 07bb14b0e..9b97bcf2b 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -6823,12 +6823,30 @@ export class ChartView { const yMeta = cpu.yMeta || g.yMeta; const [x0, x1] = this._axisRange(g.xAxis); const [y0, y1] = this._axisRange(g.yAxis); + // Interpolated like the draw is, and in ENCODED space — the same + // arithmetic `_cpuPointValue` does before decoding, so the dot lands + // where the band's cursor and title say it is instead of jumping to + // the final position for the length of a data transition. Staying + // encoded avoids a decode/re-encode round trip through f64. + const progress = g._transitionPositionProgress; + const lerp = (column, prev, i) => (prev && Number.isFinite(progress) + ? prev[i] + (column[i] - prev[i]) * progress + : column[i]); this._drawHoverPoint( g, 0, this._map(xMeta, x0, x1, g.xAxis), this._map(yMeta, y0, y1, g.yAxis), - { x: cpu.x[hit.index], y: cpu.y[hit.index], xMeta, yMeta, color: g.color }, + { + x: lerp(cpu.x, g._transitionPrevXValues, hit.index), + y: lerp(cpu.y, g._transitionPrevYValues, hit.index), + xMeta, + yMeta, + color: g.color, + // The scratch buffer holds ONE vertex, so `index` is 0; the size + // channel still has to be read at the hovered row. + index: hit.index, + }, ); } return; @@ -6885,7 +6903,8 @@ export class ChartView { this._setPolarUniforms(prog); // Size-channel points hover at their encoded size, not the scalar default // (sample traces keep no CPU copy of the size column; they fall back). - const sVal = g.sizeMode === 1 && g._cpu?.size ? g._cpu.size[index] : null; + const sizeIndex = encoded && Number.isInteger(encoded.index) ? encoded.index : index; + const sVal = g.sizeMode === 1 && g._cpu?.size ? g._cpu.size[sizeIndex] : null; const baseSize = sVal != null && Number.isFinite(sVal) ? g.sizeRange[0] + (g.sizeRange[1] - g.sizeRange[0]) * sVal : (g.size || 4); @@ -8839,8 +8858,17 @@ export class ChartView { const coord = this._axisCoord(axis, target); let best = -1; let bestDist = Infinity; - const limit = Math.min(column.length, g.n || column.length); - for (let i = 0; i < limit; i++) { + // A category-filtered trace DRAWS A SUBSET (§10): `_visMap` maps each drawn + // instance to its shipped row and `g.n` counts the drawn ones, so treating + // `g.n` as a prefix of the CPU column both scans rows the legend hid and + // drops visible rows past the prefix. Walk the map's own source indices + // when it is present; `best` stays a shipped row either way, which is what + // readouts and kernel picks address. + const visible = g._visMap; + const limit = visible ? visible.length : Math.min(column.length, g.n || column.length); + for (let k = 0; k < limit; k++) { + const i = visible ? visible[k] : k; + if (i < 0 || i >= column.length) continue; const encoded = starts && Number.isFinite(progress) ? starts[i] + (column[i] - starts[i]) * progress : column[i]; @@ -8950,7 +8978,11 @@ export class ChartView { // Half a CSS pixel of slack: f32 decode noise, not a different value. let lo = anchor.lo - 0.5; let hi = anchor.hi + 0.5; - if (anchor.bar) { + // Whenever ANY candidate is a bar, not only when the anchor is one: a line + // or area point anchoring a category that also holds grouped bars skipped + // the expansion entirely, so the band listed the line and dropped every + // bar beside it. The passes below already ignore non-bar candidates. + if (candidates.some((c) => c.bar)) { // Grouped slots of one category touch: chain bars whose footprints // touch the band. A bar series snaps to the *chain*, not the pointer — // from the gap past a category, the pointer is nearer the previous diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index b3a5ed93e..025ab0be8 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -710,9 +710,21 @@ Object.assign(ChartView.prototype, { const slot = this._bandPicks.get(msg.seq); this._bandPicks.delete(msg.seq); const rows = this._bandRows; - if (!msg.row || !rows || slot === undefined || slot >= rows.length) return; - const rowG = this.gpuTraces.find((t) => t.trace.id === msg.row.trace); - if (!rowG) return; + if (!rows || slot === undefined || slot >= rows.length) return; + // Exact or nothing (§16), as in nearest mode: a reply the kernel could not + // resolve, or one whose trace has since gone, means the band no longer + // describes the data it was built from. Returning silently left its rows + // and cursor on screen describing points that are not there. + const rowG = msg.row && this.gpuTraces.find((t) => t.trace.id === msg.row.trace); + if (!msg.row || !rowG) { + this._hideTooltip(); + return; + } + // A reply is only allowed to replace the row it was requested for. `seq` + // alone does not prove that: the map is rebuilt per band, so a late reply + // from a previous band could land in a slot that now holds another series. + const target = this._hoverTargets && this._hoverTargets[slot]; + if (!target || msg.row.trace !== target.trace || msg.row.index !== target.index) return; for (const channel of ["x", "y"]) { if (typeof msg.row[channel] !== "number") continue; const [value, kind] = this._sourceDisplayValue( From ed01bdfb074a09a93f764723301ff1a3dea8c233 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 15:31:56 +0000 Subject: [PATCH 5/7] Pin the band tooltip fixes with browser regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each fails on the unfixed client and passes with it: - a category-filtered trace whose drawn rows sit past a -sized prefix resolves to a drawn row, never a hidden one (the prefix scan returned row 1 of 5 instead of row 4, and returned a hidden row outright when the pointer sat on one); - a band pick reply for a row its slot no longer holds is ignored rather than overwriting that series (the unfixed client showed `pv12345`), and a reply the kernel could not resolve takes the band down instead of leaving a stale readout titled from the wrong category; - a line point anchoring a category of four grouped bar slots keeps every bar in the band. Four slots are what makes it visible: the inner two touch at the centre so the line beats them on distance and anchors, while the outer two do not reach it — the unfixed client listed `uv`, `amt` and the line, dropping `pv` and `qty`. --- tests/test_tooltip_band.py | 108 +++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/tests/test_tooltip_band.py b/tests/test_tooltip_band.py index 7e6b1a9b4..12876c14a 100644 --- a/tests/test_tooltip_band.py +++ b/tests/test_tooltip_band.py @@ -664,3 +664,111 @@ def test_browser_three_grouped_series_snap_to_the_chain_not_the_pointer() -> Non assert abs(payload["gapIntoC"]["cursorLeft"] - (payload["cx"] + payload["plotX"])) < 1.0, ( payload ) + + +def test_browser_bars_join_a_band_a_line_anchors() -> None: + """A line point can be the candidate nearest the pointer in a category that + also holds grouped bars. The chain expansion ran only when the ANCHOR was a + bar, so that band listed the line and dropped every bar beside it.""" + # Four grouped slots: the two inner ones touch at the category centre, so + # the line's point there beats them on distance and anchors the band, while + # the OUTER two do not reach the centre at all — without the expansion they + # fall outside the line's one-pixel footprint and vanish from the tooltip. + chart = xy.bar_chart( + xy.bar( + _CATS, + [_PV5, _UV5, [1.0, 2.0, 3.0, 4.0, 5.0], [6.0, 1.0, 4.0, 3.0, 2.0]], + series=["pv", "uv", "amt", "qty"], + ), + xy.line(_CATS, [3.5, 4.5, 3.0, 5.0, 2.5], name="trend"), + xy.tooltip(mode="x"), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + // The line's point sits on the category centre, so it is the candidate + // nearest the pointer there and becomes the anchor. + const line = view.gpuTraces.find((g) => g.trace.name === "trend"); + const [bx] = proj(1, 0, line); + hover(bx, 8); const centre = state(); + done({ centre, anchorIsLine: true }); +""", + "line anchor with bars", + ) + s = payload["centre"] + assert s["shown"] is True and s["title"] == "B", s + # Every series of the category, not the line alone. + assert s["rows"] == ["pv3", "uv5", "amt2", "qty1", "trend4.5"], s + assert s["targets"] == 5, s + + +def test_browser_band_scan_skips_legend_hidden_rows() -> None: + """A category-filtered trace draws a subset: `_visMap` maps drawn instance + to shipped row while `g.n` counts the drawn ones, so scanning `g.n` rows of + the CPU column both reads rows the legend hid and misses visible rows past + that prefix.""" + chart = xy.bar_chart( + xy.bar(_CATS, [_PV5, _UV5], series=["pv", "uv"]), + xy.tooltip(mode="x"), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + // Stand in for a category filter: draw only the LAST two rows, which a + // prefix scan of `g.n` can never reach. + const g = view.gpuTraces[0]; + const n = g._cpu.x.length; + g._visMap = Int32Array.from([n - 2, n - 1]); + g.n = 2; + const target = view._decodeValue(g._cpu.x, g._cpu.xMeta || g.xMeta, n - 1); + const picked = view._nearestCpuIndexAlong(g, "x", target); + // ...and a target nearest a hidden row still resolves to a drawn one. + const hiddenTarget = view._decodeValue(g._cpu.x, g._cpu.xMeta || g.xMeta, 0); + const pickedForHidden = view._nearestCpuIndexAlong(g, "x", hiddenTarget); + done({ n, picked, pickedForHidden }); +""", + "band scan visMap", + ) + n = payload["n"] + assert payload["picked"] == n - 1, payload + # Never a hidden row, even when the target sits on one. + assert payload["pickedForHidden"] in (n - 2, n - 1), payload + + +def test_browser_band_pick_reply_is_exact_or_nothing() -> None: + """A band sends one pick per series. A reply the kernel could not resolve + left the band's rows and cursor on screen; a reply for a row the slot no + longer holds could overwrite the wrong series.""" + payload = _run_edge( + _recharts_chart(mode="x"), + """ + const [bx] = proj(1, 0); + hover(bx, 8); + const shown = state(); + const picks = sent.filter((m) => m.type === "pick"); + // A reply for a row this slot never asked for is ignored, not applied. + view._onKernelMsg({ + type: "pick_result", seq: picks[0].seq, + row: { trace: picks[0].trace, index: picks[0].index + 3, x: 0, y: 12345 }, + }); + const afterMismatch = state(); + // A miss hides the band rather than leaving a stale readout up. + view._onKernelMsg({ type: "pick_result", seq: picks[1].seq, row: null }); + const afterMiss = state(); + done({ shown, afterMismatch, afterMiss, picks: picks.length }); +""", + "band pick reply", + ) + assert payload["picks"] == 2, payload + assert payload["shown"]["rows"] == ["pv1398", "uv3000"], payload["shown"] + # The mismatched reply changed nothing. + assert payload["afterMismatch"]["rows"] == payload["shown"]["rows"], payload + # The miss took the whole band down, cursor included. + assert payload["afterMiss"]["shown"] is False, payload["afterMiss"] + assert payload["afterMiss"]["cursorShown"] is False, payload["afterMiss"] From 80b153740929fdce6820bf71fe1b75ee1bfbfaa5 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 15:56:46 +0000 Subject: [PATCH 6/7] Keep the band inside the plot, out of retired traces, and intact when hidden greptile's first full pass over this PR found three lifecycle defects; none were in the eleven threads fixed earlier. - Shared-axis snapping accepted any finite projection, so a row panned or zoomed off the plot stayed selectable. A pointer inside the plot could therefore raise a tooltip and hover state for an invisible point, with no cursor to locate it (the cursor already refuses to draw off-plot). A candidate whose footprint lies wholly outside the band axis is rejected; a bar counts as visible while any part of it overlaps. - The band holds GPU trace objects and rows resolved from them, and both `updatePayload` and a non-in-place append replace those objects. The band survived, so the next draw painted active dots and tooltip rows from the retired traces against the new axes until the pointer moved. Both replacement sites now drop it; an in-place append keeps the object and needs no reset. - With `show=False`, `_renderBandTooltip` hid by clearing the whole band. That is wrong on its own terms -- the mode's contract keeps the hover event, the picks and the active dots, and `_hoverBand` already treats it that way -- and it dropped `_bandPicks` while their replies were in flight. Those replies then missed the band map, and the last still matched `_pickSeq`, so it fell into the single-pick handler and dispatched an `exact: true` hover carrying one series where the band promises all of them. Hiding now touches only the element, and abandoning outstanding picks advances `_pickSeq` so a late reply can never be read as an ordinary point. Three browser regression tests, each failing on the unfixed client: a view panned clear of the data selects nothing and shows no cursor (and recovers when panned back); a real `updatePayload` leaves no band behind for the next draw; and a hidden shared tooltip keeps its two targets and pick bookkeeping across replies, dispatching only whole-band exact hovers. --- js/src/50_chartview.ts | 9 +++ js/src/52_tooltip.ts | 19 ++++- js/src/54_kernel.ts | 4 + js/src/56_animation.ts | 5 ++ tests/test_tooltip_band.py | 152 +++++++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 1 deletion(-) diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 9b97bcf2b..1a63f433c 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -8944,6 +8944,15 @@ export class ChartView { hi = Math.max(a, b); } } + // A row whose coordinate has left the plot after a pan or zoom cannot be + // picked by a pointer that is inside it. The cursor already refuses to draw + // off-plot (`_positionTooltipCursor`), so accepting one produced a tooltip + // and hover state for an invisible point with nothing marking where it is. + // A bar counts as visible while any part of its footprint overlaps, and the + // half-pixel slack matches the band's own (f32 decode noise, not a + // different value). + const extent = dim === "x" ? this.plot.w : this.plot.h; + if (hi < -0.5 || lo > extent + 0.5) return null; return { trace: g.trace.id, index: idx, g, px, lo, hi, bar, x, y, dist: 0, synthetic: true }; } diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index 025ab0be8..453c435cc 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -555,6 +555,11 @@ Object.assign(ChartView.prototype, { // -- shared-axis bands (interaction spec §7.3) ------------------------------ _clearBandHover() { + // Outstanding band picks are being abandoned. Their seqs were drawn from + // `_pickSeq`, and the last one still equals it, so a reply arriving after + // this would pass the single-pick guard and render as an ordinary point + // readout. Advance past them. + if (this._bandPicks && this._bandPicks.size) this._pickSeq = (this._pickSeq || 0) + 1; this._bandKey = null; this._hoverTargets = null; this._bandRows = null; @@ -662,10 +667,22 @@ Object.assign(ChartView.prototype, { _renderBandTooltip(clientX, clientY, options: any = {}) { const rows = this._bandRows; const hits = this._hoverTargets; - if (!rows || !rows.length || !hits || this.spec.show_tooltip === false) { + if (!rows || !rows.length || !hits) { this._hideTooltip(); return; } + // `show=False` drops the tooltip ELEMENT, not the band (§7.3): the hover + // event, the kernel picks and the active dots all still run, which is how + // `_hoverBand` already treats it. Clearing the band from here instead — + // this method is also called when an exact reply lands — dropped the + // remaining `_bandPicks` while their replies were in flight. Those replies + // then missed the band map, and the last of them still matched `_pickSeq`, + // so it fell into the single-pick handler and dispatched an `exact: true` + // hover carrying one series where the band promises all of them. + if (this.spec.show_tooltip === false) { + this.tooltip.style.display = "none"; + return; + } const items = this._bandTooltipItems(rows, hits); if (!this._customTooltip) this._renderBuiltinTooltip(items); if (this.a11yLive && options.announce !== false) { diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index f91b9fba0..4f85da2e3 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -482,6 +482,10 @@ Object.assign(ChartView.prototype, { ? prevSpec.traces.find((t) => t.id === id) : null; if (this._appendTraceInPlace(this.gpuTraces[i], prevTs, prevSpec, ts, payload)) continue; + // Not an in-place append: this trace object is replaced, and the band may + // be holding it (and rows resolved from it). An in-place append keeps the + // object and only grows its columns, so it needs no reset. + this._clearBandHover?.(); this._destroyTraceResources(this.gpuTraces[i], texSeen); this.gpuTraces[i] = this._buildTrace(payload, ts); } diff --git a/js/src/56_animation.ts b/js/src/56_animation.ts index d803e5771..598d89228 100644 --- a/js/src/56_animation.ts +++ b/js/src/56_animation.ts @@ -560,6 +560,11 @@ Object.assign(ChartView.prototype, { this._markBestLegendsDirty?.(); return true; } + // The band holds GPU trace objects and rows resolved from them. Every one + // is about to be replaced, so a draw before the next pointer move would + // paint active dots and tooltip rows from the old data against the new + // axes. The band is rebuilt by the next hover; it cannot survive this. + this._clearBandHover?.(); this.gpuTraces = spec.traces.map((trace) => this._buildTrace(buffer, trace)); // The legend DOM is unchanged on updatePayload, but every rendered mark // underneath it was replaced. Keep this pending through `_dataAnim`; the diff --git a/tests/test_tooltip_band.py b/tests/test_tooltip_band.py index 12876c14a..56c4aa059 100644 --- a/tests/test_tooltip_band.py +++ b/tests/test_tooltip_band.py @@ -10,6 +10,8 @@ from __future__ import annotations +import base64 +import json import sys import tempfile from pathlib import Path @@ -772,3 +774,153 @@ def test_browser_band_pick_reply_is_exact_or_nothing() -> None: # The miss took the whole band down, cursor included. assert payload["afterMiss"]["shown"] is False, payload["afterMiss"] assert payload["afterMiss"]["cursorShown"] is False, payload["afterMiss"] + + +def test_browser_band_skips_rows_panned_off_the_plot() -> None: + """A row whose coordinate has left the plot cannot be selected by a pointer + inside it. The cursor already refuses to draw off-plot, so accepting one + showed a tooltip for an invisible point with nothing marking where it is.""" + payload = _run_edge( + _recharts_chart(mode="x"), + """ + // Pan clear of the data: every category now sits left of the plot, so the + // nearest row to any in-plot pointer is one that cannot be seen. + view.view.ranges.x = [20, 24]; + view._drawNow(); + hover(4, 8); const leftEdge = state(); + hover(view.plot.w / 2, 8); const middle = state(); + hover(view.plot.w - 4, 8); const rightEdge = state(); + // Panning back brings the band straight back. + view.view.ranges.x = [0, 6]; + view._drawNow(); + const [bx] = proj(1, 0); + hover(bx, 8); const backInView = state(); + done({ leftEdge, middle, rightEdge, backInView }); +""", + "off-plot band rows", + ) + for key in ("leftEdge", "middle", "rightEdge"): + s = payload[key] + # Nothing is selectable, so nothing is shown and no cursor is drawn. + assert s["shown"] is False, (key, s) + assert s["targets"] == 0, (key, s) + assert s["cursorShown"] is False, (key, s) + # The guard is about visibility, not a permanent refusal. + back = payload["backInView"] + assert back["shown"] is True and back["title"] == "Page B", back + assert back["rows"] == ["pv1398", "uv3000"], back + + +def test_browser_live_update_drops_the_previous_band(tmp_path) -> None: + """The band holds GPU trace objects and rows resolved from them, and + `updatePayload` replaces every one. Left in place, the next draw painted + active dots and tooltip rows from the retired traces against the new axes.""" + chromium = find_chromium() + if chromium is None: + pytest.skip("headless chromium not found") + next_spec, next_buffer = _recharts_chart(mode="x").figure().build_payload() + # Same shape, different values: the update replaces the trace objects the + # live band is holding. + for trace in next_spec.get("traces", []): + trace["name"] = f"{trace.get('name', '')}2" + head = ( + f"' + ) + document = probe_document( + _recharts_chart(mode="x"), + _edge_probe( + """ + const decode = (b64) => { + const binary = atob(b64); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; + }; + const [bx] = proj(1, 0); + hover(bx, 8); + const before = { ...state(), traces: view._hoverTargets.map((h) => h.g.trace.name) }; + const applied = view.updatePayload(NEXT_SPEC, decode(NEXT_B64)); + const afterUpdate = { + targets: (view._hoverTargets || []).length, + rows: view._bandRows, + key: view._bandKey, + cursorShown: !!cursor() && cursor().style.display === "block", + }; + // The draw that used to reach into the retired trace objects. + if (view._raf) cancelAnimationFrame(view._raf); + view._raf = null; + view._drawNow(); + const names = view.gpuTraces.map((g) => g.trace.name); + done({ before, applied, afterUpdate, names }); +""" + ), + head=head, + ) + payload = run_browser_probe( + chromium, + document, + tmp_path / "band_update.html", + "data-xy-bandedge", + label="band cleared on updatePayload", + ) + + assert payload["before"]["targets"] == 2, payload["before"] + assert payload["applied"] is True, payload + # The traces really were replaced. + assert payload["names"] == ["pv2", "uv2"], payload + # And nothing of the old band survived the swap to be drawn against them. + assert payload["afterUpdate"]["targets"] == 0, payload["afterUpdate"] + assert payload["afterUpdate"]["rows"] is None, payload["afterUpdate"] + assert payload["afterUpdate"]["key"] is None, payload["afterUpdate"] + assert payload["afterUpdate"]["cursorShown"] is False, payload["afterUpdate"] + + +def test_browser_hidden_tooltip_keeps_its_band_bookkeeping() -> None: + """`show=False` drops the tooltip element, not the band: the hover event, + the picks and the active dots still run. Hiding used to dissolve the band + mid-flight, so a later reply fell into the single-pick handler and + dispatched an `exact: true` hover carrying one series, not the band.""" + chart = xy.line_chart( + xy.line(PAGES, PV, name="pv", color="#8884d8", width=2), + xy.line(PAGES, UV, name="uv", color="#82ca9d", width=2), + xy.tooltip(mode="x", show=False), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + const [bx] = proj(1, 0); + hover(bx, 8); + const picks = sent.filter((m) => m.type === "pick"); + const bandPicks = view._bandPicks ? view._bandPicks.size : 0; + // Replies land one at a time; the first must not dissolve the band. + view._onKernelMsg({ + type: "pick_result", seq: picks[0].seq, + row: { trace: picks[0].trace, index: picks[0].index, x: 1, y: 1398 }, + }); + const afterFirst = { targets: (view._hoverTargets || []).length, picks: view._bandPicks.size }; + view._onKernelMsg({ + type: "pick_result", seq: picks[1].seq, + row: { trace: picks[1].trace, index: picks[1].index, x: 1, y: 3000 }, + }); + const exact = hovers.filter((h) => h.exact); + done({ + picks: picks.length, bandPicks, afterFirst, + tooltipShown: tip.style.display === "block", + exactPointCounts: exact.map((h) => (h.points || []).length), + }); +""", + "hidden band bookkeeping", + ) + assert payload["picks"] == 2 and payload["bandPicks"] == 2, payload + # The band survives the first reply instead of being cleared by the hide. + assert payload["afterFirst"]["targets"] == 2, payload["afterFirst"] + assert payload["afterFirst"]["picks"] == 1, payload["afterFirst"] + # The tooltip element stays hidden throughout. + assert payload["tooltipShown"] is False, payload + # Every exact hover describes the whole band, never a lone series. + assert payload["exactPointCounts"], payload + assert all(n == 2 for n in payload["exactPointCounts"]), payload From 4a542b6032ea17f11d33f0b1a573a403d5720bc8 Mon Sep 17 00:00:00 2001 From: Sriman Date: Tue, 22 Sep 2026 17:15:07 +0000 Subject: [PATCH 7/7] Keep the cursor on a clipped bar, honor fields=["x"], drop the band on a dead context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings across greptile, cubic and CodeRabbit, each with a regression test that fails without its fix. - A bar joins the band while ANY part of its footprint overlaps the plot, but `_positionTooltipCursor` hides on an off-plot coordinate, so a bar more than half clipped by the edge was selectable with its band centre outside: the tooltip appeared with nothing locating it. The cursor now carries the band's footprint and is drawn on the part that is visible. A point band's footprint is its own coordinate, so nothing moves. - `fields=["x"]` in `mode="x"` asks for the band coordinate and nothing else, which the title already carries — the rows should be names alone. Filtering the band field out and then testing the remainder for emptiness read that as "no fields configured" and fell back to the default y value, ignoring the selection. An authored EMPTY list still means unset, as in nearest mode. - `updatePayload` and `_applyAppend` return early when the GL context is gone, having already replaced the retained spec and payload. Recovery rebuilds every GPU trace from those and touches no hover state, so the band outlived the traces it was resolved from across the whole restore. Both early returns now drop it, as the live paths already did. - The shared-tooltip docs promised an active dot for "each series"; bars get none, as §7.3 says, because the bar is the mark. --- docs/components/tooltips.md | 9 +-- js/src/50_chartview.ts | 17 +++++- js/src/52_tooltip.ts | 38 +++++++++--- js/src/54_kernel.ts | 7 ++- js/src/56_animation.ts | 5 ++ spec/api/interaction.md | 13 ++++- tests/test_tooltip_band.py | 112 ++++++++++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 16 deletions(-) diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md index 2c67a698a..3ff029ff7 100644 --- a/docs/components/tooltips.md +++ b/docs/components/tooltips.md @@ -200,10 +200,11 @@ Recharts uses by default and Plotly calls `hovermode="x unified"`. The pointer only has to be inside the plot: its horizontal position snaps to the nearest x value and every series' point at that x is listed at once, while the vertical position is ignored. The plot divides into full-height bands with boundaries -halfway between adjacent points, a cursor line marks the selected x, each series -shows an active dot, and the tooltip follows the pointer. Bars join by their -footprint: a grouped bar chart lists every series of the category under the -pointer, with one cursor on the category centre. `mode="y"` does the same along +halfway between adjacent points, a cursor line marks the selected x, each +series shows an active dot, and the tooltip follows the pointer. Bars join by +their footprint: a grouped bar chart lists every series of the category under +the pointer, with one cursor on the category centre — and no active dots, since +the bar is already the mark. `mode="y"` does the same along the y axis — useful for horizontal layouts, but available on any Cartesian chart. The default, `mode="nearest"`, keeps the 12 px nearest-point behavior. diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 1a63f433c..7511d1bd9 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -9077,7 +9077,22 @@ export class ChartView { this._bandCursor = null; this._hideTooltipCursor(); } else { - this._bandCursor = { dim, xAxis: ag.xAxis, yAxis: ag.yAxis, x: at.x, y: at.y }; + // Carry the band's footprint along the band axis (plot-relative px, the + // union over its slots) so the cursor can be drawn on the part of a + // clipped bar that is actually visible. A point band's footprint is its + // own coordinate, which changes nothing. + const spans = band.hits.filter( + (h) => Number.isFinite(h.lo) && Number.isFinite(h.hi), + ); + this._bandCursor = { + dim, + xAxis: ag.xAxis, + yAxis: ag.yAxis, + x: at.x, + y: at.y, + lo: spans.length ? Math.min(...spans.map((h) => h.lo)) : undefined, + hi: spans.length ? Math.max(...spans.map((h) => h.hi)) : undefined, + }; this._renderBandTooltip(e.clientX, e.clientY); this._positionTooltipCursor(); } diff --git a/js/src/52_tooltip.ts b/js/src/52_tooltip.ts index 453c435cc..90f662d06 100644 --- a/js/src/52_tooltip.ts +++ b/js/src/52_tooltip.ts @@ -587,19 +587,34 @@ Object.assign(ChartView.prototype, { const el = this._ensureTooltipCursor(); const [lx, ly] = this._projectDataPoint(a.xAxis, a.yAxis, a.x, a.y); const p = this.plot; - const pos = a.dim === "x" ? lx : ly; + let pos = a.dim === "x" ? lx : ly; const lo = a.dim === "x" ? p.x : p.y; const hi = a.dim === "x" ? p.x + p.w : p.y + p.h; - if (!Number.isFinite(pos) || pos < lo || pos > hi) { el.style.display = "none"; return; } + if (!Number.isFinite(pos)) { el.style.display = "none"; return; } + // A bar joins the band while ANY part of its footprint overlaps the plot + // (`_bandCandidate`), so a bar clipped by the edge can be selected with its + // category centre outside. The band is real and the pointer is on it, so + // mark the part that IS visible rather than dropping the cursor and + // leaving the tooltip with nothing locating it. A point band's footprint + // is its own coordinate, so this never moves it. + const spanLo = lo + a.lo; + const spanHi = lo + a.hi; + if ( + Number.isFinite(spanLo) && Number.isFinite(spanHi) && spanHi > spanLo + && spanHi >= lo && spanLo <= hi + ) { + pos = Math.min(Math.max(pos, Math.max(spanLo, lo)), Math.min(spanHi, hi)); + } + if (pos < lo || pos > hi) { el.style.display = "none"; return; } el.style.display = "block"; if (a.dim === "x") { - el.style.left = `${lx}px`; + el.style.left = `${pos}px`; el.style.top = `${p.y}px`; el.style.width = "1px"; el.style.height = `${p.h}px`; } else { el.style.left = `${p.x}px`; - el.style.top = `${ly}px`; + el.style.top = `${pos}px`; el.style.width = `${p.w}px`; el.style.height = "1px"; } @@ -638,14 +653,23 @@ Object.assign(ChartView.prototype, { title = this._formatTooltipValue(first[along], first[`${along}_kind`], formats[along]); } if (title) items.push({ kind: "title", value: title }); - const fields = Array.isArray(tooltip.fields) - ? tooltip.fields.filter((f) => typeof f === "string" && f !== along) + // `fields=["x"]` in `mode="x"` asks for the band coordinate and nothing + // else, which the title already carries — so the rows are names alone. + // Filtering first and then testing `.length` read that as "no fields + // configured" and fell back to the default `across` value, silently + // ignoring the selection. An authored EMPTY list still means unset, as it + // does in nearest mode. + const authored = Array.isArray(tooltip.fields) + ? tooltip.fields.filter((f) => typeof f === "string") + : null; + const fields = authored && authored.length + ? authored.filter((f) => f !== along) : null; rows.forEach((row, i) => { const g = hits[i] && hits[i].g; const name = this._tooltipSeriesName(row) || `series ${i + 1}`; let value; - if (fields && fields.length) { + if (fields) { value = fields .map((f) => { const [v, k] = this._tooltipLookup(row, f); diff --git a/js/src/54_kernel.ts b/js/src/54_kernel.ts index 4f85da2e3..b807fa72b 100644 --- a/js/src/54_kernel.ts +++ b/js/src/54_kernel.ts @@ -472,7 +472,12 @@ Object.assign(ChartView.prototype, { // context is lost. The restore path rebuilds every affected GPU object // from this latest payload; attempting partial uploads to a dead context // would only create handles that must immediately be discarded. - if (this._glLost || !this.gl) return; + if (this._glLost || !this.gl) { + // As in `updatePayload`: the payload is retained, the restore path + // rebuilds the GPU traces from it, and nothing else drops the band. + this._clearBandHover?.(); + return; + } const texSeen = new Set(); for (const id of msg.affected || []) { const i = this.gpuTraces.findIndex((g) => g.trace.id === id); diff --git a/js/src/56_animation.ts b/js/src/56_animation.ts index 598d89228..81156106c 100644 --- a/js/src/56_animation.ts +++ b/js/src/56_animation.ts @@ -556,6 +556,11 @@ Object.assign(ChartView.prototype, { }); const target = { ...this.view0 }; if (this._glLost || !this.gl) { + // The retained spec and payload have already been replaced above, and + // the restore path rebuilds every GPU trace from them without touching + // hover state. The band would then hold retired trace objects across the + // recovery, exactly as it would on the live path below. + this._clearBandHover?.(); this.view = { ...target }; this._markBestLegendsDirty?.(); return true; diff --git a/spec/api/interaction.md b/spec/api/interaction.md index 08dd184ff..a02fd4901 100644 --- a/spec/api/interaction.md +++ b/spec/api/interaction.md @@ -431,12 +431,19 @@ Wire: `tooltip.mode`, shipped only when not `"nearest"`. `title` template resolved against the anchor series' row), then one row per series in band order: the series name painted in the series colour, then its value along the other axis — or the authored `fields`, minus the band - field — through the same `format` grammar. It **follows the pointer**: the + field — through the same `format` grammar. A `fields` list naming only the + band field therefore leaves the rows as names alone: the title already + carries that value, and falling back to the default would ignore the + selection. An empty `fields` list still means unset, as in nearest mode. It **follows the pointer**: the one exception to the data-space anchoring above, because a band has several points and the cursor already marks it. The `tooltip_cursor` DOM slot draws that line across the plot at the snapped coordinate, reprojected on every - draw exactly as an anchor would be, hidden when the coordinate leaves the - plot. + draw exactly as an anchor would be. It hides when the coordinate leaves the + plot — except where the band is a bar, which joins while any part of its + footprint overlaps: a bar clipped by the plot edge can be selected with its + category centre outside, and the cursor is then drawn on the part of the + footprint that is visible rather than dropped, so the tooltip is never left + with nothing locating it. - Every series in the band gets an active dot, drawn from its CPU columns rather than its vertex buffer (a smoothed or stepped line's vertex index is not its data index) in the series colour. Adding it exposed that the diff --git a/tests/test_tooltip_band.py b/tests/test_tooltip_band.py index 56c4aa059..b394e701a 100644 --- a/tests/test_tooltip_band.py +++ b/tests/test_tooltip_band.py @@ -924,3 +924,115 @@ def test_browser_hidden_tooltip_keeps_its_band_bookkeeping() -> None: # Every exact hover describes the whole band, never a lone series. assert payload["exactPointCounts"], payload assert all(n == 2 for n in payload["exactPointCounts"]), payload + + +def test_browser_a_clipped_bar_keeps_its_cursor() -> None: + """A bar joins the band while any part of its footprint overlaps the plot, + so a bar more than half clipped by the edge is selectable with the centre + of its footprint — where the cursor goes — outside. The cursor hides on an + off-plot coordinate, so the two rules disagreed and the tooltip appeared + with nothing locating the band. It is now drawn on the visible part.""" + chart = xy.bar_chart( + xy.bar(_CATS, _PV5), + xy.tooltip(mode="x"), + xy.interaction_config(hover=True), + width=640, + height=360, + ) + payload = _run_edge( + chart, + """ + // Creep the left edge of the view across category B's bar until the bar's + // footprint centre has left the plot while part of the bar is still drawn + // inside it — the window where it stays selectable but its cursor + // coordinate does not. + const span = view.view.ranges.x[1] - view.view.ranges.x[0]; + let atEdge = null, centre = null, steps = 0; + for (let k = 1; k <= 80; k++) { + const lo = 1 + (k / 80) * 0.5; + view.view.ranges.x = [lo, lo + span]; + view._drawNow(); + view._hoverId = -1; view._bandKey = null; + hover(3, 8); + const s = state(); + if (!s.shown || s.title !== "B") continue; + const c = view._bandCursor; + const [px] = view._projectDataPoint(c.xAxis, c.yAxis, c.x, c.y); + if (px - view.plot.x >= 0) continue; + steps = k; atEdge = s; centre = px - view.plot.x; + break; + } + done({ centre, atEdge, steps, plotX: view.plot.x, plotW: view.plot.w }); +""", + "clipped bar cursor", + ) + # The band's own cursor coordinate really is off-plot, which is what makes + # this reachable at all. + assert payload["centre"] is not None and payload["centre"] < 0, payload + edge = payload["atEdge"] + assert edge["shown"] is True and edge["title"] == "B", edge + assert edge["rows"] == ["series 13"], edge + # Drawn, and inside the plot rather than at the off-plot centre. + assert edge["cursorShown"] is True, edge + assert payload["plotX"] <= edge["cursorLeft"] <= payload["plotX"] + payload["plotW"], ( + edge, + payload, + ) + + +def test_browser_band_fields_naming_only_the_band_field_leave_names_alone() -> None: + """`fields=["x"]` asks for the band coordinate and nothing else, which the + title already carries. Filtering the band field out and then testing the + remainder for emptiness read that as "no fields configured" and fell back + to the default y value, ignoring the selection.""" + only_band = _run_edge( + _recharts_chart(mode="x", fields=["x"]), + """ + const [bx] = proj(1, 0); + hover(bx, 8); done(state()); +""", + "band fields x only", + ) + assert only_band["shown"] is True and only_band["title"] == "Page B", only_band + # Names alone: no value follows either series name. + assert only_band["rows"] == ["pv", "uv"], only_band + + # An authored field other than the band's is unaffected, and so is the + # default when no fields are configured at all. + other = _run_edge( + _recharts_chart(mode="x", fields=["y"]), + """ + const [bx] = proj(1, 0); + hover(bx, 8); done(state()); +""", + "band fields y", + ) + assert other["rows"] == ["pv1398", "uv3000"], other + + +def test_browser_a_lost_context_drops_the_band_too() -> None: + """`updatePayload` and `_applyAppend` return early when the GL context is + gone, having already replaced the retained spec and payload. Recovery + rebuilds every GPU trace from those and touches no hover state, so the band + outlived the traces it was resolved from across the whole restore.""" + payload = _run_edge( + _recharts_chart(mode="x"), + """ + const [bx] = proj(1, 0); + hover(bx, 8); const before = state(); + // Lose the context, then push an update through the early-return path. + view._glLost = true; + const spec = JSON.parse(JSON.stringify(view.spec)); + spec.traces.forEach((t, i) => { t.name = `renamed${i}`; }); + view.updatePayload(spec, new ArrayBuffer(0)); + const after = state(); + done({ before, after, key: view._bandKey ?? null, rowsLeft: (view._bandRows || []).length, + picks: Object.keys(view._bandPicks || {}).length }); +""", + "lost context band", + ) + assert payload["before"]["shown"] is True and payload["before"]["targets"] == 2, payload + assert payload["after"]["targets"] == 0, payload + assert payload["after"]["cursorShown"] is False, payload + assert payload["key"] is None and payload["rowsLeft"] == 0, payload + assert payload["picks"] == 0, payload