Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Click anywhere feature #5443

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
28 changes: 23 additions & 5 deletions src/components/fx/click.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic means that if the plot has a geo subplot, we're assuming the click was on that, even if the plot has other subplots too. But we can do a lot better than that, and there are more subplot types than just geo and 2D cartesian. And fortunately we already have a mechanism to detect this: the third arg to click is subplot and it should tell us exactly which subplot the click came from.

2D cartesian, ternary, and polar subplots report this correctly (eg 'x2y3', 'ternary2', 'polar3'), geo doesn't but it should be easy to fix that. Then what we need to do is find the actual subplot object, and depending on its type calculate the appropriate coordinates within that particular subplot.

Pie, sankey, and funnelarea also all reach this point but don't give a subplot. We could have them give the trace number I guess, but is there anything useful to report for them? Just the raw coordinates within the plot?

3D, parcoords, and parcats don't even get here, I'm happy to ignore those for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that something that you intend to do in a second round or within this PR. If it is something I should fix, then I'd like a short example ideally for each plot type as I am not familiar with most of the ones you listed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Expand Down
9 changes: 7 additions & 2 deletions src/components/fx/layout_attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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: {
Expand Down
206 changes: 206 additions & 0 deletions test/jasmine/tests/anywhere_test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 3 additions & 2 deletions test/plot-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1037,15 +1037,16 @@
]
},
"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": [
"none"
],
"flags": [
"event",
"select"
"select",
"anywhere"
],
"valType": "flaglist"
},
Expand Down