diff --git a/docs/components/tooltips.md b/docs/components/tooltips.md
index 74803b17..50aa1fb5 100644
--- a/docs/components/tooltips.md
+++ b/docs/components/tooltips.md
@@ -193,6 +193,44 @@ 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. 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
+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..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
@@ -6561,8 +6565,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 +6811,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 || g.trace.bar) 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 +6850,38 @@ 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.
+ // 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;
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 +6893,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 +6912,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 +8821,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 +8854,197 @@ 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.
+ // 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); 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 target = this._bandTarget(g, dim, cssX, cssY);
+ if (!Number.isFinite(target)) continue;
+ 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) {
+ 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 };
+ }
+ return { hits, anchor, dim, center };
+ }
+
+ _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).
+ 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;
+ 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;
+ 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();
+ }
+
_nearestPolarCpuIndex(g, cssX, cssY) {
const cpu = g && g._cpu;
if (!cpu || !cpu.x || !cpu.y) return -1;
@@ -9035,6 +9333,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;
@@ -9283,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 73e26d09..b3a5ed93 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,191 @@ 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;
+ this._bandTitleValue = undefined;
+ 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 (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]);
+ }
+ 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/news/509.feature.md b/news/509.feature.md
new file mode 100644
index 00000000..5656c216
--- /dev/null
+++ b/news/509.feature.md
@@ -0,0 +1,13 @@
+`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. 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
+per-item factor of zero); it draws again.
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..08dd184f 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,76 @@ 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, 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
+ 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`.
+- `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).
+
+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-bars.png b/spec/assets/tooltip-x-band-bars.png
new file mode 100644
index 00000000..cb699a84
Binary files /dev/null and b/spec/assets/tooltip-x-band-bars.png differ
diff --git a/spec/assets/tooltip-x-band.png b/spec/assets/tooltip-x-band.png
new file mode 100644
index 00000000..c4dc9217
Binary files /dev/null and b/spec/assets/tooltip-x-band.png differ
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..7e6b1a9b
--- /dev/null
+++ b/tests/test_tooltip_band.py
@@ -0,0 +1,666 @@
+"""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
+
+
+# --- 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
+ )
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",