diff --git a/draftlogs/7966_add.md b/draftlogs/7966_add.md new file mode 100644 index 00000000000..22b84f9ebb2 --- /dev/null +++ b/draftlogs/7966_add.md @@ -0,0 +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 [[#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)] diff --git a/src/components/dragelement/unhover.js b/src/components/dragelement/unhover.js index efe75ca1957..e0811ecc40b 100644 --- a/src/components/dragelement/unhover.js +++ b/src/components/dragelement/unhover.js @@ -16,11 +16,30 @@ unhover.wrapped = function(gd, evt, subplot) { throttle.clear(gd._fullLayout._uid + hoverConstants.HOVERID); } - unhover.raw(gd, evt, subplot); + const oldhoverdata = gd._hoverdata; + + 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(shouldEmitUnhover && gd._hoverAnywhereActive) { + gd._hoverAnywhereActive = false; + + // Make sure hoveranywhere is still enabled + if(gd._fullLayout?.hoveranywhere && evt?.target && !oldhoverdata) { + gd.emit('plotly_unhover', { + event: evt, + points: [] + }); + } + } }; // 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; @@ -28,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(); @@ -42,4 +61,5 @@ unhover.raw = function raw(gd, evt) { points: oldhoverdata }); } + return true; }; 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..92c7dd8b784 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); @@ -818,6 +823,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; } @@ -977,7 +987,11 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { xaxes: xaArray, yaxes: yaArray, xvals: helpers.c2dApply(xaArray, xvalArray), - yvals: helpers.c2dApply(yaArray, yvalArray) + 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..963ad29a9c9 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -112,7 +112,10 @@ 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.', + 'A `plotly_unhover` event fires when the cursor leaves the plot area.' ].join(' ') }, clickanywhere: { @@ -123,7 +126,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: { 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. */ diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index a1a5ecc0a7f..56bb19bb153 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -15945,7 +15945,7 @@ export interface Layout { */ calendar?: Calendar; /** - * 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. + * 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. * @default false */ clickanywhere?: boolean; @@ -16058,7 +16058,7 @@ export interface Layout { */ height?: number; /** - * 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. + * 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. * @default false */ hoveranywhere?: boolean; 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_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js index a9bfd391402..67f4850bf45 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; @@ -93,6 +103,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,14 +159,143 @@ describe('hoveranywhere', () => { .then(done, done.fail); }); - it('respects hovermode:false', (done) => { + 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 events = []; + var hoverData, unhoverData; + makePlot(gd, {}, { hoveranywhere: true, hovermode: false }) .then(() => { + gd.on('plotly_hover', () => { + events.push('hover'); + hoverData = d; + }); + gd.on('plotly_unhover', (d) => { + events.push('unhover'); + unhoverData = d; + }); gd.on('plotly_hover', (d) => (hoverData = d)); _hover(250, 50); + _leavePlotArea(); expect(hoverData).toBeUndefined(); + expect(unhoverData).toBeUndefined(); + expect(events).toEqual([]); + }) + .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); }); @@ -188,11 +329,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 +344,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 +463,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 +480,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/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 () { diff --git a/test/plot-schema.json b/test/plot-schema.json index ddf9ad0238d..f8ac0c0c9fc 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. A `plotly_unhover` event fires when the cursor leaves the plot area.", "dflt": false, "editType": "none", "valType": "boolean"