From e789a1ed605f1a90f964e7e23df15b924284062a Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Wed, 2 Sep 2026 12:00:15 -0700 Subject: [PATCH 1/3] 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 74803b17..c80970b9 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 88ad0b0d..21dc45d1 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 526d659e..6a5dcde9 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 4803d127..ceeb0f82 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 2c6df776..7a9e7db4 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 73e26d09..4ff30313 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 e7df9f40..f91b9fba 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 8d3b65f7..bedf7a39 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 f365432d..d4b1fa03 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 08a51347..d087c06b 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 069fc218..8465db13 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 259e403d..3e7f8fbc 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 33870d82..4ab3f7a3 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 2bbf64c8..ca669639 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 6e006814..d6f90efd 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 4806e0c2..85ba1795 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 00000000..e25978d1 --- /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 f5e12505..bf057121 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/3] 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 00000000..a6a49252 --- /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/3] 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 c80970b9..50aa1fb5 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 7a9e7db4..07bb14b0 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 4ff30313..b3a5ed93 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 a6a49252..5656c216 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 4ab3f7a3..08dd184f 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 e25978d1..7e6b1a9b 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 + )