Skip to content

Commit 6ac9e7f

Browse files
Filmbostock
andauthored
pointer pool option (#2382)
* 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. * state.pool * simpler (per Mike's review) * comment pool * Apply suggestions from code review Co-authored-by: Mike Bostock <mbostock@gmail.com> * apply suggestions from code review * avoid rendering twice in the case where ii==null and !pool * fix tests * pratier * cleaner pool.frame * promote pool to MarkOptions * update documentation * mark defaults are optional --------- Co-authored-by: Mike Bostock <mbostock@gmail.com>
1 parent f262a76 commit 6ac9e7f

14 files changed

Lines changed: 2156 additions & 33 deletions

File tree

docs/features/marks.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ All marks support the following style options:
492492
* **pointerEvents** - the [pointer events](https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events) (*e.g.*, *none*)
493493
* **clip** - whether and how to clip the mark
494494
* **tip** - whether to generate an implicit [pointer](../interactions/pointer.md) [tip](../marks/tip.md) <VersionBadge version="0.6.7" />
495+
* **pool** - whether the [pointer transform](../interactions/pointer.md) is exclusive <VersionBadge pr="2382" />
495496

496497
If the **clip** option<a id="clip" href="#clip" aria-label="Permalink to &quot;clip&quot;"></a> 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 <VersionBadge version="0.6.17" pr="2243" />, the mark will be clipped to the projected geometry.
497498

docs/interactions/pointer.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ The following options control the pointer transform:
178178
- **maxRadius** - the reach, or maximum distance, in pixels; defaults to 40
179179
- **frameAnchor** - how to position the target within the frame; defaults to *middle*
180180

181+
The **pool** mark option <VersionBadge pr="2382" /> 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.
182+
181183
To resolve the horizontal target position, the pointer transform applies the following order of precedence:
182184

183185
1. the **px** channel, if present;

docs/marks/tip.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ Plot.plot({
4646
})
4747
```
4848

49+
The tip mark defaults the [**pool** option](../interactions/pointer.md#pointer-options) <VersionBadge pr="2382" /> 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.
50+
4951
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).
5052

5153
:::plot defer https://observablehq.com/@observablehq/plot-static-annotations

src/interactions/pointer.js

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {composeRender} from "../mark.js";
33
import {isArray} from "../options.js";
44
import {applyFrameAnchor} from "../style.js";
55

6-
const states = new WeakMap();
6+
const states = new WeakMap(); // ownerSVGElement → per-plot pointer state
77
const handledEvents = new WeakSet();
88

99
function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) {
@@ -29,8 +29,13 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
2929

3030
// Isolate state per-pointer, per-plot; if the pointer is reused by
3131
// multiple marks, they will share the same state (e.g., sticky modality).
32+
// The pool maps renderIndex → {ii, ri, render} for marks competing for
33+
// the pointer (e.g., tips); only the closest point is shown.
3234
let state = states.get(svg);
33-
if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: []}));
35+
if (!state) {
36+
state = {sticky: false, roots: [], renders: [], pool: this.pool ? {map: new Map()} : null};
37+
states.set(svg, state);
38+
}
3439

3540
// This serves as a unique identifier of the rendered mark per-plot; it is
3641
// used to record the currently-rendered elements (state.roots) so that we
@@ -53,12 +58,12 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
5358
// mark (!), since each facet has its own pointer event listeners; we only
5459
// want the closest point across facets to be visible.
5560
const faceted = index.fi != null;
56-
let facetState;
61+
let facetPool;
5762
if (faceted) {
58-
let facetStates = state.facetStates;
59-
if (!facetStates) state.facetStates = facetStates = new Map();
60-
facetState = facetStates.get(this);
61-
if (!facetState) facetStates.set(this, (facetState = new Map()));
63+
let facetPools = state.facetPools;
64+
if (!facetPools) state.facetPools = facetPools = new Map();
65+
facetPool = facetPools.get(this);
66+
if (!facetPool) facetPools.set(this, (facetPool = {map: new Map()}));
6267
}
6368

6469
// The order of precedence for the pointer position is: px & py; the
@@ -72,32 +77,23 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
7277
let i; // currently focused index
7378
let g; // currently rendered mark
7479
let s; // currently rendered stickiness
75-
let f; // current animation frame
7680

77-
// When faceting, if more than one pointer would be visible, only show
78-
// this one if it is the closest. We defer rendering using an animation
79-
// frame to allow all pointer events to be received before deciding which
80-
// mark to render; although when hiding, we render immediately.
81+
// When pooling or faceting, if more than one pointer would be visible,
82+
// only show the closest. We defer rendering using an animation frame to
83+
// allow all pointer events to be received before deciding which mark to
84+
// render; although when hiding, we render immediately.
85+
const pool = state.pool ?? facetPool;
8186
function update(ii, ri) {
82-
if (faceted) {
83-
if (f) f = cancelAnimationFrame(f);
84-
if (ii == null) facetState.delete(index.fi);
85-
else {
86-
facetState.set(index.fi, ri);
87-
f = requestAnimationFrame(() => {
88-
f = null;
89-
for (const [fi, r] of facetState) {
90-
if (r < ri || (r === ri && fi < index.fi)) {
91-
ii = null;
92-
break;
93-
}
94-
}
95-
render(ii);
96-
});
97-
return;
98-
}
99-
}
100-
render(ii);
87+
if (!pool) return void render(ii);
88+
if (ii == null) render(ii);
89+
pool.map.set(renderIndex, {ii, ri, render});
90+
if (pool.frame !== undefined) cancelAnimationFrame(pool.frame);
91+
pool.frame = requestAnimationFrame(() => {
92+
pool.frame = undefined;
93+
let best = null;
94+
for (const c of pool.map.values()) if (!best || c.ri < best.ri) best = c;
95+
for (const c of pool.map.values()) c.render(c === best ? c.ii : null);
96+
});
10197
}
10298

10399
function render(ii) {
@@ -128,7 +124,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op
128124

129125
// Dispatch the value. When simultaneously exiting this facet and
130126
// entering a new one, prioritize the entering facet.
131-
if (!(i == null && facetState?.size > 1)) {
127+
if (!(i == null && facetPool?.map.size > 1)) {
132128
const value = i == null ? null : isArray(data) ? data[i] : data.get(i);
133129
context.dispatchValue(value);
134130
}

src/mark.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,13 @@ export interface MarkOptions {
292292
/** Whether to generate a tooltip for this mark, and any tip options. */
293293
tip?: boolean | TipPointer | (TipOptions & PointerOptions & {pointer?: TipPointer});
294294

295+
/**
296+
* Whether this mark participates in the pointer pool, which ensures that
297+
* only the closest point is shown when multiple pointer marks are present;
298+
* defaults to true for the tip mark.
299+
*/
300+
pool?: boolean;
301+
295302
/**
296303
* How to clip the mark; one of:
297304
*

src/mark.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class Mark {
2626
clip = defaults?.clip,
2727
channels: extraChannels,
2828
tip,
29+
pool = defaults?.pool,
2930
render
3031
} = options;
3132
this.data = data;
@@ -72,6 +73,7 @@ export class Mark {
7273
this.marginLeft = +marginLeft;
7374
this.clip = maybeClip(clip);
7475
this.tip = maybeTip(tip);
76+
this.pool = !!pool;
7577
this.className = string(className);
7678
// Super-faceting currently disallow position channels; in the future, we
7779
// could allow position to be specified in fx and fy in addition to (or

src/marks/tip.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {cut, clipper, splitter, maybeTextOverflow} from "./text.js";
1515
const defaults = {
1616
ariaLabel: "tip",
1717
fill: "var(--plot-background)",
18-
stroke: "currentColor"
18+
stroke: "currentColor",
19+
pool: true
1920
};
2021

2122
// These channels are not displayed in the default tip; see formatChannels.

test/output/tipBoxX.svg

Lines changed: 91 additions & 0 deletions
Loading

test/output/tipCrosshair.svg

Lines changed: 89 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)