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..5a32246d4b 100644
--- a/src/interactions/pointer.js
+++ b/src/interactions/pointer.js
@@ -3,7 +3,7 @@ import {composeRender} from "../mark.js";
import {isArray} from "../options.js";
import {applyFrameAnchor} from "../style.js";
-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;
@@ -28,8 +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 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: []}));
+ 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
@@ -52,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
@@ -71,32 +74,23 @@ 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 = state.pool ?? facetPool;
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 (!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(() => {
+ 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) {
@@ -127,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);
}
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/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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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);
+ });
+});