diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 7aa19f25f9..70a47a56e0 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -33,7 +33,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // the pointer (e.g., tips); only the closest point is shown. let state = states.get(svg); if (!state) { - state = {sticky: false, roots: [], renders: [], pool: this.pool ? {map: new Map()} : null}; + state = {sticky: false, roots: [], renders: [], focus: new Map(), pool: this.pool ? {map: new Map()} : null}; states.set(svg, state); } @@ -122,10 +122,20 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op } state.roots[renderIndex] = g = r; + // Record the focused datum of this rendered mark, if any. The plot’s + // value is that of the most recently focused pointer that is still + // active, so that clearing one pointer (e.g., a tip losing focus, or a + // pooled tip losing to a closer one) falls back to another pointer + // that is still focused rather than resetting the value to null. + const {focus} = state; + focus.delete(renderIndex); + if (i != null) focus.set(renderIndex, isArray(data) ? data[i] : data.get(i)); + // Dispatch the value. When simultaneously exiting this facet and // entering a new one, prioritize the entering facet. if (!(i == null && facetPool?.map.size > 1)) { - const value = i == null ? null : isArray(data) ? data[i] : data.get(i); + let value = null; + for (const v of focus.values()) value = v; context.dispatchValue(value); } diff --git a/test/pointer-test.js b/test/pointer-test.js index f00282f0ac..6e5ae42977 100644 --- a/test/pointer-test.js +++ b/test/pointer-test.js @@ -142,3 +142,55 @@ describe("pointer pool", () => { assert.strictEqual(visibleTips(svg), 0); }); }); + +describe("pointer value", () => { + let pointer, flushAnimationFrame; + + beforeEach(() => ({pointer, flushAnimationFrame} = setup())); + afterEach(teardown); + + it("falls back to another active pointer when one pointer clears", () => { + // A pointerY with an infinite radius stays active anywhere in the frame, + // while the dot’s tip is only active near the dot. + const svg = Plot.plot({ + marks: [ + Plot.frame(), + Plot.rect(["foo"], Plot.pointerY({y: (d) => d, maxRadius: Infinity, fill: "orange"})), + Plot.dot([{y: "foo"}], {y: "y", tip: true}) + ] + }); + pointer(svg, "pointerenter", 50, 40); + pointer(svg, "pointermove", 50, 40); + flushAnimationFrame(); + assert.strictEqual(svg.value, "foo"); + pointer(svg, "pointermove", 340, 40); + flushAnimationFrame(); + assert.deepStrictEqual(svg.value, {y: "foo"}); + pointer(svg, "pointermove", 50, 40); + flushAnimationFrame(); + assert.strictEqual(svg.value, "foo"); + pointer(svg, "pointerleave", 0, 0); + flushAnimationFrame(); + assert.strictEqual(svg.value, null); + }); + + it("keeps the pooled winner’s value when the other pooled tips clear", () => { + const svg = Plot.plot({ + marks: [ + Plot.dot([{x: 0, y: "foo"}], {x: "x", y: "y", tip: {maxRadius: 1000}}), + Plot.dot([{x: 1, y: "foo"}], {x: "x", y: "y", tip: {maxRadius: 1000}}) + ] + }); + pointer(svg, "pointerenter", 50, 40); + pointer(svg, "pointermove", 50, 40); + flushAnimationFrame(); + assert.deepStrictEqual(svg.value, {x: 0, y: "foo"}); + pointer(svg, "pointermove", 620, 40); + flushAnimationFrame(); + assert.deepStrictEqual(svg.value, {x: 1, y: "foo"}); + pointer(svg, "pointermove", 50, 40); + flushAnimationFrame(); + assert.deepStrictEqual(svg.value, {x: 0, y: "foo"}); + assert.strictEqual(visibleTips(svg), 1); + }); +});