diff --git a/docs/interactions/pointer.md b/docs/interactions/pointer.md index e00df011b8..9b371dcd31 100644 --- a/docs/interactions/pointer.md +++ b/docs/interactions/pointer.md @@ -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: diff --git a/docs/marks/tip.md b/docs/marks/tip.md index ccb4481624..751fb4741a 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -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 diff --git a/src/interactions/pointer.d.ts b/src/interactions/pointer.d.ts index 89bb611ce0..a74fc5adca 100644 --- a/src/interactions/pointer.d.ts +++ b/src/interactions/pointer.d.ts @@ -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. diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index f0c0f765b0..5a32246d4b 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -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 function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...options} = {}) { maxRadius = +maxRadius; @@ -28,8 +28,11 @@ 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) + states.set(svg, (state = {sticky: false, roots: [], renders: [], pool: this.pool ? new Map() : null})); // 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 @@ -52,12 +55,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 = new Map())); } // The order of precedence for the pointer position is: px & py; the @@ -71,32 +74,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.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) if (!best || c.ri < best.ri) best = c; + for (const [, c] of pool) c.render(c === best ? c.ii : null); + }); } function render(ii) { @@ -127,7 +121,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?.size > 1)) { const value = i == null ? null : isArray(data) ? data[i] : data.get(i); context.dispatchValue(value); } diff --git a/src/marks/tip.js b/src/marks/tip.js index bfb9d04cb2..fb9611bab7 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -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, @@ -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) { diff --git a/test/output/tipBoxX.svg b/test/output/tipBoxX.svg new file mode 100644 index 0000000000..a833dc214f --- /dev/null +++ b/test/output/tipBoxX.svg @@ -0,0 +1,91 @@ + + + + + 1 + 2 + 3 + 4 + 5 + + + Expt + + + + 650 + 700 + 750 + 800 + 850 + 900 + 950 + 1,000 + 1,050 + + + Speed → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipCrosshair.svg b/test/output/tipCrosshair.svg new file mode 100644 index 0000000000..d49bdeb536 --- /dev/null +++ b/test/output/tipCrosshair.svg @@ -0,0 +1,89 @@ + + + + + + 60 + 70 + 80 + 90 + 100 + 110 + 120 + 130 + 140 + 150 + 160 + 170 + 180 + 190 + + + ↑ Close + + + + 2014 + 2015 + 2016 + 2017 + 2018 + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipCrosshairFacet.svg b/test/output/tipCrosshairFacet.svg new file mode 100644 index 0000000000..8cea3ec90d --- /dev/null +++ b/test/output/tipCrosshairFacet.svg @@ -0,0 +1,507 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + + ↑ culmen_depth_mm + + + + + + 35 + 40 + 45 + 50 + 55 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipPool.svg b/test/output/tipPool.svg new file mode 100644 index 0000000000..949f2ebe62 --- /dev/null +++ b/test/output/tipPool.svg @@ -0,0 +1,608 @@ + + + + + 10 + 15 + 20 + 25 + 30 + 35 + 40 + 45 + + + ↑ economy (mpg) + + + + 60 + 80 + 100 + 120 + 140 + 160 + 180 + 200 + 220 + + + power (hp) → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/output/tipPoolFacet.svg b/test/output/tipPoolFacet.svg new file mode 100644 index 0000000000..bcfdd68876 --- /dev/null +++ b/test/output/tipPoolFacet.svg @@ -0,0 +1,628 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + + + + species + + + + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + 14 + 16 + 18 + 20 + + + + ↑ culmen_depth_mm + + + + + + 35 + 40 + 45 + 50 + 55 + + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/tip.ts b/test/plots/tip.ts index 6c827e7b85..3fbf9a13e0 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -181,6 +181,51 @@ export async function tipLineY() { return Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot(); } +export async function tipPool() { + const cars = await d3.csv("data/cars.csv", d3.autoType); + return Plot.plot({ + marks: [ + Plot.hexagon(cars, Plot.hexbin({fill: "count"}, {x: "power (hp)", y: "economy (mpg)", tip: true})), + Plot.dot(cars, {x: "power (hp)", y: "economy (mpg)", tip: true}) + ] + }); +} + +export async function tipCrosshair() { + const aapl = await d3.csv("data/aapl.csv", d3.autoType); + return Plot.plot({ + y: {grid: true}, + marks: [Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}), Plot.crosshairX(aapl, {x: "Date", y: "Close"})] + }); +} + +export async function tipCrosshairFacet() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species"}), + Plot.crosshair(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species"}) + ] + }); +} + +export async function tipPoolFacet() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.plot({ + grid: true, + marks: [ + Plot.dot(penguins, Plot.hexbin({}, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species", tip: true})), + Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", fy: "species", fill: "sex", tip: true}) + ] + }); +} + +export async function tipBoxX() { + const morley = await d3.csv("data/morley.csv", d3.autoType); + return Plot.boxX(morley, {x: "Speed", y: "Expt", tip: true}).plot(); +} + export async function tipLongText() { return Plot.tip([{x: "Long sentence that gets cropped after a certain length"}], {x: "x"}).plot(); } diff --git a/test/pointer-test.js b/test/pointer-test.js new file mode 100644 index 0000000000..ac2ef3e930 --- /dev/null +++ b/test/pointer-test.js @@ -0,0 +1,145 @@ +import * as Plot from "@observablehq/plot"; +import assert from "assert"; +import {JSDOM} from "jsdom"; + +function setup() { + const jsdom = new JSDOM(""); + const window = jsdom.window; + global.window = window; + global.document = window.document; + global.Event = window.Event; + global.Node = window.Node; + global.NodeList = window.NodeList; + global.HTMLCollection = window.HTMLCollection; + window.SVGElement.prototype.getBBox = () => ({x: 0, y: 0, width: 100, height: 20}); + + const rafQueue = []; + global.requestAnimationFrame = (fn) => rafQueue.push(fn); + global.cancelAnimationFrame = (id) => (rafQueue[id - 1] = null); + + function flushAnimationFrame() { + let q; + while ((q = rafQueue.splice(0)).length) q.forEach((fn) => fn?.()); + } + + function pointer(svg, type, x, y) { + const e = new window.Event(type, {bubbles: true}); + e.clientX = x; + e.clientY = y; + e.pointerType = "mouse"; + svg.dispatchEvent(e); + } + + return {pointer, flushAnimationFrame}; +} + +function teardown() { + delete global.window; + delete global.document; + delete global.Event; + delete global.Node; + delete global.NodeList; + delete global.HTMLCollection; + delete global.requestAnimationFrame; + delete global.cancelAnimationFrame; +} + +function visibleTips(svg) { + return [...svg.querySelectorAll("[aria-label=tip]")].filter((g) => g.querySelector("text")).length; +} + +describe("pointer pool", () => { + afterEach(teardown); + + it("multiple tip: true marks show only one tip", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [ + Plot.dot([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y", tip: true}), // prettier-ignore + Plot.dot([{x: 1.1, y: 1.1}, {x: 2.1, y: 2.1}], {x: "x", y: "y", tip: true}) // prettier-ignore + ] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + }); + + it("compound marks with tip: true show only one tip", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.boxX([1, 2, 3, 4, 5, 10, 20], {tip: true}).plot(); + const tips = svg.querySelectorAll("[aria-label=tip]"); + assert.ok(tips.length > 1, "boxX should create multiple tip marks"); + pointer(svg, "pointerenter", 100, 15); + pointer(svg, "pointermove", 100, 15); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + }); + + it("crosshair renders all sub-marks (does not pool)", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [Plot.crosshair([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y"})] // prettier-ignore + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + // crosshair creates 4 sub-marks (ruleX, ruleY, textX, textY); + // all should render independently since they don't pool + const rules = svg.querySelectorAll("[aria-label^='crosshair']"); + assert.ok(rules.length >= 2, "crosshair should have at least 2 sub-marks rendered"); + }); + + it("crosshair and tip: true coexist", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [ + Plot.dot([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y", tip: true}), // prettier-ignore + Plot.crosshair( + [ + {x: 1, y: 1}, + {x: 2, y: 2} + ], + {x: "x", y: "y"} + ) + ] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + // The tip should render (1 from the dot's tip: true) + assert.strictEqual(visibleTips(svg), 1); + // The crosshair sub-marks should also render + const crosshairMarks = svg.querySelectorAll("[aria-label^='crosshair']"); + assert.ok(crosshairMarks.length >= 2, "crosshair sub-marks should also render"); + }); + + it("explicit tip(pointer) pools with tip: true", () => { + const {pointer, flushAnimationFrame} = setup(); + const data = [{x: 1, y: 1}, {x: 2, y: 2}]; // prettier-ignore + const svg = Plot.plot({ + marks: [Plot.dot(data, {x: "x", y: "y", tip: true}), Plot.tip(data, Plot.pointer({x: "x", y: "y", pool: true}))] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + }); + + it("pointerleave hides all tips", () => { + const {pointer, flushAnimationFrame} = setup(); + const svg = Plot.plot({ + marks: [ + Plot.dot([{x: 1, y: 1}, {x: 2, y: 2}], {x: "x", y: "y", tip: true}), // prettier-ignore + Plot.dot([{x: 1.1, y: 1.1}, {x: 2.1, y: 2.1}], {x: "x", y: "y", tip: true}) // prettier-ignore + ] + }); + pointer(svg, "pointerenter", 40, 370); + pointer(svg, "pointermove", 40, 370); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 1); + pointer(svg, "pointerleave", 0, 0); + flushAnimationFrame(); + assert.strictEqual(visibleTips(svg), 0); + }); +});