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

Axis constraints #1522

Merged
merged 16 commits into from
Apr 3, 2017
Merged

Axis constraints #1522

merged 16 commits into from
Apr 3, 2017

Conversation

alexcjohnson
Copy link
Collaborator

@alexcjohnson alexcjohnson commented Mar 28, 2017

Closes #272 - defines and enforces constraints between axis scales, so you can have a fixed aspect ratio plot. Allows arbitrary chaining of many axes together, each with its own aspect ratio, along with unconstrained subplots.

TODO:

  • decide what to do if one of the axes with a constraint is also labeled fixedrange - should we forbid this combination? Or make the whole constraint group fixed? Or something else? I can't think of a use case for this so I'm inclined to forbid it, at least for now.
  • tests for the dynamic behavior
  • gl2d compatibility? This might be as simple as constraining the zoom box, given the limited zoom functionality available in gl2d right now, but there is some weird behavior right now that may also need to be addressed (open http://localhost:3000/devtools/test_dashboard/#gl2d_stacked_subplots and click "autoscale", for example)
  • enforce constraints in relayout calls that don't come from the GUI. This may actually allow me to simplify some things in dragbox.js too...
  • allow x-x and y-y constraints, not just x-y

@etpinard @cpsievert @rreusser I'd be grateful if one or two of you could play with the interactions - zoom box, dragging an axis end, dragging a corner of a plot - and tell me if they feel right. The mock in the PR - http://localhost:3000/devtools/test_dashboard/#axes_scalewith if you run the test dashboard - should be a good starting point, as it has two linkages across two coupled subplots.

@etpinard etpinard added this to the v1.26.0 milestone Mar 28, 2017
@@ -578,7 +580,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {

redrawObjs(fullLayout.annotations || [], Registry.getComponentMethod('annotations', 'drawOne'));
redrawObjs(fullLayout.shapes || [], Registry.getComponentMethod('shapes', 'drawOne'));
redrawObjs(fullLayout.images || [], Registry.getComponentMethod('images', 'draw'));
redrawObjs(fullLayout.images || [], Registry.getComponentMethod('images', 'draw'), true);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice touch 🐎


// This is specifically directed at scatter traces, applying an inverse
// scale to individual points to counteract the scale of the trace
// as a whole:
.select('.scatterlayer').selectAll('.points').selectAll('.point')
.call(Drawing.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor);
.call(Drawing.setPointGroupScale, xScaleFactor, yScaleFactor);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this a bug currently?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

no, I just flipped the definitions of x/yScaleFactor since I noticed they were being inverted in most of their uses.

// (or autoranged, if we have no initial range, to match the logic in
// doubleClickConfig === 'reset' below), we reset.
// If they are *all* at their initial ranges, then we autosize.
if(doubleClickConfig === 'reset+autosize') {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This reorg was to fix a subtle bug:

  • set explicit initial ranges for both x and y,
  • autoscale one of them (doubleclick the middle of the x axis, for example)
  • doubleclick the plot repeatedly: it will toggle between autoscaled x, initial y, and autoscaled y, initial x (with this change it toggles between fully autoscaled and fully initial-scaled).

Copy link
Contributor

Choose a reason for hiding this comment

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

Great. Let's make sure to 🔒 this down before merging.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@@ -135,6 +159,8 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
clearSelect(zoomlayer);
}
else if(isSelectOrLasso(dragModeNow)) {
dragOptions.xaxes = xa;
dragOptions.yaxes = ya;
Copy link
Collaborator Author

@alexcjohnson alexcjohnson Mar 28, 2017

Choose a reason for hiding this comment

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

Fixing what I broke in 98900a2#diff-1bd3dd52cddb8638876bbe17cef28a0dL105 when I consolidated to use recomputeAxisLists for the initial settings too (when dragOptions isn't yet available, and I didn't see anyone using the xaxes/yaxes properties). But it turns out it was subtly broken before anyway, as the version in recomputeAxisLists used the wrong names (dragoptions.xa instead of dragoptions.xaxes).

dragbox.js is still a bit of a mess, but... I think it's a little better now.

@cpsievert
Copy link

cpsievert commented Mar 28, 2017

@alexcjohnson the zooming on your example looks great, and even does a sensible thing in extreme cases 🍻

zoom

I am, however, thrown out a bit by the choice of expanding the relevant axis range to match scaleratio. I'm more familiar with approaches that shrink/restrict axisID.domain's domain accordingly.

@alexcjohnson
Copy link
Collaborator Author

@cpsievert

I am, however, thrown out a bit by the choice of expanding the relevant axis range to match scaleratio. I'm more familiar with approaches that shrink/restrict axisID.domain's domain accordingly.

Hmm, yeah, I see that in your ggplot example #272 (comment) - it's obviously not the way I think about it, but I'm not sure if I've seen precedent for this way or not - other than I guess the way we handle maps. The only way I can really see shrinking the actual axis size making sense within our structure is some sort of algorithm to automatically reduce the total plot width to minimize white space, but that seems a bit tricky to get right. I'd also say this approach strikes me as rooted in a static presentation of the plot - those long narrow subplots are not so useful once you start zooming and panning. But of course we want to be able to replicate all the plots folks feed in!

Do you think that for ggplotly's purposes you could pull out the overall plot dimensions from ggplot's results and just pass them along to plotly.js? In which case I guess it would basically be ggplot enforcing the initial constraints (with plotly.js just verifying and possibly correcting for small differences in layout) and plotly.js's role in this would primarily be in dynamic behavior.

And re: the part that disappeared from your comment above - do you think the with/width confusion merits changing the name scalewith to something else?

@cpsievert
Copy link

Do you think that for ggplotly's purposes you could pull out the overall plot dimensions from ggplot's results and just pass them along to plotly.js?

That's essentially what I'm doing in plotly/plotly.R#509. As you mention, it unfortunately requires estimating the axis dimensions, which is approximate, at best. It also significantly complicates the logic on my end, but I suppose it's possible.

And re: the part that disappeared from your comment above - do you think the with/width confusion merits changing the name scalewith to something else?

I'm not highly opposed to the name, but I see your point. How about scaleanchor?

FWIW, at least for the R package, I'm going to add more robust name checking that should catch instances like this.

@etpinard
Copy link
Contributor

[from @cpsievert] How about scaleanchor?

👍 much better than scalewith in my mind.

@etpinard
Copy link
Contributor

etpinard commented Mar 28, 2017

decide what to do if one of the axes with a constraint is also labeled fixedrange - should we forbid this combination? Or make the whole constraint group fixed? Or something else? I can't think of a use case for this so I'm inclined to forbid it, at least for now.

I'd vote for skipping coerce('scale*') when fixedrange is turned on. The scale* and fixedrange attributes sound to me like two conflicting things.

gl2d compatibility? This might be as simple as constraining the zoom box, given the limited zoom functionality available in gl2d right now, but there is some weird behavior right now that may also need to be addressed (open http://localhost:3000/devtools/test_dashboard/#gl2d_stacked_subplots and click "autoscale", for example)

That would require a few patches in gl-select-box which could use some ❤️ at the moment. Maybe it's worth investigating using cartesian/dragbox.js for gl2d zoom-box interactions instead? I don't see a performance benefit from using a gl solution for zoom-box. Though, hovering, panning and scrolling interactions would have to remain in the gl universe for speed 🐎

@alexcjohnson
Copy link
Collaborator Author

I'd vote for skipping coerce('scale*') when fixedrange is turned on. The scale* and fixedrange sound to me like two conflicting things.

👍

That would require a few patches in gl-select-box which could use some ❤️ at the moment. Maybe it's worth investigating using cartesian/dragbox.js for gl2d zoom-box interactions instead?

I'll take a look at it, that might not be too hard. I ran into a few more issues gl2d zoom on multi-subplot plots - the one I mentioned above seems to be dropped contexts, the relayout makes new ones for some reason; but there's another one that the grey overlay covers the whole plot (margins included) EXCEPT whatever other gl subplots happen to be in front of the plot you're zooming on. That probably wouldn't be hard to fix but if we just swap in dragbox.js it would be moot.

@cpsievert
Copy link

Do you think that for ggplotly's purposes you could pull out the overall plot dimensions from ggplot's results and just pass them along to plotly.js?

After thinking about this again, I wouldn't be able to support aspect ratios in ggplotly() on resize (in such a way that mimics ggplot2's spacing). That's because axis domains (computed in R) depend on the container height/width (which may change after we lose connection to an R session). So, if it isn't a lot of trouble, it would be a win for ggplotly() if we could implement the other way (shrinking the domain of the appropriate axis). That being said, I see your point that ggplot2's approach is "rooted in a static presentation of the plot" in that current implementation is more convenient for zooming.

I guess what I'm trying to say is I would still slightly prefer axis shrinking, but I can make do with the current implementation (and understand why you prefer it). We'll just have to educate ggplotly() users why the spacing is different. I've tried to stick to the philosophy of "do our damnest to replicate the static result by default", but I suppose we can deviate from that somewhat ;)

@alexcjohnson
Copy link
Collaborator Author

After a discussion with @cpsievert I think we'll leave the general structure as it is and let ggplotly handle explicitly setting the subplot shapes to match what ggplot generates.

But in the discussion it came up that there are cases you want to constrain several x (or y) axis scales without a corresponding x<->y constraint. See eg plotly/plotly.R#908 - so I'll see if I can add that in here too, letting scaleanchor reference ANY other axis, regardless of its orientation. I think there's a good chance that it'll "just work" ™️ but we'll see.

@@ -771,7 +771,7 @@ describe('Test click interactions:', function() {
var translate = Drawing.getTranslate(mockEl),
scale = Drawing.getScale(mockEl);

expect([translate.x, translate.y]).toBeCloseToArray([61.070, 97.712]);
expect([translate.x, translate.y]).toBeCloseToArray([-25.941, 43.911]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This test was actually incorrect, as wheel events weren't passing along the mouse coordinates before 86e0d5a#diff-d48932d4d7f81cc4201054d397d53f1aR17 and these matter to wheel zoom.
(the change from -1000 to -20 above is irrelevant, this event handler clips at +/-20 per event fired)

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks 👍

ai.indexOf('calendar') !== -1 ||
ai.match(/^(bar|box|font)/)) {
flags.docalc = true;
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

had to move this block above the following _has('gl2d') block, so scaleanchor/scaleratio get the necessary recalc. Seems like as a general rule, we might want to reorder these from biggest change to smallest, ie all the recalcs, then all the replots, then the ticks & styles... if we don't find an altogether better way to manage these flags (via the schema?).

Copy link
Contributor

Choose a reason for hiding this comment

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

I really like the idea of adding a recalc true flag to the attributes that require a recalc on restyle / relayout. But that can wait. We should do this in one PR crossing off #648.


// gl-select-box clips to the plot area bounds,
// which breaks the axis constraint, so don't allow
// this box to go out of bounds
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

in contrast, svg lets you make these constrained boxes extend outside the plot area. Didn't seem worth diving into gl-select-box to make this marginal feature match up precisely.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agreed. No need to do this for now 👍

// otherwise clamp small changes to the origin so we get 1D zoom
else {
if(smallDx) result.boxEnd[0] = result.boxStart[0];
if(smallDy) result.boxEnd[1] = result.boxStart[1];
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@etpinard while I was at it I added in a few of the bits of behavior used in svg, such as 1D zoom, minimum zoom size, and clamping panning to pure x (y) if dy (dx) is small enough. Do you want to take it for a spin and see what you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Loving it. Thanks!

xaxis: this.xaxis,
yaxis: this.yaxis
};
enforceAxisConstraints({_fullLayout: mockLayout});
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

kind of hacky, but enforceAxisConstraints has to happen after the Axes.doAutoRange just above, and these aren't the gd._fullLayout axes...

A bunch of things here would break if/when we support overlaid axes...

}

// Remove hover effects if we're not over a point OR
// if we're zooming or panning (in which case result is not set)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

also to match svg

Copy link
Contributor

Choose a reason for hiding this comment

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

Very nice. Thanks 👍

@@ -246,12 +246,20 @@ describe('Test hover and click interactions', function() {
pointNumber: 0
});

// after the restyle, autorange changes the y range
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

For some reason I don't understand, when this test ran before my changes, the y-axis didn't get autoranged after the restyle, even though when I make the same restyle call on master in the test dashboard it does autorange. Anyway, now it does get autoranged during the test, as it should, which is why I needed to alter the run the second time around.

// after the restyle, autorange changes the x AND y ranges
// I don't get why the x range changes, nor why the y changes in
// a different way than in the previous test, but they do look
// correct on the screen during the test.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think it makes sense that the axis ranges are different here than in the test above, as this is scattergl-fancy... but then why are they the same as the test above during the first run? This test suite baffles me...

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah. Maybe I tried to be too fancy in this test suite.

@alexcjohnson
Copy link
Collaborator Author

@etpinard ready for another review. Single gl2d plots work well with scale constraints now (please try it out and let me know what you think). I did not investigate multiple gl2d subplots (coupled or otherwise) as I was thrown off for a while by #1200 (comment) but now that I understand that I could look into this, if we think it's important.

@@ -14,7 +14,7 @@ module.exports = function(type, x, y, opts) {
ev;

if(type === 'scroll') {
ev = new window.WheelEvent('wheel', opts);
ev = new window.WheelEvent('wheel', Object.assign({}, fullOpts, opts));
Copy link
Contributor

Choose a reason for hiding this comment

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

can you use Lib.extendFlat here instead - in case we run these tests on old browsers some day?

Copy link
Contributor

Choose a reason for hiding this comment

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

done in 745b953


I should really be reviewing all commits before making comments.


afterEach(destroyGraphDiv);

it('updates ranges when adding, removing, or changing a constraint', function(done) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice test 🎉

@etpinard
Copy link
Contributor

etpinard commented Apr 3, 2017

💃 if you don't feel like including a patch for #1200 in this PR.

This gets my vote for PR of the year 🏆

@alexcjohnson
Copy link
Collaborator Author

Thanks @etpinard 😊 I'll put a note about this into #1200 but I think I should move on for now.

@martintburgess
Copy link

martintburgess commented Jan 21, 2019

@alexcjohnson, I am trying to build a plot that is:

  • responsive to changes in window size (using config={'responsive': true})
  • NOT zoomable (both axes have fixedrange: true
  • has a fixed aspect ratio/uniform axis units.
    Am I correct in thinking this is not possible because scaleanchor does not work with fixedrange with this PR? Thanks for your help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature something new
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Aspect ratio key for 2d plots
4 participants