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

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.

Expand Down
2 changes: 2 additions & 0 deletions docs/interactions/pointer.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ The following options control the pointer transform:
- **maxRadius** - the reach, or maximum distance, in pixels; defaults to 40
- **frameAnchor** - how to position the target within the frame; defaults to *middle*

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.

To resolve the horizontal target position, the pointer transform applies the following order of precedence:

1. the **px** channel, if present;
Expand Down
2 changes: 2 additions & 0 deletions docs/marks/tip.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Plot.plot({
})
```

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.

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).

:::plot defer https://observablehq.com/@observablehq/plot-static-annotations
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(() => {
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 @@ export class Mark {
clip = defaults?.clip,
channels: extraChannels,
tip,
pool = defaults?.pool,
render
} = options;
this.data = data;
Expand Down Expand Up @@ -72,6 +73,7 @@ export class Mark {
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