diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4dc1cccd8..acd0dbe26 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -55,6 +55,8 @@ ProPlot v0.4.0 (2020-##-##) precedence instead (:pr:`95`). - `~proplot.axes.Axes.dualx` and `~proplot.axes.Axes.dualx` no longer accept "scale-spec" arguments. Must be a function, two functions, or an axis scale instance (:pr:`96`). +- Remove `~proplot.axes.Axes` ``share[x|y]``, ``span[x|y]``, and ``align[x|y]`` kwargs (:pr:`99`). + These settings are now always figure-wide. - Rename `~proplot.styletools.Cycle` ``samples`` to ``N``, rename `~proplot.styletools.show_colors` ``nbreak`` to ``nhues`` (:pr:`98`). @@ -66,6 +68,8 @@ ProPlot v0.4.0 (2020-##-##) - Add TeX Gyre Heros as Helvetica-alternative (:pr:`95`). This is the new open-source default font. - Add `xlinewidth`, `ylinewidth`, `xgridcolor`, `ygridcolor` keyword args to `~proplot.axes.XYAxes.format` (:pr:`95`). +- Add getters and setters for various `~proplot.subplots.Figure` settings like ``share[x|y]``, + ``span[x|y]``, and ``align[x|y]`` (:pr:`99`). - Add `~proplot.subplots.Figure` ``fallback_to_cm`` kwarg. This is used by `~proplot.styletools.show_fonts` to show dummy glyphs to clearly illustrate when fonts are missing characters, but preserve graceful fallback for end user. diff --git a/proplot/axes.py b/proplot/axes.py index 92fa22be9..82b47c14f 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -119,30 +119,18 @@ def _parse_format(mode=2, rc_kw=None, **kwargs): class Axes(maxes.Axes): """Lowest-level axes subclass. Handles titles and axis sharing. Adds several new methods and overrides existing ones.""" - - def __init__(self, *args, number=None, - sharex=0, sharey=0, - spanx=None, spany=None, alignx=None, aligny=None, - main=False, - **kwargs): + def __init__(self, *args, number=None, main=False, **kwargs): """ Parameters ---------- number : int - The subplot number, used for a-b-c labelling (see - `~Axes.format`). - sharex, sharey : {3, 2, 1, 0}, optional - The "axis sharing level" for the *x* axis, *y* axis, or both - axes. See `~proplot.subplots.subplots` for details. - spanx, spany : bool, optional - Boolean toggle for whether spanning labels are enabled for the - *x* and *y* axes. See `~proplot.subplots.subplots` for details. - alignx, aligny : bool, optional - Boolean toggle for whether aligned axis labels are enabled for the - *x* and *y* axes. See `~proplot.subplots.subplots` for details. + The subplot number, used for a-b-c labeling. See `~Axes.format` + for details. Note the first axes is ``1``, not ``0``. main : bool, optional Used internally, indicates whether this is a "main axes" rather than a twin, panel, or inset axes. + *args, **kwargs + Passed to `~matplotlib.axes.Axes`. See also -------- @@ -155,7 +143,6 @@ def __init__(self, *args, number=None, # Ensure isDefault_minloc enabled at start, needed for dual axes self.xaxis.isDefault_minloc = self.yaxis.isDefault_minloc = True # Properties - self.number = number self._abc_loc = None self._abc_text = None self._titles_dict = {} # dictionary of titles and locs @@ -180,8 +167,6 @@ def __init__(self, *args, number=None, self._altx_parent = None self._auto_colorbar = {} # stores handles and kwargs for auto colorbar self._auto_legend = {} - # Text labels - # TODO: Add text labels as panels instead of as axes children? coltransform = mtransforms.blended_transform_factory( self.transAxes, self.figure.transFigure) rowtransform = mtransforms.blended_transform_factory( @@ -194,15 +179,10 @@ def __init__(self, *args, number=None, 0.5, 0.05, '', va='top', ha='center', transform=coltransform) self._tlabel = self.text( 0.5, 0.95, '', va='bottom', ha='center', transform=coltransform) - # Shared and spanning axes + self._share_setup() + self.number = number # for abc numbering if main: self.figure._axes_main.append(self) - self._spanx_on = spanx - self._spany_on = spany - self._alignx_on = alignx - self._aligny_on = aligny - self._sharex_level = sharex - self._sharey_level = sharey self.format(mode=1) # mode == 1 applies the rcShortParams def _draw_auto_legends_colorbars(self): @@ -360,7 +340,7 @@ def _loc_translate(loc, default=None): return loc def _make_inset_locator(self, bounds, trans): - """Helper function, copied from private matplotlib version.""" + """Return a locator that determines inset axes bounds.""" def inset_locator(ax, renderer): bbox = mtransforms.Bbox.from_bounds(*bounds) bb = mtransforms.TransformedBbox(bbox, trans) @@ -370,22 +350,22 @@ def inset_locator(ax, renderer): return inset_locator def _range_gridspec(self, x): - """Gets the column or row range for the axes.""" - subplotspec = self.get_subplotspec() + """Return the column or row gridspec range for the axes.""" + if not hasattr(self, 'get_subplotspec'): + raise RuntimeError(f'Axes is not a subplot.') + ss = self.get_subplotspec() if x == 'x': - _, _, _, _, col1, col2 = subplotspec.get_active_rows_columns() + _, _, _, _, col1, col2 = ss.get_active_rows_columns() return col1, col2 else: - _, _, row1, row2, _, _ = subplotspec.get_active_rows_columns() + _, _, row1, row2, _, _ = ss.get_active_rows_columns() return row1, row2 def _range_tightbbox(self, x): - """Gets span of tight bounding box, including twin axes and panels - which are not considered real children and so aren't ordinarily - included in the tight bounding box calc. - `~proplot.axes.Axes.get_tightbbox` caches tight bounding boxes when + """Return the tight bounding box span from the cached bounding box. + `~proplot.axes.Axes.get_tightbbox` caches bounding boxes when `~Figure.get_tightbbox` is called.""" - # TODO: Better resting for axes visibility + # TODO: Better testing for axes visibility bbox = self._tightbbox if bbox is None: return np.nan, np.nan @@ -466,47 +446,54 @@ def _reassign_title(self): pad = tax.xaxis.get_tick_padding() tax._set_title_offset_trans(self._title_pad + pad) - def _sharex_setup(self, sharex, level): - """Sets up panel axis sharing.""" + def _sharex_setup(self, sharex, level=None): + """Configure x-axis sharing for panels. Main axis sharing is done in + `~CartesianAxes._sharex_setup`.""" + if level is None: + level = self.figure._sharex if level not in range(4): raise ValueError( - 'Level can be 0 (share nothing), ' - '1 (do not share limits, just hide axis labels), ' - '2 (share limits, but do not hide tick labels), or ' - '3 (share limits and hide tick labels). Got {level}.' + 'Invalid sharing level sharex={value!r}. ' + 'Axis sharing level can be 0 (share nothing), ' + '1 (hide axis labels), ' + '2 (share limits and hide axis labels), or ' + '3 (share limits and hide axis and tick labels).' ) - # enforce, e.g. if doing panel sharing - self._sharex_level = max(self._sharex_level, level) self._share_short_axis(sharex, 'l', level) self._share_short_axis(sharex, 'r', level) self._share_long_axis(sharex, 'b', level) self._share_long_axis(sharex, 't', level) - def _sharey_setup(self, sharey, level): - """Sets up panel axis sharing.""" + def _sharey_setup(self, sharey, level=None): + """Configure y-axis sharing for panels. Main axis sharing is done in + `~CartesianAxes._sharey_setup`.""" + if level is None: + level = self.figure._sharey if level not in range(4): raise ValueError( - 'Level can be 0 (share nothing), ' - '1 (do not share limits, just hide axis labels), ' - '2 (share limits, but do not hide tick labels), or ' - '3 (share limits and hide tick labels). Got {level}.' + 'Invalid sharing level sharey={value!r}. ' + 'Axis sharing level can be 0 (share nothing), ' + '1 (hide axis labels), ' + '2 (share limits and hide axis labels), or ' + '3 (share limits and hide axis and tick labels).' ) - self._sharey_level = max(self._sharey_level, level) self._share_short_axis(sharey, 'b', level) self._share_short_axis(sharey, 't', level) self._share_long_axis(sharey, 'l', level) self._share_long_axis(sharey, 'r', level) def _share_setup(self): - """Applies axis sharing for axes that share the same horizontal or - vertical extent, and for their panels.""" + """Automatically configure axis sharing based on the horizontal and + vertical extent of subplots in the figure gridspec.""" # Panel axes sharing, between main subplot and its panels - # Top and bottom def shared(paxs): return [ - pax for pax in paxs if not pax._panel_filled - and pax._panel_share] + pax for pax in paxs + if not pax._panel_filled and pax._panel_share + ] + if not self._panel_side: # this is a main axes + # Top and bottom bottom = self paxs = shared(self._bpanels) if paxs: @@ -529,16 +516,15 @@ def shared(paxs): iax._sharey_setup(left, 3) # Main axes, sometimes overrides panel axes sharing - # TODO: This can get very repetitive, but probably minimal impact - # on performance? + # TODO: This can get very repetitive, but probably minimal impact? # Share x axes parent, *children = self._get_extent_axes('x') for child in children: - child._sharex_setup(parent, parent._sharex_level) + child._sharex_setup(parent) # Share y axes parent, *children = self._get_extent_axes('y') for child in children: - child._sharey_setup(parent, parent._sharey_level) + child._sharey_setup(parent) def _share_short_axis(self, share, side, level): """Share the "short" axes of panels along a main subplot with panels @@ -587,7 +573,7 @@ def _update_axislabels(self, x='x', **kwargs): # Apply to spanning axes and their panels axs = [ax] - if getattr(ax, '_span' + x + '_on'): + if getattr(ax.figure, '_span' + x): s = axis.get_label_position()[0] if s in 'lb': axs = ax._get_side_axes(s) @@ -1617,16 +1603,16 @@ def number(self, num): # TODO: More systematic approach? -dualxy_kwargs = ( +_twin_kwargs = ( 'label', 'locator', 'formatter', 'ticks', 'ticklabels', 'minorlocator', 'minorticks', 'tickminor', 'ticklen', 'tickrange', 'tickdir', 'ticklabeldir', 'tickrotation', - 'bounds', 'margin', 'color', 'grid', 'gridminor', + 'bounds', 'margin', 'color', 'linewidth', 'grid', 'gridminor', 'gridcolor', 'locator_kw', 'formatter_kw', 'minorlocator_kw', 'label_kw', ) -dualxy_descrip = """ -Makes a secondary *%(x)s* axis for denoting equivalent *%(x)s* +_dual_doc = """ +Return a secondary *%(x)s* axis for denoting equivalent *%(x)s* coordinates in *alternate units*. Parameters @@ -1641,11 +1627,20 @@ def number(self, num): Prepended with ``'%(x)s'`` and passed to `Axes.format`. """ -altxy_descrip = """ -Alias and more intuitive name for `~XYAxes.twin%(y)s`. -The matplotlib `~matplotlib.axes.Axes.twin%(y)s` function -generates two *%(x)s* axes with a shared ("twin") *%(y)s* axis. -Enforces the following settings. +_alt_doc = """ +Return an axes in the same location as this one but whose %(x)s axis is on +the %(x2)s. This is an alias and more intuitive name for +`~CartesianAxes.twin%(y)s`, which generates two *%(x)s* axes with +a shared ("twin") *%(y)s* axes. + +Parameters +---------- +%(args)s : optional + Prepended with ``'%(x)s'`` and passed to `Axes.format`. + +Note +---- +This function enforces the following settngs. * Places the old *%(x)s* axis on the %(x1)s and the new *%(x)s* axis on the %(x2)s. @@ -1655,11 +1650,20 @@ def number(self, num): according to the visible spine positions. * Locks the old and new *%(y)s* axis limits and scales, and makes the new %(y)s axis labels invisible. + """ -twinxy_descrip = """ -Mimics matplotlib's `~matplotlib.axes.Axes.twin%(y)s`. -Enforces the following settings. +_twin_doc = """ +Mimics the builtin `~matplotlib.axes.Axes.twin%(y)s` method. + +Parameters +---------- +%(args)s : optional + Prepended with ``'%(x)s'`` and passed to `Axes.format`. + +Note +---- +This function enforces the following settngs. * Places the old *%(x)s* axis on the %(x1)s and the new *%(x)s* axis on the %(x2)s. @@ -1672,29 +1676,25 @@ def number(self, num): """ -def _parse_dualxy_args(x, kwargs): - """Detect `~XYAxes.format` arguments with the leading ``x`` or ``y`` - removed. Translate to valid `~XYAxes.format` arguments.""" - kwargs_bad = {} - for key in (*kwargs.keys(),): - value = kwargs.pop(key) - if key[0] == x and key[1:] in dualxy_kwargs: +def _parse_alt(x, kwargs): + """Interpret keyword args passed to all "twin axis" methods so they + can be passed to Axes.format.""" + kw_bad, kw_out = {}, {} + for key, value in kwargs.items(): + if key in _twin_kwargs: + kw_out[x + key] = value + elif key[0] == x and key[1:] in _twin_kwargs: _warn_proplot( - f'dual{x}() keyword arg {key!r} is deprecated. ' - f'Use {key[1:]!r} instead.' - ) - kwargs[key] = value - elif key in dualxy_kwargs: - kwargs[x + key] = value + f'Twin axis keyword arg {key!r} is deprecated. ' + f'Use {key[1:]!r} instead.') + kw_out[key] = value elif key in RC_NODOTSNAMES: - kwargs[key] = value + kw_out[key] = value else: - kwargs_bad[key] = value - if kwargs_bad: - raise TypeError( - f'dual{x}() got unexpected keyword argument(s): {kwargs_bad}' - ) - return kwargs + kw_bad[key] = value + if kw_bad: + raise TypeError(f'Unexpected keyword argument(s): {kw_bad!r}') + return kw_out def _parse_rcloc(x, string): # figures out string location @@ -1757,7 +1757,7 @@ def __init__(self, *args, **kwargs): self._dualx_cache = None def _altx_overrides(self): - """Applies alternate *x* axis overrides.""" + """Apply alternate *x* axis overrides.""" # Unlike matplotlib API, we strong arm user into certain twin axes # settings... doesn't really make sense to have twin axes without this if self._altx_child is not None: # altx was called on this axes @@ -1777,7 +1777,7 @@ def _altx_overrides(self): self.patch.set_visible(False) def _alty_overrides(self): - """Applies alternate *y* axis overrides.""" + """Apply alternate *y* axis overrides.""" if self._alty_child is not None: self._shared_x_axes.join(self, self._alty_child) self.spines['right'].set_visible(False) @@ -1870,7 +1870,7 @@ def _hide_labels(self): axis = getattr(self, x + 'axis') share = getattr(self, '_share' + x) if share is not None: - level = getattr(self, '_share' + x + '_level') + level = getattr(self.figure, '_share' + x) if level > 0: axis.label.set_visible(False) if level > 2: @@ -1878,10 +1878,25 @@ def _hide_labels(self): # Enforce no minor ticks labels. TODO: Document? axis.set_minor_formatter(mticker.NullFormatter()) - def _sharex_setup(self, sharex, level): - """Sets up shared axes. The input is the 'parent' axes, from which - this one will draw its properties.""" + def _make_twin_axes(self, *args, **kwargs): + """Return a twin of this axes. This is used for twinx and twiny and was + copied from matplotlib in case the private API changes.""" + # Typically, SubplotBase._make_twin_axes is called instead of this. + # There is also an override in axes_grid1/axes_divider.py. + if 'sharex' in kwargs and 'sharey' in kwargs: + raise ValueError('Twinned Axes may share only one axis.') + ax2 = self.figure.add_axes(self.get_position(True), *args, **kwargs) + self.set_adjustable('datalim') + ax2.set_adjustable('datalim') + self._twinned_axes.join(self, ax2) + return ax2 + + def _sharex_setup(self, sharex, level=None): + """Configure shared axes accounting for panels. The input is the + 'parent' axes, from which this one will draw its properties.""" # Call Axes method + if level is None: + level = self.figure._sharex super()._sharex_setup(sharex, level) # sets up panels if sharex in (None, self) or not isinstance(sharex, XYAxes): return @@ -1891,10 +1906,12 @@ def _sharex_setup(self, sharex, level): if level > 1: self._shared_x_axes.join(self, sharex) - def _sharey_setup(self, sharey, level): - """Sets up shared axes. The input is the 'parent' axes, from which - this one will draw its properties.""" + def _sharey_setup(self, sharey, level=None): + """Configure shared axes accounting for panels. The input is the + 'parent' axes, from which this one will draw its properties.""" # Call Axes method + if level is None: + level = self.figure._sharey super()._sharey_setup(sharey, level) if sharey in (None, self) or not isinstance(sharey, XYAxes): return @@ -2574,8 +2591,8 @@ def _grid_dict(grid): self.set_aspect(aspect) super().format(**kwargs) - def altx(self): - # TODO: Accept format **kwargs? Is this already in #50? + def altx(self, **kwargs): + """Docstring is replaced below.""" # Cannot wrap twiny() because we want to use XYAxes, not # matplotlib Axes. Instead use hidden method _make_twin_axes. # See https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py # noqa @@ -2591,9 +2608,11 @@ def altx(self): ax._altx_overrides() self.add_child_axes(ax) # to facilitate tight layout self.figure._axstack.remove(ax) # or gets drawn twice! + ax.format(**_parse_alt('x', kwargs)) return ax - def alty(self): + def alty(self, **kwargs): + """Docstring is replaced below.""" if self._alty_child or self._alty_parent: raise RuntimeError('No more than *two* twin axes are allowed.') with self.figure._authorize_add_subplot(): @@ -2606,23 +2625,24 @@ def alty(self): ax._alty_overrides() self.add_child_axes(ax) # to facilitate tight layout self.figure._axstack.remove(ax) # or gets drawn twice! + ax.format(**_parse_alt('y', kwargs)) return ax def dualx(self, arg, **kwargs): + """Docstring is replaced below.""" # NOTE: Matplotlib 3.1 has a 'secondary axis' feature. For the time # being, our version is more robust (see FuncScale) and simpler, since # we do not create an entirely separate _SecondaryAxis class. - ax = self.altx() + ax = self.altx(**kwargs) self._dualx_arg = arg self._dualx_overrides() - ax.format(**_parse_dualxy_args('x', kwargs)) return ax def dualy(self, arg, **kwargs): - ax = self.alty() + """Docstring is replaced below.""" + ax = self.alty(**kwargs) self._dualy_arg = arg self._dualy_overrides() - ax.format(**_parse_dualxy_args('y', kwargs)) return ax def draw(self, renderer=None, *args, **kwargs): @@ -2653,32 +2673,39 @@ def get_tightbbox(self, renderer, *args, **kwargs): return super().get_tightbbox(renderer, *args, **kwargs) def twinx(self): + """Docstring is replaced below.""" return self.alty() def twiny(self): + """Docstring is replaced below.""" return self.altx() - altx.__doc__ = altxy_descrip % { + # Add documentation + altx.__doc__ = _alt_doc % { 'x': 'x', 'x1': 'bottom', 'x2': 'top', 'y': 'y', 'y1': 'left', 'y2': 'right', + 'args': ', '.join(_twin_kwargs), } - alty.__doc__ = altxy_descrip % { + alty.__doc__ = _alt_doc % { 'x': 'y', 'x1': 'left', 'x2': 'right', 'y': 'x', 'y1': 'bottom', 'y2': 'top', + 'args': ', '.join(_twin_kwargs), } - dualx.__doc__ = dualxy_descrip % { - 'x': 'x', 'args': ', '.join(dualxy_kwargs) - } - dualy.__doc__ = dualxy_descrip % { - 'x': 'y', 'args': ', '.join(dualxy_kwargs) - } - twinx.__doc__ = twinxy_descrip % { + twinx.__doc__ = _twin_doc % { 'x': 'y', 'x1': 'left', 'x2': 'right', 'y': 'x', 'y1': 'bottom', 'y2': 'top', + 'args': ', '.join(_twin_kwargs), } - twiny.__doc__ = twinxy_descrip % { + twiny.__doc__ = _twin_doc % { 'x': 'x', 'x1': 'bottom', 'x2': 'top', 'y': 'y', 'y1': 'left', 'y2': 'right', + 'args': ', '.join(_twin_kwargs), + } + dualx.__doc__ = _dual_doc % { + 'x': 'x', 'args': ', '.join(_twin_kwargs) + } + dualy.__doc__ = _dual_doc % { + 'x': 'y', 'args': ', '.join(_twin_kwargs) } diff --git a/proplot/subplots.py b/proplot/subplots.py index a1fab71a8..09adfcdf6 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -13,6 +13,7 @@ import matplotlib.figure as mfigure import matplotlib.transforms as mtransforms import matplotlib.gridspec as mgridspec +from numbers import Integral from .rctools import rc from .utils import _warn_proplot, _notNone, _counter, _setstate, units from . import projs, axes @@ -801,11 +802,12 @@ class Figure(mfigure.Figure): def __init__( self, tight=None, - ref=1, pad=None, axpad=None, panelpad=None, - includepanels=False, - autoformat=True, + ref=1, pad=None, axpad=None, panelpad=None, includepanels=False, + span=None, spanx=None, spany=None, + align=None, alignx=None, aligny=None, + share=None, sharex=None, sharey=None, + autoformat=True, fallback_to_cm=None, gridspec_kw=None, subplots_kw=None, subplots_orig_kw=None, - fallback_to_cm=None, **kwargs ): """ @@ -835,6 +837,36 @@ def __init__( Whether to include panels when centering *x* axis labels, *y* axis labels, and figure "super titles" along the edge of the subplot grid. Default is ``False``. + sharex, sharey, share : {3, 2, 1, 0}, optional + The "axis sharing level" for the *x* axis, *y* axis, or both axes. + Default is ``3``. This can considerably reduce redundancy in your + figure. Options are as follows. + + 0. No axis sharing. Also sets the default `spanx` and `spany` + values to ``False``. + 1. Only draw *axis label* on the leftmost column (*y*) or + bottommost row (*x*) of subplots. Axis tick labels + still appear on every subplot. + 2. As in 1, but forces the axis limits to be identical. Axis + tick labels still appear on every subplot. + 3. As in 2, but only show the *axis tick labels* on the + leftmost column (*y*) or bottommost row (*x*) of subplots. + + spanx, spany, span : bool or {0, 1}, optional + Toggles "spanning" axis labels for the *x* axis, *y* axis, or both + axes. Default is ``False`` if `sharex`, `sharey`, or `share` are + ``0``, ``True`` otherwise. When ``True``, a single, centered axis + label is used for all axes with bottom and left edges in the same + row or column. This can considerably redundancy in your figure. + + "Spanning" labels integrate with "shared" axes. For example, + for a 3-row, 3-column figure, with ``sharey > 1`` and ``spany=1``, + your figure will have 1 ylabel instead of 9. + alignx, aligny, align : bool or {0, 1}, optional + Default is ``False``. Whether to `align axis labels \ +`__ + for the *x* axis, *y* axis, or both axes. Only has an effect when + `spanx`, `spany`, or `span` are ``False``. autoformat : bool, optional Whether to automatically configure *x* axis labels, *y* axis labels, axis formatters, axes titles, colorbar labels, and legend @@ -859,9 +891,7 @@ def __init__( See also -------- `~matplotlib.figure.Figure` - """ - # Initialize first, because need to provide fully initialized figure - # as argument to gridspec, because matplotlib tight_layout does that + """ # noqa tight_layout = kwargs.pop('tight_layout', None) constrained_layout = kwargs.pop('constrained_layout', None) if tight_layout or constrained_layout: @@ -871,10 +901,36 @@ def __init__( 'own tight layout algorithm, activated by default or with ' 'tight=True.' ) + + # Initialize first, because need to provide fully initialized figure + # as argument to gridspec, because matplotlib tight_layout does that self._authorized_add_subplot = False self._is_preprocessing = False self._is_resizing = False super().__init__(**kwargs) + + # Axes sharing and spanning settings + sharex = _notNone(sharex, share, rc['share']) + sharey = _notNone(sharey, share, rc['share']) + spanx = _notNone(spanx, span, 0 if sharex == 0 else None, rc['span']) + spany = _notNone(spany, span, 0 if sharey == 0 else None, rc['span']) + if spanx and (alignx or align): + _warn_proplot(f'"alignx" has no effect when spanx=True.') + if spany and (aligny or align): + _warn_proplot(f'"aligny" has no effect when spany=True.') + alignx = _notNone(alignx, align, rc['align']) + aligny = _notNone(aligny, align, rc['align']) + self.set_alignx(alignx) + self.set_aligny(aligny) + self.set_sharex(sharex) + self.set_sharey(sharey) + self.set_spanx(spanx) + self.set_spany(spany) + + # Various other attributes + gridspec_kw = gridspec_kw or {} + gridspec = GridSpec(self, **gridspec_kw) + nrows, ncols = gridspec.get_active_geometry() self._pad = units(_notNone(pad, rc['subplots.pad'])) self._axpad = units(_notNone(axpad, rc['subplots.axpad'])) self._panelpad = units(_notNone(panelpad, rc['subplots.panelpad'])) @@ -890,8 +946,6 @@ def __init__( self._tpanels = [] self._lpanels = [] self._rpanels = [] - gridspec = GridSpec(self, **(gridspec_kw or {})) - nrows, ncols = gridspec.get_active_geometry() self._barray = np.empty((0, ncols), dtype=bool) self._tarray = np.empty((0, ncols), dtype=bool) self._larray = np.empty((0, nrows), dtype=bool) @@ -942,7 +996,6 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): with self._authorize_add_subplot(): pax = self.add_subplot( gridspec[idx1, idx2], - sharex=ax._sharex_level, sharey=ax._sharey_level, projection='xy', ) getattr(ax, '_' + s + 'panels').append(pax) @@ -1121,6 +1174,7 @@ def _adjust_tight_layout(self, renderer, resize=True): bottom = bbox.ymin right = obox.xmax - bbox.xmax top = obox.ymax - bbox.ymax + # Apply new bounds, permitting user overrides # TODO: Account for bounding box NaNs? for key, offset in zip( @@ -1136,9 +1190,11 @@ def _adjust_tight_layout(self, renderer, resize=True): panelpad = self._panelpad gridspec = self._gridspec_main nrows, ncols = gridspec.get_active_geometry() - wspace, hspace = subplots_kw['wspace'], subplots_kw['hspace'] + wspace = subplots_kw['wspace'] + hspace = subplots_kw['hspace'] wspace_orig = subplots_orig_kw['wspace'] hspace_orig = subplots_orig_kw['hspace'] + # Get new subplot spacings, axes panel spacing, figure panel spacing spaces = [] for (w, x, y, nacross, ispace, ispace_orig) in zip( @@ -1202,7 +1258,6 @@ def _adjust_tight_layout(self, renderer, resize=True): x2 = min(ax._range_tightbbox(x)[0] for ax in group2) jspaces.append((x2 - x1) / self.dpi) if jspaces: - # TODO: why max 0? space = max(0, space - min(jspaces) + pad) space = _notNone(space_orig, space) # user input overwrite jspace[i] = space @@ -1234,10 +1289,9 @@ def _align_axislabels(self, b=True): if not isinstance(ax, axes.XYAxes): continue for x, axis in zip('xy', (ax.xaxis, ax.yaxis)): - # top or bottom, left or right s = axis.get_label_position()[0] - span = getattr(ax, '_span' + x + '_on') - align = getattr(ax, '_align' + x + '_on') + span = getattr(self, '_span' + x) + align = getattr(self, '_align' + x) if s not in 'bl' or axis in tracker: continue axs = ax._get_side_axes(s) @@ -1282,8 +1336,9 @@ def _align_axislabels(self, b=True): mtransforms.IdentityTransform(), self.transFigure) for axis in axises: axis.label.set_visible((axis is spanaxis)) - spanlabel.update( - {'position': position, 'transform': transform}) + spanlabel.update({ + 'position': position, 'transform': transform + }) def _align_labels(self, renderer): """Adjusts position of row and column labels, and aligns figure super @@ -1642,16 +1697,57 @@ def colorbar( *args, **kwargs Passed to `~proplot.axes.Axes.colorbar`. """ - if 'cax' in kwargs: - return super().colorbar(*args, **kwargs) - elif 'ax' in kwargs: - return kwargs.pop('ax').colorbar( - *args, space=space, width=width, **kwargs) - else: - ax = self._add_figure_panel( - loc, space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols) - return ax.colorbar(*args, loc='_fill', **kwargs) + ax = kwargs.pop('ax', None) + cax = kwargs.pop('cax', None) + # Fill this axes + if cax is not None: + return super().colorbar(*args, cax=cax, **kwargs) + # Generate axes panel + elif ax is not None: + return ax.colorbar(*args, space=space, width=width, **kwargs) + # Generate figure panel + ax = self._add_figure_panel( + loc, space=space, width=width, span=span, + row=row, col=col, rows=rows, cols=cols + ) + return ax.colorbar(*args, loc='_fill', **kwargs) + + def get_alignx(self): + """Return the *x* axis label alignment mode.""" + return self._alignx + + def get_aligny(self): + """Return the *y* axis label alignment mode.""" + return self._aligny + + def get_gridspec(self): + """Return the single `GridSpec` instance associated with this figure. + If the `GridSpec` has not yet been initialized, returns ``None``.""" + return self._gridspec + + def get_ref_axes(self): + """Return the reference axes associated with the reference axes + number `Figure.ref`.""" + for ax in self._mainaxes: + if ax.number == self.ref: + return ax + return None # no error + + def get_sharex(self): + """Return the *x* axis sharing level.""" + return self._sharex + + def get_sharey(self): + """Return the *y* axis sharing level.""" + return self._sharey + + def get_spanx(self): + """Return the *x* axis label spanning mode.""" + return self._spanx + + def get_spany(self): + """Return the *y* axis label spanning mode.""" + return self._spany def draw(self, renderer): # Certain backends *still* have issues with the tight layout @@ -1715,16 +1811,16 @@ def legend( *args, **kwargs Passed to `~proplot.axes.Axes.legend`. """ - if 'ax' in kwargs: - return kwargs.pop('ax').legend( - *args, space=space, width=width, **kwargs - ) - else: - ax = self._add_figure_panel( - loc, space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols - ) - return ax.legend(*args, loc='_fill', **kwargs) + ax = kwargs.pop('ax', None) + # Generate axes panel + if ax is not None: + return ax.legend(*args, space=space, width=width, **kwargs) + # Generate figure panel + ax = self._add_figure_panel( + loc, space=space, width=width, span=span, + row=row, col=col, rows=rows, cols=cols + ) + return ax.legend(*args, loc='_fill', **kwargs) def save(self, filename, **kwargs): # Alias for `~Figure.savefig` because ``fig.savefig`` is redundant. @@ -1753,6 +1849,75 @@ def set_canvas(self, canvas): canvas.print_figure = _canvas_preprocess(canvas, 'print_figure') super().set_canvas(canvas) + def set_alignx(self, value): + """Set the *x* axis label alignment mode.""" + self.stale = True + self._alignx = bool(value) + + def set_aligny(self, value): + """Set the *y* axis label alignment mode.""" + self.stale = True + self._aligny = bool(value) + + def set_sharex(self, value): + """Set the *x* axis sharing level.""" + value = int(value) + if value not in range(4): + raise ValueError( + 'Invalid sharing level sharex={value!r}. ' + 'Axis sharing level can be 0 (share nothing), ' + '1 (hide axis labels), ' + '2 (share limits and hide axis labels), or ' + '3 (share limits and hide axis and tick labels).' + ) + self.stale = True + self._sharex = value + + def set_sharey(self, value): + """Set the *y* axis sharing level.""" + value = int(value) + if value not in range(4): + raise ValueError( + 'Invalid sharing level sharey={value!r}. ' + 'Axis sharing level can be 0 (share nothing), ' + '1 (hide axis labels), ' + '2 (share limits and hide axis labels), or ' + '3 (share limits and hide axis and tick labels).' + ) + self.stale = True + self._sharey = value + + def set_spanx(self, value): + """Set the *x* axis label spanning mode.""" + self.stale = True + self._spanx = bool(value) + + def set_spany(self, value): + """Set the *y* axis label spanning mode.""" + self.stale = True + self._spany = bool(value) + + @property + def gridspec(self): + """The single `GridSpec` instance used for all subplots + in the figure.""" + return self._gridspec_main + + @property + def ref(self): + """The reference axes number. The `axwidth`, `axheight`, and `aspect` + `subplots` and `figure` arguments are applied to this axes, and aspect + ratio is conserved for this axes in tight layout adjustment.""" + return self._ref + + @ref.setter + def ref(self, ref): + if not isinstance(ref, Integral) or ref < 1: + raise ValueError( + f'Invalid axes number {ref!r}. Must be integer >=1.') + self.stale = True + self._ref = ref + def set_size_inches(self, w, h=None, forward=True, auto=False): # Set the figure size and, if this is being called manually or from # an interactive backend, override the geometry tracker so users can @@ -1882,11 +2047,7 @@ def subplots( hspace=None, wspace=None, space=None, hratios=None, wratios=None, width_ratios=None, height_ratios=None, - flush=None, wflush=None, hflush=None, left=None, bottom=None, right=None, top=None, - span=None, spanx=None, spany=None, - align=None, alignx=None, aligny=None, - share=None, sharex=None, sharey=None, basemap=False, proj=None, projection=None, proj_kw=None, projection_kw=None, **kwargs @@ -1981,36 +2142,6 @@ def subplots( subplots and the figure edge. Units are interpreted by `~proplot.utils.units`. By default, these are determined by the "tight layout" algorithm. - - sharex, sharey, share : {3, 2, 1, 0}, optional - The "axis sharing level" for the *x* axis, *y* axis, or both axes. - Default is ``3``. The options are as follows: - - 0. No axis sharing. Also sets the default `spanx` and `spany` values - to ``False``. - 1. Only draw *axis label* on the leftmost column (*y*) or - bottommost row (*x*) of subplots. Axis tick labels - still appear on every subplot. - 2. As in 1, but forces the axis limits to be identical. Axis - tick labels still appear on every subplot. - 3. As in 2, but only show the *axis tick labels* on the - leftmost column (*y*) or bottommost row (*x*) of subplots. - - spanx, spany, span : bool or {1, 0}, optional - Toggles "spanning" axis labels for the *x* axis, *y* axis, or both - axes. Default is ``False`` if `sharex`, `sharey`, or `share` are ``0``, - ``True`` otherwise. When ``True``, a single, centered axis label - is used for all axes with bottom and left edges in the same row or - column. - - Note that "spanning" labels are integrated with "shared" axes. For - example, for a 3-row, 3-column figure, with ``sharey > 1`` and - ``spany=1``, your figure will have 1 ylabel instead of 9. - alignx, aligny, align : bool or {0, 1}, optional - Whether to `align axis labels \ -`__ - for the *x* axis, *y* axis, or both axes. Only has an effect when - `spanx`, `spany`, or `span` are ``False``. Default is ``False``. proj, projection : str or dict-like, optional The map projection name. The argument is interpreted as follows. @@ -2096,36 +2227,12 @@ def subplots( ) nrows, ncols = array.shape - # Figure out rows and columns "spanned" by each axes in list, for - # axis sharing and axis label spanning settings - sharex = int(_notNone(sharex, share, rc['share'])) - sharey = int(_notNone(sharey, share, rc['share'])) - if sharex not in range(4) or sharey not in range(4): - raise ValueError( - f'Axis sharing level can be 0 (no sharing), ' - '1 (sharing, but keep all tick labels), ' - '2 (sharing, keep one set of tick labels), ' - 'or 3 (sharing, keep one axis label and one set of tick labels)' - 'Got sharex={sharex} and sharey={sharey}.' - ) - spanx = _notNone(spanx, span, 0 if sharex == 0 else None, rc['span']) - spany = _notNone(spany, span, 0 if sharey == 0 else None, rc['span']) - alignx = _notNone(alignx, align) - aligny = _notNone(aligny, align) - if (spanx and alignx) or (spany and aligny): - _warn_proplot( - f'The "alignx" and "aligny" args have no effect when ' - '"spanx" and "spany" are True.' - ) - alignx = _notNone(alignx, rc['align']) - aligny = _notNone(alignx, rc['align']) # Get some axes properties, where locations are sorted by axes id. # NOTE: These ranges are endpoint exclusive, like a slice object! axids = [np.where(array == i) for i in np.sort( np.unique(array)) if i > 0] # 0 stands for empty xrange = np.array([[x.min(), x.max()] for _, x in axids]) - yrange = np.array([[y.min(), y.max()] - for y, _ in axids]) # range accounting for panels + yrange = np.array([[y.min(), y.max()] for y, _ in axids]) xref = xrange[ref - 1, :] # range for reference axes yref = yrange[ref - 1, :] @@ -2234,6 +2341,9 @@ def subplots( } # Apply default spaces + share = kwargs.get('share', None) + sharex = _notNone(kwargs.get('sharex', None), share, rc['share']) + sharey = _notNone(kwargs.get('sharey', None), share, rc['share']) left = _notNone(left, _get_space('left')) right = _notNone(right, _get_space('right')) bottom = _notNone(bottom, _get_space('bottom')) @@ -2272,11 +2382,9 @@ def subplots( subplotspec = gridspec[y0:y1 + 1, x0:x1 + 1] with fig._authorize_add_subplot(): axs[idx] = fig.add_subplot( - subplotspec, number=num, - spanx=spanx, spany=spany, alignx=alignx, aligny=aligny, - sharex=sharex, sharey=sharey, - main=True, - **axes_kw[num]) + subplotspec, number=num, main=True, + **axes_kw[num] + ) # Shared axes setup # TODO: Figure out how to defer this to drawtime in #50