From 861d3b209d7eff7e922f9c13ae2898f904f10d28 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:35:57 -0400 Subject: [PATCH 01/12] add top-level xPixel/yPixel to hover/click event data --- src/components/fx/click.js | 2 ++ src/components/fx/hover.js | 6 ++++++ src/components/fx/layout_attributes.js | 8 ++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/fx/click.js b/src/components/fx/click.js index bb51bc23028..3f3f531b9fd 100644 --- a/src/components/fx/click.js +++ b/src/components/fx/click.js @@ -24,6 +24,8 @@ module.exports = function click(gd, evt, subplot) { clickData.yaxes ??= gd._hoverYAxes; clickData.xvals ??= gd._hoverXVals && helpers.c2dApply(gd._hoverXAxes, gd._hoverXVals); clickData.yvals ??= gd._hoverYVals && helpers.c2dApply(gd._hoverYAxes, gd._hoverYVals); + clickData.xPixel ??= gd._hoverPointerX; + clickData.yPixel ??= gd._hoverPointerY; gd.emit('plotly_click', clickData); } diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 5acfeb31442..ea594fdb23a 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -483,6 +483,8 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { gd._hoverYVals = yvalArray; gd._hoverXAxes = xaArray; gd._hoverYAxes = yaArray; + gd._hoverPointerX = evt.pointerX; + gd._hoverPointerY = evt.pointerY; } // the pixel distance to beat as a matching point @@ -978,6 +980,10 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { yaxes: yaArray, xvals: helpers.c2dApply(xaArray, xvalArray), yvals: helpers.c2dApply(yaArray, yvalArray) + // Note: top-level xPixel/yPixel correspond to the pixel position of the cursor. + // Inside `points` array, points[i].xPixel/yPixel correspond to the pixel position of the point itself. + xPixel: evt.pointerX, + yPixel: evt.pointerY }); } } diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 73c76275dc1..1772ae9f7ab 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -112,7 +112,9 @@ module.exports = { 'If true, `plotly_hover` events will fire for any cursor position', 'within the plot area, not just over traces.', 'When the cursor is not over a trace, the event will have an empty `points` array', - 'but will include `xvals` and `yvals` with cursor coordinates in data space.' + 'but will include `xvals` and `yvals` with cursor coordinates in data space,', + 'and `xPixel` and `yPixel` with cursor coordinates in pixels,', + 'relative to the top-left corner of the graph div.' ].join(' ') }, clickanywhere: { @@ -123,7 +125,9 @@ module.exports = { 'If true, `plotly_click` events will fire for any click position', 'within the plot area, not just over traces.', 'When clicking where there is no trace data, the event will have an empty `points` array', - 'but will include `xvals` and `yvals` with click coordinates in data space.' + 'but will include `xvals` and `yvals` with click coordinates in data space,', + 'and `xPixel` and `yPixel` with click coordinates in pixels,', + 'relative to the top-left corner of the graph div.' ].join(' ') }, hoverdistance: { From a6fa7b4ce2d45b15e3e11a46e61c8d79b6603f05 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:50:38 -0400 Subject: [PATCH 02/12] add/update jasmine tests and plot-schema for top-level xPixel/yPixel --- .../tests/hover_click_anywhere_test.js | 46 +++++++++++++++++-- test/plot-schema.json | 4 +- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index a9bfd391402..85680355be4 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -93,6 +93,8 @@ describe('hoveranywhere', () => { expect(hoverData.yvals.length).toBe(1); expect(hoverData.xvals[0]).toBeCloseTo(250 / 30, 2); expect(hoverData.yvals[0]).toBeCloseTo(10 - 50 / 30, 2); + expect(hoverData.xPixel).toBeCloseTo(300, 1); // hover x-position (250) + left margin (50) + expect(hoverData.yPixel).toBeCloseTo(100, 1); // hover y-position (50) + top margin (50) }) .then(done, done.fail); }); @@ -147,6 +149,27 @@ describe('hoveranywhere', () => { .then(done, done.fail); }); + it('reports cursor position in top-level xPixel/yPixel, and point position in point-level xPixel/yPixel', (done) => { + var hoverData; + + makePlot(gd, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_hover', (d) => (hoverData = d)); + + // hover near, but not exactly on, the point (2, 3), which is at px (60, 210) + _hover(65, 205); + + expect(hoverData.points.length).toBe(1); + // top-level: cursor position + expect(hoverData.xPixel).toBeCloseTo(115, 1); // hover x-position (65) + left margin (50) + expect(hoverData.yPixel).toBeCloseTo(255, 1); // hover y-position (205) + top margin (50) + // point-level: position of the point itself + expect(hoverData.points[0].xPixel).toBeCloseTo(110, 1); // point x-position in plot area (60) + left margin (50) + expect(hoverData.points[0].yPixel).toBeCloseTo(260, 1); // point y-position in plot area (210) + top margin (50) + }) + .then(done, done.fail); + }); + it('respects hovermode:false', (done) => { var hoverData; @@ -188,11 +211,13 @@ describe('hoveranywhere', () => { const bb = gd.getBoundingClientRect(); const s = gd._fullLayout._size; // center of shape at data (7.5, 7.5) = plot-area px (225, 75) + const mouseX = bb.left + s.l + 225; + const mouseY = bb.top + s.t + 75; shapePath.dispatchEvent( new MouseEvent('mousemove', { bubbles: true, - clientX: bb.left + s.l + 225, - clientY: bb.top + s.t + 75 + clientX: mouseX, + clientY: mouseY }) ); Lib.clearThrottle(); @@ -201,6 +226,10 @@ describe('hoveranywhere', () => { expect(hoverData.points).toEqual([]); expect(hoverData.xvals[0]).toBeCloseTo(7.5, 1); expect(hoverData.yvals[0]).toBeCloseTo(7.5, 1); + // mouseX and mouseY are relative to the full page, so subtract the bounding box + // to get pixel coordinates relative to the graph div, which should match hoverData.xPixel/yPixel + expect(hoverData.xPixel).toBeCloseTo(mouseX - bb.left, 1); + expect(hoverData.yPixel).toBeCloseTo(mouseY - bb.top, 1); }) .then(done, done.fail); }); @@ -316,9 +345,12 @@ describe('clickanywhere', () => { .then(() => { gd.on('plotly_click', (d) => (clickData = d)); - var bb = gd.getBoundingClientRect(); - var s = gd._fullLayout._size; - click(bb.left + s.l + 250, bb.top + s.t + 50); + const bb = gd.getBoundingClientRect(); + const s = gd._fullLayout._size; + const clickX = bb.left + s.l + 250; + const clickY = bb.top + s.t + 50; + + click(clickX, clickY); expect(clickData).toBeDefined(); expect(clickData.points).toEqual([]); @@ -330,6 +362,10 @@ describe('clickanywhere', () => { expect(clickData.xvals[0]).toBeCloseTo(250 / 30, 2); // click at 50px into 300px plot area, yrange [0,10]: 10 - 50/300*10 = 8.33 expect(clickData.yvals[0]).toBeCloseTo(10 - 50 / 30, 2); + // click pixels: clickX and clickY are relative to full page, so subtract the graph div bounding box + // to get pixel coordinates relative to the graph div, which should match clickData.xPixel/yPixel + expect(clickData.xPixel).toBeCloseTo(clickX - bb.left, 1); + expect(clickData.yPixel).toBeCloseTo(clickY - bb.top, 1); }) .then(done, done.fail); }); diff --git a/test/plot-schema.json b/test/plot-schema.json index ddf9ad0238d..345f85895ca 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -1140,7 +1140,7 @@ ] }, "clickanywhere": { - "description": "If true, `plotly_click` events will fire for any click position within the plot area, not just over traces. When clicking where there is no trace data, the event will have an empty `points` array but will include `xvals` and `yvals` with click coordinates in data space.", + "description": "If true, `plotly_click` events will fire for any click position within the plot area, not just over traces. When clicking where there is no trace data, the event will have an empty `points` array but will include `xvals` and `yvals` with click coordinates in data space, and `xPixel` and `yPixel` with click coordinates in pixels, relative to the top-left corner of the graph div.", "dflt": false, "editType": "none", "valType": "boolean" @@ -2785,7 +2785,7 @@ "valType": "number" }, "hoveranywhere": { - "description": "If true, `plotly_hover` events will fire for any cursor position within the plot area, not just over traces. When the cursor is not over a trace, the event will have an empty `points` array but will include `xvals` and `yvals` with cursor coordinates in data space.", + "description": "If true, `plotly_hover` events will fire for any cursor position within the plot area, not just over traces. When the cursor is not over a trace, the event will have an empty `points` array but will include `xvals` and `yvals` with cursor coordinates in data space, and `xPixel` and `yPixel` with cursor coordinates in pixels, relative to the top-left corner of the graph div.", "dflt": false, "editType": "none", "valType": "boolean" From f2a1cc636acee5c353e58cbf49c0afb70f3d74b5 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:31:37 -0400 Subject: [PATCH 03/12] when is true, emit plotly_unhover event when cursor leaves plot area --- src/components/dragelement/unhover.js | 16 ++++++++++++++++ src/components/fx/hover.js | 5 +++++ src/components/fx/layout_attributes.js | 3 ++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/components/dragelement/unhover.js b/src/components/dragelement/unhover.js index efe75ca1957..908755888ee 100644 --- a/src/components/dragelement/unhover.js +++ b/src/components/dragelement/unhover.js @@ -16,7 +16,23 @@ unhover.wrapped = function(gd, evt, subplot) { throttle.clear(gd._fullLayout._uid + hoverConstants.HOVERID); } + var oldhoverdata = gd._hoverdata; + unhover.raw(gd, evt, subplot); + + // Special handling for `hoveranywhere`, to ensure we emit exactly one unhover event + // when the cursor leaves the plot area. + // gd._hoverAnywhereActive is set in fx/hover.js when we emit an empty-space hover event. + if(gd._hoverAnywhereActive) { + gd._hoverAnywhereActive = false; + + if(evt && evt.target && !oldhoverdata) { + gd.emit('plotly_unhover', { + event: evt, + points: [] + }); + } + } }; diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index ea594fdb23a..fd1c5e75ea6 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -820,6 +820,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { gd._hoverdata = []; } emitHover([]); + + // Set a flag to note that an empty-space hover event is being emitted, + // so that we know to emit an unhover event when the mouse leaves the plot area. + // See dragelement/unhover.js. + gd._hoverAnywhereActive = true; } return result; } diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 1772ae9f7ab..963ad29a9c9 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -114,7 +114,8 @@ module.exports = { 'When the cursor is not over a trace, the event will have an empty `points` array', 'but will include `xvals` and `yvals` with cursor coordinates in data space,', 'and `xPixel` and `yPixel` with cursor coordinates in pixels,', - 'relative to the top-left corner of the graph div.' + 'relative to the top-left corner of the graph div.', + 'A `plotly_unhover` event fires when the cursor leaves the plot area.' ].join(' ') }, clickanywhere: { From 8cc30971c920af75169e329fb15d552d49cf430e Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:33:10 -0400 Subject: [PATCH 04/12] add tests for plotly_unhover when hoveranywhere is true --- .../tests/hover_click_anywhere_test.js | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index 85680355be4..97e327cd7ce 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -5,6 +5,7 @@ var Lib = require('../../../src/lib'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var click = require('../assets/click'); +var mouseEvent = require('../assets/mouse_event'); function makePlot(gd, traceExtras = {}, layoutExtras = {}, configExtras) { return Plotly.newPlot( @@ -73,6 +74,15 @@ describe('hoveranywhere', () => { Lib.clearThrottle(); } + // leave the plot area, as the maindrag sees it + function _leavePlotArea() { + var bb = gd.getBoundingClientRect(); + mouseEvent('mouseout', bb.left - 50, bb.top - 50, { + element: gd.querySelector('.nsewdrag') + }); + Lib.clearThrottle(); + } + it('emits plotly_hover with coordinate data on empty space', (done) => { var hoverData; @@ -182,6 +192,102 @@ describe('hoveranywhere', () => { .then(done, done.fail); }); + it('emits plotly_unhover when the cursor leaves the plot area after hovering empty space', (done) => { + var events = []; + var unhoverData; + + makePlot(gd, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_hover', () => events.push('hover')); + gd.on('plotly_unhover', (d) => { + events.push('unhover'); + unhoverData = d; + }); + + _hover(250, 50); + expect(events).toEqual(['hover']); + + _leavePlotArea(); + + expect(events).toEqual(['hover', 'unhover']); + expect(unhoverData.points).toEqual([]); + }) + .then(done, done.fail); + }); + + it('emits only one unhover per departure from the plot area', (done) => { + var events = []; + + makePlot(gd, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_unhover', () => events.push('unhover')); + + _hover(250, 50); + _leavePlotArea(); + _leavePlotArea(); + + expect(events).toEqual(['unhover']); + }) + .then(done, done.fail); + }); + + it('does not emit unhover while moving within empty space', (done) => { + var events = []; + + makePlot(gd, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_hover', () => events.push('hover')); + gd.on('plotly_unhover', () => events.push('unhover')); + + _hover(250, 50); + _hover(255, 55); + _hover(260, 60); + + expect(events).toEqual(['hover', 'hover', 'hover']); + }) + .then(done, done.fail); + }); + + it('emits unhover with point data, not empty points, when leaving from a point', (done) => { + var events = []; + var unhoverData; + + makePlot(gd, { hoveranywhere: true }) + .then(() => { + gd.on('plotly_unhover', (d) => { + events.push('unhover'); + unhoverData = d; + }); + + // hover empty space, then the point (2, 3), then leave + _hover(250, 50); + _hover(60, 210); + _leavePlotArea(); + + expect(events).toEqual(['unhover']); + expect(unhoverData.points.length).toBe(1); + expect(unhoverData.points[0].x).toBe(2); + expect(unhoverData.points[0].y).toBe(3); + }) + .then(done, done.fail); + }); + + it('does not emit unhover on leaving empty space when hoveranywhere is false', (done) => { + var events = []; + + makePlot(gd) + .then(() => { + gd.on('plotly_hover', () => events.push('hover')); + gd.on('plotly_unhover', () => events.push('unhover')); + + _hover(250, 50); + _leavePlotArea(); + + expect(events).toEqual([]); + }) + .then(done, done.fail); + }); + it('emits plotly_hover over an editable shape', (done) => { let hoverData; From 0373485b067f5ada56e28529076250fbb1d2bfe1 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:35:01 -0400 Subject: [PATCH 05/12] regenerate plot-schema --- test/plot-schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plot-schema.json b/test/plot-schema.json index 345f85895ca..f8ac0c0c9fc 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -2785,7 +2785,7 @@ "valType": "number" }, "hoveranywhere": { - "description": "If true, `plotly_hover` events will fire for any cursor position within the plot area, not just over traces. When the cursor is not over a trace, the event will have an empty `points` array but will include `xvals` and `yvals` with cursor coordinates in data space, and `xPixel` and `yPixel` with cursor coordinates in pixels, relative to the top-left corner of the graph div.", + "description": "If true, `plotly_hover` events will fire for any cursor position within the plot area, not just over traces. When the cursor is not over a trace, the event will have an empty `points` array but will include `xvals` and `yvals` with cursor coordinates in data space, and `xPixel` and `yPixel` with cursor coordinates in pixels, relative to the top-left corner of the graph div. A `plotly_unhover` event fires when the cursor leaves the plot area.", "dflt": false, "editType": "none", "valType": "boolean" From 48b390ea5fc0734564461ef07d0e431028c4d24f Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:45:16 -0400 Subject: [PATCH 06/12] add draftlog --- draftlogs/7966_add.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 draftlogs/7966_add.md diff --git a/draftlogs/7966_add.md b/draftlogs/7966_add.md new file mode 100644 index 00000000000..b8e21ff54f9 --- /dev/null +++ b/draftlogs/7966_add.md @@ -0,0 +1 @@ +- Add top-level `xPixel` and `yPixel` keys to hover and click event data, corresponding to the pixel position of the cursor relative to the top-left corner of the graph div. Also, when `hoveranywhere` is enabled, emit a `plotly_unhover` event when the cursor leaves the plot area. [[#7966](https://github.com/plotly/plotly.js/pull/7966)] From eac82eab9af04094c40262653506302ad76ecb2a Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:48:38 -0400 Subject: [PATCH 07/12] update draftlog --- draftlogs/7966_add.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/draftlogs/7966_add.md b/draftlogs/7966_add.md index b8e21ff54f9..22b84f9ebb2 100644 --- a/draftlogs/7966_add.md +++ b/draftlogs/7966_add.md @@ -1 +1,2 @@ -- Add top-level `xPixel` and `yPixel` keys to hover and click event data, corresponding to the pixel position of the cursor relative to the top-left corner of the graph div. Also, when `hoveranywhere` is enabled, emit a `plotly_unhover` event when the cursor leaves the plot area. [[#7966](https://github.com/plotly/plotly.js/pull/7966)] +- Add top-level `xPixel` and `yPixel` keys to hover and click event data, corresponding to the pixel position of the cursor relative to the top-left corner of the graph div [[#7966](https://github.com/plotly/plotly.js/pull/7966)] +- When `hoveranywhere` is enabled, emit a `plotly_unhover` event when the cursor leaves the plot area [[#7966](https://github.com/plotly/plotly.js/pull/7966)] From 11d8c9cfa1b889869cd7fcfe567b8584bfdf289e Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:49:53 -0400 Subject: [PATCH 08/12] check that hoveranywhere is still true before emitting unhover event --- src/components/dragelement/unhover.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/dragelement/unhover.js b/src/components/dragelement/unhover.js index 908755888ee..5017057802f 100644 --- a/src/components/dragelement/unhover.js +++ b/src/components/dragelement/unhover.js @@ -26,7 +26,8 @@ unhover.wrapped = function(gd, evt, subplot) { if(gd._hoverAnywhereActive) { gd._hoverAnywhereActive = false; - if(evt && evt.target && !oldhoverdata) { + // Make sure hoveranywhere is still enabled + if(gd._fullLayout?.hoveranywhere && evt?.target && !oldhoverdata) { gd.emit('plotly_unhover', { event: evt, points: [] From c6afbc68997561d038473dc25c94f249e4b8f24f Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:48:16 -0400 Subject: [PATCH 09/12] ensure unhover event is not emitted if beforehover handler returns false --- src/components/dragelement/unhover.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/dragelement/unhover.js b/src/components/dragelement/unhover.js index 5017057802f..e0811ecc40b 100644 --- a/src/components/dragelement/unhover.js +++ b/src/components/dragelement/unhover.js @@ -16,14 +16,14 @@ unhover.wrapped = function(gd, evt, subplot) { throttle.clear(gd._fullLayout._uid + hoverConstants.HOVERID); } - var oldhoverdata = gd._hoverdata; + const oldhoverdata = gd._hoverdata; - unhover.raw(gd, evt, subplot); + const shouldEmitUnhover = unhover.raw(gd, evt, subplot); // Special handling for `hoveranywhere`, to ensure we emit exactly one unhover event // when the cursor leaves the plot area. // gd._hoverAnywhereActive is set in fx/hover.js when we emit an empty-space hover event. - if(gd._hoverAnywhereActive) { + if(shouldEmitUnhover && gd._hoverAnywhereActive) { gd._hoverAnywhereActive = false; // Make sure hoveranywhere is still enabled @@ -38,6 +38,8 @@ unhover.wrapped = function(gd, evt, subplot) { // remove hover effects on mouse out, and emit unhover event +// returns false if unhover was skipped due to the plotly_beforehover handler returning false; +// returns true otherwise unhover.raw = function raw(gd, evt) { var fullLayout = gd._fullLayout; var oldhoverdata = gd._hoverdata; @@ -45,7 +47,7 @@ unhover.raw = function raw(gd, evt) { if(!evt) evt = {}; if(evt.target && !gd._dragged && Events.triggerHandler(gd, 'plotly_beforehover', evt) === false) { - return; + return false; } fullLayout._hoverlayer.selectAll('g').remove(); @@ -59,4 +61,5 @@ unhover.raw = function raw(gd, evt) { points: oldhoverdata }); } + return true; }; From f52c0723254703f706603469c769c86e59caa8b8 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:04:21 -0400 Subject: [PATCH 10/12] make xPixel/yPixel always available even without hoveranywhere/clickanywhere --- src/components/fx/hover.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index fd1c5e75ea6..09d11aff8d1 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -471,6 +471,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { if ('yval' in evt) yvalArray = helpers.flat(subplots, evt.yval); else yvalArray = helpers.p2c(yaArray, ypx); + // Save pointer position to gd so that it can be included in all hover/click event data, + // even when hoveranywhere and clickanywhere are not enabled + gd._hoverPointerX = evt.pointerX; + gd._hoverPointerY = evt.pointerY; + if (!isNumeric(xvalArray[0]) || !isNumeric(yvalArray[0])) { Lib.warn('Fx.hover failed', evt, gd); return dragElement.unhoverRaw(gd, evt); @@ -483,8 +488,6 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { gd._hoverYVals = yvalArray; gd._hoverXAxes = xaArray; gd._hoverYAxes = yaArray; - gd._hoverPointerX = evt.pointerX; - gd._hoverPointerY = evt.pointerY; } // the pixel distance to beat as a matching point From d1446b7f2698d09311ec240ddff91d43d4cc97aa Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:05:21 -0400 Subject: [PATCH 11/12] add tests for hover/click event data keys (also rename hover_label_test to hover_test) --- test/jasmine/tests/click_test.js | 23 ++++++++++-- .../{hover_label_test.js => hover_test.js} | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) rename test/jasmine/tests/{hover_label_test.js => hover_test.js} (99%) diff --git a/test/jasmine/tests/click_test.js b/test/jasmine/tests/click_test.js index b360bc600f6..0428b9fe3b7 100644 --- a/test/jasmine/tests/click_test.js +++ b/test/jasmine/tests/click_test.js @@ -110,6 +110,23 @@ describe('Test click interactions:', function() { expect(contextPassthroughs).toBe(0); }); + function checkData() { + expect(Object.keys(futureData).sort()).toEqual([ + 'event', 'points', 'xaxes', 'yaxes', 'xvals', 'yvals', 'xPixel', 'yPixel' + ].sort()); + + expect(futureData.event).not.toBe(null); + checkPointData(); + // xvals, yvals, xaxes, and yaxes should all be undefined since clickanywhere is not enabled + expect(futureData.xvals).toBe(undefined); + expect(futureData.yvals).toBe(undefined); + expect(futureData.xaxes).toBe(undefined); + expect(futureData.yaxes).toBe(undefined); + // However, xPixel and yPixel should be defined and match the click position + expect(futureData.xPixel).toEqual(pointPos[0]); + expect(futureData.yPixel).toEqual(pointPos[1]); + } + function checkPointData() { expect(futureData.points.length).toEqual(1); expect(clickPassthroughs).toBe(2); @@ -131,14 +148,14 @@ describe('Test click interactions:', function() { expect(evt.clientY).toEqual(pointPos[1]); } - it('should contain the correct fields', function() { + it('should contain the correct fields with the correct values', function() { click(pointPos[0], pointPos[1]); - checkPointData(); + checkData(); }); it('should work with a sloppy click (shift < minDrag before mouseup)', function() { click(pointPos[0], pointPos[1], {slop: [4, 4]}); - checkPointData(); + checkData(); }); it('works with fixedrange axes', function(done) { diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_test.js similarity index 99% rename from test/jasmine/tests/hover_label_test.js rename to test/jasmine/tests/hover_test.js index 9b843ed1f98..061fd8ac4e5 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_test.js @@ -1827,6 +1827,42 @@ describe('hover info', function () { }) .then(done, done.fail); }); + + it('should emit event data with the expected keys and values', function (done) { + var hoverData; + gd.on('plotly_hover', function (d) { + hoverData = d; + }); + + Promise.resolve() + .then(function () { + _hoverNatural(gd, 250, 200); + }) + .then(function () { + expect(Object.keys(hoverData).sort()).toEqual( + ['event', 'points', 'xaxes', 'yaxes', 'xvals', 'yvals', 'xPixel', 'yPixel'].sort() + ); + + expect(hoverData.event).not.toBe(null); + var pt = hoverData.points[0]; + expect(Object.keys(pt).sort()).toEqual([ + 'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex', + 'bbox', 'label', 'value', + 'x', 'y', 'xaxis', 'yaxis', 'xPixel', 'yPixel' + ].sort()); + expect(pt.curveNumber).toEqual(0); + expect(pt.pointNumber).toEqual(1); + expect(pt.x).toEqual(2); + expect(pt.y).toEqual(3); + expect(hoverData.xvals).toEqual([2.2045454545454546]); + expect(hoverData.yvals).toEqual([0.28708133971291905]); + expect(hoverData.xaxes).toEqual([gd._fullLayout.xaxis]); + expect(hoverData.yaxes).toEqual([gd._fullLayout.yaxis]); + expect(hoverData.xPixel).toEqual(330); + expect(hoverData.yPixel).toEqual(300); + }) + .then(done, done.fail); + }); }); describe('overflowing hover labels', function () { From 0e3c85cace85e885aa974be04b4adc068bcd6cfe Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:11:52 -0400 Subject: [PATCH 12/12] add xPixel and yPixel keys to PlotMouseEvent type --- src/types/core/events.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/types/core/events.d.ts b/src/types/core/events.d.ts index 9355d47f2e5..0325177e344 100644 --- a/src/types/core/events.d.ts +++ b/src/types/core/events.d.ts @@ -104,6 +104,9 @@ export interface PlotMouseEvent { points: PlotDatum[]; /** The original DOM mouse event. */ event: MouseEvent; + /** x and y pixel position of the mouse */ + xPixel: number; + yPixel: number; } /** Payload for `plotly_hover` — augments `PlotMouseEvent` with axis values. */