From 2ef64ffd7269e3377d1dd333c79220962f2c3030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 17 Mar 2026 15:20:23 +0100 Subject: [PATCH 1/7] pool option for tip marks Adds a pool option to the pointer transform, that defaults to true on the tip mark (including markes derived with {tip: true}). Pooled marks show only the closest point, preventing collisions. Non-pooled marks (like crosshair sub-marks) are unaffected. Pooling also handles facet deduplication. --- docs/interactions/pointer.md | 1 + docs/marks/tip.md | 2 +- src/interactions/pointer.d.ts | 7 + src/interactions/pointer.js | 43 +- src/marks/tip.js | 4 +- src/plot.js | 1 + test/output/tipBoxX.svg | 91 +++++ test/output/tipCrosshair.svg | 89 +++++ test/output/tipCrosshairFacet.svg | 507 ++++++++++++++++++++++++ test/output/tipPool.svg | 608 +++++++++++++++++++++++++++++ test/output/tipPoolFacet.svg | 628 ++++++++++++++++++++++++++++++ test/plots/tip.ts | 45 +++ test/pointer-test.js | 145 +++++++ 13 files changed, 2145 insertions(+), 26 deletions(-) create mode 100644 test/output/tipBoxX.svg create mode 100644 test/output/tipCrosshair.svg create mode 100644 test/output/tipCrosshairFacet.svg create mode 100644 test/output/tipPool.svg create mode 100644 test/output/tipPoolFacet.svg create mode 100644 test/pointer-test.js diff --git a/docs/interactions/pointer.md b/docs/interactions/pointer.md index e00df011b8..9b371dcd31 100644 --- a/docs/interactions/pointer.md +++ b/docs/interactions/pointer.md @@ -176,6 +176,7 @@ The following options control the pointer transform: - **x2** - the ending horizontal↔︎ target position; bound to the *x* scale - **y2** - the ending vertical↕︎ target position; bound to the *y* scale - **maxRadius** - the reach, or maximum distance, in pixels; defaults to 40 +- **pool** - if true, pool with other pointer marks, showing only the closest; defaults to true for the [tip mark](../marks/tip.md) - **frameAnchor** - how to position the target within the frame; defaults to *middle* To resolve the horizontal target position, the pointer transform applies the following order of precedence: diff --git a/docs/marks/tip.md b/docs/marks/tip.md index ccb4481624..751fb4741a 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -201,7 +201,7 @@ Plot.plot({ ::: :::tip -When multiple tips are visible simultaneously, some may collide; consider using the pointer interaction to show only the one closest to the pointer, or use multiple tip marks and adjust the **anchor** option for each to minimize occlusion. +The tip mark defaults the [**pool**](../interactions/pointer.md#pointer-options) option to true, ensuring multiple marks with the *tip* option don’t collide. ::: ## Tip options diff --git a/src/interactions/pointer.d.ts b/src/interactions/pointer.d.ts index 89bb611ce0..a74fc5adca 100644 --- a/src/interactions/pointer.d.ts +++ b/src/interactions/pointer.d.ts @@ -17,6 +17,13 @@ export interface PointerOptions { /** The vertical target position channel, typically bound to the *y* scale. */ py?: ChannelValue; + /** + * Whether this mark participates in the pointer pool, which ensures that + * only the closest point is shown when multiple pointer marks are present. + * Defaults to true for the tip mark. + */ + pool?: boolean; + /** * The fallback horizontal target position channel, typically bound to the *x* * scale; used if **px** is not specified. diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index f0c0f765b0..638f9aa73a 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -4,6 +4,7 @@ import {isArray} from "../options.js"; import {applyFrameAnchor} from "../style.js"; const states = new WeakMap(); +const frames = new WeakMap(); function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { maxRadius = +maxRadius; @@ -71,32 +72,26 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op let i; // currently focused index let g; // currently rendered mark let s; // currently rendered stickiness - let f; // current animation frame - // When faceting, if more than one pointer would be visible, only show - // this one if it is the closest. We defer rendering using an animation - // frame to allow all pointer events to be received before deciding which - // mark to render; although when hiding, we render immediately. + // When pooling or faceting, if more than one pointer would be visible, + // only show the closest. We defer rendering using an animation frame to + // allow all pointer events to be received before deciding which mark to + // render; although when hiding, we render immediately. + const pool = this.pool ? context.pointerPool : faceted ? facetState : null; function update(ii, ri) { - if (faceted) { - if (f) f = cancelAnimationFrame(f); - if (ii == null) facetState.delete(index.fi); - else { - facetState.set(index.fi, ri); - f = requestAnimationFrame(() => { - f = null; - for (const [fi, r] of facetState) { - if (r < ri || (r === ri && fi < index.fi)) { - ii = null; - break; - } - } - render(ii); - }); - return; - } - } - render(ii); + if (ii == null) render(ii); + if (!pool) return void render(ii); + pool.set(render, {ii, ri, render}); + if (frames.has(pool)) cancelAnimationFrame(frames.get(pool)); + frames.set( + pool, + requestAnimationFrame(() => { + frames.delete(pool); + let best = null; + for (const [, c] of pool) if (!best || c.ri < best.ri) best = c; + for (const [, c] of pool) c.render(c === best ? c.ii : null); + }) + ); } function render(ii) { diff --git a/src/marks/tip.js b/src/marks/tip.js index bfb9d04cb2..fb9611bab7 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -49,7 +49,8 @@ export class Tip extends Mark { textPadding = 8, title, pointerSize = 12, - pathFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))" + pathFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))", + pool = true } = options; super( data, @@ -84,6 +85,7 @@ export class Tip extends Mark { for (const key in defaults) if (key in this.channels) this[key] = defaults[key]; // apply default even if channel this.splitLines = splitter(this); this.clipLine = clipper(this); + this.pool = pool; this.format = typeof format === "string" || typeof format === "function" ? {title: format} : {...format}; // defensive copy before mutate; also promote nullish to empty } render(index, scales, values, dimensions, context) { diff --git a/src/plot.js b/src/plot.js index 16976c2585..7b85a0567e 100644 --- a/src/plot.js +++ b/src/plot.js @@ -157,6 +157,7 @@ export function plot(options = {}) { let figure = svg; // replaced with the figure element, if any context.ownerSVGElement = svg; context.className = className; + context.pointerPool = new Map(); context.projection = createProjection(options, subdimensions); // A path generator for marks that want to draw GeoJSON. diff --git a/test/output/tipBoxX.svg b/test/output/tipBoxX.svg new file mode 100644 index 0000000000..a833dc214f --- /dev/null +++ b/test/output/tipBoxX.svg @@ -0,0 +1,91 @@ + + + + + 1 + 2 + 3 + 4 + 5 + + + Expt + + + + 650 + 700 + 750 + 800 + 850 + 900 + 950 + 1,000 + 1,050 + + + Speed → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipCrosshair.svg b/test/output/tipCrosshair.svg new file mode 100644 index 0000000000..d49bdeb536 --- /dev/null +++ b/test/output/tipCrosshair.svg @@ -0,0 +1,89 @@ + + + + + + 60 + 70 + 80 + 90 + 100 + 110 + 120 + 130 + 140 + 150 + 160 + 170 + 180 + 190 + + + ↑ Close + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipCrosshairFacet.svg b/test/output/tipCrosshairFacet.svg new file mode 100644 index 0000000000..8cea3ec90d --- /dev/null +++ b/test/output/tipCrosshairFacet.svg @@ -0,0 +1,507 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + + ↑ culmen_depth_mm + + + + + + 35 + 40 + 45 + 50 + 55 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipPool.svg b/test/output/tipPool.svg new file mode 100644 index 0000000000..949f2ebe62 --- /dev/null +++ b/test/output/tipPool.svg @@ -0,0 +1,608 @@ + + + + + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + + + ↑ economy (mpg) + + + + 60 + 80 + 100 + 120 + 140 + 160 + 180 + 200 + 220 + + + power (hp) → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipPoolFacet.svg b/test/output/tipPoolFacet.svg new file mode 100644 index 0000000000..bcfdd68876 --- /dev/null +++ b/test/output/tipPoolFacet.svg @@ -0,0 +1,628 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + + ↑ culmen_depth_mm + + + + + + 35 + 40 + 45 + 50 + 55 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/tip.ts b/test/plots/tip.ts index 6c827e7b85..3fbf9a13e0 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -181,6 +181,51 @@ export async function tipLineY() { return Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot(); } +export async function tipPool() { + const cars = await d3.csv("data/cars.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.hexagon(cars, Plot.hexbin({fill: "count"}, {x: "power (hp)", y: "economy (mpg)", tip: true})), + Plot.dot(cars, {x: "power (hp)", y: "economy (mpg)", tip: true}) + ] + }); +} + +export async function tipCrosshair() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.plot({ + y: {grid: true}, + marks: [Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}), Plot.crosshairX(aapl, {x: "Date", y: "Close"})] + }); +} + +export async function tipCrosshairFacet() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species"}), + Plot.crosshair(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species"}) + ] + }); +} + +export async function tipPoolFacet() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(penguins, Plot.hexbin({}, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species", tip: true})), + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species", fill: "sex", tip: true}) + ] + }); +} + +export async function tipBoxX() { + const morley = await d3.csv("data/morley.csv", d3.autoType); + return Plot.boxX(morley, {x: "Speed", y: "Expt", tip: true}).plot(); +} + export async function tipLongText() { return Plot.tip([{x: "Long sentence that gets cropped after a certain length"}], {x: "x"}).plot(); } diff --git a/test/pointer-test.js b/test/pointer-test.js new file mode 100644 index 0000000000..ac2ef3e930 --- /dev/null +++ b/test/pointer-test.js @@ -0,0 +1,145 @@ +import * as Plot from "@observablehq/plot"; +import assert from "assert"; +import {JSDOM} from "jsdom"; + +function setup() { + const jsdom = new JSDOM(""); + const window = jsdom.window; + global.window = window; + global.document = window.document; + global.Event = window.Event; + global.Node = window.Node; + global.NodeList = window.NodeList; + global.HTMLCollection = window.HTMLCollection; + window.SVGElement.prototype.getBBox = () => ({x: 0, y: 0, width: 100, height: 20}); + + const rafQueue = []; + global.requestAnimationFrame = (fn) => rafQueue.push(fn); + global.cancelAnimationFrame = (id) => (rafQueue[id - 1] = null); + + function flushAnimationFrame() { + let q; + while ((q = rafQueue.splice(0)).length) q.forEach((fn) => fn?.()); + } + + function pointer(svg, type, x, y) { + const e = new window.Event(type, {bubbles: true}); + e.clientX = x; + e.clientY = y; + e.pointerType = "mouse"; + svg.dispatchEvent(e); + } + + return {pointer, flushAnimationFrame}; +} + +function teardown() { + delete global.window; + delete global.document; + delete global.Event; + delete global.Node; + delete global.NodeList; + delete global.HTMLCollection; + delete global.requestAnimationFrame; + delete global.cancelAnimationFrame; +} + +function visibleTips(svg) { + return [...svg.querySelectorAll("[aria-label=tip]")].filter((g) => g.querySelector("text")).length; +} + +describe("pointer pool", () => { + afterEach(teardown); + + it("multiple tip: true marks show only one tip", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [ + Plot.dot([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y", tip: true}), // prettier-ignore + Plot.dot([{x: 1.1, y: 1.1}, {x: 2.1, y: 2.1}], {x: "x", y: "y", tip: true}) // prettier-ignore + ] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + }); + + it("compound marks with tip: true show only one tip", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.boxX([1, 2, 3, 4, 5, 10, 20], {tip: true}).plot(); + const tips = svg.querySelectorAll("[aria-label=tip]"); + assert.ok(tips.length > 1, "boxX should create multiple tip marks"); + pointer(svg, "pointerenter", 100, 15); + pointer(svg, "pointermove", 100, 15); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + }); + + it("crosshair renders all sub-marks (does not pool)", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [Plot.crosshair([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y"})] // prettier-ignore + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + // crosshair creates 4 sub-marks (ruleX, ruleY, textX, textY); + // all should render independently since they don't pool + const rules = svg.querySelectorAll("[aria-label^='crosshair']"); + assert.ok(rules.length >= 2, "crosshair should have at least 2 sub-marks rendered"); + }); + + it("crosshair and tip: true coexist", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [ + Plot.dot([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y", tip: true}), // prettier-ignore + Plot.crosshair( + [ + {x: 1, y: 1}, + {x: 2, y: 2} + ], + {x: "x", y: "y"} + ) + ] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + // The tip should render (1 from the dot's tip: true) + assert.strictEqual(visibleTips(svg), 1); + // The crosshair sub-marks should also render + const crosshairMarks = svg.querySelectorAll("[aria-label^='crosshair']"); + assert.ok(crosshairMarks.length >= 2, "crosshair sub-marks should also render"); + }); + + it("explicit tip(pointer) pools with tip: true", () => { + const {pointer, flushAnimationFrame} = setup(); + const data = [{x: 1, y: 1}, {x: 2, y: 2}]; // prettier-ignore + const svg = Plot.plot({ + marks: [Plot.dot(data, {x: "x", y: "y", tip: true}), Plot.tip(data, Plot.pointer({x: "x", y: "y", pool: true}))] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + }); + + it("pointerleave hides all tips", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [ + Plot.dot([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y", tip: true}), // prettier-ignore + Plot.dot([{x: 1.1, y: 1.1}, {x: 2.1, y: 2.1}], {x: "x", y: "y", tip: true}) // prettier-ignore + ] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + pointer(svg, "pointerleave", 0, 0); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 0); + }); +}); From 4745ccedc0bdbc7b67a8ac3d891be6aa5ebbbf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 17 Mar 2026 16:26:43 +0100 Subject: [PATCH 2/7] state.pool --- src/interactions/pointer.js | 4 ++-- src/plot.js | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 638f9aa73a..0c4a9b1a8d 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -30,7 +30,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // Isolate state per-pointer, per-plot; if the pointer is reused by // multiple marks, they will share the same state (e.g., sticky modality). let state = states.get(svg); - if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: []})); + if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: [], pool: new Map()})); // This serves as a unique identifier of the rendered mark per-plot; it is // used to record the currently-rendered elements (state.roots) so that we @@ -77,7 +77,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // only show the closest. We defer rendering using an animation frame to // allow all pointer events to be received before deciding which mark to // render; although when hiding, we render immediately. - const pool = this.pool ? context.pointerPool : faceted ? facetState : null; + const pool = this.pool ? state.pool : faceted ? facetState : null; function update(ii, ri) { if (ii == null) render(ii); if (!pool) return void render(ii); diff --git a/src/plot.js b/src/plot.js index 7b85a0567e..16976c2585 100644 --- a/src/plot.js +++ b/src/plot.js @@ -157,7 +157,6 @@ export function plot(options = {}) { let figure = svg; // replaced with the figure element, if any context.ownerSVGElement = svg; context.className = className; - context.pointerPool = new Map(); context.projection = createProjection(options, subdimensions); // A path generator for marks that want to draw GeoJSON. From 7da78e3750ff7095f425881f9d13b1b5a10cb427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 19 Mar 2026 17:39:08 +0100 Subject: [PATCH 3/7] simpler (per Mike's review) --- src/interactions/pointer.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 0c4a9b1a8d..d02ab9b02e 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -3,8 +3,8 @@ import {composeRender} from "../mark.js"; import {isArray} from "../options.js"; import {applyFrameAnchor} from "../style.js"; +// Pointer state on the current plot: {sticky, roots, renders, pool, …}. const states = new WeakMap(); -const frames = new WeakMap(); function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { maxRadius = +maxRadius; @@ -82,16 +82,13 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op if (ii == null) render(ii); if (!pool) return void render(ii); pool.set(render, {ii, ri, render}); - if (frames.has(pool)) cancelAnimationFrame(frames.get(pool)); - frames.set( - pool, - requestAnimationFrame(() => { - frames.delete(pool); - let best = null; - for (const [, c] of pool) if (!best || c.ri < best.ri) best = c; - for (const [, c] of pool) c.render(c === best ? c.ii : null); - }) - ); + if (pool.frame !== undefined) cancelAnimationFrame(pool.frame); + pool.frame = requestAnimationFrame(() => { + pool.frame = undefined; + let best = null; + for (const [, c] of pool) if (!best || c.ri < best.ri) best = c; + for (const [, c] of pool) c.render(c === best ? c.ii : null); + }); } function render(ii) { From edbcdc2c1329d4f89e7b0145ba5dd27e5a4562d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 19 Mar 2026 17:43:15 +0100 Subject: [PATCH 4/7] comment pool --- src/interactions/pointer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index d02ab9b02e..0922a89eba 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -29,6 +29,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // Isolate state per-pointer, per-plot; if the pointer is reused by // multiple marks, they will share the same state (e.g., sticky modality). + // The pool groups various marks (_e.g._ tip) to compete for the closest point. let state = states.get(svg); if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: [], pool: new Map()})); From 44a6981510a526726b6f22743fa069bde555fef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 19 Mar 2026 20:30:05 +0100 Subject: [PATCH 5/7] Apply suggestions from code review Co-authored-by: Mike Bostock --- src/interactions/pointer.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 0922a89eba..84b64cb57f 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -3,8 +3,7 @@ import {composeRender} from "../mark.js"; import {isArray} from "../options.js"; import {applyFrameAnchor} from "../style.js"; -// Pointer state on the current plot: {sticky, roots, renders, pool, …}. -const states = new WeakMap(); +const states = new WeakMap(); // ownerSVGElement → per-plot pointer state function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { maxRadius = +maxRadius; @@ -31,7 +30,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // multiple marks, they will share the same state (e.g., sticky modality). // The pool groups various marks (_e.g._ tip) to compete for the closest point. let state = states.get(svg); - if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: [], pool: new Map()})); + if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: [], pool: this.pool ? new Map() : null})); // This serves as a unique identifier of the rendered mark per-plot; it is // used to record the currently-rendered elements (state.roots) so that we @@ -78,7 +77,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // only show the closest. We defer rendering using an animation frame to // allow all pointer events to be received before deciding which mark to // render; although when hiding, we render immediately. - const pool = this.pool ? state.pool : faceted ? facetState : null; + const pool = state.pool ?? facetPool; function update(ii, ri) { if (ii == null) render(ii); if (!pool) return void render(ii); From 8e7f034d6225670c4243199b71f2402513687c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 19 Mar 2026 20:37:13 +0100 Subject: [PATCH 6/7] apply suggestions from code review --- src/interactions/pointer.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 84b64cb57f..19884bc954 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -28,9 +28,11 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // Isolate state per-pointer, per-plot; if the pointer is reused by // multiple marks, they will share the same state (e.g., sticky modality). - // The pool groups various marks (_e.g._ tip) to compete for the closest point. + // The pool maps renderIndex → {ii, ri, render} for marks competing for + // the pointer (e.g., tips); only the closest point is shown. let state = states.get(svg); - if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: [], pool: this.pool ? new Map() : null})); + if (!state) + states.set(svg, (state = {sticky: false, roots: [], renders: [], pool: this.pool ? new Map() : null})); // This serves as a unique identifier of the rendered mark per-plot; it is // used to record the currently-rendered elements (state.roots) so that we @@ -53,12 +55,12 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // mark (!), since each facet has its own pointer event listeners; we only // want the closest point across facets to be visible. const faceted = index.fi != null; - let facetState; + let facetPool; if (faceted) { - let facetStates = state.facetStates; - if (!facetStates) state.facetStates = facetStates = new Map(); - facetState = facetStates.get(this); - if (!facetState) facetStates.set(this, (facetState = new Map())); + let facetPools = state.facetPools; + if (!facetPools) state.facetPools = facetPools = new Map(); + facetPool = facetPools.get(this); + if (!facetPool) facetPools.set(this, (facetPool = new Map())); } // The order of precedence for the pointer position is: px & py; the @@ -81,7 +83,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op function update(ii, ri) { if (ii == null) render(ii); if (!pool) return void render(ii); - pool.set(render, {ii, ri, render}); + pool.set(renderIndex, {ii, ri, render}); if (pool.frame !== undefined) cancelAnimationFrame(pool.frame); pool.frame = requestAnimationFrame(() => { pool.frame = undefined; @@ -119,7 +121,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // Dispatch the value. When simultaneously exiting this facet and // entering a new one, prioritize the entering facet. - if (!(i == null && facetState?.size > 1)) { + if (!(i == null && facetPool?.size > 1)) { const value = i == null ? null : isArray(data) ? data[i] : data.get(i); context.dispatchValue(value); } From 0174b947add70bb518b9946bdcf746b34132526b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 19 Mar 2026 20:47:30 +0100 Subject: [PATCH 7/7] avoid rendering twice in the case where ii==null and !pool --- src/interactions/pointer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 19884bc954..5a32246d4b 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -81,8 +81,8 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // render; although when hiding, we render immediately. const pool = state.pool ?? facetPool; function update(ii, ri) { - if (ii == null) render(ii); if (!pool) return void render(ii); + if (ii == null) render(ii); pool.set(renderIndex, {ii, ri, render}); if (pool.frame !== undefined) cancelAnimationFrame(pool.frame); pool.frame = requestAnimationFrame(() => {