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

1852 persistent click #2824

Closed
wants to merge 46 commits into from
Closed

1852 persistent click #2824

wants to merge 46 commits into from

Conversation

rmoestl
Copy link
Contributor

@rmoestl rmoestl commented Jul 18, 2018

Though not complete, this pull request will introduce the select-on-click / persistent selection feature discussed in issue #1852.

The feature requires a change of the trace selection interface and how selection state is determined and controlled in general.

Tests (image and Jasmine alike) are primarily caused by trace types not yet migrated to the new selection interface.

- Just a proof of concept only working
  for scatter trace type and with
  single trace plots only.
- Selection state doesn't move back to nothing selected state though.
- Note: discussion needed if new behavior is wanted.
- Given multiple points selected, deselecting one of those should
  start a new selection, or when Shift is pressed the point should be
  removed from retained selection.
- If the Shift key is down, data points previously selected by a click
  are now retained when user adds to a selection through box or lasso
  select mode.
- Reason: new approach is needed to support proper
  interplay of polygon and click select. See
  #1852
  for a discussion.
- New approach separates the two concerns
  'what was selected' and 'set selected state of
  data points', that were previously handled
  by `selectPoints`, into multiple functions.
- Also the polygons now longer implicitly
  carry selection state in cartesian/select.js
  but are only visual cues anymore.
- Introduce a new Lib function for calculating
  the set difference of two arrays.
- Select-on-click wasn't checking if an outlines
  object was passed which is the case in zoom and
  pan mode for example.
- Instead of four functions only have two anymore.
- Also update modebar to detect a selectable trace.
- Also update supplyDefaults code.
- Just rename a parameter name.
- Reason: DRY with selectOnClick, whereas extracted method has logic
  in it to determine if a trace is selectable.
- Prepare scattergl as next target of new selection interface migration.
- Thereby had to change controlling code in
  cartesian/select#selectOnClick.
- No longer restrict selected mode rendering to
  lasso/box select mode.
- Account for difference in hoverData having no pointNumber but
  binNumber and pointNumbers in case of histogram.
- Created a messy entireSelectionToBeCleared function
- Reason: more efficient than the extra hack for scattergl for the case
  when no point is selected at all.
- When drawing lasso bar and scatter trace weren't showing
  their points as deselected.
- Needs cleanup in select.js
- Controlling code in cartesian/select.js needs a change due to a false assumption about hoverData: box produces a multi-element hoverData array whereas only the first element is really the clicked point
- Reason: box trace type stuffing not even points but also hoverData
  about boxes which appears on mouseover into gd._hoverdata.
- So based on analysis of fx/hover.js, which sorts the hoverdata array
  so that the closest point comes first, it is assumed now that only
  the first element in hoverData represents a clicked point.
- For box trace type particulary if a box is clicked the first
  hoverdata element is not a point but a box with pointnumber being
  the index of the point group that's represented as a box plot
  element visually.
- By introducing a hoverOnBox property on hoverData.
- Still needs cleaning things up.
- Left over misbehaviour: clicking a box deselects
  all points and instead should not change the
  current selection at all.
- Reason: multiPolygon tester no longer needed due to changes
  how selection code works.
- Until clickMode attribute is not introduced in public API
  this hack needs to be there to satisfy ESLint.
- Also adapt the new `toggleSelected` function to style
  text data points properly.
- Violin uses box trace type select implementation.
- Thereby fix failing geo_point-selection.json image test.
- Only the proper mapping to scatter's select interface was needed.
@rmoestl
Copy link
Contributor Author

rmoestl commented Jul 19, 2018

Since this is a PR in progress, here's a quick overview of what is missing:

  • Transition not yet migrated trace types to new selection interface
  • Introducing the clickmode attribute and the corresponding logic in dragbox.js
  • Emitting the correct plotly_selected, plotly_deselected etc. events after a click-on-select action
  • Writing Jasmine tests ensuring click-to-select works for each supported trace type
  • Writing Jasmine tests ensuring that a combination of subsequent polygon and click selects work as expected
  • Clean up things (added TODOs in particular) and here and there adjust coding style
  • Maybe adapt other bases plot types besides cartesian to use the new trace selection interface?!
  • Also adapt API documentation of hovermode because click-to-select works best with closest
  • Should a selection be cleared when user clicks on an empty area?
  • Read through issue Persistent and Multiple Clicking and Unclicking Behaviour #1852 to ensure we haven't forgotten anything

@@ -194,7 +193,7 @@ function isSelectable(fullData) {

var trace = fullData[i];

if(!trace._module || !trace._module.selectPoints) continue;
if(!trace._module || !trace._module.getPointsIn || !trace._module.toggleSelected) continue;
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be better to make Plotly.register set a boolean _module.selected somewhere here and use that boolean downstream (e.g. in Plots.supplyDefaults) instead of looking for getPointsIn and toggleSelected.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Neat! Would _module.selectable even be a better name?

return a;
}
return a.filter(function(e) {
return b.indexOf(e) < 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like Lib.difference is only called on array of number. We could take advantage of that by using a hash table instead of indexOf which can be slow for arrays with length > 1e5.

Something like:

var small;
var large;
var hash = {};
var out = [];
var i;

if(a.length < b.length) {
  small = a;
  large = b;
} else {
  small = b;
  large = a;
}

for(i = 0; i < small.length; i++) {
  hash[small[i]] = 1;
}

for(i = 0; i < large.length; i++) {
  var l = large[i];
  if(hash[l]) {
    out.push(l)
  }
}

return out;

Copy link
Contributor

Choose a reason for hiding this comment

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

... it might be nice to look at the lodash's implementation.

!trace._module.toggleSelected || !trace._module.getPointsIn) continue;

// TODO is dragOptions.subplot is ever set? If not, delete.
// if(dragOptions.subplot) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like ternary subplots still set that subplot field as well as geo and mapbox subplots.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thx! Really great you're also going over my TODOs because some of them would have required your or AJ's input anyways!

@@ -590,32 +592,30 @@ function plot(gd, subplot, cdata) {
null;
});

if(selectMode) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah no. We need to keep that selectMode condition.

As creating one regl context takes about ~300ms, we don't want to create the selection regl context when in non-select interaction modes.

But perhaps, you only remove it before implementing clickmode. Please ignore if that's the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, I'll have to dig into that again. With if(selectMode) scattergl traces weren't updated on select-on-click when in zoom/pan dragMode. I guess I'll have to adapt the logic when selectMode flag is set to true.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As far as I understand, the selection regl context is created by a call to createScatter(). When user switches to lasso or box mode, the plot is re-rendered for some reason and regl contexts are creating again.

I think you are absolutely right to take clickMode into account to decide if scene.select2d context needs to be created.

For now, I'll reintroduce the selectMode condition and add a TODO for later when I introduce clickmode.

// console.log('els : ' + JSON.stringify(scene.selectBatch[stash.index]));
// console.log('unels: ' + JSON.stringify(scene.unselectBatch[stash.index]));
// console.log('selection: ' + JSON.stringify(selection));
return selection;
Copy link
Contributor

Choose a reason for hiding this comment

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

I have some bad news. The new selection algo perform very poorly above 1e5 points (which in practice only affects scattergl). From

var N = 1e5
var x = []
var y = []
var ms = []

for(var i = 0; i < N; i++) {
  x.push(Math.random())
  y.push(Math.random())
  ms.push(i % 2 ? 20 : 10)
}

console.time(N)
Plotly.newPlot(gd, [{
  type: 'scattergl',
  mode: 'markers',
  x: x,
  y: y,
  marker: {size: ms}
}], {
  dragmode: 'select',
})
console.timeEnd(N)

selecting is painfully slow.


I'll try to pin down what's causing this slow down later today.

Copy link
Contributor Author

@rmoestl rmoestl Jul 20, 2018

Choose a reason for hiding this comment

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

Okay, thanks! Let me know if I can be of any assistance, e.g. if some of my code isn't clear...

BTW I didn't know console.time and console.timeEnd! Awesome!!

gd.emit('plotly_deselect', null);
}
else {
// TODO What to do with the code below because we now have behavior for a single click
Copy link
Contributor

@etpinard etpinard Jul 19, 2018

Choose a reason for hiding this comment

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

Thanks for writing down that TODO.

I'm thinking when clickmode: 'select' we'll emit plotly_selected with the selected points info. Otherwise, we'll have to keep that dummy event below (until v2).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thx.

}

updateSelectedState(gd, searchTraces);

// clear visual boundaries of selection area if displayed
outlines.remove();
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did you move this after updateSelectedState?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

TBH, I'm not sure. Fun fact: I even had it beneath emitting plotly_selected event which then broke some unit tests that assume outline elements are no longer in DOM when plotly_selected event is received.

But why did I really moved it below updateStelectedState? As far as I can remember, in the early commits, I thought I could move the line before the if(numClicks === 2) condition. For some reason that wasn't correct and I moved it back into the if block. So I guess no rationale behind it other than me not putting it back where it always should have been.

// Determine clicked points,
// call selection modification functions of the trace's module
// and collect the resulting set of selected points
clickedPts = clickedPtsFor(searchInfo, hoverData);
Copy link
Contributor

Choose a reason for hiding this comment

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

Does the clickedPts array ever have more than one item?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At the moment not. I changed it back when I was about to integrate the box trace type. See 231fa6d.

I figured it'd be a good idea to be ready for hoverdata one day containing more than one valid data point. E.g. when multiple data points are at the same location that was clicked.

Is allocating an array costly in terms of performance? Happy to change it back to non-array style if that is the case! Or, if we want to keep it, I could add a comment!

Copy link
Contributor

Choose a reason for hiding this comment

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

Is allocating an array costly in terms of performance?

It can be costly, but I'll have to test on some real-world data to know for sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now I postpone moving back to a non-array return type until there's proof that it hurts performance considerably.

Copy link
Contributor

@etpinard etpinard left a comment

Choose a reason for hiding this comment

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

This PR is very impressive. 🎉 💪 🥇

Thanks very much @rmoestl for taking the time to improve parts of our internal API. Selections have been suffering from some growing pains in the last few months, this is for sure a step in the right direction.

I do have a few concerns about performance. Hopefully, speeding up Lib.difference will suffice. I'll report back what I found later today.

var out = [];
var i;

for(i = 0; i < b.length; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This commit makes selection of 1e5 scattergl markers work ok, but still about an order of magnitude too slow.

I'll have to dig deeper tomorrow.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Have you dug deeper since then?

Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't had the time unfortunately.

Copy link
Contributor

@etpinard etpinard Aug 7, 2018

Choose a reason for hiding this comment

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

... I'll find some time to review it before the meeting on Thursday though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No problem. There's no hurry at the moment.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok very briefly just to get a conversion started (and not break my promise):

Doing some order of operation analysis in the hot selection code path comparing:

searchInfo = searchTraces[i];
traceSelection = searchInfo._module.selectPoints(searchInfo, testPoly);
traceSelections.push(traceSelection);

on master to this branch's

searchInfo = searchTraces[i];
module = searchInfo._module;
if(!retainSelection) module.toggleSelected(searchInfo, false);
var currentPolygonTester = polygonTester(currentPolygon);
var pointsInCurrentPolygon = module.getPointsIn(searchInfo, currentPolygonTester);
module.toggleSelected(searchInfo, !subtract, pointsInCurrentPolygon);
var pointsNoLongerSelected = difference(pointsInPolygon[i], pointsInCurrentPolygon);
traceSelection = module.toggleSelected(searchInfo, false, pointsNoLongerSelected);
pointsInPolygon[i] = pointsInCurrentPolygon;

we can see why this branch is roughly an order of magnitude slower:

On master that block scales as O(n). To handle addition/subtraction/merge selections, we use an algo acting on the selections polygon with I believe is O(number-of-vertices^2) but results nonetheless in not that many computations in most use cases (e.g. rectangles with 4 vertices)

On this branch, we have

if(!retain) toggleSelected('clear')
// which is currently O(2*n)

getPolyonTester()
// not sure why this one is called in the hot code path

getPointsIn()
// this one is O(n) assuming 'fast' is-in-polygon computations

toggleSelected('add or substract')
// this one currently is O(n + p)

difference()
// O(n + p)

toggleSelected('clear')
// this one is O(n + (n-p))

which when added all up gives a total of O(6n + p) where n is the number of points and p is number of selected points ~ roughly one order of magnitude.

So, we should try to combine all the toggleSelected loops into one. Don't get me wrong, I don't think splitting up selectedPoints into toggleSelected and getPointsIn was a bad idea, but perhaps we could bring a version of selectPoints that reuses getPointsIn? We can talk more about this tomorrow during the meeting. Thanks for your hard work!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In hindsight it seams the new approach of splitting up selectedPoints wasn't such a good idea :-( I'm not sure if it's possible to speed things up that much with that approach. To be honest, it feels like to be at square one again.

Thinking back, the reason for splitting up selectPoints was to cover a couple of edge cases when users would use click-to-select and polygon-select interchangeably. Back then we discussed possible approaches here.

Let me jot down some thoughts for the upcoming meeting:

Current approach on master

The approach to selections before tackling persistent select was as follows: each trace type module supporting selection exposes a selectPoints function that accepts a searchInfo parameter describing the actual trace and a polygon object that offers a contains function. selectPoints iterates through the data points of the passed trace (identified by searchInfo) and calls polygon.contains for each data point to see if the data point is within the selection area. Adding/subtracting from an existing selection is supported by wrapping multiple polygons into one multiPolygon and passing that to selectPoints.

Possible new approach to support click-to-select

The whole polygon testing approach is covered in lib/polygon.js which exposes tester (for testing a single polygon) and multiTester (wrapper for multiple tester objects). What about having a third tester type that tests by data point index? What would that mean for the client code?

  • Go back to selectPoints in trace type modules
  • Add data point index as a parameter to the signature of contains
  • In selectPoints implementations do not only pass the x and y (or lan, lat etc.) of a data point when calling contains but also the data point index
  • Adapt multiTester to be able to wrap the new index-based tester

Copy link
Contributor

@etpinard etpinard Aug 9, 2018

Choose a reason for hiding this comment

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

Your new approach sounds very promising! I'd say give it a shot with scatter and scattergl and ping me so that I can quickly review and perf-test it. 👌

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@etpinard proof-of-concept is ready to review. It was easier to start from scratch. So the new approach can be found at https://github.com/plotly/plotly.js/tree/1852-persistent-click-via-selectPoints. (Although it would make reviewing easier, I figured code is not quite ready for a new PR).

Copy link
Contributor

Choose a reason for hiding this comment

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

I made a few comments on 2fbaaaa . Your solution is looking great! 💎

I'd say go ahead and try to implement it for all selectable trace modules. 🚀

@rmoestl
Copy link
Contributor Author

rmoestl commented Jul 20, 2018

Thanks @etpinard for taking the time to do such a thorough review! 🙇‍♂️
I'll gradually work through the feedback.

- Reason: code for checking if a trace supports selection was duplicated
  across the code base.
- Reason: to prevent confusion when comparing code to previous
  versions.
- See comment in PR #2824 for more info.
- Reason: to spare the creation of an unnecessary regl context when
  selection can't take place.
- TODO: use the not yet introduced clickmode layout attribute to set
  the select mode.
@rmoestl
Copy link
Contributor Author

rmoestl commented Aug 7, 2018

@etpinard (CC @alexcjohnson) I've incorporated your feedback as much as possible at this stage and left TODOs etc. where implementation makes more sense later.

Next steps: I'll let CI run the tests and then tackle failing tests. That'll involve transitioning further trace types to the new selection interface. After that I'll start to add my own tests.

- Reason: mocking of `gd._fullData._module` was missing the `selectable`
  property introduced a few commits earlier to centralize checking if
  a trace supports selection.
- Reason: `cartesian/select.js.prepSelect` expects
  `dragOptions.plotinfo` to have an `id` property set. Mapbox base plot
  wasn't setting this id property and thus cartesian select always
  cleared existing outlines (user holding Shift key)
- Reason: until clickmode attribute is not introduced, Jasmine spies in
  gl2d_plot_interact_test are expecting different default behavior.
@etpinard
Copy link
Contributor

Now in -> #2944

@etpinard etpinard closed this Aug 27, 2018
@etpinard etpinard deleted the 1852-persistent-click branch September 10, 2018 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants