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

Aspect ratio key for 2d plots #272

Closed
jackparmer opened this issue Feb 22, 2016 · 22 comments · Fixed by #1522
Closed

Aspect ratio key for 2d plots #272

jackparmer opened this issue Feb 22, 2016 · 22 comments · Fixed by #1522
Labels
feature something new

Comments

@jackparmer
Copy link
Contributor

Similar to:
https://plot.ly/python/reference/#layout-scene-aspectratio
For 3d plots

@jackparmer jackparmer added the feature something new label Feb 22, 2016
@n-riesco
Copy link
Contributor

n-riesco commented Apr 7, 2016

@jackparmer I'm not entirely clear what the API would be.

For gl3d, the API is layout.scene.aspectratio: { x: number, y: number, z: number }.

What'd it be for gl2d (layout.????.aspectratio: { x: number, y: number })?
gl2d only has exports.attr = ['xaxis', 'yaxis']

cc/ @etpinard @mdtusz

@etpinard
Copy link
Contributor

etpinard commented Apr 7, 2016

I'm not entirely clear what the API would be

I'm not sure either.

@jackparmer are you referring to the aspect ratio of the either graph OR of each xy subplot?

If the former, then a layout.aspectratio attribute could be used to pre-populate layout.width and layout.height.

If the latter, then ... hmm there's no obvious way to add aspectratio in 2D graphs in our current schema.

I'd vote for the former. Since, our layout.?axis.domain are in normalized coordinates (i.e. in [0, 1]), 2D subplot will adjust proportionally under resize given a layout.aspectratio - which I think is the desired effect. cc @cpsievert @alexcjohnson

Moreover, maybe this issue could be merged with #106 ?

@alexcjohnson
Copy link
Collaborator

There are two separate issues here. One is making sure the overall plot keeps a nice shape when the window is resized - like if you have a responsive webpage, it would be cool if the plot could take its width from the container element and its height from this aspect ratio. That should work fine with layout.aspectratio.

The second is where the data on a plot has a 2D metric, like if you're showing physical positions in two dimensions, or in momentum space, really anything where x and y have identical units so relative scales and angles are meaningful. The first (layout.aspectratio) can be used to solve this problem, though not if you allow zooming, not if you allow auto-margin, and anyway you have to do a bunch of calculations to get it right even in the beginning. Really we need something per-subplot that enforces pixel equivalence in the two directions. This is tricky for a multitude of reasons:

  • API - as already mentioned, we don't have per-subplot settings for cartesian, we only have per-axis settings. That might be a blessing in disguise though, as you can't have more than one constraint per axis.
    • one option: define axis.constrainwith and axis.aspectratio. I would think that if you provide neither, then both are omitted, but if you provide constrainwith then aspectratio defaults to 1, and if you provide aspectratio then constrainwith defaults to axis.anchor, or the first counteraxis if anchor=='free'.
    • another option: define something like layout.xy_aspectratio which could perhaps be just a number for simple plots (or to give all subplots the same ratio), or an object like {xy: 1, x2y: 2} (my comments below about couplings should apply just as well if we choose this form, except that the obviously silly contradiction wouldn't be possible, but a circular reference could still be created out of 4 subplots).
  • couplings - what happens when you have two subplots sharing an x axis, with two y axes that both want to enforce an aspect ratio? We could certainly start out allowing only one constraint per axis, but eventually someone will want subplots sharing an axis and both to have aspect ratio constraints. But with a 2x2 or bigger layout, if all x and y axes had aspect ratio constraints you could actually define a self-contradictory system:
xaxis: {constrainwith: 'y', aspectratio: 1}
yaxis: {constrainwith: 'x2', aspectratio: 1}
xaxis2: {constrainwith: 'y2', aspectratio: 1}
yaxis2: {constrainwith: 'x', aspectratio: 2}

In fact you could say something obviously silly:

xaxis: {constrainwith: 'y', aspectratio: 1}
yaxis: {constrainwith: 'x', aspectratio: 2}

I think for the most general case we need to allow both x and y axes to define constraints, otherwise there would be no way to create a legitimate fully-constrained 2x2 layout, or even three subplots (central, top, and right, for example). We could just say it's an error to define a circular constraint reference, that should avoid this problem.

  • conflict resolution - say the user specifies both x and y ranges, as well as aspect ratio. What do we do? It seems like we want the aspect ratio to be enforced, and probably only show more than what was asked for, ie never reduce a range, only expand (symmetrically). Similarly for autoscale, just symmetrically expand one axis. But then what happens if someone resizes the plot multiple times, does the range keep growing (ie the x range grows when you make the plot wider, then the y range grows when you make it narrower...)? Or does it somehow remember what the last range was that the user really asked for, and size based on that? Maybe it would work to have layout just keep what the user asked for, and _fullLayout the recalculated (expanded) ranges?
  • zoom - I already had to do one form of constrained zoom for ternary Ternary phase diagrams #390 - and I suspect we could do something similar here for the main zoom... like the smallest rectangle that contains the start and end points of the drag, centered in the direction that needs to be bigger than you've dragged. But for ternary I totally ignored the single-axis and corner draggers. At least initially we could do the same here too, though I imagine we could come up with fairly compelling arguments as to what should happen with each of these draggers. Corner draggers could just project your drag onto the diagonal from that corner to the opposite one. Single-axis pan draggers (and 2D pan) are no problem as they don't alter aspect ratio. Single-axis endpoint draggers (And again for coupled subplots, where one axis change is triggered by the main or corner draggers of a different subplot), the obvious choice seems that the counteraxis should get a symmetric change keeping the same center point.

@cpsievert
Copy link

cpsievert commented Apr 8, 2016

I agree there are two different types of ratios, one which ignores the data range (in ggplot2, this is theme(aspect.ratio = ...)), and another which scales to the data range (in ggplot2, this is coord_fixed()). That being said, I can't think of any scenarios where I would want different aspect ratios, so maybe we could get away with just layout.aspectmode and layout.aspectratio?

By the way, the main idea behind plotly/plotly.R#509 is to essentially ensure ratio = diff(layout.yaxisid.domain) / diff(layout.xaxisid.domain) for each relevant combination of axes. The problem I ran into was that, in some cases, domains have to be recomputed on resize, which requires moving a bunch of logic to the browser (I've generally been trying to avoid as much as possible to provide a consistent experience in viewing local vs. remote plots). If need be, I suppose I could disable autosize, but it'd be nice to have a more general solution.

@alexcjohnson
Copy link
Collaborator

@cpsievert right, this has to be done in the js library itself for the aspect ratio to survive nearly all our interactivity. I suppose you're right that having different aspect ratios on different subplots is an unusual case, but having some subplots that want a fixed ratio and others that don't seems like a fairly common case, at which point we've nearly arrived at each one supporting its own aspect ratio anyway.

@cpsievert
Copy link

having some subplots that want a fixed ratio and others that don't seems like a fairly common case, at which point we've nearly arrived at each one supporting its own aspect ratio anyway.

True, I suppose it would be nice to have that flexibility if it's doable (just FYI, I won't need this for our ggplot2 converter). In that case, from a user perspective, it might be nice for subplot specific ratios to inherit from a global ratio.

@n-riesco
Copy link
Contributor

Thanks everyone for the discussions. I feel they help make the problem more clear in my mind.

The issue with the layout aspect ratio, as @etpinard said, could be addressed by letting layout.width and layout.height take percentage sizes (issue #106).

On the other hand, from @alexcjohnson 's comment, I now understand that setting the subplot aspect ratio through layout.xaxis is not only very complicated for the user but also it may lead to inconsistent circular constraints.

Currently, gl3d sets the aspect ratio using layout.scene.aspectratio: { x: number, y: number, z: number } and layout.scene.aspectmode. I find this way of specifying the aspect ratio easier than using a ratio (no need to remember whether the ratio is x/y or y/x, y/z, ...

Assuming the layout aspect ratio is specified using percentage units in layout.width and layout.height, layout.aspectratio could be used to specify a table of subplot aspect ratios (similarly to what Alex described), i.e. layout.aspectratio: {xy: { x: number, y: number }, x2y: { x: number, y: number }}.

Instead of defining another table for the aspect mode (layout.aspectmode), we could include this field in layout.aspectratio, like this:

layout.aspectratio: {
    xy: {
        mode: enumerated,
        x: number,
        y: number
    },
    x2y: {
        mode: enumerated,
        x: number,
        y: number
    }

What do you think? Would something like this work?

@cpsievert
Copy link

To be a little more clear, I would be estatic with layout.aspectmode/layout.aspectratio that fails (silently) in cases where setting the aspect ratio doesn't make sense. In fact, this is exactly what ggplot2 does:

library(ggplot2)
ggplot(mtcars) + 
  geom_point(aes(wt, mpg)) + 
  facet_wrap(~vs) + 
  coord_equal()

screen shot 2017-02-21 at 10 43 15 am

versus

ggplot(mtcars) + 
  geom_point(aes(wt, mpg)) + 
  facet_wrap(~vs, scales = "free_x") + 
  coord_equal()

screen shot 2017-02-21 at 10 43 29 am

So, in other words, when layout.aspectratio="data" and there are multiple x/y ranges, it fails to set the aspect ratio. Couldn't we do the same? Specifying aspect ratio at the axis-level seems complex, error-prone, and more flexible than most would need/want.

@rreusser
Copy link
Contributor

rreusser commented Feb 21, 2017

Some various use-cases of aspect ratios:

@cpsievert
Copy link

cpsievert commented Feb 21, 2017

To put it bluntly, important parts of the R community won't take us seriously until we have 2D aspect ratios, and our ggplotly() converter will never work "as expected" for a significant number of use cases.

In attempt to generalize the use cases already mentioned, aspect ratios are important whenever you have 2 axes that have some sort of meaningful connection in what they measure (not necessarily physical space). So, they are not only important for data visualization (e.g., maps), but also for statistical graphics, where we often visualize (via biplots, tours, etc.) 2D projections of a high-dimensional data (e.g., MDS, PCA, etc). In those projections, there is almost always a meaningful connection between the axes (e.g., each axis might represent the amount of variation explained).

@monfera
Copy link
Contributor

monfera commented Mar 16, 2017

I agree with, as e.g. @n-riesco and @alexcjohnson said on the top, that these are separate concerns:

  1. shape of the plot (and perhaps responsiveness to screen size change etc.)
  2. uniform axis scaling (or more generally, axis scaling of specific domain extent to screen length)

This is a currently working, manual way of achieving uniform axis scaling (put together on a client request). Of course the problem is that things have to be calculated to make it just right.

I think that to achieve the 2nd goal - uniform, or specified axis scaling - it should be possible to express relationship among axes, or generalizing it to our multi-axis support for scatter etc., between pairwise axis relations. However it could lead to an overconstrained system as scaling ratios between axis pairs could contradict each other, when the axes form a space (i.e. they're orthogonal axis vectors in the same space). So a clean solution would represent axis scaling in n dimensional space as an n-1 dimensional vector. Or maybe a simplification is useful, just adding a boolean flag to indicate that uniform axis scaling.

Btw. if there's a more accepted term than uniform axis scaling, we could use that.

In my mind, aspect ratio is a relationship between the width and height of a rectangle in screen space while uniform axis scaling, or relative axis scaling is a relationship among axes about their domain extent (length) to corresponding screen projection length ratio.

image

For example, this codepen example has a uniform (1:1) axis scale ratio but a 3:5 aspect ratio. Going through the aspect ratio is convoluted because it assumes knowledge of the projected axis domain extent.

@alexcjohnson
Copy link
Collaborator

alexcjohnson commented Mar 16, 2017

However it could lead to an overconstrained system as scaling ratios between axis pairs could contradict each other

Yes, this is a tricky one - on the other hand we would like it to be flexible and clear which axes you're linking with any given attribute. I think what I'd like to do is describe these linkages within the axis objects and then check for incompatible constraints at the end.

A closely related feature, that I'm not going to build right now but I'd like to think about so it can fit into this framework, is forcing two axes to be identical - not just the same scaling, but in fact exactly the same range. I'm thinking of plots like this one:
machine-learning-classifier-comparison
where all the x axes are really the same axis, as are all the y axes. Usually this would link x-x or y-y but I guess maybe x-y links should be allowed with this framework too?

For an API, I'm thinking:

axis.scalewith: id - must be the id of an axis of the opposite letter, ie xaxis2.scalewith='y3'
axis.scaleratio: number, ratio of the pixel size of a unit on this axis to the scalewith axis.
    defaults to 1, only applies together with scalewith
axis.match: id - if specified, the range of this axis must exactly match the other one.
    we could also have this axis inherit defaults for style attributes from the one it matches?

I'm also thinking while technically possible, it might be best to forbid a single axis having both scalewith and match. The effect should be supported, like if you want a 2x2 SPLOM with x axes matching, y axes matching, and a fixed y:x ratio of 10, you could do:

xaxis: {},
yaxis: {scalewith: 'x', scaleratio: 10},
xaxis2: {match: 'x'},
yaxis2: {match: 'y'}

Then all we'd need to do to avoid incompatible constraints is disallow loops - probably just refuse to set scalewith or match when we see a loop, log a warning and keep going without it.

Thoughts about this API?

@monfera
Copy link
Contributor

monfera commented Mar 16, 2017

Identical axes are great for SPLOM, small multiple plots, even parcoords. Speaking of which, a side note is that the convention there isn't currently a yaxisN: {} but it's more implicit in dimensions (I don't know if it's forever, and how it'll fall with the SPLOM). Other than this wrinkle the suggested API seems to do what you set it out to do. I suppose that matching axes would implicitly form a union of the data, and by default, the axis domain would be determined based on the union (unless the user can override). The benefit of the proposed API is that it blends in nicely with the current structures. A minor drawback is that as you say, it's easy to overconstrain.

Re scalewith, I'm not sure because it implies (or maybe doesn't?) that there are only two orthogonal dimensions. I'm thinking of datasets with N dimensions where a trellised plot shows them in pairwise 2 dimensional relationship, yet maybe you want all N axes to scale together. I'm not sure if the proposal can cover it or if we want it.

Very loosely related thing: on past work, sometimes I needed to model things such as projections and axes separately. For example, there would be a small multiples grid, but there'd also be a hero chart - such as a highlight of one of the ones in the grid - with essentially the same axes but projecting it over a larger screen area. Another example, minimap - a miniature version of the hero chart, which shows the entire data domains (and a rectangle highlight for the hero's current domain extent). Some but not all axis data are shared.

Maybe it's worth thinking about internally modeling scales / projections / axes as shareable 1st class entities, otherwise a lot of charts in a trellised or faceted arrangement will repeat the same expensive axis generation calculation. It might not be an issue now, but it would probably be for trellises with a lot of panels or points, or some dashboard with live updates on streaming data. As these things are probably heading our way, maybe it's good to consider an internal projection abstraction to let shared calculations happen only once.

@alexcjohnson
Copy link
Collaborator

Re scalewith, I'm not sure because it implies (or maybe doesn't?) that there are only two orthogonal dimensions.

I was imagining that it would be chainable:

xaxis: {},
yaxis: {scalewith: 'x'},

// multiple axes all scaled with the first one
yaxis2: {scalewith: 'x'},

// another one, that implicitly links the scale back to x
xaxis2: {scalewith: 'y'},

// this would be equivalent to scalewith 'x' but if you
// use scaleratio these will multiply together differently
// depending on where you hook yaxis3 into the chain
yaxis3: {scalewith: 'x2'}

Maybe it's worth thinking about internally modeling scales / projections / axes as shareable 1st class entities

Good point. I don't think we want to change the API based on this, but down the road this would be a good way to boost our capabilities for these large, complex plots.

@alexcjohnson
Copy link
Collaborator

I guess match is more grammatically similar to other attributes we have than matches - changed above.

@etpinard
Copy link
Contributor

I, of course, don't have as much expertise on the topic as @monfera or @cpsievert so I have a hard time picturing if @alexcjohnson's proposal scales well enough to cover all the interesting cases. It definitely takes care of the easy cases well and is pretty friction-less. I'd say adding another first-class object as @monfera proposed sounds like an overkill for this feature.


About @alexcjohnson 's

Then all we'd need to do to avoid incompatible constraints is disallow loops - probably just refuse to set scalewith or match when we see a loop, log a warning and keep going without it.

👍 about logging something. I would be nice to make Plotly.validate log something when infinite loops are detected too.

Maybe we should add a ?mode attribute in the mix too e.g. scalemode so that users can easily toggle between the default no-scaling view and the scalewith result? It that something that users would want to do?


Moreover, to me match doesn't sound verbose enough. Maybe matchaxis instead but I guess that's redundant. So, maybe

scalemode: 'independent' (the default of course) || 'ratio' || 'match',
value: /* numeric value for 'ratio',  string id for 'match' */,
// or
scaleratio: /* only coerced for 'ratio' */
scalematch: /* only coerced for 'match' */

would be best?

@rreusser
Copy link
Contributor

To state/clarify the obvious, is this only a concern of the cartesian plot type? Would 2d gl plots need/want this too and need to hoist it up out of the cartesian plot code?

@alexcjohnson
Copy link
Collaborator

To state/clarify the obvious, is this only a concern of the cartesian plot type? Would 2d gl plots need/want this too and need to hoist it up out of the cartesian plot code?

We should definitely make this work with gl2d. When we get to match or whatever that feature gets called, we should also make it work for maps - that would be a sweet experience, like linking a data view and a satellite view, or two different data views. Also I guess we could do that with 3d scenes and ternary subplots. Spin one 3D shape and the neighboring scene spins with it?

@alexcjohnson
Copy link
Collaborator

Chatted about this with @etpinard on slack - scalemode: 'match' doesn't sound quite right to me, since I want it to copy more than just the scale, also the styling if not overridden. I think I'll push ahead with scalewith and scaleratio, and since I'm not building it now we can ruminate on naming match.

@tachim
Copy link

tachim commented Mar 21, 2017

+1. Related to plotly/plotly.py#70 as well?

@jackparmer
Copy link
Contributor Author

@tachim Yup. This will solve that issue for the Python lib 👍

@immotus
Copy link

immotus commented Jun 10, 2020

So, is it actually possible to link multiple axes while maintaining their aspect ratios?

When I try the following as discussed above:

xaxis: {},
yaxis: {scalewith: 'x', scaleratio: 10},
xaxis2: {matches: 'x'},
yaxis2: {matches: 'y'}

it throws a warning:
WARN: Axis x is set with both a *scaleanchor* and *matches* constraint; ignoring the scale constraint.

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 a pull request may close this issue.

9 participants