From e25a526050932e439b51b16a66b4cc1f94531957 Mon Sep 17 00:00:00 2001 From: Sahinur Date: Fri, 9 Oct 2026 12:10:26 +0600 Subject: [PATCH] =?UTF-8?q?fall=20back=20to=20the=20remaining=20active=20p?= =?UTF-8?q?ointer=E2=80=99s=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When several pointer marks are active (e.g., a pointerY on a rect and a tip on a dot, or pooled tips), each render dispatched its own value, so clearing one pointer set the plot’s value to null even though another pointer was still focused. Track the focused datum per rendered mark and dispatch the value of the most recently focused pointer that is still active. Fixes #2431 --- src/interactions/pointer.js | 14 ++++++++-- test/pointer-test.js | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) 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); + }); +});