Skip to content

WIP ENH secondary axes: #11589

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

Closed
wants to merge 22 commits into from
Closed
87 changes: 87 additions & 0 deletions examples/subplots_axes_and_figures/secondary_axis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
==============
Secondary Axis
==============

Sometimes we want as secondary axis on a plot, for instance to convert
radians to degrees on the same plot. We can do this by making a child
axes with only one axis visible via `.Axes.axes.secondary_xaxis` and
`.Axes.axes.secondary_yaxis`.

"""

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.transforms import Transform

fig, ax = plt.subplots(constrained_layout=True)
x = np.arange(0, 360, 1)
y = np.sin(2 * x * np.pi / 180)
ax.plot(x, y)
ax.set_xlabel('angle [degrees]')
ax.set_ylabel('signal')
ax.set_title('Sine wave')

secax = ax.secondary_xaxis('top', conversion=[np.pi / 180])
secax.set_xlabel('angle [rad]')
plt.show()

###########################################################################
# The conversion can be a linear slope and an offset as a 2-tuple. It can
# also be more complicated. The strings "inverted", "power", and "linear"
# are accepted as valid arguments for the ``conversion`` kwarg, and scaling
# is set by the ``otherargs`` kwarg.
#
# .. note ::
#
# In this case, the xscale of the parent is logarithmic, so the child is
# made logarithmic as well.

fig, ax = plt.subplots(constrained_layout=True)
x = np.arange(0.02, 1, 0.02)
np.random.seed(19680801)
y = np.random.randn(len(x)) ** 2
ax.loglog(x, y)
ax.set_xlabel('f [Hz]')
ax.set_ylabel('PSD')
ax.set_title('Random spetrum')

secax = ax.secondary_xaxis('top', conversion='inverted', otherargs=1)
secax.set_xlabel('period [s]')
secax.set_xscale('log')
plt.show()

###########################################################################
# Considerably more complicated, the user can define their own transform
# to pass to ``conversion``:

fig, ax = plt.subplots(constrained_layout=True)
ax.plot(np.arange(2, 11), np.arange(2, 11))


class LocalInverted(Transform):
"""
Return a/x
"""

input_dims = 1
output_dims = 1
is_separable = True
has_inverse = True

def __init__(self, fac):
Transform.__init__(self)
self._fac = fac

def transform_non_affine(self, values):
with np.errstate(divide="ignore", invalid="ignore"):
q = self._fac / values
return q

def inverted(self):
""" we are just our own inverse """
return LocalInverted(1 / self._fac)

secax = ax.secondary_xaxis('top', conversion="inverted", otherargs=1)

plt.show()
11 changes: 9 additions & 2 deletions lib/matplotlib/_constrained_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad,
sup = fig._suptitle
bbox = invTransFig(sup.get_window_extent(renderer=renderer))
height = bbox.y1 - bbox.y0
sup._layoutbox.edit_height(height+h_pad)
if np.isfinite(height):
sup._layoutbox.edit_height(height+h_pad)

# OK, the above lines up ax._poslayoutbox with ax._layoutbox
# now we need to
Expand Down Expand Up @@ -267,10 +268,14 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad):
"""
fig = ax.figure
invTransFig = fig.transFigure.inverted().transform_bbox

pos = ax.get_position(original=True)
tightbbox = ax.get_tightbbox(renderer=renderer)
bbox = invTransFig(tightbbox)
# this can go wrong:
if not np.isfinite(bbox.y0 + bbox.x0 + bbox.y1 + bbox.x1):
# just abort, this is likely a bad set of co-ordinates that
# is transitory...
return
# use stored h_pad if it exists
h_padt = ax._poslayoutbox.h_pad
if h_padt is None:
Expand All @@ -288,6 +293,8 @@ def _make_layout_margins(ax, renderer, h_pad, w_pad):
_log.debug('left %f', (-bbox.x0 + pos.x0 + w_pad))
_log.debug('right %f', (bbox.x1 - pos.x1 + w_pad))
_log.debug('bottom %f', (-bbox.y0 + pos.y0 + h_padt))
_log.debug('bbox.y0 %f', bbox.y0)
_log.debug('pos.y0 %f', pos.y0)
# Sometimes its possible for the solver to collapse
# rather than expand axes, so they all have zero height
# or width. This stops that... It *should* have been
Expand Down
138 changes: 138 additions & 0 deletions lib/matplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
safe_first_element)
from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer
from matplotlib.axes._base import _AxesBase, _process_plot_format
from matplotlib.axes._secondary_axes import Secondary_Axis

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -639,6 +640,143 @@ def indicate_inset_zoom(self, inset_ax, **kwargs):

return rectpatch, connects

def secondary_xaxis(self, location, *, conversion=None,
otherargs=None, **kwargs):
"""
Add a second x-axis to this axes.

For example if we want to have a second scale for the data plotted on
the xaxis.

Warnings
--------

This method is experimental as of 3.1, and the API may change.

Parameters
----------
location : string or scalar
The position to put the secondary axis. Strings can be 'top' or
'bottom', scalar can be a float indicating the relative position
on the axes to put the new axes (0 being the bottom, and 1.0 being
the top.)

conversion : scalar, two-tuple of scalars, string, or Transform
If a scalar or a two-tuple of scalar, the secondary axis is converted
via a linear conversion with slope given by the first element
and offset given by the second. i.e. ``conversion = [2, 1]``
for a paretn axis between 0 and 1 gives a secondary axis between
1 and 3.

If a string, if can be one of "linear", "power", and "inverted".
If "linear", the value of ``otherargs`` should be a float or
two-tuple as above. If "inverted" the values in the secondary axis
are inverted and multiplied by the value supplied by ``oterargs``.
If "power", then the original values are transformed by
``newx = otherargs[1] * oldx ** otherargs[0]``.

Finally, the user can supply a subclass of `.transforms.Transform`
to arbitrarily transform between the parent axes and the
secondary axes.
See :doc:`/gallery/subplots_axes_and_figures/secondary_axis.py`
for an example of making such a transform.


Other Parameters
----------------
**kwargs : `~matplotlib.axes.Axes` properties.
Other miscellaneous axes parameters.

Returns
-------
ax : axes._secondary_axes.Secondary_Axis

Examples
--------

Add a secondary axes that converts degrees to radians.

.. plot::

fig, ax = plt.suplots()
ax.loglog(range(1, 360, 5), range(1, 360, 5))
secax = ax.secondary_xaxis('top', conversion='inverted',
otherargs=1.)
secax.set_xscale('log')

"""
if (location in ['top', 'bottom'] or isinstance(location, Number)):
secondary_ax = Secondary_Axis(self, 'x', location,
conversion, otherargs=otherargs,
**kwargs)
self.add_child_axes(secondary_ax)
return secondary_ax
else:
raise ValueError('secondary_xaxis location must be either '
'"top" or "bottom"')

def secondary_yaxis(self, location, *, conversion=None,
otherargs=None, **kwargs):
"""
Add a second y-axis to this axes.

For example if we want to have a second scale for the data plotted on
the xaxis.

Warnings
--------

This method is experimental as of 3.1, and the API may change.

Parameters
----------
location : string or scalar
The position to put the secondary axis. Strings can be 'left' or
'right', scalar can be a float indicating the relative position
on the axes to put the new axes (0 being the left, and 1.0 being
the right.)

conversion : scalar, two-tuple of scalars, string, or Transform
If a scalar or a two-tuple of scalar, the secondary axis is converted
via a linear conversion with slope given by the first element
and offset given by the second. i.e. ``conversion = [2, 1]``
for a paretn axis between 0 and 1 gives a secondary axis between
1 and 3.

If a string, if can be one of "linear", "power", and "inverted".
If "linear", the value of ``otherargs`` should be a float or
two-tuple as above. If "inverted" the values in the secondary axis
are inverted and multiplied by the value supplied by ``oterargs``.
If "power", then the original values are transformed by
``newy = otherargs[1] * oldy ** otherargs[0]``.

Finally, the user can supply a subclass of `.transforms.Transform`
to arbitrarily transform between the parent axes and the
secondary axes.
See :doc:`/gallery/subplots_axes_and_figures/secondary_axis.py`
for an example of making such a transform.


Other Parameters
----------------
**kwargs : `~matplotlib.axes.Axes` properties.
Other miscellaneous axes parameters.

Returns
-------
ax : axes._secondary_axes.Secondary_Axis

"""
if location in ['left', 'right'] or isinstance(location, Number):
secondary_ax = Secondary_Axis(self, 'y', location,
conversion, otherargs=otherargs,
**kwargs)
self.add_child_axes(secondary_ax)
return secondary_ax
else:
raise ValueError('secondary_yaxis location must be either '
'"left" or "right"')

def text(self, x, y, s, fontdict=None, withdash=False, **kwargs):
"""
Add text to the axes.
Expand Down
35 changes: 24 additions & 11 deletions lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2510,7 +2510,16 @@ def _update_title_position(self, renderer):
y = 1.0
# need to check all our twins too...
axs = self._twinned_axes.get_siblings(self)

# and all the children
for ax in self.child_axes:
if ax is not None:
locator = ax.get_axes_locator()
if locator:
pos = locator(self, renderer)
ax.apply_aspect(pos)
else:
ax.apply_aspect()
axs = axs + [ax]
for ax in axs:
try:
if (ax.xaxis.get_label_position() == 'top'
Expand Down Expand Up @@ -2542,12 +2551,15 @@ def draw(self, renderer=None, inframe=False):

# prevent triggering call backs during the draw process
self._stale = True
locator = self.get_axes_locator()
if locator:
pos = locator(self, renderer)
self.apply_aspect(pos)
else:
self.apply_aspect()

# loop over self and child axes...
for ax in [self]:
locator = ax.get_axes_locator()
if locator:
pos = locator(self, renderer)
ax.apply_aspect(pos)
else:
ax.apply_aspect()

artists = self.get_children()
artists.remove(self.patch)
Expand Down Expand Up @@ -4198,7 +4210,6 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
bb_xaxis = self.xaxis.get_tightbbox(renderer)
if bb_xaxis:
bb.append(bb_xaxis)

self._update_title_position(renderer)
bb.append(self.get_window_extent(renderer))

Expand All @@ -4218,9 +4229,11 @@ def get_tightbbox(self, renderer, call_axes_locator=True,
bbox_artists = self.get_default_bbox_extra_artists()

for a in bbox_artists:
bbox = a.get_tightbbox(renderer)
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
bb.append(bbox)
bbox = a.get_tightbbox(renderer, )
if (bbox is not None and
(bbox.width != 0 or bbox.height != 0) and
np.isfinite(bbox.x0 + bbox.x1 + bbox.y0 + bbox.y1)):
bb.append(bbox)

_bbox = mtransforms.Bbox.union(
[b for b in bb if b.width != 0 or b.height != 0])
Expand Down
Loading