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

Rangeslider allow zoom on oppaxis #2364

Conversation

TomDemulierChevret
Copy link
Contributor

@TomDemulierChevret TomDemulierChevret commented Feb 14, 2018

By default we can't currently zoom on the y-axis if we set a rangeslider on the x-axis.
If we write fixedrange: false in our y-axis config, it will override the lock put by the rangeslider and allow us to zoom both in x-and y-axis.

The issue is that the rangeslider is kinda broken if we zoom on y-axis since it will not keep the initial range on y-axis (like it does for the x-axis).

This PR :

  • maintain the correct y-axis range for data previsualization in the rangeslider even when the user zoom
  • add a visual clue to show the user where he has zoomed alongside the y-axis

Here is a set of screenshot to illustrate the issue and the PR :

Without zoom

no-zoom

With zoom on y-axis before PR

before-pr

With zoom on y-axis after PR

after-pr

If you have multiple y-axis and not all of them are zoomable, then you must add anchor: 'y' in your x-axis config (with y replaced with the name of one of the zoomable y-axis).

@etpinard
Copy link
Contributor

etpinard commented Feb 15, 2018

Thanks for the PR. Impressive work and clear explanation!

But I have to say I'm not 100% sure the current behavior is incorrect. It might just be a matter of preference. Perhaps we can add an attribute e.g. xaxis.rangeslider.anchor: 'fixed' || 'mirror' (we can probably come up with a better name though 🤔 ) to toggle between the current behavior and yours.

@TomDemulierChevret
Copy link
Contributor Author

Just added a new attributes perpendicularaxesinitialrange to switch between the new and old behaviour.
I put the default to new behaviour as the old one may not be the most intuitive.

@TomDemulierChevret
Copy link
Contributor Author

Not really sure why the last test is failling, any hint from your side ?

@@ -89,5 +89,17 @@ module.exports = {
'If visible, perpendicular axes will be set to `fixedrange`'
].join(' ')
},
perpendicularaxesinitialrange: {
Copy link
Contributor

@etpinard etpinard Feb 16, 2018

Choose a reason for hiding this comment

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

This is probably a little to verbose for our needs. Attribute names rarely have more than two words smashed together.

The best option that comes to my mind is xaxis.rangeslider.fixedanchor: false || true, but anchor is used already elsewhere to declare a different thing.

Looking for opinions from @alexcjohnson @cldougl and @chriddyp

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I find it also too verbose but I prefered to use a clear name until we have a better proposal (since anchor is already used).

Copy link
Collaborator

Choose a reason for hiding this comment

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

given that the range slider is always an x axis, we could use something like fixedyrange - and if we ever make a y axis range slider it can get a fixedxrange attribute.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It does makes sense, I was not quite sure rangeslider was for x-axis only.

@etpinard
Copy link
Contributor

Not really sure why the last test is failling, any hint from your side ?

We've been fighting some intermittent test failures lately. Your latest commit is now ✅

@alexcjohnson
Copy link
Collaborator

referencing also #2172 - I love the effect here @TomDemulierChevret, just want to make sure we have a path forward when we add potentially stacked multiple axes. I guess it just means we'd end up with multiple clear and shaded regions stacked up, which should be fine.

@TomDemulierChevret
Copy link
Contributor Author

TomDemulierChevret commented Feb 16, 2018

By stacked multiple axes, you're referencing multiple x-axis right ?
Because if you're talking only about multiple y-axis, only one instance of each [clear/shaded] region is added.

edit : just saw the issue you're referencing.
I don't really know how it will behaved since I never experienced this use case (and doesn't know how exactly you will handle it).
But your proposal does makes sense.

@@ -389,6 +389,10 @@ axes.saveRangeInitial = function(gd, overwrite) {
ax._rangeInitial = ax.range.slice();
hasOneAxisChanged = true;
}
// store the initial range for the rangeslider if we zoom on oppaxis
if((isNew && ax.fixedrange === false) || (overwrite && hasChanged)) {
ax._rangesliderInitialRange = ax.range.slice();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm, one issue with this: _rangesliderInitialRange isn't part of the figure JSON... so if you zoom in on the y axis, then you decide you like the plot and you save it, or you snapshot it even, the rangeslider y axis will not have the same range in the snapshot or when you reopen the plot.

That seems to me to mean that we need a real attribute for the range of each y axis on the rangeslider, and perhaps even an attribute for whether to autorange that y axis on the range slider - it would be cool if the y axis could be told to autorange in the range slider even if you start out zoomed in on the main plot, wouldn't it?

BTW I came to this realization as I was thinking "what tests should we have for this feature", noting that a big part of this is drawing code so it should have an image test, but then realizing as it stands we can't make an image test because we have no mechanism to force _rangesliderInitialRange to be different from the y axis range in an image test, therefore we can't make the opp axis masks appear there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, I didn't think about that.
I'll look into it.

@TomDemulierChevret
Copy link
Contributor Author

TomDemulierChevret commented Feb 19, 2018

Added new attributes to the figure JSON as suggested by @alexcjohnson.
It allows to make the rangeslider y-axis work in 3 ways :

  • rangesliderFixedrange: false => the range is the same than the one displayed on the main plot (old behaviour before the PR)
  • rangesliderFixedrange: true => the range is fixed and set via rangesliderRange: [min, max]
  • rangesliderFixedrange: 'auto' [default] => the range is fixed and set via the range of the y-axis (or autorange if none provided)

Not sure exactly what id did broke for the plot_api React test to fail.

@alexcjohnson
Copy link
Collaborator

Good call putting the new attributes into the y axis - originally I was thinking that they belonged in the rangeslider container, but that wouldn't support multiple y axes correctly. A couple of tweaks I'd suggest:

  • We should only coerce these attributes if letter === y.
  • Instead of rangesliderFixedrange: (true | false | 'auto') I might suggest rangeslidermode: ('fixed' | 'match' | 'auto') - I think that's a bit more descriptive, and we've been moving away from these mixed-type attributes anyway. Also despite this being javascript, for portability we use only lowercase in attribute names (so rangesliderRange -> rangesliderrange). @etpinard opinions about this?
  • There may be places (Axes.expand?) where we need to check for rangeslidermode: 'auto' alongside autorange: true, in order to make sure the autorange precursor calculations are done. To test this: make a plot where the y axis is NOT autoranged at the beginning, but it IS supposed to be autoranged in the rangeslider, and see if it gets handled correctly.

I'll try out plot_api_test on this branch tomorrow, see if I can figure out what the problem is there.
the image test failed because we had master in an improper state that just got fixed late this afternoon - if you merge in master again, that one should work again.

// style constants

maskColor: 'rgba(0,0,0,0.4)',
maskOppColor: 'rgba(0,0,0,0.2)',
Copy link
Contributor

Choose a reason for hiding this comment

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

Just noticed this. Why pick a different opacity value here?

Copy link
Collaborator

Choose a reason for hiding this comment

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

I like this effect - you see that the y axis is constrained, but it's not shaded as darkly as the x axis constraint, which aligns with the fact that you can only affect the x axis constraint with the range slider handles.

But a different thought occurred to me on looking at this: this is semitransparent black, so does that mean range sliders don't work on a black background? That's a totally separate issue, doesn't need to be addressed in this PR, but we should look into 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.

Indeed, that's what I wanted to show with the 2 different values of opacity.

And yeah, the black backgrond will be an issue.

@etpinard
Copy link
Contributor

Hmm. I think people will inevitably confuse yaxis?.rangesliderrange with xaxis?.rangeslider.range, especially when we implement y-axis (i.e. vertical) range sliders.

Perhaps, it would have been best to make range sliders a root layout component? 😡

Oh well, for now I can think of 3 options:

//  option 1
xaxis: {
  rangeslider: {
     // ...
   }
},
yaxis: {
   // finding a good concise name for this thing is very hard
   oppositerangeslider: {
      mode: '  ',
      range: [/* */],
   }
}

// option 2
xaxis: {
  rangeslider: {
     yrangemode: ' ',
     yrange: [/* */],
     y2rangemode: ' ',
     y2range: [/* */],
     // ...
  }
},

// option 3 - which would be more similar to other 
// attributes with axis counters
xaxis: {
   rangeslider: {
     rangemodey: ' ',
     rangey: [/* */],
     rangemodey2: ' ',
     rangey2: [/* */],
     // ....
}

After writing the 3 options down, I'd say I prefer option 3 by far. It keeps range sliders attributes contained in their base x-axis object. While axis counters (e.g. 'y', 'y2', 'y3', ...) aren't great, they are already a pretty big part of the plotly.js api. Thoughts?

@alexcjohnson
Copy link
Collaborator

After writing the 3 options down, I'd say I prefer option 3 by far.

I was hoping to avoid something like that, but I just realized there's a use case where we need to do it this way. Kinda convoluted, but imagine you have two side-by-side x axes sharing a y axis, each x axis has its own range slider, and you want different range settings for the y axes in the two rangesliders. Would be weird, as the y axes on the main plots are one axis so they must share a range... but I guess I can imagine wanting to restrict the range of the y axes in each range slider to just the data on the respective subplot.

This is not possible with yaxis.rangesliderrange - one way or another the attribute path must include both axis ids/names.

Option 3 is pretty good, but let me add one more to the mix:

xaxis: {
  rangeslider: {
    yaxis: {range: [/* */], rangemode: ' '},
    yaxis2: {range: [/* */], rangemode: ' '},
  }
}

@alexcjohnson
Copy link
Collaborator

(@TomDemulierChevret sorry for the back-and-forth here, attribute specs can be really tricky to get right, especially when we start thinking of all the possible edge cases involved)

@etpinard
Copy link
Contributor

@alexcjohnson oh right, true. A given y axis can be linked to multiple x range sliders in general. Nice catch!

Option 3 is pretty good, but let me add one more to the mix:

I like it, but I still prefer my option 3 by a slight margin (mainly due to possible confusion between layout.yaxis and layout.xaxis.rangeslider.yaxis), but since we might have to add more opposite axis attributes down the road (e.g. grid line style in the range slider panel), your solution should scale better.

We could also do something like:

xaxis: {
  rangeslider: {
      oppaxis: {
          rangemode: ' ',
          range: [/* */]
       },
       oppaxis2: {

       }
   }
}

that way, the same oppaxis? containers could be used in future yaxis.rangeslider containers?

But really, maybe we should thinking about pulling rangeslider out of the axis containers entirely, wouldn't something like this be easier to manage and scale:

layout: {
  xaxis: {},
  yaxis: {},
  yaxis2: {},

  rangesliders: [{
    xref: 'x',
    yref: ['y', 'y2'],

    xaxis: {
      range: [/* */], // equivalent to current `xaxis.rangeslider.range`
      /// style attributes ...
    },
    yaxis: { 
      rangemode: ' ',
      range: [/* */]
    }
  }]

@etpinard
Copy link
Contributor

etpinard commented Mar 7, 2018

@TomDemulierChevret would if be ok if we start pushing a few commits to this branch directly? We're very close to merging your PR. Thanks again for all your efforts!

@TomDemulierChevret
Copy link
Contributor Author

Yes of course, no problem.

yaxis3: {}
});

expect(out).toBeUndefined();
Copy link
Contributor

Choose a reason for hiding this comment

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

As promised in https://github.com/plotly/plotly.js/pull/2364/files#r171957082

Please note, the _isSubplotObj flag in the range slider opposite axis attributes is very important. Without it, this test would spit out:

image

that is Plotly.validate would think that rangeslider.yaxis2 and rangeslider.yaxis3 aren't part of the schema.

Copy link
Contributor

@etpinard etpinard Mar 7, 2018

Choose a reason for hiding this comment

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

Note that this appears to work in plotly.py aswell.

Copying the new plot-schema.json over there gives:

image

i.e. does not fail like:

image

cc @cldougl @Kully

@etpinard etpinard added this to the v1.35.0 milestone Mar 7, 2018
// the logic is complicated to figure this out later, particularly for y axes since
// the settings can be spread out in the x axes... so instead we'll collect them
// during supplyDefaults
containerOut._rangesliderAutorange = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

Very clear comment. Thanks!

rangemodeDflt = 'fixed';
}

var rangeMode = coerceRange(rangeContainerIn, rangeContainerOut, 'rangemode', rangemodeDflt);
if(rangeMode !== 'match') {
coerceRange(rangeContainerIn, rangeContainerOut, 'range', layoutOut[yName].range.slice());
coerceRange(rangeContainerIn, rangeContainerOut, 'range', yAxOut.range.slice());
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. Looks like rangeslider.yaxis?.range is still getting coerced when rangemode: 'auto' though this appears standard for subplot xaxis and yaxis range under autorange: true, so this sounds fine 👌 .

That said, it would be nice in the future to make:

Plotly.validate([], {
  xaxis: {
    autorange: true,
    range: [0, 1]
  }
})

spit out an unused log message for xaxis.range

},
"title": "Rangeslider Y2 rangemode fixed"
},
"yaxis": {"type": "log", "range": [2, 6], "title": "Y explicit range"},
Copy link
Contributor

Choose a reason for hiding this comment

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

nice mock!

values: ['auto', 'fixed', 'match'],
dflt: 'match',
role: 'style',
editType: 'calc',
Copy link
Contributor

Choose a reason for hiding this comment

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

@alexcjohnson we should be able to change these to editType: 'plot', correct?

Copy link
Collaborator

Choose a reason for hiding this comment

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

oh hmm... actually I don't think we can, it's like ax.autorange which is still calc, because this affects _rangesliderAutorange which determines whether we even bother calculating ax._min/_max during the calc step.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok. I tried switching them to 'plot', but that didn't break any test 😕

.then(done);
});

it('should update rangeslider x/y ranges when data changes even if main axes are not autoranged', 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.

Thanks for testing this case!

@etpinard
Copy link
Contributor

etpinard commented Mar 7, 2018

All right. Time to merge to thing 💃

I'll let @alexcjohnson do the honors after a failing test case for #2364 (comment) is added.

1.35.0 will be out in the few hours.


Thanks a million to @TomDemulierChevret for first bringing this issue and persevering through our numerous iterations at solving this problem 💪

I'd like to make this PR an early nominee for community PR of the year 🏆

this test fails if rangeslider.yaxis.rangemode or rangeslider.(auto)range doesn't have editType: 'calc'
@alexcjohnson alexcjohnson merged commit beaec8d into plotly:master Mar 7, 2018
@TomDemulierChevret TomDemulierChevret deleted the rangeslider-allow-zoom-on-oppaxis branch March 7, 2018 21:59
@etpinard etpinard mentioned this pull request Jul 3, 2018
2 tasks
@xapple
Copy link

xapple commented Jul 29, 2018

So how do we make the range slider keep a constant y axis with the latest version of plotly? What's the configuration?

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.

5 participants