Skip to content
Open
16 changes: 14 additions & 2 deletions src/components/fx/click.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down
28 changes: 26 additions & 2 deletions src/components/fx/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ exports.loneHover = function loneHover(hoverItems, opts) {
y1: y1 + gTop
};

eventData.xPixel = (_x0 + _x1) / 2;
eventData.yPixel = (_y0 + _y1) / 2;

if (opts.inOut_bbox) {
opts.inOut_bbox.push(eventData.bbox);
}
Expand Down Expand Up @@ -473,6 +476,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;
Expand Down Expand Up @@ -778,6 +787,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;
}

Expand Down Expand Up @@ -877,6 +897,9 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
y0: y0 + gTop,
y1: y1 + gTop
};

eventData.xPixel = (_x0 + _x1) / 2;
eventData.yPixel = (_y0 + _y1) / 2;
}

pt.eventData = [eventData];
Expand Down Expand Up @@ -914,9 +937,10 @@ function _hover(gd, evt, subplot, noHoverEvent, eventTarget) {
}

// don't emit events if called manually
if (!eventTarget || noHoverEvent || !hoverChanged(gd, evt, oldhoverdata)) return;
var _hoverChanged = hoverChanged(gd, evt, oldhoverdata);
if (!eventTarget || noHoverEvent || (!_hoverChanged && !fullLayout.hoveranywhere)) return;

if (oldhoverdata) {
if (oldhoverdata && _hoverChanged) {
gd.emit('plotly_unhover', {
event: evt,
points: oldhoverdata
Expand Down
2 changes: 2 additions & 0 deletions src/components/fx/hovermode_defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ module.exports = function handleHoverModeDefaults(layoutIn, layoutOut) {

coerce('clickmode');
coerce('hoversubplots');
coerce('hoveranywhere');
coerce('clickanywhere');
return coerce('hovermode');
};
22 changes: 22 additions & 0 deletions src/components/fx/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
137 changes: 137 additions & 0 deletions test/jasmine/tests/hover_click_anywhere_test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion test/jasmine/tests/map_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ var assertHoverLabelContent = customAssertions.assertHoverLabelContent;
var SORTED_EVENT_KEYS = [
'data', 'fullData', 'curveNumber', 'pointNumber', 'pointIndex',
'lon', 'lat',
'bbox'
'bbox',
'xPixel', 'yPixel'
].sort();

var TRANSITION_DELAY = 500;
Expand Down
12 changes: 12 additions & 0 deletions test/plot-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down