Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
:::

## 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;

/**
* The fallback horizontal target position channel, typically bound to the *x*
* scale; used if **px** is not specified.
Expand Down
45 changes: 20 additions & 25 deletions src/interactions/pointer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {isArray} from "../options.js";
import {applyFrameAnchor} from "../style.js";

const states = new WeakMap();
const frames = new WeakMap();
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.

Can you add a comment on these maps saying what they store? It’s especially hard without types and the names are not obvious. Sometimes I use the convention thingByKey to name maps, if it helps.

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.

(And the pool map too.)


function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) {
maxRadius = +maxRadius;
Expand All @@ -29,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
Expand Down Expand Up @@ -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 ? state.pool : 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);
Copy link
Copy Markdown
Member

@mbostock mbostock Mar 19, 2026

Choose a reason for hiding this comment

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

Why use the pool as a key? Could you just store the current animation frame handle on the state object too, eliminating the frames map?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

oh, right! so we can simplify this further

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
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
Loading