From 8e3d3a15f1a566e3a5c97c20b7a4a981b63ddba4 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 1/6] add top-level xPixel/yPixel to hover/click event data --- src/components/fx/click.js | 2 ++ src/components/fx/hover.js | 8 +++++++- src/components/fx/layout_attributes.js | 8 ++++++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/fx/click.js b/src/components/fx/click.js index 268dd6ae05d..adfedae6164 100644 --- a/src/components/fx/click.js +++ b/src/components/fx/click.js @@ -23,6 +23,8 @@ module.exports = function click(gd, evt, subplot) { clickData.yaxes ??= gd._hoverYAxes; clickData.xvals ??= gd._hoverXVals; clickData.yvals ??= 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 05c03ebdfb1..3b36c67d139 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 @@ -977,7 +979,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { xaxes: xaArray, yaxes: yaArray, xvals: xvalArray, - yvals: yvalArray + yvals: 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 c50e9d09fbefdf91d0c92cc29eb7e7081815b362 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 2/6] 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 6a940078be2..bed723df899 100644 --- a/test/jasmine/tests/hover_click_anywhere_test.js +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -75,6 +75,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); }); @@ -129,6 +131,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; @@ -170,11 +193,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(); @@ -183,6 +208,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); }); @@ -248,9 +277,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([]); @@ -262,6 +294,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 f5623e929be..dbbcda998e1 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 a606c3a3d5695bfef9348298ed4d5bbbdf0ba794 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 3/6] 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 3b36c67d139..840b520b4d3 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 a43a4b6a3cd788d3eb644e0f16c3e2c192844600 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 4/6] 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 bed723df899..c35b54cac85 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, layoutExtras = {}, configExtras) { return Plotly.newPlot( @@ -55,6 +56,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; @@ -164,6 +174,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 c1c46622b6119437f477205a242254fb6f62a07a 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 5/6] 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 dbbcda998e1..951c8f406e2 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 1e34379bfe26ef479716bb0cbc987c08b1ed3eab 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 6/6] 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)]