2

While working on improving my answer to this question, I have stumbled into a dead end.

What I want to achieve, is create a "fake" 3D waterfall plot in matplotlib, where individual line plots (or potentially any other plot type) are offset in figure pixel coordinates and plotted behind each other. This part works fine already, and using my code example (see below) you should be able to plot ten equivalent lines which are offset by fig.dpi/10. in x- and y-direction, and plotted behind each other via zorder.

Note that I also added fill_between()'s to make the "depth-cue" zorder more visible.

enter image description here

Where I'm stuck is that I'd like to add a "third axis", i.e. a line (later on perhaps formatted with some ticks) which aligns correctly with the base (i.e. [0,0] in data units) of each line.

This problem is perhaps further complicated by the fact that this isn't a one-off thing (i.e. the solutions should not only work in static pixel coordinates), but has to behave correctly on rescale, especially when working interactively. As you can see, setting e.g. the xlim's allows one to rescale the lines "as expected" (best if you try it interactively), yet the red line (future axis) that I tried to insert is not transposed in the same way as the bases of each line plot.

What I'm not looking for are solutions which rely on mpl_toolkits.mplot3d's Axes3D, as this would lead to many other issues regarding to zorder and zoom, which are exactly what I'm trying to avoid by coming up with my own "fake 3D plot".

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.transforms import Affine2D,IdentityTransform

def offset(myFig,myAx,n=1,xOff=60,yOff=60):
    """
        this function will apply a shift of  n*dx, n*dy
        where e.g. n=2, xOff=10 would yield a 20px offset in x-direction
    """
    ## scale by fig.dpi to have offset in pixels!
    dx, dy = xOff/myFig.dpi , yOff/myFig.dpi 
    t_data = myAx.transData 
    t_off = mpl.transforms.ScaledTranslation( n*dx, n*dy, myFig.dpi_scale_trans)
    return t_data + t_off

fig,axes=plt.subplots(nrows=1, ncols=3,figsize=(10,5))

ys=np.arange(0,5,0.5)
print(len(ys))

## just to have the lines colored in some uniform way
cmap = mpl.cm.get_cmap('viridis')
norm=mpl.colors.Normalize(vmin=ys.min(),vmax=ys.max())

## this defines the offset in pixels
xOff=10 
yOff=10

for ax in axes:
    ## plot the lines
    for yi,yv in enumerate(ys):
        zo=(len(ys)-yi)
        ax.plot([0,0.5,1],[0,1,0],color=cmap(norm(yv)),
                zorder=zo, ## to order them "behind" each other
        ## here we apply the offset to each plot:
                transform=offset(fig,ax,n=yi,xOff=xOff,yOff=yOff)
        )

        ### optional: add a fill_between to make layering more obvious
        ax.fill_between([0,0.5,1],[0,1,0],0,
                facecolor=cmap(norm(yv)),edgecolor="None",alpha=0.1,
                zorder=zo-1, ## to order them "behind" each other
        ## here we apply the offset to each plot:
                transform=offset(fig,ax,n=yi,xOff=xOff,yOff=yOff)
        )

    ##################################
    ####### this is the important bit:
    ax.plot([0,2],[0,2],color='r',zorder=100,clip_on=False,
        transform=ax.transData+mpl.transforms.ScaledTranslation(0.,0., fig.dpi_scale_trans)
    )

## make sure to set them "manually", as autoscaling will fail due to transformations
for ax in axes:
    ax.set_ylim(0,2)

axes[0].set_xlim(0,1)
axes[1].set_xlim(0,2)
axes[2].set_xlim(0,3)

### Note: the default fig.dpi is 100, hence an offset of of xOff=10px will become 30px when saving at 300dpi!
# plt.savefig("./test.png",dpi=300)

plt.show()

Update:

I've now included an animation below, which shows how the stacked lines behave on zooming/panning, and how their "baseline" (blue circles) moves with the plot, instead of the static OriginLineTrans solution (green line) or my transformed line (red, dashed).

The attachment points observe different transformations and can be inserted by:

ax.scatter([0],[0],edgecolors="b",zorder=200,facecolors="None",s=10**2,)
ax.scatter([0],[0],edgecolors="b",zorder=200,facecolors="None",s=10**2,transform=offset(fig,ax,n=len(ys)-1,xOff=xOff,yOff=yOff),label="attachment points")

Plot behaviour on zoom and rescale

Asmus
  • 5,117
  • 1
  • 16
  • 21
  • It would still be easier to fix mplot3d than to reinvent the wheel, wouldn't it? – ImportanceOfBeingErnest Apr 29 '19 at 19:28
  • @ImportanceOfBeingErnest I'm not so sure about this, unfortunately. I think that my solution (so far) is already giving an *"ok"* waterfall (perhaps best to try it interactively) which only requires specifying a transform, and is *way more comprehensible*, IMHO, then `PolyCollection`, if one thinks of "stacked line plots". Also note how all I'm missing for "wheel reinvention" right now is - *I think* - understanding another (perhaps simple?) transform to add a scale vector. Otherwise I could already simply add a colorbar and have a "print ready", comprehensible figure. – Asmus Apr 29 '19 at 20:37
  • @ImportanceOfBeingErnest Also try, for example [this waterfall plot](https://stackoverflow.com/a/13244026/565489), which looks quite nice in a static case; but once you try to zoom in, or rotate the plot into a "slightly looking from top" position, the plot content runs out of the axes. I'm aware that this is due to the unawareness of the 2D backends to 3D bbox clipping, but that's precisely why I think that *the specific case* of a waterfall plot might be better fixed in a 2D bbox, don't you agree? – Asmus Apr 29 '19 at 20:53
  • No, I don't agree, but that may simply be because I do not understand the initial issue either. To me it looks like one can simply plot a poly collection in 3D. – ImportanceOfBeingErnest Apr 29 '19 at 21:33
  • @ImportanceOfBeingErnest Ok, so I probably shouldn't have mentioned `mplot3d` at all, as it actually does *not really play a role* in the core of my question :-) Do you perhaps have an idea/starting point how to solve my question, which is about applying a 2D transform on a line such that it aligns with my other transform, *disregarding the underlying concept of "faking" 3D*? Your input is greatly appreciated :-) – Asmus Apr 29 '19 at 21:43
  • I doubt one will be able to get a single transform that does this, because inverted transforms are frozen in mpl. Options I can think of: (1) Use mpl 3.1 and a ConnectionPatch (PR [here](https://github.com/matplotlib/matplotlib/pull/11780)). (2) Recreate a frozen transform via a callback, (3) Create a custom artist (possibly subclassing `Line2D`) which calculates the coordinates at draw time. – ImportanceOfBeingErnest Apr 30 '19 at 09:28
  • Am I correct in understanding that this is then currently *not possible* natively [apart from your suggested (1)-(3), of course], since I can not have one end of a `Line2d` attached to a untransformed system [at (0,0)] and the other to one that is offset by `n*(dx,dy)` [but technically still at (0,0)]? – Asmus Apr 30 '19 at 09:41

1 Answers1

1

The question boils down to the following:

How to produce a line that

  • starts from the origin (0,0) in axes coordinates and
  • evolves at an angle angle in physical coordinates (pixel space)

by using a matpotlib transform?

The problem is that the origin in axes coordinates may vary depending on the subplot position. So the only option I see is to create some custom transform that

  • transforms to pixel space
  • translates to the origin in pixel space
  • skews the coordinate system (say, in x direction) by the given angle
  • translates back to the origin of the axes

That could look like this

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.transforms as mtrans


class OriginLineTrans(mtrans.Transform):
    input_dims = 2
    output_dims = 2
    def __init__(self, origin, angle, axes):
        self.axes = axes
        self.origin = origin
        self.angle = angle # in radiants
        super().__init__()

    def get_affine(self):
        origin = ax.transAxes.transform_point(self.origin)
        trans = ax.transAxes + \
                mtrans.Affine2D().translate(*(-origin)) \
                .skew(self.angle, 0).translate(*origin)
        return trans.get_affine()



fig, ax = plt.subplots()
ax.plot([0,0], [0,1], transform=OriginLineTrans((0,0), np.arctan(1), ax))

plt.show()

Note that in the case of the original question, the angle would be np.arctan(dx/dy).

ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
  • Thanks for your effort, but unfortunately, this is not what I'm after (although it teaches me a lot already). I've updated my question to include an animation which shows how the stacked lines behave on zooming/panning, and you can see that your `OriginLineTrans` does not attach to the baseline points (blue circles) as I had hoped. Do you think that the `ConnectionPatch` you've mentioned in the comments above could be used to "connect" the two - differently transformed - `[0,0]`s (see added scatter plot in the question)? – Asmus Apr 30 '19 at 12:36
  • 1
    You want the "axis" to attach to the points in the plot? That's quite unusual (the normal axis spines do not follow any of the data either!). Anyways, that would be a matter of replacing `transAxes` by `transData`, right? – ImportanceOfBeingErnest Apr 30 '19 at 12:43
  • Well in principle, my approach yields consistent dimensions for each plotted line (i.e. you can still read out their height/width using the existing axes), but only the foremost (in this case) line is attached to the untransformed coordinates. I just need a line that connects the two blue circles and then I could format it to give a "poor man's spine" to aid in visualisation. If I exchange `transAxes` by `transData` in your solution it's **nearly perfect**, only that the length of the vector scales upon zooming in y-direction. – Asmus Apr 30 '19 at 12:53
  • 1
    Yeah, let's continue the guessing game... what about `origin = ax.transData.transform_point(self.origin); trans = ax.figure.transFigure + \ mtrans.Affine2D().translate(origin[0],0) \ .translate(*(-origin)) \ .skew(self.angle, 0).translate(*origin)` – ImportanceOfBeingErnest Apr 30 '19 at 13:12
  • Wow, you're good, this is basically 99.9% of what I want! :-) Last bit: do you know how to (a) scale this vector so that it only goes from one point to the other? Simply using `ax.plot([0,0], [0,0.27], transform=…` reintroduces the wrong scaling again (and it feels more and more like I have no idea what I'm doing).I've used a `ScaledTranslation` (see `def offset()`) before to make each offset by 10px (in this case), would it be possible to use it here, too? (b) *completely optional, I rarely use this:* I'm getting divide by zero errors if I set the axes to log scale, could this be eliminated? – Asmus Apr 30 '19 at 14:07
  • (b) (0,0) isn't defined on a log scale. (a) You started off by wanting to use a transform (as opposed to other options I mentionned). So one would stay in the unit frame (instead of some 0.27??). I feel like there is need for a complete tutorial on that matter... which I'm currently not capable of providing. Possibly if someone was paying for it. But then again, one would rather put the time and money into fixing the problem of [inverted transforms being frozen](https://github.com/matplotlib/matplotlib/issues/10741). – ImportanceOfBeingErnest Apr 30 '19 at 14:54
  • Btw, you have a dpi depence in your original code, so for the general case of a finite line that would need to be fixed as well (or one would need to live with the fact that dpi cannot change, and this to break if e.g. shown on a mac retina monitor). – ImportanceOfBeingErnest Apr 30 '19 at 14:59
  • I did not intend to beleaguer you, nor waste your time. If I'd use `mpl.transforms.ScaledTranslation( n*xOff, n*yOff, mtrans.IdentityTransform())` within `offset()`, instead of my dpi-dependent code, and remove the `/myFig.dpi` corrections, the results don't change. I presumed that scaling the vector via `ax.plot([0,0], [0,.5],…` would keep it affixed at `ax.transData.transform_point(self.origin)`, but it rather appears to be attached to (0,0) in transAxes coordinates. Since I hadn't specified the length of the vector originally, I'm accepting your answer even though it's only 99% "close". – Asmus Apr 30 '19 at 16:01