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",