From f3e0b2934b297ba2de8b0924a22df4e586687eb6 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 01/13] 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 23475194fe..28c43a1c14 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -5,6 +5,7 @@ import {applyFrameAnchor} from "../style.js"; const states = new WeakMap(); const handledEvents = new WeakSet(); +const frames = new WeakMap(); function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { maxRadius = +maxRadius; @@ -72,32 +73,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 3fd9853688..2ef1ab2ffe 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 e5ce3bec12..6ea2d1d467 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 6eb44e3468..4540b9cf35 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -182,6 +182,51 @@ test(async function tipLineY() { return Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot(); }); +test(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}) + ] + }); +}); + +test(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"})] + }); +}); + +test(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"}) + ] + }); +}); + +test(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}) + ] + }); +}); + +test(async function tipBoxX() { + const morley = await d3.csv("data/morley.csv", d3.autoType); + return Plot.boxX(morley, {x: "Speed", y: "Expt", tip: true}).plot(); +}); + test(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 e6ef8e4655f7e0cbb8898228987b152af7b8964c 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 02/13] 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 28c43a1c14..215be7c255 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -31,7 +31,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 @@ -78,7 +78,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 6ea2d1d467..e5ce3bec12 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 dd65c3a786c83af73c0c9673380911c623f72e35 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 03/13] 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 215be7c255..d39b4024e2 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -3,9 +3,9 @@ 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 handledEvents = new WeakSet(); -const frames = new WeakMap(); function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { maxRadius = +maxRadius; @@ -83,16 +83,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 0a1ea245600da00b73e1fc3a2dc32896ce95e640 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 04/13] 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 d39b4024e2..cccf6bf1e5 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -30,6 +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). + // 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 4a7126b4bf9cec3de06f8c02046882cb33457f00 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 05/13] 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 cccf6bf1e5..dfd452a36c 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 const handledEvents = new WeakSet(); function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { @@ -32,7 +31,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 @@ -79,7 +78,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 2c8922ae6e6b7c4a1526bee3708a2d7cee71465f 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 06/13] 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 dfd452a36c..ecd25de8b0 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -29,9 +29,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 @@ -54,12 +56,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 @@ -82,7 +84,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; @@ -120,7 +122,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 ed914e964c0f6db9da949652de54888d02722ff1 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 07/13] 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 ecd25de8b0..5b160b1532 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -82,8 +82,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(() => { From 8fca1122a8bbfe95af47dff83e0843af87dc29fe Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 10:22:31 -0700 Subject: [PATCH 08/13] fix tests --- test/output/tipBoxX.svg | 2 +- test/pointer-test.js | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/test/output/tipBoxX.svg b/test/output/tipBoxX.svg index a833dc214f..9daf1ea08a 100644 --- a/test/output/tipBoxX.svg +++ b/test/output/tipBoxX.svg @@ -62,7 +62,7 @@ - + diff --git a/test/pointer-test.js b/test/pointer-test.js index ac2ef3e930..f00282f0ac 100644 --- a/test/pointer-test.js +++ b/test/pointer-test.js @@ -1,6 +1,8 @@ +// @vitest-environment node + import * as Plot from "@observablehq/plot"; -import assert from "assert"; import {JSDOM} from "jsdom"; +import {afterEach, assert, beforeEach, describe, it} from "vitest"; function setup() { const jsdom = new JSDOM(""); @@ -49,10 +51,12 @@ function visibleTips(svg) { } describe("pointer pool", () => { + let pointer, flushAnimationFrame; + + beforeEach(() => ({pointer, flushAnimationFrame} = setup())); 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 @@ -66,7 +70,6 @@ describe("pointer pool", () => { }); 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"); @@ -77,7 +80,6 @@ describe("pointer pool", () => { }); 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 }); @@ -91,7 +93,6 @@ describe("pointer pool", () => { }); 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 @@ -115,7 +116,6 @@ describe("pointer pool", () => { }); 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}))] @@ -127,7 +127,6 @@ describe("pointer pool", () => { }); 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 From 97dd4e2e28400ec3c724707da49cca1e00427327 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 10:39:22 -0700 Subject: [PATCH 09/13] pratier --- src/interactions/pointer.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 5b160b1532..f89af25747 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -32,8 +32,10 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // 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) { + state = {sticky: false, roots: [], renders: [], pool: this.pool ? new Map() : null}; + states.set(svg, state); + } // 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 From 04bccc4320915e93f56647a7ccb3ed8e04b21f4a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 10:49:53 -0700 Subject: [PATCH 10/13] cleaner pool.frame --- src/interactions/pointer.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index f89af25747..7aa19f25f9 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -33,7 +33,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // the pointer (e.g., tips); only the closest point is shown. let state = states.get(svg); if (!state) { - state = {sticky: false, roots: [], renders: [], pool: this.pool ? new Map() : null}; + state = {sticky: false, roots: [], renders: [], pool: this.pool ? {map: new Map()} : null}; states.set(svg, state); } @@ -63,7 +63,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op let facetPools = state.facetPools; if (!facetPools) state.facetPools = facetPools = new Map(); facetPool = facetPools.get(this); - if (!facetPool) facetPools.set(this, (facetPool = new Map())); + if (!facetPool) facetPools.set(this, (facetPool = {map: new Map()})); } // The order of precedence for the pointer position is: px & py; the @@ -86,13 +86,13 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op function update(ii, ri) { if (!pool) return void render(ii); if (ii == null) render(ii); - pool.set(renderIndex, {ii, ri, render}); + pool.map.set(renderIndex, {ii, ri, render}); 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); + for (const c of pool.map.values()) if (!best || c.ri < best.ri) best = c; + for (const c of pool.map.values()) c.render(c === best ? c.ii : null); }); } @@ -124,7 +124,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 && facetPool?.size > 1)) { + if (!(i == null && facetPool?.map.size > 1)) { const value = i == null ? null : isArray(data) ? data[i] : data.get(i); context.dispatchValue(value); } From bbd32a20c44d5070babdb08a33dc76fad0a856fd Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 10:53:54 -0700 Subject: [PATCH 11/13] promote pool to MarkOptions --- src/interactions/pointer.d.ts | 7 ------- src/mark.d.ts | 7 +++++++ src/mark.js | 2 ++ src/marks/tip.js | 7 +++---- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/interactions/pointer.d.ts b/src/interactions/pointer.d.ts index a74fc5adca..89bb611ce0 100644 --- a/src/interactions/pointer.d.ts +++ b/src/interactions/pointer.d.ts @@ -17,13 +17,6 @@ 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/mark.d.ts b/src/mark.d.ts index 43e61da15f..5866750c28 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -292,6 +292,13 @@ export interface MarkOptions { /** Whether to generate a tooltip for this mark, and any tip options. */ tip?: boolean | TipPointer | (TipOptions & PointerOptions & {pointer?: TipPointer}); + /** + * 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; + /** * How to clip the mark; one of: * diff --git a/src/mark.js b/src/mark.js index 080e9a3f03..a84bf752a8 100644 --- a/src/mark.js +++ b/src/mark.js @@ -26,6 +26,7 @@ export class Mark { clip = defaults?.clip, channels: extraChannels, tip, + pool = defaults.pool, render } = options; this.data = data; @@ -72,6 +73,7 @@ export class Mark { this.marginLeft = +marginLeft; this.clip = maybeClip(clip); this.tip = maybeTip(tip); + this.pool = !!pool; this.className = string(className); // Super-faceting currently disallow position channels; in the future, we // could allow position to be specified in fx and fy in addition to (or diff --git a/src/marks/tip.js b/src/marks/tip.js index 2ef1ab2ffe..89a0d808ad 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -15,7 +15,8 @@ import {cut, clipper, splitter, maybeTextOverflow} from "./text.js"; const defaults = { ariaLabel: "tip", fill: "var(--plot-background)", - stroke: "currentColor" + stroke: "currentColor", + pool: true }; // These channels are not displayed in the default tip; see formatChannels. @@ -49,8 +50,7 @@ export class Tip extends Mark { textPadding = 8, title, pointerSize = 12, - pathFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))", - pool = true + pathFilter = "drop-shadow(0 3px 4px rgba(0,0,0,0.2))" } = options; super( data, @@ -85,7 +85,6 @@ 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) { From 60e830b0b5be8224579355d2a4bcbed7570eeff7 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 11:11:52 -0700 Subject: [PATCH 12/13] update documentation --- docs/features/marks.md | 1 + docs/interactions/pointer.md | 3 ++- docs/marks/tip.md | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index 230ebc51ee..dcd88268bf 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -492,6 +492,7 @@ All marks support the following style options: * **pointerEvents** - the [pointer events](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events) (*e.g.*, *none*) * **clip** - whether and how to clip the mark * **tip** - whether to generate an implicit [pointer](../interactions/pointer.md) [tip](../marks/tip.md) +* **pool** - whether the [pointer transform](../interactions/pointer.md) is exclusive If the **clip** option is *frame* (or equivalently true), the mark is clipped to the frame’s dimensions. If the **clip** option is null (or equivalently false), the mark is not clipped. If the **clip** option is *sphere*, the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection); a [geographic projection](./projections.md) is required in this case. Lastly if the **clip** option is a GeoJSON object , the mark will be clipped to the projected geometry. diff --git a/docs/interactions/pointer.md b/docs/interactions/pointer.md index 9b371dcd31..40ef9135b4 100644 --- a/docs/interactions/pointer.md +++ b/docs/interactions/pointer.md @@ -176,9 +176,10 @@ 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* +The **pool** mark option determines whether the pointer transform is exclusive across marks. If false, pointer transforms operate independently, potentially allowing multiple marks to be visible simultaneously. If true, pointer transforms will coordinate such that at most one mark will be visible at a time. The **pool** option defaults to true for the [tip mark](../marks/tip.md). Regardless of this option, when faceting, the pointer transform is exclusive across facets. + To resolve the horizontal target position, the pointer transform applies the following order of precedence: 1. the **px** channel, if present; diff --git a/docs/marks/tip.md b/docs/marks/tip.md index 751fb4741a..9ea3c7fe37 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -46,6 +46,8 @@ Plot.plot({ }) ``` +The tip mark defaults the [**pool** option](../interactions/pointer.md#pointer-options) to true, such that if there are multiple tip marks and pointer transforms, at most one tip will be visible at a time. Setting the **pool** option to false allows multiple tips to be visible simultaneously; in this case, beware that tips may collide. + The tip mark can also be used for static annotations, say to draw attention to elements of interest or to add context. The tip text is supplied via the **title** channel. If the tip mark‘s data is an array of strings, the **title** channel defaults to [identity](../features/transforms.md#identity). :::plot defer https://observablehq.com/@observablehq/plot-static-annotations @@ -201,7 +203,7 @@ Plot.plot({ ::: :::tip -The tip mark defaults the [**pool**](../interactions/pointer.md#pointer-options) option to true, ensuring multiple marks with the *tip* option don’t collide. +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. ::: ## Tip options From d198cd1273a4d906316ce5b57e5018cbed50082e Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 11 Apr 2026 11:13:10 -0700 Subject: [PATCH 13/13] mark defaults are optional --- src/mark.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mark.js b/src/mark.js index a84bf752a8..9def7301bf 100644 --- a/src/mark.js +++ b/src/mark.js @@ -26,7 +26,7 @@ export class Mark { clip = defaults?.clip, channels: extraChannels, tip, - pool = defaults.pool, + pool = defaults?.pool, render } = options; this.data = data;