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
60 changes: 28 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,13 @@ 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) {
state = {sticky: false, roots: [], renders: [], pool: this.pool ? {map: 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
Expand All @@ -53,12 +58,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 = {map: new Map()}));
}

// The order of precedence for the pointer position is: px & py; the
Expand All @@ -72,32 +77,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.map.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.map.values()) if (!best || c.ri < best.ri) best = c;
for (const c of pool.map.values()) c.render(c === best ? c.ii : null);
});
}

function render(ii) {
Expand Down Expand Up @@ -128,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 && facetState?.size > 1)) {
if (!(i == null && facetPool?.map.size > 1)) {
const value = i == null ? null : isArray(data) ? data[i] : data.get(i);
context.dispatchValue(value);
}
Expand Down
7 changes: 7 additions & 0 deletions src/mark.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down
2 changes: 2 additions & 0 deletions src/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
clip = defaults?.clip,
channels: extraChannels,
tip,
pool = defaults.pool,

Check failure on line 29 in src/mark.js

View workflow job for this annotation

GitHub Actions / test

test/plots/penguin-species.ts > penguinSpeciesGradient

TypeError: Cannot read properties of undefined (reading 'pool') ❯ new Mark src/mark.js:29:23 ❯ new Render src/plot.js:390:5 ❯ markify src/plot.js:384:53 ❯ flatMarks src/plot.js:380:6 ❯ Module.plot src/plot.js:29:52 ❯ penguinSpeciesGradient test/plots/penguin-species.ts:35:15 ❯ doesNotWarnAsync test/assert.js:53:14 ❯ test/plot-snapshot.js:12:18

Check failure on line 29 in src/mark.js

View workflow job for this annotation

GitHub Actions / test

test/plots/penguin-species.ts > penguinSpeciesCheysson

TypeError: Cannot read properties of undefined (reading 'pool') ❯ new Mark src/mark.js:29:23 ❯ new Render src/plot.js:390:5 ❯ markify src/plot.js:384:53 ❯ flatMarks src/plot.js:380:6 ❯ Module.plot src/plot.js:29:52 ❯ penguinSpeciesCheysson test/plots/penguin-species.ts:8:15 ❯ doesNotWarnAsync test/assert.js:53:14 ❯ test/plot-snapshot.js:12:18

Check failure on line 29 in src/mark.js

View workflow job for this annotation

GitHub Actions / test

test/plots/empty.ts > empty

TypeError: Cannot read properties of undefined (reading 'pool') ❯ new Mark src/mark.js:29:23 ❯ new Render src/plot.js:390:5 ❯ markify src/plot.js:384:53 ❯ flatMarks src/plot.js:380:6 ❯ Module.plot src/plot.js:29:52 ❯ empty test/plots/empty.ts:6:15 ❯ doesNotWarnAsync test/assert.js:53:20 ❯ test/plot-snapshot.js:12:95

Check failure on line 29 in src/mark.js

View workflow job for this annotation

GitHub Actions / test

test/plots/arc.ts > arcCollatzUp

TypeError: Cannot read properties of undefined (reading 'pool') ❯ new Mark src/mark.js:29:23 ❯ new Render src/plot.js:390:5 ❯ markify src/plot.js:384:53 ❯ flatMarks src/plot.js:380:6 ❯ Module.plot src/plot.js:29:52 ❯ arcCollatzUp test/plots/arc.ts:37:15 ❯ doesNotWarnAsync test/assert.js:53:20 ❯ test/plot-snapshot.js:12:95

Check failure on line 29 in src/mark.js

View workflow job for this annotation

GitHub Actions / test

test/scales/scales-test.js > Plot.plot passes render functions re-initialized scale descriptors and functions

TypeError: Cannot read properties of undefined (reading 'pool') ❯ new Mark src/mark.js:29:23 ❯ new Render src/plot.js:390:5 ❯ markify src/plot.js:384:53 ❯ flatMarks src/plot.js:380:6 ❯ Module.plot src/plot.js:29:52 ❯ test/scales/scales-test.js:2161:8

Check failure on line 29 in src/mark.js

View workflow job for this annotation

GitHub Actions / test

test/scales/scales-test.js > Plot.plot passes render functions scale descriptors

TypeError: Cannot read properties of undefined (reading 'pool') ❯ new Mark src/mark.js:29:23 ❯ new Render src/plot.js:390:5 ❯ markify src/plot.js:384:53 ❯ flatMarks src/plot.js:380:6 ❯ Module.plot src/plot.js:29:52 ❯ test/scales/scales-test.js:2128:8
render
} = options;
this.data = data;
Expand Down Expand Up @@ -72,6 +73,7 @@
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
Expand Down
3 changes: 2 additions & 1 deletion src/marks/tip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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