-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
Common interface to interpret and execute API methods #1016
Conversation
@etpinard @alexcjohnson The code isn't ready for review yet, but the internal usage is: To hook this up, you provide it the gd, an array of commands (e.g. [{method: 'restyle', args: [...]}, {method: 'restyle', args: [...]}, ...] and a callback to make an update. If the commands are straightforward, it will create lookup table by value so that when a restyle or relayout comes through, it can easily check to see if the value has changed (via _fullLayout or _fullData) and execute the callback if there appears to be a matching component state. From if(!sliderOpts._commandObserver) {
sliderOpts._commandObserver = Plots.createCommandObserver(gd, sliderOpts.steps, function(data) {
setActive(gd, d3.select(this), sliderOpts, data.index, false, true);
}.bind(this));
} |
(oh, and there's also a |
@@ -2124,6 +2126,7 @@ Plotly.update = function update(gd, traceUpdate, layoutUpdate, traces) { | |||
data: restyleSpecs.eventData, | |||
layout: relayoutSpecs.eventData | |||
}); | |||
gd.emit('plotly_plotmodified'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not just subscribe to the existing plotly_afterplot
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Didn't know about it! Just need to add that event in a couple more places, but that should be perfect.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, other reason, now that I think about it:
Should afterplot be emitted between frames? The exact sequence definitely matters, so the other logic was that a unique event would free us from tight coupling.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should afterplot be emitted between frames?
Hmm. Very good question. I'd say no. plotly_afterplot
should be the fired just before the promise is returned. But, I can be convinced otherwise.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think it should be a separate event for this purpose? Again, my fear was coupling the component update code with events that serve other purposes. But I can try to use plotly_afterplot
if you prefer. (though, again, it would need to be called during animation)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have no strong opinion here. I'll leave it up to you.
@@ -2302,6 +2305,8 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { | |||
var newFrame = trans._currentFrame = trans._frameQueue.shift(); | |||
|
|||
if(newFrame) { | |||
gd._fullLayout._currentFrame = newFrame.name; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nice. I'll use that for layout.breakpoints
'plotly_update', | ||
'plotly_animatingframe', | ||
'plotly_afterplot' | ||
]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@etpinard I refactored this to just hook into a list of existing events. This seems much simpler and more straightforward.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nicely done. Thank you
I think this is ready for review. (oops, just a baseline image missing. adding now.) I'm going to go back over the code, but here's a quick summary of need-to-know:
|
Also, here's a live example illustrating what we're actually talking about here: https://rreusser.github.io/animation-experiments/#binding |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good! Your test cases are looking pretty solid, I think you pull if off 🎉
I wrote down a few questions for you in this review.
My main concern: what happens to the command-observer when a users tries to relayout an updatemenu button or slider step?
// call button method | ||
var args = buttonOpts.args; | ||
Plotly[buttonOpts.method](gd, args[0], args[1], args[2]); | ||
Plots.executeAPICommand(gd, buttonOpts.method, buttonOpts.args); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
much cleaner 🎉
@@ -326,6 +319,22 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { | |||
Lib.setTranslate(gButton, menuOpts.lx, menuOpts.ly); | |||
} | |||
|
|||
function setActive(gd, menuOpts, buttonOpts, gHeader, gButton, buttonIndex, isSilentUpdate) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice abstraction
@@ -2324,6 +2317,15 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) { | |||
newFrame.frameOpts, | |||
newFrame.transitionOpts | |||
); | |||
|
|||
gd.emit('plotly_animatingframe', { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rreusser can you share some info about why the emit('plotly_animatingframe')
changed location?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are a lot of things taking place synchronously and asynchronously so that I wanted to move the event trigger as late as possible to ensure the changes have actually taken place in order to be picked up by bound components. I think maybe a better long-term solution is an event after Plots.transition
has supplied defaults (coming from that method itself to ensure correctness) in order to ensure that the changes have absolutely 100% certainly been applied.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the info 👍
|
||
// If any command has no method, refuse to bind: | ||
if(!method) { | ||
return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps logging something like two-way binding unavailable using Lib.log
(which are hidden by default) would be nice.
var container, value, obj; | ||
var changed = false; | ||
|
||
if(binding.type === 'data') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this is the exact same block as the check
function in the createCommandObserver
closure. Maybe DRY this up?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good observation. Had to change the command a little, but dried it up 👍
|
||
// Determine whether there's anything to do for this binding: | ||
var binding; | ||
if((binding = exports.hasSimpleAPICommandBindings(gd, commandList, lookupTable))) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I've seen if(a = b)
before, but I'm not sure if I totally understand what that does.
@rreusser would you mind helping me out? Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This kinda became a degenerate case where I might as well move it into the above variable declaration. It's nothing fancy. Just assigns a variable and checks it in one step.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
got it. thanks!
}); | ||
|
||
it('updates bindings on plot', function(done) { | ||
Plotly.restyle(gd, 'marker.size', 10).catch(fail).then(done); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would you mind adding an asserting checking if the updatemenus DOM nodes updated correctly. This might be easier to check using when restyling 'marker.color'
instead of 'marker.size'
.
describe('restyle', function() { | ||
describe('with invalid notation', function() { | ||
it('with a scalar value', function() { | ||
var result = Plots.computeAPICommandBindings(gd, 'restyle', [['x']]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for checking this here - as we don't currently don't dive into args
checking that its items are valid 🎉
y: [[3, 4, 5]], | ||
'marker.size': [10, 20, 25], | ||
'line.color': 'red', | ||
line: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, line: { width: [2, 8] }
does not override line.color: 'red'
here. Go figure. Thanks for checking!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh no! Good catch. I changed the order so that this is no longer accurate 😞 I need to fix this. Sigh.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might need to slightly modify my strategy here. On the bright side, that's a totally bizarre case that's actually undefined in the ecmascript standard itself, so I sorta think you get what you deserve if you rely upon it… To be clear, the multi-property cases (not necessarily single-property multi-trace though) will currently always just bail out early with the result that it's not currently handled by bindings.
I'll see if I can resolve this simply, otherwise I'm fine documenting it and moving on. Strictly speaking, the ecmascript standard really doesn't define how this works, though most browsers tend to iterate keys in the order in which they were added.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
otherwise I'm fine documenting it and moving on
I'm fine with that also.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a note about this to the specific place in the tests where this should be fixed. Long story short, it will definitely cause bindings to get ignored, so I think it's okay since for current use-cases, the result won't be used.
}); | ||
}); | ||
|
||
describe('component bindings', function() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How is this suite different than the new binding_test.js
here:
https://github.com/plotly/plotly.js/pull/1016/files#diff-7f09c177b80364e96a43622b17a9d8e7R24
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see, one test sliders and the other updatemenus.
@rreusser would you mind merging binding_test.js
into this describe
block?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved. Also made the tests effectively analogous between sliders and updatemenus to ensure the API is the same for both. (which for consistency makes active
instead of using a label/value for sliders a nice choice)
destroyGraphDiv(gd); | ||
}); | ||
|
||
it('attaches and updates bindings for sliders', function(done) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@etpinard See tests. It checks:
- event listeners are attached when sliders are added
- event listeners are removed when sliders are update and are no longer bindable
- changing slider options triggers recomputing the lookup table so it still works
@etpinard I think this is reviewable. I've addressed the concerns and have added a pretty detailed test for both updatemenus and sliders that tests values changing. Unfortunately (since this isn't immutablejs), detecting arg changes means we need to scan through the steps more often than I'd like (on each relayout) since we can't otherwise be certain whether the values have changed, but it's a pretty cheap check and tries to bail out early and cache when possible. SVG rendering should almost always be way more of a bottleneck than iterating through a few values, so I'm okay with it. |
sliderOpts._commandObserver = Plots.createCommandObserver(gd, sliderOpts.steps, function(data) { | ||
if(sliderOpts.active === data.index) return; | ||
if(sliderOpts._dragging) return; | ||
Plots.createCommandObserver(gd, sliderOpts, sliderOpts.steps, function(data) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm big a fan of this new pattern here where one routine creates + updates command observer. This should make the command observer logic more easily reused in the future when we integrate it in other components 👍
One small suggestion, now that createCommandObsever
can create and update a command observer, maybe we should rename it something like manageCommandObserver
similar to manageModebar
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. I was going to go with createOrUpdateCommandObserver
, but that seemed excessive. Changing now.
// objects is not strictly defined but is at least consistent across browsers. | ||
// The worst-case scenario right now isn't too bad though since it's an obscure | ||
// case that will definitely cause bailout anyway before any bindings would | ||
// happen. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks !
@@ -33,6 +33,8 @@ module.exports = function updateMenusDefaults(layoutIn, layoutOut) { | |||
// used to determine object constancy | |||
menuOut._index = i; | |||
|
|||
menuOut._input.active = menuOut.active; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. Why do we need this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without this, there's an intermediate state in which this value is updated when changes occur, but is undefined
before that.
I ran into this while testing: I checked this to see if the external value was updated when component interactions occurred. That worked fine, but before changes were made, the value was undefined
, which was perhaps desired but did not reflect the actual state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed this line.
@@ -48,7 +50,9 @@ function menuDefaults(menuIn, menuOut, layoutOut) { | |||
var visible = coerce('visible', buttons.length > 0); | |||
if(!visible) return; | |||
|
|||
coerce('active'); | |||
// Default to zero since active must be *something*: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If that's the case, we should add a dflt
in the attribute declaration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, good point. That works for me. I think that's best since undefined
isn't a state that really makes sense (because it's always something).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops. The default is set. Removing this. I think this may have been a change that I thought was necessary while debugging but ultimately did not need.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. Hold on. The updatemenu active
was first designed to correspond to the last-clicked button (with no notion of data
/ layout
state). So, on first-render active
is undefined meaning that no buttons have been clicked on yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah okay. Let me adjust on my end then. That makes sense. I guess it just bothered me that the first item is selected when the chart loads but the variable is not set.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reverted updatemenus change and fixed sliders initialization + defaults to match the behavior.
it('udpates bound components when the value changes', function(done) { | ||
expect(gd.layout.sliders[0].active).toBe(0); | ||
|
||
Plotly.restyle(gd, 'marker.color', 'blue').then(function() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a big fan of this test and the next. 🍻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, confused it with the deluxe tests down below. Yeah, these are just nice and simple 😄
}); | ||
|
||
it('does not set active on initial plot', function() { | ||
expect(gd.layout.updatemenus[0].active).toBeUndefined(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
very nice. Thanks for 🔒 this !
Hard fought 💃 Awesome work @rreusser |
This PR works toward a common interface for executing commands. This includes:
Plots.executeAPICommand(gd, method, args)
which executes a command (i.e. from components)Plots.computeAPICommandBindings(gd, method, args)
which interprets the intent of a command and returns a list of properties which affect the value of the component.Completion of this feature will complete the implementation of sliders (#986) with two-way binding and will be applicable to other components.