Skip to content
Open
Show file tree
Hide file tree
Changes from all 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();

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