Skip to content
1 change: 1 addition & 0 deletions docs/interactions/pointer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/marks/tip.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is losing a useful tip relevant to the example above, which is rendering multiple tips statically!

:::

## Tip options
Expand Down
7 changes: 7 additions & 0 deletions src/interactions/pointer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

@mbostock mbostock Apr 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn’t the right place to declare this option. The pointer transform ignores options.pool; it only looks at this.pool (a.k.a. mark.pool), and mark.pool is only currently set by the tip mark. So, trying to use this option on any other mark won’t have any effect.

Either we should make this option work for every mark (either reading it from options.pool instead of this.pool, or by having the base mark constructor populate this.pool for all marks) or we should move the pool option to TipOptions where it will only work for the tip mark.

Moving it to PointerOptions (options.pool) feels the cleanest, but I’m not sure then how the default should be applied. According to this language, it should default to this instanceof Tip, but that seems funky (there’s no way for a custom mark to provide its own default for pool)… And there’s no way that the tip mark can reach into the pointer transform options to populate the default since the pointer transform is constructed prior to the tip mark. So I guess that means it belongs on MarkOptions, even though it’s only used by the pointer transform?

Why is it that only the tip mark wants pool to default to true?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To answer my own question, I guess the tip mark wants pool to default to true because the tip mark is generally “exclusive” as in typically you only want to see one tip at a time. (Though we do support rendering tips statically as annotations, in which case you do want multiple tips at the same time.)


/**
* The fallback horizontal target position channel, typically bound to the *x*
* scale; used if **px** is not specified.
Expand Down
58 changes: 26 additions & 32 deletions src/interactions/pointer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
const handledEvents = new WeakSet();

function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) {
Expand All @@ -29,8 +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 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
Expand All @@ -53,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
Expand All @@ -72,32 +75,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(() => {
Comment thread
mbostock marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -128,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);
}
Expand Down
4 changes: 3 additions & 1 deletion src/marks/tip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
91 changes: 91 additions & 0 deletions test/output/tipBoxX.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 89 additions & 0 deletions test/output/tipCrosshair.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading