diff --git a/src/components/fx/click.js b/src/components/fx/click.js index 96a47a4dfe8..40f831a69c8 100644 --- a/src/components/fx/click.js +++ b/src/components/fx/click.js @@ -14,12 +14,30 @@ 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(data) { gd.emit('plotly_click', {points: data, event: evt}); } - if(gd._hoverdata && evt && evt.target) { - if(annotationsDone && annotationsDone.then) { - annotationsDone.then(emitClick); - } else emitClick(); + var clickmode = gd._fullLayout.clickmode; + var data; + if(evt && evt.target) { + if(gd._hoverdata) { + data = gd._hoverdata; + } else if(clickmode.indexOf('anywhere') > -1) { + if(gd._fullLayout.geo) { + var lat = gd._fullLayout.geo._subplot.xaxis.p2c(); + var lon = gd._fullLayout.geo._subplot.yaxis.p2c(); + data = [{lat: lat, lon: lon}]; + } else { + var bb = evt.target.getBoundingClientRect(); + var x = gd._fullLayout.xaxis.p2d(evt.clientX - bb.left); + var y = gd._fullLayout.yaxis.p2d(evt.clientY - bb.top); + data = [{x: x, y: y}]; + } + } + if(data) { + if(annotationsDone && annotationsDone.then) { + annotationsDone.then(function() { emitClick(data); }); + } else emitClick(data); + } // why do we get a double event without this??? if(evt.stopImmediatePropagation) evt.stopImmediatePropagation(); diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index e2deaa1ed3f..7ee4c5f7f57 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -12,7 +12,7 @@ fontAttrs.size.dflt = constants.HOVERFONTSIZE; module.exports = { clickmode: { valType: 'flaglist', - flags: ['event', 'select'], + flags: ['event', 'select', 'anywhere'], dflt: 'event', editType: 'plot', extras: ['none'], @@ -29,7 +29,12 @@ module.exports = { '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.' + 'events are not fired.', + 'The *anywhere* flag extends the *select* flag by allowing to trigger a', + 'click event anywhere in the plot. The click event will always include *x*', + 'and *y* coordinates and if a data point is below the cursor it will also', + 'include information about the data point. When specifying *anywhere* the', + '*select* flag becomes superfluous.' ].join(' ') }, dragmode: { diff --git a/test/jasmine/tests/anywhere_test.js b/test/jasmine/tests/anywhere_test.js new file mode 100644 index 00000000000..3c6ced1b169 --- /dev/null +++ b/test/jasmine/tests/anywhere_test.js @@ -0,0 +1,206 @@ +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); +var click = require('../assets/click'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var DBLCLICKDELAY = require('@src/plot_api/plot_config').dfltConfig.doubleClickDelay; + +var clickEvent; +var clickedPromise; + +function resetEvents(gd) { + clickEvent = null; + + gd.removeAllListeners(); + + clickedPromise = new Promise(function(resolve) { + gd.on('plotly_click', function(data) { + clickEvent = data.points[0]; + resolve(); + }); + }); +} + +describe('Click-to-select', function() { + var mock14PtsScatter = { + 'in-margin': { x: 28, y: 28 }, + 'point-0': { x: 92, y: 102 }, + 'between-point-0-and-1': { x: 117, y: 110 }, + 'point-11': { x: 339, y: 214 }, + }; + var expectedEventsScatter = { + 'in-margin': false, + 'point-0': { + curveNumber: 0, + pointIndex: 0, + pointNumber: 0, + x: 0.002, + y: 16.25 + }, + 'between-point-0-and-1': { x: 0.002990379231567056, y: 14.169142943944111 }, + 'point-11': { + curveNumber: 0, + pointIndex: 11, + pointNumber: 11, + x: 0.125, + y: 2.125 + }, + }; + + var mockPtsGeoscatter = { + 'start': {lat: 40.7127, lon: -74.0059}, + 'end': {lat: 51.5072, lon: 0.1275}, + }; + var mockPtsGeoscatterClick = { + 'in-margin': { x: 28, y: 28 }, + 'start': {x: 239, y: 174}, + 'end': {x: 426, y: 157}, + 'iceland': {x: 322, y: 150}, + }; + var expectedEventsGeoscatter = { + 'in-margin': false, + 'start': { + curveNumber: 0, + pointIndex: 0, + pointNumber: 0, + lat: 40.7127, + lon: -74.0059, + }, + 'end': { + curveNumber: 0, + pointIndex: 1, + pointNumber: 1, + lat: 51.5072, + lon: 51.5072, + }, + 'iceland': {lat: -18.666562962962963, lon: 56.66635185185185}, + }; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + resetEvents(gd); + destroyGraphDiv(); + }); + + function plotMock14Anywhere(layoutOpts) { + var mock = require('@mocks/14.json'); + var defaultLayoutOpts = { + layout: { + clickmode: 'event+anywhere', + hoverdistance: 1 + } + }; + var mockCopy = Lib.extendDeep( + {}, + mock, + defaultLayoutOpts, + { layout: layoutOpts }); + + return Plotly.newPlot(gd, mockCopy.data, mockCopy.layout); + } + + function plotMock14AnywhereSelect(layoutOpts) { + var mock = require('@mocks/14.json'); + var defaultLayoutOpts = { + layout: { + clickmode: 'select+event+anywhere', + hoverdistance: 1 + } + }; + var mockCopy = Lib.extendDeep( + {}, + mock, + defaultLayoutOpts, + { layout: layoutOpts }); + + return Plotly.newPlot(gd, mockCopy.data, mockCopy.layout); + } + + function plotGeoscatterAnywhere() { + var layout = { + clickmode: 'event+anywhere', + hoverdistance: 1 + }; + var data = [{ + type: 'scattergeo', + lat: [ mockPtsGeoscatter.start.lat, mockPtsGeoscatter.end.lat ], + lon: [ mockPtsGeoscatter.start.lon, mockPtsGeoscatter.end.lat ], + mode: 'lines', + line: { + width: 2, + color: 'blue' + } + }]; + return Plotly.newPlot(gd, data, layout); + } + + function isSubset(superObj, subObj) { + return superObj === subObj || + typeof superObj === 'object' && + typeof subObj === 'object' && ( + subObj.valueOf() === superObj.valueOf() || + Object.keys(subObj).every(function(k) { return isSubset(superObj[k], subObj[k]); }) + ); + } + + /** + * Executes a click and before resets event handlers. + * Returns the `clickedPromise` for convenience. + */ + function _click(x, y, clickOpts) { + resetEvents(gd); + setTimeout(function() { + click(x, y, clickOpts); + }, DBLCLICKDELAY * 1.03); + return clickedPromise; + } + + function clickAndTestPoint(mockPts, expectedEvents, pointKey, clickOpts) { + var x = mockPts[pointKey].x; + var y = mockPts[pointKey].y; + var expectedEvent = expectedEvents[pointKey]; + var result = _click(x, y, clickOpts); + if(expectedEvent) { + result.then(function() { + expect(isSubset(clickEvent, expectedEvent)).toBe(true); + }); + } else { + expect(clickEvent).toBe(null); + result = null; + } + return result; + } + + it('selects point and/or coordinate when clicked - scatter - event+anywhere', function(done) { + plotMock14Anywhere() + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'in-margin'); }) + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'point-0'); }) + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'between-point-0-and-1'); }) + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'point-11'); }) + .then(done, done.fail); + }); + + it('selects point and/or coordinate when clicked - scatter - select+event+anywhere', function(done) { + plotMock14AnywhereSelect() + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'in-margin'); }) + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'point-0'); }) + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'between-point-0-and-1'); }) + .then(function() { return clickAndTestPoint(mock14PtsScatter, expectedEventsScatter, 'point-11'); }) + .then(done, done.fail); + }); + + it('selects point and/or coordinate when clicked - geoscatter - event+anywhere', function(done) { + plotGeoscatterAnywhere() + .then(function() { return clickAndTestPoint(mockPtsGeoscatterClick, expectedEventsGeoscatter, 'in-margin'); }) + .then(function() { return clickAndTestPoint(mockPtsGeoscatterClick, expectedEventsGeoscatter, 'start'); }) + .then(function() { return clickAndTestPoint(mockPtsGeoscatterClick, expectedEventsGeoscatter, 'end'); }) + .then(function() { return clickAndTestPoint(mockPtsGeoscatterClick, expectedEventsGeoscatter, 'iceland'); }) + .then(done, done.fail); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index 5cb02325f61..596d959a660 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -1037,7 +1037,7 @@ ] }, "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.", + "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. The *anywhere* flag extends the *select* flag by allowing to trigger a click event anywhere in the plot. The click event will always include *x* and *y* coordinates and if a data point is below the cursor it will also include information about the data point. When specifying *anywhere* the *select* flag becomes superfluous.", "dflt": "event", "editType": "plot", "extras": [ @@ -1045,7 +1045,8 @@ ], "flags": [ "event", - "select" + "select", + "anywhere" ], "valType": "flaglist" },