Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
43 changes: 18 additions & 25 deletions src/interactions/pointer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {composeRender} from "../mark.js";
import {isArray} from "../options.js";
import {applyFrameAnchor} from "../style.js";

// Pointer state on the current plot: {sticky, roots, renders, pool, …}.
const states = new WeakMap();

function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) {
Expand All @@ -28,8 +29,9 @@ 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 groups various marks (_e.g._ tip) to compete for the closest point.
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 doesn’t explain how the pool map is structured. The key is currently render instance… but that doesn’t sound right to me. Shouldn’t it be the renderIndex as described below instead? (You can reuse a render transform, theoretically.)

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.

(Also don’t use Markdown formatting in comments.)

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 +73,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 = 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 (pool.frame !== undefined) cancelAnimationFrame(pool.frame);
pool.frame = requestAnimationFrame(() => {
pool.frame = undefined;
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