diff --git a/docs/features/marks.md b/docs/features/marks.md index 230ebc51ee..dcd88268bf 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -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) +* **pool** - whether the [pointer transform](../interactions/pointer.md) is exclusive If the **clip** option 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 , the mark will be clipped to the projected geometry. diff --git a/docs/interactions/pointer.md b/docs/interactions/pointer.md index e00df011b8..40ef9135b4 100644 --- a/docs/interactions/pointer.md +++ b/docs/interactions/pointer.md @@ -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 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; diff --git a/docs/marks/tip.md b/docs/marks/tip.md index ccb4481624..9ea3c7fe37 100644 --- a/docs/marks/tip.md +++ b/docs/marks/tip.md @@ -46,6 +46,8 @@ Plot.plot({ }) ``` +The tip mark defaults the [**pool** option](../interactions/pointer.md#pointer-options) 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 diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 23475194fe..7aa19f25f9 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 const handledEvents = new WeakSet(); 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 // 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 @@ -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 @@ -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) { @@ -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); } diff --git a/src/mark.d.ts b/src/mark.d.ts index 43e61da15f..5866750c28 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -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: * diff --git a/src/mark.js b/src/mark.js index 080e9a3f03..9def7301bf 100644 --- a/src/mark.js +++ b/src/mark.js @@ -26,6 +26,7 @@ export class Mark { clip = defaults?.clip, channels: extraChannels, tip, + pool = defaults?.pool, render } = options; this.data = data; @@ -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 diff --git a/src/marks/tip.js b/src/marks/tip.js index 3fd9853688..89a0d808ad 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -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. diff --git a/test/output/tipBoxX.svg b/test/output/tipBoxX.svg new file mode 100644 index 0000000000..9daf1ea08a --- /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 6eb44e3468..4540b9cf35 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -182,6 +182,51 @@ test(async function tipLineY() { return Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot(); }); +test(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}) + ] + }); +}); + +test(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"})] + }); +}); + +test(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"}) + ] + }); +}); + +test(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}) + ] + }); +}); + +test(async function tipBoxX() { + const morley = await d3.csv("data/morley.csv", d3.autoType); + return Plot.boxX(morley, {x: "Speed", y: "Expt", tip: true}).plot(); +}); + test(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..f00282f0ac --- /dev/null +++ b/test/pointer-test.js @@ -0,0 +1,144 @@ +// @vitest-environment node + +import * as Plot from "@observablehq/plot"; +import {JSDOM} from "jsdom"; +import {afterEach, assert, beforeEach, describe, it} from "vitest"; + +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", () => { + let pointer, flushAnimationFrame; + + beforeEach(() => ({pointer, flushAnimationFrame} = setup())); + afterEach(teardown); + + it("multiple tip: true marks show only one tip", () => { + 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 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 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 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 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 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); + }); +});