diff --git a/examples/subplots_axes_and_figures/secondary_axis.py b/examples/subplots_axes_and_figures/secondary_axis.py new file mode 100644 index 000000000000..040be159f0c7 --- /dev/null +++ b/examples/subplots_axes_and_figures/secondary_axis.py @@ -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() diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index 7f740b9980a5..189b9c986579 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -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 @@ -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: @@ -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 diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index ccccae9db634..ca4c0f794406 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -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__) @@ -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. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index f1e1116a5a58..25a6d2db3966 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -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' @@ -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) @@ -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)) @@ -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]) diff --git a/lib/matplotlib/axes/_secondary_axes.py b/lib/matplotlib/axes/_secondary_axes.py new file mode 100644 index 000000000000..070dce4a238a --- /dev/null +++ b/lib/matplotlib/axes/_secondary_axes.py @@ -0,0 +1,621 @@ +import collections +import numpy as np +import numbers + +import warnings + +import matplotlib.ticker as mticker +import matplotlib.transforms as mtransforms +import matplotlib.scale as mscale + +from matplotlib.axes._base import _AxesBase + +from matplotlib.ticker import ( + AutoLocator, + FixedLocator, + NullLocator, + NullFormatter, + FuncFormatter, + ScalarFormatter, + AutoMinorLocator, + LogLocator, + LogFormatterSciNotation +) + +from matplotlib.scale import Log10Transform + + +def _make_secondary_locator(rect, parent): + """ + Helper function to locate the secondary axes. + + A locator gets used in `Axes.set_aspect` to override the default + locations... It is a function that takes an axes object and + a renderer and tells `set_aspect` where it is to be placed. + + This locator make the transform be in axes-relative co-coordinates + because that is how we specify the "location" of the secondary axes. + + Here *rect* is a rectangle [l, b, w, h] that specifies the + location for the axes in the transform given by *trans* on the + *parent*. + """ + _rect = mtransforms.Bbox.from_bounds(*rect) + _parent = parent + _trans = _parent.transAxes + + def secondary_locator(ax, renderer): + bbox = _rect + bb = mtransforms.TransformedBbox(bbox, _trans) + tr = _parent.figure.transFigure.inverted() + bb = mtransforms.TransformedBbox(bb, tr) + return bb + + return secondary_locator + + +def _parse_conversion(name, otherargs): + if name == 'inverted': + if otherargs is None: + otherargs = [1.] + otherargs = np.atleast_1d(otherargs) + return _InvertTransform(otherargs[0]) + elif name == 'power': + otherargs = np.atleast_1d(otherargs) + return _PowerTransform(a=otherargs[0], b=otherargs[1]) + elif name == 'linear': + otherargs = np.asarray(otherargs) + return _LinearTransform(slope=otherargs[0], offset=otherargs[1]) + else: + raise ValueError('"{}" not a possible conversion string'.format(name)) + + +class Secondary_Axis(_AxesBase): + """ + General class to hold a Secondary_X/Yaxis. + """ + + def __init__(self, parent, orientation, + location, conversion, otherargs=None, **kwargs): + + self._conversion = conversion + self._parent = parent + self._otherargs = otherargs + self._orientation = orientation + + if self._orientation == 'x': + super().__init__(self._parent.figure, [0, 1., 1, 0.0001], **kwargs) + self._axis = self.xaxis + self._locstrings = ['top', 'bottom'] + self._otherstrings = ['left', 'right'] + elif self._orientation == 'y': + super().__init__(self._parent.figure, [0, 1., 0.0001, 1], **kwargs) + self._axis = self.yaxis + self._locstrings = ['right', 'left'] + self._otherstrings = ['top', 'bottom'] + # this gets positioned w/o constrained_layout so exclude: + self._layoutbox = None + self._poslayoutbox = None + + self.set_location(location) + self.set_conversion(conversion, self._otherargs) + + # styling: + if self._orientation == 'x': + otheraxis = self.yaxis + else: + otheraxis = self.xaxis + + otheraxis.set_major_locator(mticker.NullLocator()) + otheraxis.set_ticks_position('none') + + for st in self._otherstrings: + self.spines[st].set_visible(False) + for st in self._locstrings: + self.spines[st].set_visible(True) + + if self._pos < 0.5: + # flip the location strings... + self._locstrings = self._locstrings[::-1] + self.set_axis_orientation(self._locstrings[0]) + + def set_axis_orientation(self, orient): + """ + Set if axes spine and labels are drawn at top or bottom of the + axes. + + Parameters + ---------- + orient :: string + either 'top' or 'bottom' + + """ + if orient in self._locstrings: + if orient == self._locstrings[1]: + # need to change the orientation. + self._locstrings = self._locstrings[::-1] + elif orient != self._locstrings[0]: + warnings.warn('"{}" is not a valid axis orientation, ' + 'not changing the orientation;' + 'choose "{}" or "{}""'.format(orient, + self._locstrings[0], self._locstrings[1])) + self.spines[self._locstrings[0]].set_visible(True) + self.spines[self._locstrings[1]].set_visible(False) + self._axis.set_ticks_position(orient) + self._axis.set_label_position(orient) + + def set_location(self, location): + """ + Set the vertical location of the axes in parent-normalized + co-ordinates. + + 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 parent axes to put the new axes, 0 being the bottom, and + 1.0 being the top. + """ + + # This puts the rectangle into figure-relative coordinates. + if isinstance(location, str): + if location in ['top', 'right']: + self._pos = 1. + elif location in ['bottom', 'left']: + self._pos = 0. + else: + warnings.warn("location must be '{}', '{}', or a " + "float, not '{}'.".format(location, + self._locstrings[0], self._locstrings[1])) + return + else: + self._pos = location + self._loc = location + + if self._orientation == 'x': + bounds = [0, self._pos, 1., 1e-10] + else: + bounds = [self._pos, 0, 1e-10, 1] + + secondary_locator = _make_secondary_locator(bounds, self._parent) + + # this locator lets the axes move in the parent axes coordinates. + # so it never needs to know where the parent is explicitly in + # figure co-ordinates. + # it gets called in `ax.apply_aspect() (of all places) + self.set_axes_locator(secondary_locator) + + def apply_aspect(self, position=None): + self._set_lims() + super().apply_aspect(position) + + def set_ticks(self, ticks, minor=False): + """ + Set the x ticks with list of *ticks* + + Parameters + ---------- + ticks : list + List of x-axis tick locations. + + minor : bool, optional + If ``False`` sets major ticks, if ``True`` sets minor ticks. + Default is ``False``. + """ + ret = self._axis.set_ticks(ticks, minor=minor) + self.stale = True + + if self._orientation == 'x': + lims = self._parent.get_xlim() + self.set_xlim(self._convert.transform(lims)) + else: + lims = self._parent.get_ylim() + self.set_ylim(self._convert.transform(lims)) + + return ret + + def set_conversion(self, conversion, otherargs=None): + """ + Set how the secondary axis converts limits from the parent axes. + + Parameters + ---------- + conversion : float, two-tuple of floats, transform, or string + transform between the parent xaxis values and the secondary xaxis + values. If a single floats, a linear transform with the + float as the slope is used. If a 2-tuple of floats, the first + is the slope, and the second the offset. + + If a transform is supplied, then the transform must have an + inverse. + + For convenience a few common transforms are provided by using + a string: + - 'linear': as above. ``otherargs = (slope, offset)`` must + be supplied. + - 'inverted': a/x where ``otherargs = a`` can be supplied + (defaults to 1) + - 'power': b x^a where ``otherargs = (a, b)`` must be + supplied + + """ + + if self._orientation == 'x': + set_scale = self.set_xscale + parent_scale = self._parent.get_xscale() + else: + set_scale = self.set_yscale + parent_scale = self._parent.get_yscale() + if parent_scale == 'log': + defscale = 'arbitrarylog' + else: + defscale = 'arbitrary' + + + # make the _convert function... + if isinstance(conversion, mtransforms.Transform): + self._convert = conversion + set_scale(defscale, transform=conversion.inverted()) + elif isinstance(conversion, str): + self._convert = _parse_conversion(conversion, otherargs) + print(self._convert) + set_scale(defscale, transform=self._convert.inverted()) + else: + # linear conversion with offset + if isinstance(conversion, numbers.Number): + conversion = np.asanyarray([conversion]) + if len(conversion) > 2: + raise ValueError('secondary_axes conversion can be a ' + 'float, two-tuple of float, a transform ' + 'with an inverse, or a string.') + elif len(conversion) < 2: + conversion = np.array([conversion, 0.]) + conversion = _LinearTransform(slope=conversion[0], + offset=conversion[1]) + self._convert = conversion + # this will track log/non log so long as the user sets... + if self._orientation == 'x': + set_scale(self._parent.get_xscale()) + else: + set_scale(self._parent.get_yscale()) + + + def draw(self, renderer=None, inframe=False): + """ + Draw the secondary axes. + + Consults the parent axes for its xlimits and converts them + using the converter specified by + `~.axes._secondary_axes.set_conversion` (or *conversion* + parameter when axes initialized.) + + """ + + self._set_lims() + super().draw(renderer=renderer, inframe=inframe) + + def _set_lims(self): + + if self._orientation == 'x': + lims = self._parent.get_xlim() + set_lim = self.set_xlim + if self._orientation == 'y': + lims = self._parent.get_ylim() + set_lim = self.set_ylim + print('parent lims', lims) + order = lims[0] < lims[1] + lims = self._convert.transform(lims) + neworder = lims[0] < lims[1] + print(order, neworder) + if neworder != order: + # flip because the transform will take care of the flipping.. + lims = lims[::-1] + set_lim(lims) + + print('new lims', lims) + + def get_tightbbox(self, renderer, call_axes_locator=True): + """ + Return the tight bounding box of the axes. + The dimension of the Bbox in canvas coordinate. + + If *call_axes_locator* is *False*, it does not call the + _axes_locator attribute, which is necessary to get the correct + bounding box. ``call_axes_locator==False`` can be used if the + caller is only intereted in the relative size of the tightbbox + compared to the axes bbox. + """ + + bb = [] + + if not self.get_visible(): + return None + + self._set_lims() + locator = self.get_axes_locator() + if locator and call_axes_locator: + pos = locator(self, renderer) + self.apply_aspect(pos) + else: + self.apply_aspect() + + if self._orientation == 'x': + bb_axis = self.xaxis.get_tightbbox(renderer) + else: + bb_axis = self.yaxis.get_tightbbox(renderer) + if bb_axis: + bb.append(bb_axis) + + bb.append(self.get_window_extent(renderer)) + _bbox = mtransforms.Bbox.union( + [b for b in bb if b.width != 0 or b.height != 0]) + + return _bbox + + def set_aspect(self, *args, **kwargs): + """ + """ + warnings.warn("Secondary axes can't set the aspect ratio") + + def set_xlabel(self, xlabel, fontdict=None, labelpad=None, **kwargs): + """ + Set the label for the x-axis. + + Parameters + ---------- + xlabel : str + The label text. + + labelpad : scalar, optional, default: None + Spacing in points between the label and the x-axis. + + Other Parameters + ---------------- + **kwargs : `.Text` properties + `.Text` properties control the appearance of the label. + + See also + -------- + text : for information on how override and the optional args work + """ + if labelpad is not None: + self.xaxis.labelpad = labelpad + return self.xaxis.set_label_text(xlabel, fontdict, **kwargs) + + def set_ylabel(self, ylabel, fontdict=None, labelpad=None, **kwargs): + """ + Set the label for the x-axis. + + Parameters + ---------- + ylabel : str + The label text. + + labelpad : scalar, optional, default: None + Spacing in points between the label and the x-axis. + + Other Parameters + ---------------- + **kwargs : `.Text` properties + `.Text` properties control the appearance of the label. + + See also + -------- + text : for information on how override and the optional args work + """ + if labelpad is not None: + self.yaxis.labelpad = labelpad + return self.yaxis.set_label_text(ylabel, fontdict, **kwargs) + + def set_color(self, color): + """ + Change the color of the secondary axes and all decorators + Parameters + ---------- + color : Matplotlib color + """ + + if self._orientation == 'x': + self.tick_params(axis='x', colors=color) + self.spines['bottom'].set_color(color) + self.spines['top'].set_color(color) + self.xaxis.label.set_color(color) + else: + self.tick_params(axis='y', colors=color) + self.spines['left'].set_color(color) + self.spines['right'].set_color(color) + self.yaxis.label.set_color(color) + + +class _LinearTransform(mtransforms.AffineBase): + """ + Linear transform 1d + """ + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, slope, offset): + mtransforms.AffineBase.__init__(self) + self._slope = slope + self._offset = offset + + def transform_affine(self, values): + return np.asarray(values) * self._slope + self._offset + + def inverted(self): + return _InverseLinearTransform(self._slope, self._offset) + + +class _InverseLinearTransform(mtransforms.AffineBase): + """ + Inverse linear transform 1d + """ + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, slope, offset): + mtransforms.AffineBase.__init__(self) + self._slope = slope + self._offset = offset + + def transform_affine(self, values): + return (np.asarray(values) - self._offset) / self._slope + + def inverted(self): + return _LinearTransform(self._slope, self._offset) + + +def _mask_out_of_bounds(a): + """ + Return a Numpy array where all values outside ]0, 1[ are + replaced with NaNs. If all values are inside ]0, 1[, the original + array is returned. + """ + a = np.array(a, float) + mask = (a <= 0.0) | (a >= 1.0) + if mask.any(): + return np.where(mask, np.nan, a) + return a + + +class _InvertTransform(mtransforms.Transform): + """ + Return a/x + """ + + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, fac): + mtransforms.Transform.__init__(self) + self._fac = fac + + def transform_non_affine(self, values): + print('values', values) + with np.errstate(divide='ignore', invalid='ignore'): + q = self._fac / values + print('q', q) + return q + + def inverted(self): + """ we are just our own inverse """ + return _InvertTransform(1 / self._fac) + + +class _PowerTransform(mtransforms.Transform): + """ + Return b * x^a + """ + + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, a, b): + mtransforms.Transform.__init__(self) + self._a = a + self._b = b + + def transform_non_affine(self, values): + with np.errstate(divide="ignore", invalid="ignore"): + q = self._b * (values ** self._a) + return q + + def inverted(self): + """ we are just our own inverse """ + return _InversePowerTransform(self._a, self._b) + + +class _InversePowerTransform(mtransforms.Transform): + """ + Return b * x^a + """ + + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, a, b): + mtransforms.Transform.__init__(self) + self._a = a + self._b = b + + def transform_non_affine(self, values): + with np.errstate(divide="ignore", invalid="ignore"): + q = (values / self._b) ** (1 / self._a) + return q + + def inverted(self): + """ we are just our own inverse """ + return _PowerTransform(self._a, self._b) + + +class ArbitraryScale(mscale.ScaleBase): + + name = 'arbitrary' + + def __init__(self, axis, transform=mtransforms.IdentityTransform()): + """ + TODO + """ + self._transform = transform + + def get_transform(self): + """ + The transform for linear scaling is just the + :class:`~matplotlib.transforms.IdentityTransform`. + """ + return self._transform + + def set_default_locators_and_formatters(self, axis): + """ + Set the locators and formatters to reasonable defaults for + linear scaling. + """ + axis.set_major_locator(AutoLocator()) + axis.set_major_formatter(ScalarFormatter()) + axis.set_minor_formatter(NullFormatter()) + +mscale.register_scale(ArbitraryScale) + +class ArbitraryLogScale(mscale.ScaleBase): + + name = 'arbitrarylog' + + def __init__(self, axis, transform=mtransforms.IdentityTransform()): + """ + TODO + """ + self._transform = transform + + def get_transform(self): + """ + The transform for linear scaling is just the + :class:`~matplotlib.transforms.IdentityTransform`. + """ + return self._transform + Log10Transform() + + def set_default_locators_and_formatters(self, axis): + """ + Set the locators and formatters to reasonable defaults for + linear scaling. + """ + self.base = 10 + self.subs = None + axis.set_major_locator(LogLocator(self.base)) + axis.set_major_formatter(LogFormatterSciNotation(self.base)) + axis.set_minor_locator(LogLocator(self.base, self.subs)) + axis.set_minor_formatter( + LogFormatterSciNotation(self.base, + labelOnlyBase=(self.subs is not None))) + +mscale.register_scale(ArbitraryLogScale) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index fdebfb2156ca..73e3d56c00c9 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1155,11 +1155,10 @@ def get_tightbbox(self, renderer): if (np.isfinite(bbox.width) and np.isfinite(bbox.height) and a.get_visible()): bb.append(bbox) - bb.extend(ticklabelBoxes) bb.extend(ticklabelBoxes2) - - bb = [b for b in bb if b.width != 0 or b.height != 0] + bb = [b for b in bb if ((b.width != 0 or b.height != 0) and + np.isfinite(b.x0 + b.y0 + b.x1 + b.y1))] if bb: _bbox = mtransforms.Bbox.union(bb) return _bbox diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index 80d790ded87c..aa483535d367 100644 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -311,7 +311,6 @@ def _from_ordinalf(x, tz=None): # add hours, minutes, seconds, microseconds dt += datetime.timedelta(microseconds=remainder_musec) - return dt.astimezone(tz) diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 1f41fa4b755a..784697a5ccb9 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -1633,6 +1633,23 @@ def draw(self, renderer): if not artist.get_animated()), key=lambda artist: artist.get_zorder()) + for ax in self.axes: + locator = ax.get_axes_locator() + if locator: + pos = locator(ax, renderer) + ax.apply_aspect(pos) + else: + ax.apply_aspect() + + for child in ax.get_children(): + if hasattr(child, 'apply_aspect'): + locator = child.get_axes_locator() + if locator: + pos = locator(child, renderer) + child.apply_aspect(pos) + else: + child.apply_aspect() + try: renderer.open_group('figure') if self.get_constrained_layout() and self.axes: diff --git a/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png b/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png new file mode 100644 index 000000000000..710f35115dcc Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/secondary_xy.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index a5b7331d3777..0cb7317c99f2 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -5792,3 +5792,46 @@ def test_spines_properbbox_after_zoom(): None, False, False) bb2 = ax.spines['bottom'].get_window_extent(fig.canvas.get_renderer()) np.testing.assert_allclose(bb.get_points(), bb2.get_points(), rtol=1e-6) + + +@image_comparison(baseline_images=['secondary_xy'], style='mpl20', + extensions=['png']) +def test_secondary_xy(): + fig, axs = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True) + + from matplotlib.transforms import Transform + + class LocalInverted(Transform): + """ + Return a/x + """ + + input_dims = 1 + output_dims = 1 + is_separable = True + has_inverse = True + + def __init__(self, out_of_bounds='mask'): + Transform.__init__(self) + self._fac = 1.0 + + 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) + + for nn, ax in enumerate(axs): + ax.plot(np.arange(2, 11), np.arange(2, 11)) + if nn == 0: + secax = ax.secondary_xaxis + else: + secax = ax.secondary_yaxis + axsec = secax(0.2, conversion='power', otherargs=(1.5, 1.)) + axsec = secax(0.4, conversion='inverted', otherargs=1) + axsec = secax(0.6, conversion=[3.5, 1.]) + axsec = secax(0.8, conversion=2.5) + axsec = secax(1.0, conversion=LocalInverted())