From 5ab032d4f6cb7dcfdd7f29d9fb639efdaa58aeac Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Wed, 11 Feb 2026 19:34:17 -0500 Subject: [PATCH 1/3] Add hoveranywhere and clickanywhere attributes to emit hover/click events in empty plot space --- src/components/fx/click.js | 16 ++++++++++++++-- src/components/fx/hover.js | 17 +++++++++++++++++ src/components/fx/hovermode_defaults.js | 2 ++ src/components/fx/layout_attributes.js | 22 ++++++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/components/fx/click.js b/src/components/fx/click.js index 96a47a4dfe8..268dd6ae05d 100644 --- a/src/components/fx/click.js +++ b/src/components/fx/click.js @@ -5,6 +5,7 @@ var hover = require('./hover').hover; module.exports = function click(gd, evt, subplot) { var annotationsDone = Registry.getComponentMethod('annotations', 'onClick')(gd, gd._hoverdata); + var fullLayout = gd._fullLayout; // fallback to fail-safe in case the plot type's hover method doesn't pass the subplot. // Ternary, for example, didn't, but it was caught because tested. @@ -14,9 +15,20 @@ module.exports = function click(gd, evt, subplot) { hover(gd, evt, subplot, true); } - function emitClick() { gd.emit('plotly_click', {points: gd._hoverdata, event: evt}); } + function emitClick() { + var clickData = {points: gd._hoverdata, event: evt}; + + // get coordinate values from latest hover call, if available + clickData.xaxes ??= gd._hoverXAxes; + clickData.yaxes ??= gd._hoverYAxes; + clickData.xvals ??= gd._hoverXVals; + clickData.yvals ??= gd._hoverYVals; - if(gd._hoverdata && evt && evt.target) { + gd.emit('plotly_click', clickData); + } + + if((gd._hoverdata || fullLayout.clickanywhere) && evt && evt.target) { + if(!gd._hoverdata) gd._hoverdata = []; if(annotationsDone && annotationsDone.then) { annotationsDone.then(emitClick); } else emitClick(); diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index 4cd374b40c8..49bbebe8154 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -473,6 +473,12 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { } } + // Save coordinate values so clickanywhere can be used without hoveranywhere + gd._hoverXVals = xvalArray; + gd._hoverYVals = yvalArray; + gd._hoverXAxes = xaArray; + gd._hoverYAxes = yaArray; + // the pixel distance to beat as a matching point // in 'x' or 'y' mode this resets for each trace var distance = Infinity; @@ -778,6 +784,17 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) { createSpikelines(gd, spikePoints, spikelineOpts); } } + + if (fullLayout.hoveranywhere && !noHoverEvent && eventTarget) { + gd.emit('plotly_hover', { + event: evt, + points: [], + xaxes: xaArray, + yaxes: yaArray, + xvals: xvalArray, + yvals: yvalArray + }); + } return result; } diff --git a/src/components/fx/hovermode_defaults.js b/src/components/fx/hovermode_defaults.js index edb705e02cc..697a44ca50d 100644 --- a/src/components/fx/hovermode_defaults.js +++ b/src/components/fx/hovermode_defaults.js @@ -13,5 +13,7 @@ module.exports = function handleHoverModeDefaults(layoutIn, layoutOut) { coerce('clickmode'); coerce('hoversubplots'); + coerce('hoveranywhere'); + coerce('clickanywhere'); return coerce('hovermode'); }; diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 825b02af289..16e3057fa16 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -91,6 +91,28 @@ module.exports = { 'when `hovermode` is set to *x*, *x unified*, *y* or *y unified*.', ].join(' ') }, + hoveranywhere: { + valType: 'boolean', + dflt: false, + editType: 'none', + 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.' + ].join(' ') + }, + clickanywhere: { + valType: 'boolean', + dflt: false, + editType: 'none', + 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.' + ].join(' ') + }, hoverdistance: { valType: 'integer', min: -1, From 8d4562a7f915252572598f76410823163b8b8591 Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Wed, 11 Feb 2026 20:40:36 -0500 Subject: [PATCH 2/3] Add jasmine tests for `hoveranywhere` and `clickanywhere`r --- .../tests/hover_click_anywhere_test.js | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 test/jasmine/tests/hover_click_anywhere_test.js diff --git a/test/jasmine/tests/hover_click_anywhere_test.js b/test/jasmine/tests/hover_click_anywhere_test.js new file mode 100644 index 00000000000..ce25a8ee62a --- /dev/null +++ b/test/jasmine/tests/hover_click_anywhere_test.js @@ -0,0 +1,137 @@ +var Plotly = require('../../../lib/index'); +var Fx = require('../../../src/components/fx'); +var Lib = require('../../../src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var click = require('../assets/click'); + + +function makePlot(gd, layoutExtras) { + return Plotly.newPlot(gd, [{ + x: [1, 2, 3], + y: [1, 3, 2], + type: 'scatter', + mode: 'markers' + }], Lib.extendFlat({ + width: 400, height: 400, + margin: {l: 50, t: 50, r: 50, b: 50}, + xaxis: {range: [0, 10]}, + yaxis: {range: [0, 10]}, + hovermode: 'closest' + }, layoutExtras || {})); +} + +describe('hoveranywhere', function() { + 'use strict'; + + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + afterEach(destroyGraphDiv); + + function _hover(xPixel, yPixel) { + var bb = gd.getBoundingClientRect(); + var s = gd._fullLayout._size; + Fx.hover(gd, { + clientX: xPixel + bb.left + s.l, + clientY: yPixel + bb.top + s.t, + target: gd.querySelector('.nsewdrag') + }, 'xy'); + Lib.clearThrottle(); + } + + it('emits plotly_hover with coordinate data on empty space', function(done) { + var hoverData; + + makePlot(gd, {hoveranywhere: true}).then(function() { + gd.on('plotly_hover', function(d) { hoverData = d; }); + + // hover over empty area (no data points nearby) + _hover(250, 50); + + expect(hoverData).toBeDefined(); + expect(hoverData.points).toEqual([]); + expect(hoverData.xvals.length).toBe(1); + expect(hoverData.yvals.length).toBe(1); + expect(typeof hoverData.xvals[0]).toBe('number'); + }) + .then(done, done.fail); + }); + + it('does not fire on empty space by default', function(done) { + var hoverData; + + makePlot(gd).then(function() { + gd.on('plotly_hover', function(d) { hoverData = d; }); + _hover(250, 50); + expect(hoverData).toBeUndefined(); + }) + .then(done, done.fail); + }); + + it('still returns normal point data on traces', function(done) { + var hoverData; + + makePlot(gd, {hoveranywhere: true}).then(function() { + gd.on('plotly_hover', function(d) { hoverData = d; }); + + // hover near (2, 3) + _hover(60, 210); + + expect(hoverData.points.length).toBe(1); + expect(hoverData.points[0].x).toBe(2); + expect(hoverData.points[0].y).toBe(3); + }) + .then(done, done.fail); + }); + + it('respects hovermode:false', function(done) { + var hoverData; + + makePlot(gd, {hoveranywhere: true, hovermode: false}).then(function() { + gd.on('plotly_hover', function(d) { hoverData = d; }); + _hover(250, 50); + expect(hoverData).toBeUndefined(); + }) + .then(done, done.fail); + }); +}); + +describe('clickanywhere', function() { + 'use strict'; + + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + afterEach(destroyGraphDiv); + + it('emits plotly_click with empty points on empty space', function(done) { + var clickData; + + makePlot(gd, {clickanywhere: true}).then(function() { + gd.on('plotly_click', function(d) { clickData = d; }); + + var s = gd._fullLayout._size; + click(s.l + 250, s.t + 50); + + expect(clickData).toBeDefined(); + expect(clickData.points).toEqual([]); + }) + .then(done, done.fail); + }); + + it('does not fire on empty space by default', function(done) { + var clickData; + + makePlot(gd).then(function() { + gd.on('plotly_click', function(d) { clickData = d; }); + + var s = gd._fullLayout._size; + click(s.l + 250, s.t + 50); + + expect(clickData).toBeUndefined(); + }) + .then(done, done.fail); + }); +}); From a92246d3b08ba92224073ef8e73c8b3714cf467e Mon Sep 17 00:00:00 2001 From: Alex Hsu Date: Wed, 11 Feb 2026 21:45:11 -0500 Subject: [PATCH 3/3] Update schema --- test/plot-schema.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/plot-schema.json b/test/plot-schema.json index 211da680a56..fa5c499f5bb 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -1166,6 +1166,12 @@ "ummalqura" ] }, + "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.", + "dflt": false, + "editType": "none", + "valType": "boolean" + }, "clickmode": { "description": "Determines the mode of single click interactions. *event* is the default value and emits the `plotly_click` event. In addition this mode emits the `plotly_selected` event in drag modes *lasso* and *select*, but with no event data attached (kept for compatibility reasons). The *select* flag enables selecting single data points via click. This mode also supports persistent selections, meaning that pressing Shift while clicking, adds to / subtracts from an existing selection. *select* with `hovermode`: *x* can be confusing, consider explicitly setting `hovermode`: *closest* when using this feature. Selection events are sent accordingly as long as *event* flag is set as well. When the *event* flag is missing, `plotly_click` and `plotly_selected` events are not fired.", "dflt": "event", @@ -2807,6 +2813,12 @@ "editType": "plot", "valType": "boolean" }, + "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.", + "dflt": false, + "editType": "none", + "valType": "boolean" + }, "hoverdistance": { "description": "Sets the default distance (in pixels) to look for data to add hover labels (-1 means no cutoff, 0 means no looking for data). This is only a real distance for hovering on point-like objects, like scatter points. For area-like objects (bars, scatter fills, etc) hovering is on inside the area and off outside, but these objects will not supersede hover on point-like objects in case of conflict.", "dflt": 20,