diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..d4cf3b72b --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,8 @@ +# Install shinx extensions +# WARNING: Pip install hangs onto older commits unless __version__ is changed +# since otherwise setup.py will not think it is necessary to rebuild +pyyaml>=5.0.0 +numpy>=1.14 +ipython>=7.0.0 +matplotlib>=3.0 +git+https://github.com/lukelbd/sphinx-automodapi@v0.6.proplot-mods diff --git a/proplot/__init__.py b/proplot/__init__.py index 9b9e69b0c..50de37020 100644 --- a/proplot/__init__.py +++ b/proplot/__init__.py @@ -1,7 +1,13 @@ #!/usr/bin/env python3 # Import everything into the top-level module namespace -# Make sure to load styletools early so we can try to update TTFPATH before -# the fontManager is loaded by other modules (requiring a rebuild) +# Monkey patch warnings format for warnings issued by ProPlot, make sure to +# detect if this is just a matplotlib warning traced back to ProPlot code by +# testing whether the warned line contains "warnings.warn" +# See: https://stackoverflow.com/a/2187390/4970632 +# For internal warning call signature: +# https://docs.python.org/3/library/warnings.html#warnings.showwarning +# For default warning source code see: +# https://github.com/python/cpython/blob/master/Lib/warnings.py import os as _os import pkg_resources as _pkg from .utils import _benchmark diff --git a/proplot/axes.py b/proplot/axes.py index 9d372429b..a7ba02093 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -122,16 +122,13 @@ 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, main=False, **kwargs): + def __init__(self, *args, number=None, **kwargs): """ Parameters ---------- number : int 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`. @@ -184,8 +181,6 @@ def __init__(self, *args, number=None, main=False, **kwargs): 0.5, 0.95, '', va='bottom', ha='center', transform=coltransform) self._share_setup() self.number = number # for abc numbering - if main: - self.figure._axes_main.append(self) self.format(mode=1) # mode == 1 applies the rcShortParams def _draw_auto_legends_colorbars(self): @@ -357,10 +352,10 @@ def _range_gridspec(self, x): raise RuntimeError(f'Axes is not a subplot.') ss = self.get_subplotspec() if x == 'x': - _, _, _, _, col1, col2 = ss.get_active_rows_columns() + _, _, _, _, col1, col2 = ss.get_rows_columns() return col1, col2 else: - _, _, row1, row2, _, _ = ss.get_active_rows_columns() + _, _, row1, row2, _, _ = ss.get_rows_columns() return row1, row2 def _range_tightbbox(self, x): @@ -383,6 +378,7 @@ def _reassign_suplabel(self, side): # Place column and row labels on panels instead of axes -- works when # this is called on the main axes *or* on the relevant panel itself # TODO: Mixed figure panels with super labels? How does that work? + # TODO: Remove this when panels implemented as stacks! s = side[0] side = SIDE_TRANSLATE[s] if s == self._panel_side: @@ -415,6 +411,7 @@ def _reassign_title(self): # called on the main axes *or* on the top panel itself. This is # critical for bounding box calcs; not always clear whether draw() and # get_tightbbox() are called on the main axes or panel first + # TODO: Remove this when panels implemented as stacks! if self._panel_side == 'top' and self._panel_parent: ax, taxs = self._panel_parent, [self] else: @@ -662,8 +659,7 @@ def format( positioned inside the axes. This can help them stand out on top of artists plotted inside the axes. Defaults are :rc:`abc.border` and :rc:`title.border` - ltitle, rtitle, ultitle, uctitle, urtitle, lltitle, lctitle, lrtitle \ -: str, optional + ltitle, rtitle, ultitle, uctitle, urtitle, lltitle, lctitle, lrtitle : str, optional Axes titles in particular positions. This lets you specify multiple "titles" for each subplots. See the `abcloc` keyword. top : bool, optional @@ -675,8 +671,7 @@ def format( llabels, tlabels, rlabels, blabels : list of str, optional Aliases for `leftlabels`, `toplabels`, `rightlabels`, `bottomlabels`. - leftlabels, toplabels, rightlabels, bottomlabels : list of str, \ -optional + leftlabels, toplabels, rightlabels, bottomlabels : list of str, optional The subplot row and column labels. If list, length must match the number of subplots on the left, top, right, or bottom edges of the figure. @@ -701,7 +696,7 @@ def format( :py:obj:`XYAxes.format`, :py:obj:`ProjAxes.format`, :py:obj:`PolarAxes.format`, - """ + """ # noqa # Figure patch (for some reason needs to be re-asserted even if # declared before figure is drawn) kw = rc.fill({'facecolor': 'figure.facecolor'}, context=True) @@ -908,7 +903,6 @@ def colorbar( For outer colorbars only. The space between the colorbar and the main axes. Units are interpreted by `~proplot.utils.units`. When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. frame, frameon : bool, optional For inset colorbars, indicates whether to draw a "frame", just like `~matplotlib.axes.Axes.legend`. Default is @@ -947,34 +941,30 @@ def colorbar( self.patch.set_alpha(0) self._panel_filled = True - # Draw colorbar with arbitrary length relative to full length - # of panel + # Draw colorbar with arbitrary length relative to full panel length + # TODO: Can completely remove references to GridSpecFromSubplotSpec + # if implement colorbars as "parasite" objects instead. side = self._panel_side length = _notNone(length, rc['colorbar.length']) - subplotspec = self.get_subplotspec() + ss = self.get_subplotspec() if length <= 0 or length > 1: raise ValueError( f'Panel colorbar length must satisfy 0 < length <= 1, ' f'got length={length!r}.' ) if side in ('bottom', 'top'): - gridspec = mgridspec.GridSpecFromSubplotSpec( + gs = mgridspec.GridSpecFromSubplotSpec( nrows=1, ncols=3, wspace=0, - subplot_spec=subplotspec, + subplot_spec=ss, width_ratios=((1 - length) / 2, length, (1 - length) / 2), ) - subplotspec = gridspec[1] else: - gridspec = mgridspec.GridSpecFromSubplotSpec( + gs = mgridspec.GridSpecFromSubplotSpec( nrows=3, ncols=1, hspace=0, - subplot_spec=subplotspec, + subplot_spec=ss, height_ratios=((1 - length) / 2, length, (1 - length) / 2), ) - subplotspec = gridspec[1] - with self.figure._authorize_add_subplot(): - ax = self.figure.add_subplot(subplotspec, projection=None) - if ax is self: - raise ValueError # should never happen + ax = self.figure.add_subplot(gs[1], main=False, projection=None) self.add_child_axes(ax) # Location @@ -1150,7 +1140,6 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): For outer legends only. The space between the axes and the legend box. Units are interpreted by `~proplot.utils.units`. When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. Other parameters ---------------- @@ -1250,8 +1239,7 @@ def inset_axes( ---------- bounds : list of float The bounds for the inset axes, listed as ``(x, y, width, height)``. - transform : {'data', 'axes', 'figure'} or \ -`~matplotlib.transforms.Transform`, optional + transform : {'data', 'axes', 'figure'} or `~matplotlib.transforms.Transform`, optional The transform used to interpret `bounds`. Can be a `~matplotlib.transforms.Transform` object or a string representing the `~matplotlib.axes.Axes.transData`, @@ -1275,7 +1263,7 @@ def inset_axes( ---------------- **kwargs Passed to `XYAxes`. - """ + """ # noqa # Carbon copy with my custom axes if not transform: transform = self.transAxes @@ -1379,7 +1367,6 @@ def panel_axes(self, side, **kwargs): space : float or str or list thereof, optional Empty space between the main subplot and the panel. When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. share : bool, optional Whether to enable axis sharing between the *x* and *y* axes of the main subplot and the panel long axes for each panel in the stack. @@ -2628,8 +2615,7 @@ def altx(self, **kwargs): # See https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py # noqa if self._altx_child or self._altx_parent: raise RuntimeError('No more than *two* twin axes are allowed.') - with self.figure._authorize_add_subplot(): - ax = self._make_twin_axes(sharey=self, projection='xy') + ax = self._make_twin_axes(sharey=self, projection='xy') ax.set_autoscaley_on(self.get_autoscaley_on()) ax.grid(False) self._altx_child = ax @@ -2645,8 +2631,7 @@ 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(): - ax = self._make_twin_axes(sharex=self, projection='xy') + ax = self._make_twin_axes(sharex=self, projection='xy') ax.set_autoscalex_on(self.get_autoscalex_on()) ax.grid(False) self._alty_child = ax @@ -3737,7 +3722,7 @@ def _format_apply( p.set_clip_on(False) # so edges denoting boundary aren't cut off self._map_boundary = p else: - self.patch.update({**kw_face, 'edgecolor': 'none'}) + self.patch.update(edgecolor='none', **kw_face) for spine in self.spines.values(): spine.update(kw_edge) diff --git a/proplot/axistools.py b/proplot/axistools.py index 0d046a726..f49b5613f 100644 --- a/proplot/axistools.py +++ b/proplot/axistools.py @@ -422,7 +422,6 @@ def __init__( axis scales like `~proplot.axistools.LogScale`. We try to correct this behavior with a patch. """ - tickrange = tickrange or (-np.inf, np.inf) super().__init__(*args, **kwargs) zerotrim = _notNone(zerotrim, rc['axes.formatter.zerotrim']) self._zerotrim = zerotrim diff --git a/proplot/external/hsluv.py b/proplot/external/hsluv.py index b3a4c17d5..029215941 100644 --- a/proplot/external/hsluv.py +++ b/proplot/external/hsluv.py @@ -26,7 +26,7 @@ * `rgb_to_hsluv` * `hpluv_to_rgb` * `rgb_to_hpluv` -""" +""" # noqa # Imports (below functions are just meant to be used by user) # See: https://stackoverflow.com/a/2353265/4970632 # The HLS is actually HCL diff --git a/proplot/styletools.py b/proplot/styletools.py index ec54f52b4..a69a3d4d8 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -724,6 +724,7 @@ class LinearSegmentedColormap(mcolors.LinearSegmentedColormap, _Colormap): r""" New base class for all `~matplotlib.colors.LinearSegmentedColormap`\ s. """ + def __str__(self): return type(self).__name__ + f'(name={self.name!r})' @@ -1269,6 +1270,7 @@ class ListedColormap(mcolors.ListedColormap, _Colormap): r""" New base class for all `~matplotlib.colors.ListedColormap`\ s. """ + def __str__(self): return f'ListedColormap(name={self.name!r})' @@ -1532,7 +1534,6 @@ def __init__( ... 'luminance': [[0, 100, 100], [1, 20, 20]], ... } >>> cmap = plot.PerceptuallyUniformColormap(data) - """ # Checks space = _notNone(space, 'hsl').lower() @@ -2519,7 +2520,6 @@ class BinNorm(mcolors.BoundaryNorm): `norm` is compared against the normalized `levels` array. Its bin index is determined with `numpy.searchsorted`, and its corresponding colormap coordinate is selected using this index. - """ # See this post: https://stackoverflow.com/a/48614231/4970632 # WARNING: Must be child of BoundaryNorm. Many methods in ColorBarBase @@ -2666,6 +2666,7 @@ class LinearSegmentedNorm(mcolors.Normalize): Can be used by passing ``norm='segmented'`` or ``norm='segments'`` to any command accepting ``cmap``. The default midpoint is zero. """ + def __init__(self, levels, vmin=None, vmax=None, **kwargs): """ Parameters diff --git a/proplot/subplots.py b/proplot/subplots.py index 3eb55484f..a06da63e4 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -9,10 +9,12 @@ import numpy as np import functools import inspect -import matplotlib.pyplot as plt +from matplotlib import docstring +import matplotlib.artist as martist import matplotlib.figure as mfigure import matplotlib.transforms as mtransforms import matplotlib.gridspec as mgridspec +import matplotlib.pyplot as plt from numbers import Integral from .rctools import rc from .utils import _warn_proplot, _notNone, _counter, _setstate, units # noqa @@ -23,7 +25,8 @@ ic = lambda *a: None if not a else (a[0] if len(a) == 1 else a) # noqa __all__ = [ - 'subplot_grid', 'close', 'show', 'subplots', 'Figure', + 'subplot_grid', 'close', 'show', 'subplots', + 'EdgeStack', 'Figure', 'GeometrySolver', 'GridSpec', 'SubplotSpec', ] @@ -54,6 +57,127 @@ 'pnas3': '17.8cm', } +# Documentation +_figure_doc = """ +figsize : length-2 tuple, optional + Tuple specifying the figure ``(width, height)``. +width, height : float or str, optional + The figure width and height. Units are interpreted by + `~proplot.utils.units`. +axwidth, axheight : float or str, optional + The width and height of the `ref` axes. Units are interpreted by + `~proplot.utils.units`. Default is :rc:`subplots.axwidth`. + These arguments are convenient where you don't care about the figure + dimensions and just want your axes to have enough "room". +aspect : float or length-2 list of floats, optional + The aspect ratio of the `ref` axes as a ``width/height`` number or a + ``(width, height)`` tuple. If you do not provide the `hratios` or + `wratios` keyword args, this will control the aspect ratio of *all* axes. +ref : int, optional + The reference axes number. The `axwidth`, `axheight`, and `aspect` keyword + args are applied to this axes and conserved during tight layout adjustment. +tight : bool, optional + Toggles whether the gridspec spaces `left`, `right`, `bottom`, + `top`, `wspace`, and `hspace` are determined automatically to + make room for labels and plotted content. Default is :rc:`tight`. +pad, axpad, panelpad : float or str, optional + Padding around the edge of the figure, between subplots in adjacent + rows and columns, and between subplots and axes panels or between + "stacked" panels. Units are interpreted by `~proplot.utils.units`. + Defaults are :rc:`subplots.pad`, :rc:`subplots.axpad`, and + :rc:`subplots.panelpad`. +left, right, top, bottom, wspace, hspace : float or str, optional + The spacing parameters passed to `GridSpec`. If `tight` is ``True`` + and you pass any of these, the tight layout algorithm will be + ignored for that particular spacing. See the following examples. + + * With ``plot.figure(left='3em')``, the left margin is + fixed but the other margins are variable. + * With ``plot.subplots(ncols=3, wspace=0)``, the space between + columns is fixed at zero, but between rows is variable. + * With ``plot.subplots(ncols=3, wspace=(0, None))``, the space + between the first and second columns is fixed, but the space + between the second and third columns is variable. +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``. +includepanels : bool, optional + 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``. +autoformat : bool, optional + Whether to automatically configure *x* axis labels, *y* axis + labels, axis formatters, axes titles, colorbar labels, and legend + labels when a `~pandas.Series`, `~pandas.DataFrame` or + `~xarray.DataArray` with relevant metadata is passed to a plotting + command. +fallback_to_cm : bool, optional + Whether to replace unavailable glyphs with a glyph from Computer + Modern or the "¤" dummy character. See `mathtext \ +`__ + for details. +journal : str, optional + String name corresponding to an academic journal standard that is used + to control the figure width (and height, if specified). See below table. + + =========== ==================== ========================================================================================================================================================== + Key Size description Organization + =========== ==================== ========================================================================================================================================================== + ``'aaas1'`` 1-column `American Association for the Advancement of Science `__ (e.g. *Science*) + ``'aaas2'`` 2-column ” + ``'agu1'`` 1-column `American Geophysical Union `__ + ``'agu2'`` 2-column ” + ``'agu3'`` full height 1-column ” + ``'agu4'`` full height 2-column ” + ``'ams1'`` 1-column `American Meteorological Society `__ + ``'ams2'`` small 2-column ” + ``'ams3'`` medium 2-column ” + ``'ams4'`` full 2-column ” + ``'nat1'`` 1-column `Nature Research `__ + ``'nat2'`` 2-column ” + ``'pnas1'`` 1-column `Proceedings of the National Academy of Sciences `__ + ``'pnas2'`` 2-column ” + ``'pnas3'`` landscape page ” + =========== ==================== ========================================================================================================================================================== + +**kwargs + Passed to `matplotlib.figure.Figure`. +""" # noqa +docstring.interpd.update(figure_doc=_figure_doc) +_gridspec_doc = """ +Apply the `GridSpec` to the figure or generate a new `GridSpec` +instance with the positional and keyword arguments. For example, +``fig.set_gridspec(GridSpec(1, 1, left=0.1))`` and +``fig.set_gridspec(1, 1, left=0.1)`` are both valid. +""" # hunk for identically named methods + def close(*args, **kwargs): """Pass the input arguments to `matplotlib.pyplot.close`. This is included @@ -203,6 +327,10 @@ def __getattr__(self, attr): ... paxs.format(...) # calls "format" on all panels """ + # TODO: Consider getting rid of __getattr__ override because it is + # too much of a mind fuck for new users? Could just have bulk format + # function and colorbar, legend, and text functions for drawing + # spanning content. if not self: raise AttributeError( f'Invalid attribute {attr!r}, axes grid {self!r} is empty.' @@ -249,257 +377,311 @@ def shape(self): class SubplotSpec(mgridspec.SubplotSpec): """ Matplotlib `~matplotlib.gridspec.SubplotSpec` subclass that adds - some helpful methods. + a helpful `__repr__` method. Otherwise is identical. """ def __repr__(self): nrows, ncols, row1, row2, col1, col2 = self.get_rows_columns() return f'SubplotSpec({nrows}, {ncols}; {row1}:{row2}, {col1}:{col2})' - def get_active_geometry(self): - """Returns the number of rows, number of columns, and 1d subplot - location indices, ignoring rows and columns allocated for spaces.""" - nrows, ncols, row1, row2, col1, col2 = self.get_active_rows_columns() - num1 = row1 * ncols + col1 - num2 = row2 * ncols + col2 - return nrows, ncols, num1, num2 - - def get_active_rows_columns(self): - """Returns the number of rows, number of columns, first subplot row, - last subplot row, first subplot column, and last subplot column, - ignoring rows and columns allocated for spaces.""" - gridspec = self.get_gridspec() - nrows, ncols = gridspec.get_geometry() - row1, col1 = divmod(self.num1, ncols) - if self.num2 is not None: - row2, col2 = divmod(self.num2, ncols) - else: - row2 = row1 - col2 = col1 - return ( - nrows // 2, ncols // 2, row1 // 2, row2 // 2, col1 // 2, col2 // 2) - class GridSpec(mgridspec.GridSpec): """ Matplotlib `~matplotlib.gridspec.GridSpec` subclass that allows for grids with variable spacing between successive rows and columns of axes. - Accomplishes this by actually drawing ``nrows*2 + 1`` and ``ncols*2 + 1`` - `~matplotlib.gridspec.GridSpec` rows and columns, setting `wspace` - and `hspace` to ``0``, and masking out every other row and column - of the `~matplotlib.gridspec.GridSpec`, so they act as "spaces". - These "spaces" are then allowed to vary in width using the builtin - `width_ratios` and `height_ratios` properties. """ def __repr__(self): # do not show width and height ratios nrows, ncols = self.get_geometry() return f'GridSpec({nrows}, {ncols})' - def __init__(self, figure, nrows=1, ncols=1, **kwargs): + def __init__( + self, nrows=1, ncols=1, + left=None, right=None, bottom=None, top=None, + wspace=None, hspace=None, wratios=None, hratios=None, + width_ratios=None, height_ratios=None + ): """ Parameters ---------- - figure : `Figure` - The figure instance filled by this gridspec. Unlike - `~matplotlib.gridspec.GridSpec`, this argument is required. nrows, ncols : int, optional - The number of rows and columns on the subplot grid. - hspace, wspace : float or list of float + The number of rows and columns on the subplot grid. This is + applied automatically when the gridspec is passed. + left, right, bottom, top : float or str, optional + Denotes the margin *widths* in physical units. Units are + interpreted by `~proplot.utils.units`. These are *not* the + margin coordinates -- for example, ``left=0.1`` and ``right=0.9`` + corresponds to a left-hand margin of 0.1 inches and a right-hand + margin of 0.9 inches. + hspace, wspace : float or str or list thereof, optional The vertical and horizontal spacing between rows and columns of - subplots, respectively. In `~proplot.subplots.subplots`, ``wspace`` - and ``hspace`` are in physical units. When calling - `GridSpec` directly, values are scaled relative to - the average subplot height or width. - - If float, the spacing is identical between all rows and columns. If - list of float, the length of the lists must equal ``nrows-1`` - and ``ncols-1``, respectively. - height_ratios, width_ratios : list of float - Ratios for the relative heights and widths for rows and columns - of subplots, respectively. For example, ``width_ratios=(1,2)`` - scales a 2-column gridspec so that the second column is twice as - wide as the first column. - left, right, top, bottom : float or str - Passed to `~matplotlib.gridspec.GridSpec`, denotes the margin - positions in figure-relative coordinates. - **kwargs - Passed to `~matplotlib.gridspec.GridSpec`. + subplots, respectively. Units are interpreted by + `~proplot.utils.units`. + + If float or string, the spacing is identical between all rows and + columns. If list, this sets arbitrary spacing between different + rows and columns, and the lengths must equal ``nrows-1`` and + ``ncols-1``, respectively. + hratios, wratios + Aliases for `height_ratios` and `width_ratios`. + height_ratios, width_ratios : list of float, optional + Ratios describing the relative heights and widths of successive + rows and columns in the gridspec, respectively. For example, + ``width_ratios=(1,2)`` scales a 2-column gridspec so that the + second column is twice as wide as the first column. """ - self._nrows = nrows * 2 - 1 # used with get_geometry - self._ncols = ncols * 2 - 1 - self._nrows_active = nrows - self._ncols_active = ncols - wratios, hratios, kwargs = self._spaces_as_ratios(**kwargs) - super().__init__( - self._nrows, self._ncols, - hspace=0, wspace=0, # replaced with "hidden" slots - width_ratios=wratios, height_ratios=hratios, - figure=figure, **kwargs - ) - - def __getitem__(self, key): - """Magic obfuscation that renders `~matplotlib.gridspec.GridSpec` - rows and columns designated as 'spaces' inaccessible.""" + # Attributes + self._figures = set() # figure tracker + self._nrows, self._ncols = nrows, ncols + self.left = self.right = self.bottom = self.top = None + self.wspace = np.repeat(None, ncols) + self.hspace = np.repeat(None, nrows) + + # Apply input settings + hratios = _notNone( + hratios, height_ratios, 1, names=( + 'hratios', 'height_ratios')) + wratios = _notNone( + wratios, width_ratios, 1, names=( + 'wratios', 'width_ratios')) + hspace = _notNone( + hspace, np.mean(hratios) * 0.10) # this is relative to axes + wspace = _notNone(wspace, np.mean(wratios) * 0.10) + self.set_height_ratios(hratios) + self.set_width_ratios(wratios) + self.set_hspace(hspace) + self.set_wspace(wspace) + self.set_margins(left, right, bottom, top) + + def _sanitize_hspace(self, space): + """Sanitize the hspace vector. This needs to be set apart from + set_hspace because gridspec params adopted from the figure are often + scalar and need to be expanded into vectors during + get_grid_positions.""" + N = self._nrows + space = np.atleast_1d(units(space)) + if len(space) == 1: + space = np.repeat(space, (N - 1,)) # note: may be length 0 + if len(space) != N - 1: + raise ValueError( + f'GridSpec has {N} rows and accepts {N-1} hspaces, ' + f'but got {len(space)} hspaces.') + return space + + def _sanitize_wspace(self, space): + """Sanitize the wspace vector.""" + N = self._ncols + space = np.atleast_1d(units(space)) + if len(space) == 1: + space = np.repeat(space, (N - 1,)) # note: may be length 0 + if len(space) != N - 1: + raise ValueError( + f'GridSpec has {N} columns and accepts {N-1} wspaces, ' + f'but got {len(space)} wspaces.') + return space + filter = (space is not None) + self.wspace[filter] = space[filter] + + def add_figure(self, figure): + """Add `~matplotlib.figure.Figure` to the list of figures that are + using this gridspec. This is done automatically when calling + `~Figure.add_subplot` with a subplotspec generated by this gridspec.""" + if not isinstance(figure, Figure): + raise ValueError( + f'add_figure() accepts only ProPlot Figure instances, ' + f'you passed {type(figure)}.') + self._figures.add(figure) + + def get_grid_positions(self, figure, raw=False): + """Calculate grid positions using the input figure and scale + the width and height ratios to figure relative coordinates.""" + # Retrieve properties + # TODO: Need to completely rewrite this! Interpret physical parameters + # and permit variable spacing. + # TODO: Since we disable interactive gridspec adjustment should also + # disable interactive figure resizing. See: + # https://stackoverflow.com/q/21958534/4970632 + # https://stackoverflow.com/q/33881554/4970632 + # NOTE: This gridspec *never* uses figure subplotpars. Matplotlib fills + # subplotpars with rcParams, then GridSpec values that were *explicitly + # passed* by user overwrite subplotpars. Instead, we make sure spacing + # arguments stored on GridSpec are *never* None. Thus we eliminate + # get_subplot_params and render *subplots_adjust* useless. We *cannot* + # overwrite subplots_adjust with physical units because various widgets + # use it with figure-relative units. This approach means interactive + # subplot adjustment sliders no longer work but for now that is best + # approach; we would need to make user-specified subplots_adjust + # instead overwrite default gridspec values, gets complicated. nrows, ncols = self.get_geometry() - nrows_active, ncols_active = self.get_active_geometry() - if not isinstance(key, tuple): # usage gridspec[1,2] - num1, num2 = self._normalize(key, nrows_active * ncols_active) + if raw: + left = bottom = 0 + right = top = 1 + wspace = self._sanitize_wspace(0) + hspace = self._sanitize_hspace(0) else: - if len(key) == 2: - k1, k2 = key - else: - raise ValueError(f'Invalid index {key!r}.') - num1 = self._normalize(k1, nrows_active) - num2 = self._normalize(k2, ncols_active) - num1, num2 = np.ravel_multi_index((num1, num2), (nrows, ncols)) - num1 = self._positem(num1) - num2 = self._positem(num2) - return SubplotSpec(self, num1, num2) - - @staticmethod - def _positem(size): - """Account for negative indices.""" - if size < 0: - # want -1 to stay -1, -2 becomes -3, etc. - return 2 * (size + 1) - 1 + if not isinstance(figure, Figure): + raise ValueError( + f'Invalid figure {figure!r}. ' + f'Must be a proplot.subplots.Figure instance.') + width, height = figure.get_size_inches() + left, right, bottom, top, wspace, hspace = figure._gridspecpars + wspace = self._sanitize_wspace(wspace) + hspace = self._sanitize_hspace(hspace) + left = _notNone(self.left, left, 0) / width + right = 1 - _notNone(self.right, right, 0) / width + bottom = _notNone(self.bottom, bottom, 0) / height + top = 1 - _notNone(self.top, top, 0) / height + + # Calculate accumulated heights of columns + tot_width = right - left + tot_height = top - bottom + cell_h = tot_height / (nrows + hspace * (nrows - 1)) + sep_h = hspace * cell_h + if self._row_height_ratios is not None: + norm = cell_h * nrows / sum(self._row_height_ratios) + cell_heights = [r * norm for r in self._row_height_ratios] else: - return size * 2 - - @staticmethod - def _normalize(key, size): - """Transform gridspec index into standardized form.""" - if isinstance(key, slice): - start, stop, _ = key.indices(size) - if stop > start: - return start, stop - 1 + cell_heights = [cell_h] * nrows + sep_heights = [0] + ([sep_h] * (nrows - 1)) + cell_hs = np.cumsum(np.column_stack([sep_heights, cell_heights]).flat) + + # Calculate accumulated widths of rows + cell_w = tot_width / (ncols + wspace * (ncols - 1)) + sep_w = wspace * cell_w + if self._col_width_ratios is not None: + norm = cell_w * ncols / sum(self._col_width_ratios) + cell_widths = [r * norm for r in self._col_width_ratios] else: - if key < 0: - key += size - if 0 <= key < size: - return key, key - raise IndexError(f'Invalid index: {key} with size {size}.') - - def _spaces_as_ratios( - self, hspace=None, wspace=None, # spacing between axes - height_ratios=None, width_ratios=None, - **kwargs - ): - """For keyword arg usage, see `GridSpec`.""" - # Parse flexible input - nrows, ncols = self.get_active_geometry() - hratios = np.atleast_1d(_notNone(height_ratios, 1)) - wratios = np.atleast_1d(_notNone(width_ratios, 1)) - # this is relative to axes - hspace = np.atleast_1d(_notNone(hspace, np.mean(hratios) * 0.10)) - wspace = np.atleast_1d(_notNone(wspace, np.mean(wratios) * 0.10)) - if len(wspace) == 1: - wspace = np.repeat(wspace, (ncols - 1,)) # note: may be length 0 - if len(hspace) == 1: - hspace = np.repeat(hspace, (nrows - 1,)) - if len(wratios) == 1: - wratios = np.repeat(wratios, (ncols,)) - if len(hratios) == 1: - hratios = np.repeat(hratios, (nrows,)) - - # Verify input ratios and spacings - # Translate height/width spacings, implement as extra columns/rows - if len(hratios) != nrows: - raise ValueError(f'Got {nrows} rows, but {len(hratios)} hratios.') - if len(wratios) != ncols: - raise ValueError( - f'Got {ncols} columns, but {len(wratios)} wratios.' - ) - if len(wspace) != ncols - 1: - raise ValueError( - f'Require {ncols-1} width spacings for {ncols} columns, ' - f'got {len(wspace)}.' - ) - if len(hspace) != nrows - 1: - raise ValueError( - f'Require {nrows-1} height spacings for {nrows} rows, ' - f'got {len(hspace)}.' - ) + cell_widths = [cell_w] * ncols + sep_widths = [0] + ([sep_w] * (ncols - 1)) + cell_ws = np.cumsum(np.column_stack([sep_widths, cell_widths]).flat) + fig_tops, fig_bottoms = (top - cell_hs).reshape((-1, 2)).T + fig_lefts, fig_rights = (left + cell_ws).reshape((-1, 2)).T + return fig_bottoms, fig_tops, fig_lefts, fig_rights + + def get_subplot_params(self, figure=None): + """Raise an error. This method is disabled because ProPlot does not + and cannot use the SubplotParams stored on figures.""" + raise NotImplementedError( + f'ProPlot GridSpec does not interact with figure SubplotParams.' + ) - # Assign spacing as ratios - nrows, ncols = self.get_geometry() - wratios_final = [None] * ncols - wratios_final[::2] = [*wratios] - if ncols > 1: - wratios_final[1::2] = [*wspace] - hratios_final = [None] * nrows - hratios_final[::2] = [*hratios] - if nrows > 1: - hratios_final[1::2] = [*hspace] - return wratios_final, hratios_final, kwargs # bring extra kwargs back + def get_hspace(self): + """Return the vector of row spaces.""" + return self.hspace def get_margins(self): - """Returns left, bottom, right, top values. Not sure why this method - doesn't already exist on `~matplotlib.gridspec.GridSpec`.""" + """Return the left, bottom, right, top margin spaces.""" return self.left, self.bottom, self.right, self.top - def get_hspace(self): - """Returns row ratios allocated for spaces.""" - return self.get_height_ratios()[1::2] - def get_wspace(self): - """Returns column ratios allocated for spaces.""" - return self.get_width_ratios()[1::2] - - def get_active_height_ratios(self): - """Returns height ratios excluding slots allocated for spaces.""" - return self.get_height_ratios()[::2] - - def get_active_width_ratios(self): - """Returns width ratios excluding slots allocated for spaces.""" - return self.get_width_ratios()[::2] - - def get_active_geometry(self): - """Returns the number of active rows and columns, i.e. the rows and - columns that aren't skipped by `~GridSpec.__getitem__`.""" - return self._nrows_active, self._ncols_active - - def update(self, **kwargs): + """Return the vector of column spaces.""" + return self.wspace + + def remove_figure(self, figure): + """Remove `~matplotlib.figure.Figure` from the list of figures that + are using this gridspec.""" + self._figures.discard(figure) + + def set_height_ratios(self, ratios): + """Set the row height ratios. Value must be a vector of length + ``nrows``.""" + N = self._nrows + ratios = np.atleast_1d(ratios) + if len(ratios) == 1: + ratios = np.repeat(ratios, (N,)) + if len(ratios) != N: + raise ValueError( + f'GridSpec has {N} rows, but got {len(ratios)} height ratios.') + super().set_height_ratios(self) + + def set_hspace(self, space): + """Set the inter-row spacing in physical units. Units are interpreted + by `~proplot.utils.units`. Pass a vector of length ``nrows - 1`` to + implement variable spacing between successive rows.""" + space = self._sanitize_hspace(space) + filter = (space is not None) + self.hspace[filter] = space[filter] + + def set_margins(self, left, right, bottom, top): + """Set the margin values in physical units. Units are interpreted by + `~proplot.utils.units`.""" + if left is not None: + self.left = units(left) + if right is not None: + self.right = units(right) + if bottom is not None: + self.bottom = units(bottom) + if top is not None: + self.top = units(top) + + def set_width_ratios(self, ratios): + """Set the column width ratios. Value must be a vector of length + ``ncols``.""" + N = self._ncols + ratios = np.atleast_1d(ratios) + if len(ratios) == 1: + ratios = np.repeat(ratios, (N,)) + if len(ratios) != N: + raise ValueError( + f'GridSpec has {N} columns, but ' + f'got {len(ratios)} width ratios.') + super().set_width_ratios(self) + + def set_wspace(self, space): + """Set the inter-column spacing in physical units. Units are interpreted + by `~proplot.utils.units`. Pass a vector of length ``ncols - 1`` to + implement variable spacing between successive columns.""" + space = self._sanitize_wspace(space) + filter = (space is not None) + self.wspace[filter] = space[filter] + + def tight_layout(self, *args, **kwargs): + """Method is disabled because ProPlot has its own tight layout + algorithm.""" + raise NotImplementedError( + f'Native matplotlib tight layout is disabled.') + + def update( + self, left=None, right=None, bottom=None, top=None, + wspace=None, hspace=None, wratios=None, hratios=None, + width_ratios=None, height_ratios=None): """ Update the gridspec with arbitrary initialization keyword arguments then *apply* those updates to every figure using this gridspec. + The default `~matplotlib.gridspec.GridSpec.update` tries to update positions for axes on all active figures -- but this can fail after successive figure edits if it has been removed from the figure - manager. ProPlot insists one gridspec per figure. + manager. ProPlot insists one gridspec per figure, tracks the figures + that are using this gridspec object, and applies updates to those + tracked figures. Parameters ---------- **kwargs Valid initialization keyword arguments. See `GridSpec`. """ - # Convert spaces to ratios - wratios, hratios, kwargs = self._spaces_as_ratios(**kwargs) - self.set_width_ratios(wratios) - self.set_height_ratios(hratios) + # Setter methods only apply values if not None + self.set_margins(left, right, bottom, top) + self.set_wspace(wspace) + self.set_hspace(hspace) + hratios = _notNone(hratios, height_ratios) + wratios = _notNone(wratios, width_ratios) + if wratios is not None: + self.set_width_ratios(wratios) + if hratios is not None: + self.set_height_ratios(hratios) + for figure in self._figures: + figure._geometryconfig._init() # in case gridspec values changed! + for ax in figure.axes: + ax.update_params() + ax.set_position(ax.figbox) + figure.stale = True - # Validate args - nrows = kwargs.pop('nrows', None) - ncols = kwargs.pop('ncols', None) - nrows_current, ncols_current = self.get_active_geometry() - if (nrows is not None and nrows != nrows_current) or ( - ncols is not None and ncols != ncols_current): - raise ValueError( - f'Input geometry {(nrows, ncols)} does not match ' - f'current geometry {(nrows_current, ncols_current)}.' - ) - self.left = kwargs.pop('left', None) - self.right = kwargs.pop('right', None) - self.bottom = kwargs.pop('bottom', None) - self.top = kwargs.pop('top', None) - if kwargs: - raise ValueError(f'Unknown keyword arg(s): {kwargs}.') - # Apply to figure and all axes - fig = self.figure - fig.subplotpars.update(self.left, self.bottom, self.right, self.top) - for ax in fig.axes: - ax.update_params() - ax.set_position(ax.figbox) - fig.stale = True +def _approx_equal(num1, num2, digits=10): + """Test the equality of two floating point numbers out to `N` digits.""" + hi, lo = 10**digits, 10**-digits + return round(num1 * hi) * lo == round(num2 * hi) * lo def _canvas_preprocess(canvas, method): @@ -613,537 +795,286 @@ def _get_space(key, share=0, pad=None): return space -def _subplots_geometry(**kwargs): - """Save arguments passed to `subplots`, calculates gridspec settings and - figure size necessary for requested geometry, and returns keyword args - necessary to reconstruct and modify this configuration. Note that - `wspace`, `hspace`, `left`, `right`, `top`, and `bottom` always have fixed - physical units, then we scale figure width, figure height, and width - and height ratios to accommodate spaces.""" - # Dimensions and geometry - nrows, ncols = kwargs['nrows'], kwargs['ncols'] - aspect, xref, yref = kwargs['aspect'], kwargs['xref'], kwargs['yref'] - width, height = kwargs['width'], kwargs['height'] - axwidth, axheight = kwargs['axwidth'], kwargs['axheight'] - # Gridspec settings - wspace, hspace = kwargs['wspace'], kwargs['hspace'] - wratios, hratios = kwargs['wratios'], kwargs['hratios'] - left, bottom = kwargs['left'], kwargs['bottom'] - right, top = kwargs['right'], kwargs['top'] - # Panel string toggles, lists containing empty strings '' (indicating a - # main axes), or one of 'l', 'r', 'b', 't' (indicating axes panels) or - # 'f' (indicating figure panels) - wpanels, hpanels = kwargs['wpanels'], kwargs['hpanels'] - - # Checks, important now that we modify gridspec geometry - if len(hratios) != nrows: - raise ValueError( - f'Expected {nrows} width ratios for {nrows} rows, ' - f'got {len(hratios)}.' - ) - if len(wratios) != ncols: - raise ValueError( - f'Expected {ncols} width ratios for {ncols} columns, ' - f'got {len(wratios)}.' - ) - if len(hspace) != nrows - 1: - raise ValueError( - f'Expected {nrows - 1} hspaces for {nrows} rows, ' - f'got {len(hspace)}.' - ) - if len(wspace) != ncols - 1: - raise ValueError( - f'Expected {ncols - 1} wspaces for {ncols} columns, ' - f'got {len(wspace)}.' - ) - if len(hpanels) != nrows: - raise ValueError( - f'Expected {nrows} hpanel toggles for {nrows} rows, ' - f'got {len(hpanels)}.' - ) - if len(wpanels) != ncols: - raise ValueError( - f'Expected {ncols} wpanel toggles for {ncols} columns, ' - f'got {len(wpanels)}.' - ) +class EdgeStack(object): + """ + Container for groups of `~matplotlib.artist.Artist` objects stacked + along the edge of a subplot. Calculates bounding box coordiantes for + objects in the stack. + """ + def __init__(self, *args): + if not all(isinstance(arg, martist.Artst) for arg in args): + raise ValueError(f'Arguments must be artists.') - # Get indices corresponding to main axes or main axes space slots - idxs_ratios, idxs_space = [], [] - for panels in (hpanels, wpanels): - # Ratio indices - mask = np.array([bool(s) for s in panels]) - ratio_idxs, = np.where(~mask) - idxs_ratios.append(ratio_idxs) - # Space indices - space_idxs = [] - for idx in ratio_idxs[:-1]: # exclude last axes slot - offset = 1 - while panels[idx + offset] not in 'rbf': # main space next to this - offset += 1 - space_idxs.append(idx + offset - 1) - idxs_space.append(space_idxs) - # Separate the panel and axes ratios - hratios_main = [hratios[idx] for idx in idxs_ratios[0]] - wratios_main = [wratios[idx] for idx in idxs_ratios[1]] - hratios_panels = [ratio for idx, ratio in enumerate( - hratios) if idx not in idxs_ratios[0]] - wratios_panels = [ratio for idx, ratio in enumerate( - wratios) if idx not in idxs_ratios[1]] - hspace_main = [hspace[idx] for idx in idxs_space[0]] - wspace_main = [wspace[idx] for idx in idxs_space[1]] - # Reduced geometry - nrows_main = len(hratios_main) - ncols_main = len(wratios_main) - - # Get reference properties, account for panel slots in space and ratios - # TODO: Shouldn't panel space be included in these calculations? - (x1, x2), (y1, y2) = xref, yref - dx, dy = x2 - x1 + 1, y2 - y1 + 1 - rwspace = sum(wspace_main[x1:x2]) - rhspace = sum(hspace_main[y1:y2]) - rwratio = ( - ncols_main * sum(wratios_main[x1:x2 + 1])) / (dx * sum(wratios_main)) - rhratio = ( - nrows_main * sum(hratios_main[y1:y2 + 1])) / (dy * sum(hratios_main)) - if rwratio == 0 or rhratio == 0: - raise RuntimeError( - f'Something went wrong, got wratio={rwratio!r} ' - f'and hratio={rhratio!r} for reference axes.' - ) - if np.iterable(aspect): - aspect = aspect[0] / aspect[1] - - # Determine figure and axes dims from input in width or height dimenion. - # For e.g. common use case [[1,1,2,2],[0,3,3,0]], make sure we still scale - # the reference axes like square even though takes two columns of gridspec! - auto_width = (width is None and height is not None) - auto_height = (height is None and width is not None) - if width is None and height is None: # get stuff directly from axes - if axwidth is None and axheight is None: - axwidth = units(rc['subplots.axwidth']) - if axheight is not None: - auto_width = True + +class GeometrySolver(object): + """ + ProPlot's answer to the matplotlib `~matplotlib.figure.SubplotParams` + class. When `tight` is ``False``, this object is filled with sensible + default spacing params dependent on the axis sharing settings. When + `tight` is ``True``, this object is filled with spaces empirically + determined with the tight layout algorithm. + """ + def __init__(self, figure): + """ + Parameters + ---------- + figure : `Figure` + The figure instance associated with this geometry configuration. + """ + if not isinstance(figure, Figure): + raise ValueError( + f'GeometrySolver() accepts only proplot.subplots.Figure ' + f'instances, you passed {type(figure)}.') + self._figure = figure + self._isinit = False + + def _init(self): + """Fill the spacing parameters with defaults.""" + # Get settings + # NOTE: This gets called (1) when gridspec assigned to a figure and + # (2) when gridspec.update() is called. This roughly matches the + # matplotlib behavior -- changes to the gridspec are not applied to + # figures until update() is explicitly called. + # NOTE: Kind of redundant to inherit spacing params from gridspec + # when get_grid_positions() ignores spacing params that were explicitly + # set on the gridspec anyway. But necessary for tight layout calcs + # because we need to compare how far apart subplot content *currently* + # is with the *current* spacing values used to position them, then + # adjust those spacing values as necessary. + # NOTE: Think of this as the intersection between figure spacing params + # and gridspec params, both of which might not have been specified. + # TODO: In insert_row_column, we can just borrow spacing values + # directly from the existing gridspec. + fig = self.figure + gs = self._gridspec + if gs is None: + raise RuntimeError( + f'GridSpec is not present. Cannot initialize GeometrySolver.') + + # Add spacing params to the object for label alignment calculations + # Note that gridspec wspace and hspace are already sanitized + ncols, nrows = gs.get_geometry() + self.ncols, self.nrows = ncols, nrows + self.left = _notNone(gs.left, _get_space('left')) + self.right = _notNone(gs.right, _get_space('right')) + self.bottom = _notNone(gs.bottom, _get_space('bottom')) + self.top = _notNone(gs.top, _get_space('top')) + wspace = np.repeat(_get_space('wspace', fig._sharex), ncols - 1) + hspace = np.repeat(_get_space('hspace', fig._sharey), nrows - 1) + self.wspace = _notNone(gs.wspace, wspace) + self.hspace = _notNone(gs.hspace, hspace) + self.wratios = gs.get_width_ratios() + self.hratios = gs.get_height_ratios() + + # Add panel string toggles (contains '' for subplots, 'lrbt' for axes + # panels, and 'f' for figure panels) and figure panel array trakcers + self.barray = np.empty((0, ncols), dtype=bool) + self.tarray = np.empty((0, ncols), dtype=bool) + self.larray = np.empty((0, nrows), dtype=bool) + self.rarray = np.empty((0, nrows), dtype=bool) + self.wpanels = [''] * ncols + self.hpanels = [''] * nrows + + # Indicate we are initialized + self._isinit = True + + # def _update_gridspec(self, nrows=None, ncols=None, array=None, **kwargs): + def resize(self): + """Determine the figure size necessary to preserve physical + gridspec spacing and the widths of special *panel* slots in the + gridspec object.""" + # Pull out various properties + nrows, ncols = self.nrows, self.ncols + width, height = self.width, self.height + axwidth, axheight = self.axwidth, self.axheight + left, right = self.left, self.right + bottom, top = self.bottom, self.top + wspace, hspace = self.wspace, self.hspace + wratios, hratios = self.wratios, self.hratios + wpanels, hpanels = self.wpanels, self.hpanels + + # Horizontal spacing indices + # map(func, wpanels[idx + 1:]).index(True) - 1] + wmask = np.array([not s for s in wpanels]) + wratios_main = np.array(wratios)[wmask] + wratios_panels = np.array(wratios)[~wmask] + wspace_main = [ + wspace[idx + next(i for i, + p in enumerate(wpanels[idx + 1:]) if p == 'r')] + for idx in np.where(wmask)[0][:-1] + ] + # Vertical spacing indices + hmask = np.array([not s for s in hpanels]) + hratios_main = np.array(hratios)[hmask] + hratios_panels = np.array(hratios)[~hmask] + hspace_main = [ + hspace[idx + next(i for i, + p in enumerate(hpanels[idx + 1:]) if p == 'b')] + for idx in np.where(hmask)[0][:-1] + ] + + # Try to use ratios and spaces spanned by reference axes + # NOTE: In old version we automatically resized when figure was + # created using the reference axes *slot* inferred from the subplots + # array. In new version we just *try* to use the reference axes, but + # if it does not exist, use the average width and height for a single + # cell of the gridspec rather than a particular axes. + # gs = self._gridspec + ax = self.get_ref_axes() + if ax is None: + rwspace = 0 + rhspace = 0 + rwratio = 1 + rhratio = 1 + else: + _, _, y1, y2, x1, x2 = ax.get_subplotspec().get_rows_columns() + dx, dy = x2 - x1 + 1, y2 - y1 + 1 + nrows_main = len(hratios_main) + ncols_main = len(wratios_main) + rwspace = sum(wspace_main[x1:x2]) + rhspace = sum(hspace_main[y1:y2]) + rwratio = ( + ncols_main * sum(wratios_main[x1:x2 + 1]) + ) / (dx * sum(wratios_main)) + rhratio = ( + nrows_main * sum(hratios_main[y1:y2 + 1]) + ) / (dy * sum(hratios_main)) + if rwratio == 0 or rhratio == 0: + raise RuntimeError( + f'Something went wrong, got wratio={rwratio!r} ' + f'and hratio={rhratio!r} for reference axes.') + aspect = self.aspect + + # Determine figure and axes dims from input in width or height dim. + # For e.g. common use case [[1,1,2,2],[0,3,3,0]], make sure we still + # scale the reference axes like square even though takes two columns + # of gridspec! + auto_width = (width is None and height is not None) + auto_height = (height is None and width is not None) + if width is None and height is None: # get stuff directly from axes + if axwidth is None and axheight is None: + axwidth = units(rc['subplots.axwidth']) + if axheight is not None: + auto_width = True + axheight_all = ( + nrows_main * (axheight - rhspace)) / (dy * rhratio) + height = axheight_all + top + bottom + \ + sum(hspace) + sum(hratios_panels) + if axwidth is not None: + auto_height = True + axwidth_all = (ncols_main * (axwidth - rwspace) + ) / (dx * rwratio) + width = axwidth_all + left + right + \ + sum(wspace) + sum(wratios_panels) + if axwidth is not None and axheight is not None: + auto_width = auto_height = False + else: + if height is not None: + axheight_all = height - top - bottom - \ + sum(hspace) - sum(hratios_panels) + axheight = (axheight_all * dy * rhratio) / nrows_main + rhspace + if width is not None: + axwidth_all = width - left - right - \ + sum(wspace) - sum(wratios_panels) + axwidth = (axwidth_all * dx * rwratio) / ncols_main + rwspace + + # Automatically figure dim that was not specified above + if auto_height: + axheight = axwidth / aspect axheight_all = (nrows_main * (axheight - rhspace)) / (dy * rhratio) height = axheight_all + top + bottom + \ sum(hspace) + sum(hratios_panels) - if axwidth is not None: - auto_height = True + elif auto_width: + axwidth = axheight * aspect axwidth_all = (ncols_main * (axwidth - rwspace)) / (dx * rwratio) width = axwidth_all + left + right + \ sum(wspace) + sum(wratios_panels) - if axwidth is not None and axheight is not None: - auto_width = auto_height = False - else: - if height is not None: - axheight_all = height - top - bottom - \ - sum(hspace) - sum(hratios_panels) - axheight = (axheight_all * dy * rhratio) / nrows_main + rhspace - if width is not None: - axwidth_all = width - left - right - \ - sum(wspace) - sum(wratios_panels) - axwidth = (axwidth_all * dx * rwratio) / ncols_main + rwspace - - # Automatically figure dim that was not specified above - if auto_height: - axheight = axwidth / aspect - axheight_all = (nrows_main * (axheight - rhspace)) / (dy * rhratio) - height = axheight_all + top + bottom + \ - sum(hspace) + sum(hratios_panels) - elif auto_width: - axwidth = axheight * aspect - axwidth_all = (ncols_main * (axwidth - rwspace)) / (dx * rwratio) - width = axwidth_all + left + right + sum(wspace) + sum(wratios_panels) - if axwidth_all < 0: - raise ValueError( - f'Not enough room for axes (would have width {axwidth_all}). ' - 'Try using tight=False, increasing figure width, or decreasing ' - "'left', 'right', or 'wspace' spaces." - ) - if axheight_all < 0: - raise ValueError( - f'Not enough room for axes (would have height {axheight_all}). ' - 'Try using tight=False, increasing figure height, or decreasing ' - "'top', 'bottom', or 'hspace' spaces." - ) - - # Reconstruct the ratios array with physical units for subplot slots - # The panel slots are unchanged because panels have fixed widths - wratios_main = axwidth_all * np.array(wratios_main) / sum(wratios_main) - hratios_main = axheight_all * np.array(hratios_main) / sum(hratios_main) - for idx, ratio in zip(idxs_ratios[0], hratios_main): - hratios[idx] = ratio - for idx, ratio in zip(idxs_ratios[1], wratios_main): - wratios[idx] = ratio - - # Convert margins to figure-relative coordinates - left = left / width - bottom = bottom / height - right = 1 - right / width - top = 1 - top / height - - # Return gridspec keyword args - gridspec_kw = { - 'ncols': ncols, 'nrows': nrows, - 'wspace': wspace, 'hspace': hspace, - 'width_ratios': wratios, 'height_ratios': hratios, - 'left': left, 'bottom': bottom, 'right': right, 'top': top, - } - - return (width, height), gridspec_kw, kwargs - - -class _hidelabels(object): - """Hide objects temporarily so they are ignored by the tight bounding box - algorithm.""" - # NOTE: This will be removed when labels are implemented with AxesStack! - def __init__(self, *args): - self._labels = args - - def __enter__(self): - for label in self._labels: - label.set_visible(False) - - def __exit__(self, *args): - for label in self._labels: - label.set_visible(True) - - -class Figure(mfigure.Figure): - """The `~matplotlib.figure.Figure` class returned by `subplots`. At - draw-time, an improved tight layout algorithm is employed, and - the space around the figure edge, between subplots, and between - panels is changed to accommodate subplot content. Figure dimensions - may be automatically scaled to preserve subplot aspect ratios.""" - def __init__( - self, tight=None, - 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, - **kwargs - ): - """ - Parameters - ---------- - tight : bool, optional - Toggles automatic tight layout adjustments. Default is :rc:`tight`. - If you manually specified a spacing in the call to `subplots`, it - will be used to override the tight layout spacing. For example, - with ``left=0.1``, the left margin is set to 0.1 inches wide, - while the remaining margin widths are calculated automatically. - ref : int, optional - The reference subplot number. See `subplots` for details. Default - is ``1``. - pad : float or str, optional - Padding around edge of figure. Units are interpreted by - `~proplot.utils.units`. Default is :rc:`subplots.pad`. - axpad : float or str, optional - Padding between subplots in adjacent columns and rows. Units are - interpreted by `~proplot.utils.units`. Default is - :rc:`subplots.axpad`. - panelpad : float or str, optional - Padding between subplots and axes panels, and between "stacked" - panels. Units are interpreted by `~proplot.utils.units`. Default is - :rc:`subplots.panelpad`. - includepanels : bool, optional - 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 - labels when a `~pandas.Series`, `~pandas.DataFrame` or - `~xarray.DataArray` with relevant metadata is passed to a plotting - command. - fallback_to_cm : bool, optional - Whether to replace unavailable glyphs with a glyph from Computer - Modern or the "¤" dummy character. See `mathtext \ -`__ - for details. - gridspec_kw, subplots_kw, subplots_orig_kw - Keywords used for initializing the main gridspec, for initializing - the figure, and original spacing keyword args used for initializing - the figure that override tight layout spacing. - - Other parameters - ---------------- - **kwargs - Passed to `matplotlib.figure.Figure`. - - See also - -------- - `~matplotlib.figure.Figure` - """ # noqa - tight_layout = kwargs.pop('tight_layout', None) - constrained_layout = kwargs.pop('constrained_layout', None) - if tight_layout or constrained_layout: - _warn_proplot( - f'Ignoring tight_layout={tight_layout} and ' - f'contrained_layout={constrained_layout}. ProPlot uses its ' - 'own tight layout algorithm, activated by default or with ' - 'tight=True.' + if axwidth_all < 0: + raise ValueError( + 'Not enough room for axes ' + f'(would have width {axwidth_all}). ' + 'Try using tight=False, increasing figure width, or ' + "decreasing 'left', 'right', or 'wspace' spaces.") + if axheight_all < 0: + raise ValueError( + 'Not enough room for axes ' + f'(would have height {axheight_all}). ' + 'Try using tight=False, increasing figure height, or ' + "decreasing 'top', 'bottom', or 'hspace' spaces.") + + # Reconstruct the ratios array with physical units for subplot slots + # The panel slots are unchanged because panels have fixed widths + wratios_main = axwidth_all * np.array(wratios_main) / sum(wratios_main) + hratios_main = axheight_all * \ + np.array(hratios_main) / sum(hratios_main) + for idx, ratio in zip(np.where(hmask)[0], hratios_main): + hratios[idx] = ratio + for idx, ratio in zip(np.where(wmask)[0], wratios_main): + wratios[idx] = ratio + + # Update figure size and gridspec + self.set_size_inches((width, height), manual=True) + if self._gridspec: + self._gridspec.update( + ncols=ncols, nrows=nrows, + wspace=wspace, hspace=hspace, + width_ratios=wratios, height_ratios=hratios, + left=left, bottom=bottom, right=right, top=top, ) - - # 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'])) - self._auto_format = autoformat - self._auto_tight = _notNone(tight, rc['tight']) - self._include_panels = includepanels - self._fallback_to_cm = fallback_to_cm - self._ref_num = ref - self._axes_main = [] - self._subplots_orig_kw = subplots_orig_kw - self._subplots_kw = subplots_kw - self._bpanels = [] - self._tpanels = [] - self._lpanels = [] - self._rpanels = [] - self._barray = np.empty((0, ncols), dtype=bool) - self._tarray = np.empty((0, ncols), dtype=bool) - self._larray = np.empty((0, nrows), dtype=bool) - self._rarray = np.empty((0, nrows), dtype=bool) - self._gridspec_main = gridspec - self.suptitle('') # add _suptitle attribute - - @_counter - def _add_axes_panel(self, ax, side, filled=False, **kwargs): - """Hidden method that powers `~proplot.axes.panel_axes`.""" - # Interpret args - # NOTE: Axis sharing not implemented for figure panels, 99% of the - # time this is just used as construct for adding global colorbars and - # legends, really not worth implementing axis sharing - s = side[0] - if s not in 'lrbt': - raise ValueError(f'Invalid side {side!r}.') - ax = ax._panel_parent or ax # redirect to main axes - side = SIDE_TRANSLATE[s] - share, width, space, space_orig = _get_panelargs( - s, filled=filled, figure=False, **kwargs - ) - - # Get gridspec and subplotspec indices - subplotspec = ax.get_subplotspec() - *_, row1, row2, col1, col2 = subplotspec.get_active_rows_columns() - pgrid = getattr(ax, '_' + s + 'panels') - offset = (len(pgrid) * bool(pgrid)) + 1 - if s in 'lr': - iratio = (col1 - offset if s == 'l' else col2 + offset) - idx1 = slice(row1, row2 + 1) - idx2 = iratio else: - iratio = (row1 - offset if s == 't' else row2 + offset) - idx1 = iratio - idx2 = slice(col1, col2 + 1) - gridspec_prev = self._gridspec_main - gridspec = self._insert_row_column( - side, iratio, width, space, space_orig, figure=False - ) - if gridspec is not gridspec_prev: - if s == 't': - idx1 += 1 - elif s == 'l': - idx2 += 1 - - # Draw and setup panel - with self._authorize_add_subplot(): - pax = self.add_subplot( - gridspec[idx1, idx2], - projection='xy', + self._gridspec = GridSpec( + ncols=ncols, nrows=nrows, + wspace=wspace, hspace=hspace, + width_ratios=wratios, height_ratios=hratios, + left=left, bottom=bottom, right=right, top=top, ) - getattr(ax, '_' + s + 'panels').append(pax) - pax._panel_side = side - pax._panel_share = share - pax._panel_parent = ax + return self._gridspec - # Axis sharing and axis setup only for non-legend or colorbar axes - if not filled: - ax._share_setup() - axis = (pax.yaxis if side in ('left', 'right') else pax.xaxis) - # sets tick and tick label positions intelligently - getattr(axis, 'tick_' + side)() - axis.set_label_position(side) - - return pax - - def _add_figure_panel( - self, side, span=None, row=None, col=None, rows=None, cols=None, - **kwargs - ): - """Add a figure panel. Also modifies the panel attribute stored - on the figure to include these panels.""" - # Interpret args and enforce sensible keyword args - s = side[0] - if s not in 'lrbt': - raise ValueError(f'Invalid side {side!r}.') - side = SIDE_TRANSLATE[s] - _, width, space, space_orig = _get_panelargs( - s, filled=True, figure=True, **kwargs - ) - if s in 'lr': - for key, value in (('col', col), ('cols', cols)): - if value is not None: - raise ValueError( - f'Invalid keyword arg {key!r} for figure panel ' - f'on side {side!r}.' - ) - span = _notNone(span, row, rows, None, - names=('span', 'row', 'rows')) - else: - for key, value in (('row', row), ('rows', rows)): - if value is not None: - raise ValueError( - f'Invalid keyword arg {key!r} for figure panel ' - f'on side {side!r}.' - ) - span = _notNone(span, col, cols, None, - names=('span', 'col', 'cols')) - - # Get props - subplots_kw = self._subplots_kw - if s in 'lr': - panels, nacross = subplots_kw['hpanels'], subplots_kw['ncols'] - else: - panels, nacross = subplots_kw['wpanels'], subplots_kw['nrows'] - array = getattr(self, '_' + s + 'array') - npanels, nalong = array.shape - - # Check span array - span = _notNone(span, (1, nalong)) - if not np.iterable(span) or len(span) == 1: - span = 2 * np.atleast_1d(span).tolist() - if len(span) != 2: - raise ValueError(f'Invalid span {span!r}.') - if span[0] < 1 or span[1] > nalong: + def update(self, renderer=None): + """Update the default values in case the spacing has changed.""" + fig = self.figure + gs = fig.gridspec + if gs is None: raise ValueError( - f'Invalid coordinates in span={span!r}. Coordinates ' - f'must satisfy 1 <= c <= {nalong}.' + 'GridSpec has not been initialized yet.' + 'Cannot update GeometrySolver.' ) - start, stop = span[0] - 1, span[1] # zero-indexed - - # See if there is room for panel in current figure panels - # The 'array' is an array of boolean values, where each row corresponds - # to another figure panel, moving toward the outside, and boolean - # True indicates the slot has been filled - iratio = (-1 if s in 'lt' else nacross) # default vals - for i in range(npanels): - if not any(array[i, start:stop]): - array[i, start:stop] = True - if s in 'lt': # descending array moves us closer to 0 - # npanels=1, i=0 --> iratio=0 - # npanels=2, i=0 --> iratio=1 - # npanels=2, i=1 --> iratio=0 - iratio = npanels - 1 - i - else: # descending array moves us closer to nacross-1 - # npanels=1, i=0 --> iratio=nacross-1 - # npanels=2, i=0 --> iratio=nacross-2 - # npanels=2, i=1 --> iratio=nacross-1 - iratio = nacross - (npanels - i) - break - if iratio in (-1, nacross): # add to array - iarray = np.zeros((1, nalong), dtype=bool) - iarray[0, start:stop] = True - array = np.concatenate((array, iarray), axis=0) - setattr(self, '_' + s + 'array', array) - # Get gridspec and subplotspec indices - idxs, = np.where(np.array(panels) == '') - if len(idxs) != nalong: - raise RuntimeError - if s in 'lr': - idx1 = slice(idxs[start], idxs[stop - 1] + 1) - idx2 = max(iratio, 0) - else: - idx1 = max(iratio, 0) - idx2 = slice(idxs[start], idxs[stop - 1] + 1) - gridspec = self._insert_row_column( - side, iratio, width, space, space_orig, figure=True) + # Adjust aspect ratio + ax = fig.get_ref_axes() + if not ax: + return + mode = ax.get_aspect() + aspect = None + if mode == 'equal': + xscale, yscale = ax.get_xscale(), ax.get_yscale() + if xscale == 'linear' and yscale == 'linear': + aspect = 1.0 / ax.get_data_ratio() + elif xscale == 'log' and yscale == 'log': + aspect = 1.0 / ax.get_data_ratio_log() + else: + pass # matplotlib issues warning, forces aspect == 'auto' + if aspect is not None and not _approx_equal(aspect, self.aspect): + self.aspect = aspect + fig._update_gridspec() - # Draw and setup panel - with self._authorize_add_subplot(): - pax = self.add_subplot(gridspec[idx1, idx2], - projection='xy') - getattr(self, '_' + s + 'panels').append(pax) - pax._panel_side = side - pax._panel_share = False - pax._panel_parent = None - return pax + # Get renderer if not passed + renderer = fig._get_renderer() # noqa TODO: finish def _adjust_aspect(self): """Adjust the average aspect ratio used for gridspec calculations. This fixes grids with identically fixed aspect ratios, e.g. identically zoomed-in cartopy projections and imshow images.""" # Get aspect ratio - if not self._axes_main: + figure = self.figure + ax = figure.get_ref_axes() + if not ax: return - ax = self._axes_main[self._ref_num - 1] mode = ax.get_aspect() if mode != 'equal': return # Compare to current aspect - subplots_kw = self._subplots_kw xscale, yscale = ax.get_xscale(), ax.get_yscale() if xscale == 'linear' and yscale == 'linear': aspect = 1.0 / ax.get_data_ratio() @@ -1151,72 +1082,50 @@ def _adjust_aspect(self): aspect = 1.0 / ax.get_data_ratio_log() else: pass # matplotlib issues warning, forces aspect == 'auto' - if np.isclose(aspect, subplots_kw['aspect']): + if np.isclose(aspect, self.aspect): return - - # Apply new aspect - subplots_kw['aspect'] = aspect - figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) - self.set_size_inches(figsize, auto=True) - self._gridspec_main.update(**gridspec_kw) + self.aspect = aspect + self._update_gridspec() def _adjust_tight_layout(self, renderer, resize=True): """Apply tight layout scaling that permits flexible figure dimensions and preserves panel widths and subplot aspect ratios.""" # Initial stuff axs = self._iter_axes() - subplots_kw = self._subplots_kw - subplots_orig_kw = self._subplots_orig_kw # tight layout overrides - if not axs or not subplots_kw or not subplots_orig_kw: + gridspec = self._gridspec + if not axs or not gridspec: return - # Temporarily disable spanning labels and get correct - # positions for labels and suptitle + # Positions for labels and suptitle self._align_axislabels(False) self._align_labels(renderer) + nrows, ncols = gridspec.get_geometry() - # Tight box *around* figure - # Get bounds from old bounding box - pad = self._pad - obox = self.bbox_inches # original bbox + # Boxes and padding bbox = self.get_tightbbox(renderer) - left = bbox.xmin - 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( - ('left', 'right', 'top', 'bottom'), - (left, right, top, bottom) - ): - previous = subplots_orig_kw[key] - current = subplots_kw[key] - subplots_kw[key] = _notNone(previous, current - offset + pad) - - # Get arrays storing gridspec spacing args + bbox_orig = self.bbox_inches # original bbox + pad = self._pad axpad = self._axpad panelpad = self._panelpad - gridspec = self._gridspec_main - nrows, ncols = gridspec.get_active_geometry() - wspace = subplots_kw['wspace'] - hspace = subplots_kw['hspace'] - wspace_orig = subplots_orig_kw['wspace'] - hspace_orig = subplots_orig_kw['hspace'] + + # Tight box *around* figure permitting user overrides + left, right, bottom, top, wspace, hspace = self._gridspecpars + left = left - bbox.xmin + pad + right = right - bbox.ymin + pad + bottom = bottom - (bbox_orig.xmax - bbox.xmax) + pad + top = top - (bbox_orig.ymax - bbox.ymax) + pad # Get new subplot spacings, axes panel spacing, figure panel spacing spaces = [] - for (w, x, y, nacross, ispace, ispace_orig) in zip( - 'wh', 'xy', 'yx', (nrows, ncols), - (wspace, hspace), (wspace_orig, hspace_orig), + for (w, x, y, nacross, ispace) in zip( + 'wh', 'xy', 'yx', (nrows, ncols), (wspace, hspace) ): # Determine which rows and columns correspond to panels - panels = subplots_kw[w + 'panels'] + panels = getattr(self, '_' + w + 'panels') jspace = [*ispace] ralong = np.array([ax._range_gridspec(x) for ax in axs]) racross = np.array([ax._range_gridspec(y) for ax in axs]) - for i, (space, space_orig) in enumerate(zip(ispace, ispace_orig)): + for i, space in enumerate(ispace): # Figure out whether this is a normal space, or a # panel stack space/axes panel space pad = axpad @@ -1269,49 +1178,50 @@ def _adjust_tight_layout(self, renderer, resize=True): jspaces.append((x2 - x1) / self.dpi) if jspaces: space = max(0, space - min(jspaces) + pad) - space = _notNone(space_orig, space) # user input overwrite jspace[i] = space spaces.append(jspace) - # Update geometry solver kwargs - subplots_kw.update({ - 'wspace': spaces[0], 'hspace': spaces[1], - }) - if not resize: - width, height = self.get_size_inches() - subplots_kw = subplots_kw.copy() - subplots_kw.update(width=width, height=height) - - # Apply new spacing - figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) - if resize: - self.set_size_inches(figsize, auto=True) - self._gridspec_main.update(**gridspec_kw) + # Update gridspec params list + # NOTE: This is where users can override calculated tight bounds + self._fill_gridspecpars(left, right, bottom, top, *spaces) + self._update_gridspec() def _align_axislabels(self, b=True): - """Align spanning *x* and *y* axis labels in the perpendicular - direction and, if `b` is ``True``, the parallel direction.""" - # TODO: Ensure this is robust to complex panels and shared axes - # NOTE: Need to turn off aligned labels before _adjust_tight_layout - # call, so cannot put this inside Axes draw - tracker = {*()} - for ax in self._axes_main: - if not isinstance(ax, axes.XYAxes): - continue - for x, axis in zip('xy', (ax.xaxis, ax.yaxis)): + """Align spanning *x* and *y* axis labels, accounting for figure + margins and axes and figure panels.""" + # TODO: Ensure this is robust to complex panels and shared axes. + # NOTE: Aligned labels have to be turned off before + # _adjust_tight_layout call, so this cannot be inside Axes draw + tracker = set() + grpx = getattr(self, '_align_xlabel_grp', None) + grpy = getattr(self, '_align_ylabel_grp', None) + spanx, spany = self._spanx, self._spany + alignx, aligny = self._alignx, self._aligny + if ((spanx or alignx) and grpx) or ((spany or aligny) and grpy): + _warn_proplot( + 'Aligning *x* and *y* axis labels requires ' + 'matplotlib >=3.1.0') + return + for ax in self._mainaxes: + for x, axis, span, align, grp in zip( + 'xy', (ax.xaxis, ax.yaxis), (spanx, spany), + (alignx, aligny), (grpx, grpy)): + # Settings + if (not span and not align) or not isinstance( + ax, axes.XYAxes) or axis in tracker: + continue + # top or bottom, left or right s = axis.get_label_position()[0] - span = getattr(self, '_span' + x) - align = getattr(self, '_align' + x) - if s not in 'bl' or axis in tracker: + if s not in 'bl': continue axs = ax._get_side_axes(s) for _ in range(2): axs = [getattr(ax, '_share' + x) or ax for ax in axs] - # Align axis label offsets + + # Adjust axis label offsets axises = [getattr(ax, x + 'axis') for ax in axs] tracker.update(axises) if span or align: - grp = getattr(self, '_align_' + x + 'label_grp', None) if grp is not None: for ax in axs[1:]: # copied from source code, add to grouper @@ -1323,7 +1233,8 @@ def _align_axislabels(self, b=True): ) if not span: continue - # Get spanning label position + + # Adjust axis label centering c, spanax = self._get_align_coord(s, axs) spanaxis = getattr(spanax, x + 'axis') spanlabel = spanaxis.label @@ -1371,51 +1282,54 @@ def _align_labels(self, renderer): coords = [None] * len(axs) if s == 't' and suptitle_on: supaxs = axs - with _hidelabels(*labels): - for i, (ax, label) in enumerate(zip(axs, labels)): - label_on = label.get_text().strip() - if not label_on: - continue - # Get coord from tight bounding box - # Include twin axes and panels along the same side - extra = ('bt' if s in 'lr' else 'lr') - icoords = [] - for iax in ax._iter_panels(extra): - bbox = iax.get_tightbbox(renderer) - if s == 'l': - jcoords = (bbox.xmin, 0) - elif s == 'r': - jcoords = (bbox.xmax, 0) - elif s == 't': - jcoords = (0, bbox.ymax) - else: - jcoords = (0, bbox.ymin) - c = self.transFigure.inverted().transform(jcoords) - c = (c[0] if s in 'lr' else c[1]) - icoords.append(c) - # Offset, and offset a bit extra for left/right labels - # See: - # https://matplotlib.org/api/text_api.html#matplotlib.text.Text.set_linespacing - fontsize = label.get_fontsize() - if s in 'lr': - scale1, scale2 = 0.6, width - else: - scale1, scale2 = 0.3, height - if s in 'lb': - coords[i] = min(icoords) - ( - scale1 * fontsize / 72) / scale2 - else: - coords[i] = max(icoords) + ( - scale1 * fontsize / 72) / scale2 - # Assign coords - coords = [i for i in coords if i is not None] - if coords: - if s in 'lb': - c = min(coords) + + # Position labels + # TODO: No more _hidelabels here! Must determine positions + # with EdgeStack instead of with axes tightbbox algorithm! + for i, (ax, label) in enumerate(zip(axs, labels)): + label_on = label.get_text().strip() + if not label_on: + continue + # Get coord from tight bounding box + # Include twin axes and panels along the same side + extra = ('bt' if s in 'lr' else 'lr') + icoords = [] + for iax in ax._iter_panels(extra): + bbox = iax.get_tightbbox(renderer) + if s == 'l': + jcoords = (bbox.xmin, 0) + elif s == 'r': + jcoords = (bbox.xmax, 0) + elif s == 't': + jcoords = (0, bbox.ymax) else: - c = max(coords) - for label in labels: - label.update({x: c}) + jcoords = (0, bbox.ymin) + c = self.transFigure.inverted().transform(jcoords) + c = (c[0] if s in 'lr' else c[1]) + icoords.append(c) + # Offset, and offset a bit extra for left/right labels + # See: + # https://matplotlib.org/api/text_api.html#matplotlib.text.Text.set_linespacing + fontsize = label.get_fontsize() + if s in 'lr': + scale1, scale2 = 0.6, width + else: + scale1, scale2 = 0.3, height + if s in 'lb': + coords[i] = min(icoords) - ( + scale1 * fontsize / 72) / scale2 + else: + coords[i] = max(icoords) + ( + scale1 * fontsize / 72) / scale2 + # Assign coords + coords = [i for i in coords if i is not None] + if coords: + if s in 'lb': + c = min(coords) + else: + c = max(coords) + for label in labels: + label.update({x: c}) # Update super title position # If no axes on the top row are visible, do not try to align! @@ -1431,10 +1345,138 @@ def _align_labels(self, renderer): 'transform': self.transFigure} suptitle.update(kw) - def _authorize_add_subplot(self): - """Prevent warning message when adding subplots one-by-one. Used - internally.""" - return _setstate(self, _authorized_add_subplot=True) + @property + def figure(self): + """The `Figure` instance associated with this geometry configuration. + This cannot be modified.""" + return self._figure + + +class Figure(mfigure.Figure): + """The `~matplotlib.figure.Figure` class returned by `subplots`. At + draw-time, an improved tight layout algorithm is employed, and + the space around the figure edge, between subplots, and between + panels is changed to accommodate subplot content. Figure dimensions + may be automatically scaled to preserve subplot aspect ratios.""" + @docstring.dedent_interpd + def __init__(self, + figsize=None, width=None, height=None, journal=None, + axwidth=None, axheight=None, aspect=1, + tight=None, pad=None, axpad=None, panelpad=None, + left=None, right=None, bottom=None, top=None, + wspace=None, hspace=None, + share=None, sharex=None, sharey=None, + span=None, spanx=None, spany=None, + align=None, alignx=None, aligny=None, + includepanels=False, autoformat=True, ref=1, + fallback_to_cm=False, + **kwargs): + """ + Parameters + ---------- + %(figure_doc)s + """ + # Initialize first + self._is_preprocessing = False + self._is_resizing = False + tight_layout = kwargs.pop('tight_layout', None) + constrained_layout = kwargs.pop('constrained_layout', None) + if tight_layout or constrained_layout: + _warn_proplot( + f'Ignoring tight_layout={tight_layout} and ' + f'contrained_layout={constrained_layout}. ProPlot uses ' + 'its own tight layout algorithm, activated by default or ' + 'with tight=True.') + 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) + + # Issue warnings for conflicting dimension specifications + if journal is not None: + names = ('axwidth', 'axheight', 'width', 'height') + values = (axwidth, axheight, width, height) + figsize = _journal_figsize(journal) + spec = f'journal={journal!r}' + width, height = figsize + elif figsize is not None: + names = ('axwidth', 'axheight', 'width', 'height') + values = (axwidth, axheight, width, height) + spec = f'figsize={figsize!r}' + width, height = figsize + elif width is not None or height is not None: + names = ('axwidth', 'axheight') + values = (axwidth, axheight) + spec = [] + if width is not None: + spec.append(f'width={width!r}') + if height is not None: + spec.append(f'height={height!r}') + spec = ', '.join(spec) + for name, value in zip(names, values): + if value is not None: + _warn_proplot( + f'You specified both {spec} and {name}={value!r}. ' + f'Ignoring {name!r}.') + + # Various constants and hidden settings + # self._gridspecpars = (None,) * 6 + # self._gridspecpars_user = ( + # units(left), units(bottom), units(right), units(top), + # units(wspace), units(hspace)) + # self._dimensions = (units(width), units(height), + # units(axwidth), units(axheight), aspect) + if np.iterable(aspect): + aspect = aspect[0] / aspect[1] + self._geometryconfig = GeometrySolver( + width, height, axwidth, axheight, aspect) + self._gridspecpars = ( + units(left), + units(bottom), + units(right), + units(top), + units(wspace), + units(hspace)) + if any(np.iterable(space) for space in self._gridspecpars_user): + raise ValueError( + 'Invalid spacing parameter. ' + 'Must be scalar when passed to Figure().') + self._pad = units(_notNone(pad, rc['subplots.pad'])) + self._axpad = units(_notNone(axpad, rc['subplots.axpad'])) + self._panelpad = units(_notNone(panelpad, rc['subplots.panelpad'])) + self._auto_format = autoformat + self._auto_tight_layout = _notNone(tight, rc['tight']) + self._fallback_to_cm = fallback_to_cm + self._include_panels = includepanels + self._mainaxes = [] + self._bpanels = [] + self._tpanels = [] + self._lpanels = [] + self._rpanels = [] + self._gridspec = None + self._barray = None + self._tarray = None + self._larray = None + self._rarray = None + self._wpanels = None + self._hpanels = None + self.ref = ref + self.suptitle('') # add _suptitle attribute def _context_resizing(self): """Ensure backend calls to `~matplotlib.figure.Figure.set_size_inches` @@ -1446,9 +1488,164 @@ def _context_preprocessing(self): by figure resizes during pre-processing.""" return _setstate(self, _is_preprocessing=True) + @_counter + def _add_axes_panel(self, ax, side, filled=False, **kwargs): + """Add axes panels. This powers `~proplot.axes.panel_axes`.""" + # Interpret args + # NOTE: Axis sharing not implemented for figure panels, 99% of the + # time this is just used as construct for adding global colorbars and + # legends, really not worth implementing axis sharing + s = side[0] + if s not in 'lrbt': + raise ValueError(f'Invalid side {side!r}.') + if not self._gridspec: # needed for wpanels, hpanels, etc. + raise RuntimeError(f'Gridspec is not set.') + ax = ax._panel_parent or ax # redirect to main axes + side = SIDE_TRANSLATE[s] + share, width, space, space_orig = _get_panelargs( + s, filled=filled, figure=False, **kwargs) + + # Get gridspec and subplotspec indices + ss = ax.get_subplotspec() + nrows, ncols, row1, row2, col1, col2 = ss.get_rows_columns() + pgrid = getattr(ax, '_' + s + 'panels') + offset = (len(pgrid) * bool(pgrid)) + 1 + if s in 'lr': + iratio = (col1 - offset if s == 'l' else col2 + offset) + idx1 = slice(row1, row2 + 1) + idx2 = iratio + else: + iratio = (row1 - offset if s == 't' else row2 + offset) + idx1 = iratio + idx2 = slice(col1, col2 + 1) + gridspec_prev = self._gridspec + gs = self._insert_row_column(side, iratio, + width, space, space_orig, figure=False, + ) + if gs is not gridspec_prev: + if s == 't': + idx1 += 1 + elif s == 'l': + idx2 += 1 + + # Draw and setup panel + pax = self.add_subplot(gs[idx1, idx2], + main=False, projection='cartesian') + getattr(ax, '_' + s + 'panels').append(pax) + pax._panel_side = side + pax._panel_share = share + pax._panel_parent = ax + if not filled: # axis sharing and setup + ax._share_setup() + axis = (pax.yaxis if side in ('left', 'right') else pax.xaxis) + # sets tick and tick label positions intelligently + getattr(axis, 'tick_' + side)() + axis.set_label_position(side) + return pax + + def _add_figure_panel( + self, side, + span=None, row=None, col=None, rows=None, cols=None, + **kwargs + ): + """Add figure panels. This powers `Figure.colorbar` and + `Figure.legend`.""" + # Interpret args and enforce sensible keyword args + s = side[0] + if s not in 'lrbt': + raise ValueError(f'Invalid side {side!r}.') + if not self._gridspec: # needed for wpanels, hpanels, etc. + raise RuntimeError(f'Gridspec is not set.') + side = SIDE_TRANSLATE[s] + _, width, space, space_orig = _get_panelargs( + s, filled=True, figure=True, **kwargs) + if s in 'lr': + for key, value in (('col', col), ('cols', cols)): + if value is not None: + raise ValueError( + f'Invalid keyword arg {key!r} ' + f'for figure panel on side {side!r}.') + span = _notNone( + span, row, rows, None, names=('span', 'row', 'rows')) + else: + for key, value in (('row', row), ('rows', rows)): + if value is not None: + raise ValueError( + f'Invalid keyword arg {key!r} ' + f'for figure panel on side {side!r}.') + span = _notNone( + span, col, cols, None, names=('span', 'col', 'cols')) + + # Get props + array = getattr(self, '_' + s + 'array') + if s in 'lr': + panels, nacross = self._wpanels, self._ncols + else: + panels, nacross = self._hpanels, self._nrows + npanels, nalong = array.shape + + # Check span array + span = _notNone(span, (1, nalong)) + if not np.iterable(span) or len(span) == 1: + span = 2 * np.atleast_1d(span).tolist() + if len(span) != 2: + raise ValueError(f'Invalid span {span!r}.') + if span[0] < 1 or span[1] > nalong: + raise ValueError( + f'Invalid coordinates in span={span!r}. ' + f'Coordinates must satisfy 1 <= c <= {nalong}.') + start, stop = span[0] - 1, span[1] # zero-indexed + + # See if there is room for panel in current figure panels + # The 'array' is an array of boolean values, where each row corresponds + # to another figure panel, moving toward the outside, and boolean + # True indicates the slot has been filled + iratio = (-1 if s in 'lt' else nacross) # default vals + for i in range(npanels): + if not any(array[i, start:stop]): + array[i, start:stop] = True + if s in 'lt': # descending array moves us closer to 0 + # npanels=1, i=0 --> iratio=0 + # npanels=2, i=0 --> iratio=1 + # npanels=2, i=1 --> iratio=0 + iratio = npanels - 1 - i + else: # descending array moves us closer to nacross-1 + # npanels=1, i=0 --> iratio=nacross-1 + # npanels=2, i=0 --> iratio=nacross-2 + # npanels=2, i=1 --> iratio=nacross-1 + iratio = nacross - (npanels - i) + break + if iratio in (-1, nacross): # add to array + iarray = np.zeros((1, nalong), dtype=bool) + iarray[0, start:stop] = True + array = np.concatenate((array, iarray), axis=0) + setattr(self, '_' + s + 'array', array) + + # Get gridspec and subplotspec indices + idxs, = np.where(np.array(panels) == '') + if len(idxs) != nalong: + raise RuntimeError('Wut?') + if s in 'lr': + idx1 = slice(idxs[start], idxs[stop - 1] + 1) + idx2 = max(iratio, 0) + else: + idx1 = max(iratio, 0) + idx2 = slice(idxs[start], idxs[stop - 1] + 1) + gridspec = self._insert_row_column( + side, iratio, width, space, space_orig, figure=True) + + # Draw and setup panel + pax = self.add_subplot(gridspec[idx1, idx2], + main=False, projection='cartesian') + getattr(self, '_' + s + 'panels').append(pax) + pax._panel_side = side + pax._panel_share = False + pax._panel_parent = None + return pax + def _get_align_coord(self, side, axs): - """Return the figure coordinate for spanning labels or super titles. - The `x` can be ``'x'`` or ``'y'``.""" + """Return the figure coordinate for spanning labels and super titles. + """ # Get position in figure relative coordinates s = side[0] x = ('y' if s in 'lr' else 'x') @@ -1482,17 +1679,153 @@ def _get_align_axes(self, side): else: x, y = 'y', 'x' # Get edge index - axs = self._axes_main + axs = self._mainaxes if not axs: return [] ranges = np.array([ax._range_gridspec(x) for ax in axs]) min_, max_ = ranges[:, 0].min(), ranges[:, 1].max() edge = (min_ if s in 'lt' else max_) # Return axes on edge sorted by order of appearance - axs = [ax for ax in self._axes_main if ax._range_gridspec(x)[ + axs = [ax for ax in self._mainaxes if ax._range_gridspec(x)[ idx] == edge] - ranges = [ax._range_gridspec(y)[0] for ax in axs] - return [ax for _, ax in sorted(zip(ranges, axs)) if ax.get_visible()] + ord = [ax._range_gridspec(y)[0] for ax in axs] + return [ax for _, ax in sorted(zip(ord, axs)) if ax.get_visible()] + + def _insert_row_column(self, side, idx, + ratio, space, space_orig, figure=False, + ): + """"Overwrite" the main figure gridspec to make room for a panel. The + `side` is the panel side, the `idx` is the slot the panel will occupy, + and the remaining args are the panel widths and spacings.""" + # # Constants and stuff + # # TODO: This is completely broken, must fix + # # Insert spaces to the left of right panels or to the right of + # # left panels. And note that since .insert() pushes everything in + # # that column to the right, actually must insert 1 slot farther to + # # the right when inserting left panels/spaces + # s = side[0] + # if s not in 'lrbt': + # raise ValueError(f'Invalid side {side}.') + # idx_space = idx - 1*bool(s in 'br') + # idx_offset = 1*bool(s in 'tl') + # if s in 'lr': + # sidx, ridx, pidx, ncols = -6, -4, -2, 'ncols' + # else: + # sidx, ridx, pidx, ncols = -5, -3, -1, 'nrows' + # + # # Load arrays and test if we need to insert + # gridspecpars, gridspecpars_user = self._fill_gridspecpars() + # ratios = gridspecpars[ridx] + # panels = gridspecpars[pidx] + # # space = gridspecpars[sidx] + # # space_user = gridspecpars_user[sidx] + # # Test if panel slot already exists + # slot_name = ('f' if figure else s) + # slot_new = (idx in (-1, len(panels)) or panels[idx] == slot_name) + # if slot_new: # already exists! + # idx += idx_offset + # idx_space += idx_offset + # setattr(self, '_' + ncols, getattr(self, '_' + ncols) + 1) + # spaces_orig.insert(idx_space, space_orig) + # spaces.insert(idx_space, space) + # ratios.insert(idx, ratio) + # panels.insert(idx, slot_name) + # else: + # if spaces_orig[idx_space] is None: + # spaces_orig[idx_space] = units(space_orig) + # spaces[idx_space] = _notNone(spaces_orig[idx_space], space) + # + # # Update geometry + # if slot_new: + # self._gridspec = GridSpec(nrows, ncols) + # self._gridspec.remove_figure(self) + # gs = self._update_gridspec() # also sets self._gridspec + # + # # Reassign subplotspecs to all axes and update positions + # # May seem inefficient but it literally just assigns a hidden, + # # attribute, and the creation time for subpltospecs is tiny + # if slot_new: + # axs = [ + # iax for ax in self._iter_axes() for iax + # in (ax, *ax.child_axes)] + # for ax in axs: + # # Get old index + # # NOTE: Endpoints are inclusive, not exclusive! + # if not hasattr(ax, 'get_subplotspec'): + # continue + # if s in 'lr': + # inserts = (None, None, idx, idx) + # else: + # inserts = (idx, idx, None, None) + # ss = ax.get_subplotspec() + # igs = ss.get_gridspec() + # tss = ss.get_topmost_subplotspec() + # # Apply new subplotspec! + # nrows, ncols, *coords = tss.get_rows_columns() + # for i in range(4): + # if inserts[i] is not None and coords[i] >= inserts[i]: + # coords[i] += 1 + # row1, row2, col1, col2 = coords + # ssnew = gs[row1:row2+1, col1:col2+1] + # if tss is ss: + # ax.set_subplotspec(ssnew) + # elif tss is igs._subplot_spec: + # igs._subplot_spec = ssnew + # else: + # raise RuntimeError( + # f'Found unexpected GridSpecFromSubplotSpec nesting.' + # ) + # # Update parent or child position + # ax.update_params() + # ax.set_position(ax.figbox) + # return gs, slot_new + + def _iter_axes(self): + """Return a list of all axes and panels in the figure belonging to the + `~proplot.axes.Axes` class, excluding inset and twin axes.""" + axs = [] + for ax in (*self._mainaxes, *self._lpanels, *self._rpanels, + *self._bpanels, *self._tpanels): + if not ax or not ax.get_visible(): + continue + axs.append(ax) + for ax in axs: + for s in 'lrbt': + for iax in getattr(ax, '_' + s + 'panels'): + if not iax or not iax.get_visible(): + continue + axs.append(iax) + return axs + + def _update_axislabels(self, axis=None, **kwargs): + """Apply axis labels to the relevant shared axis. If spanning + labels are toggled, keep the labels synced for all subplots in the + same row or column. Label positions will be adjusted at draw-time + with _align_axislabels.""" + x = axis.axis_name + if x not in 'xy': + return + # Update label on this axes + axis.label.update(kwargs) + kwargs.pop('color', None) + + # Defer to parent (main) axes if possible, then get the axes + # shared by that parent + ax = axis.axes + ax = ax._panel_parent or ax + ax = getattr(ax, '_share' + x) or ax + + # Apply to spanning axes and their panels + axs = [ax] + if getattr(self, '_span' + x): + s = axis.get_label_position()[0] + if s in 'lb': + axs = ax._get_side_axes(s) + for ax in axs: + getattr(ax, x + 'axis').label.update(kwargs) # apply to main axes + pax = getattr(ax, '_share' + x) + if pax is not None: # apply to panel? + getattr(pax, x + 'axis').label.update(kwargs) def _get_renderer(self): """Get a renderer at all costs, even if it means generating a brand @@ -1511,109 +1844,6 @@ def _get_renderer(self): renderer = canvas.get_renderer() return renderer - def _insert_row_column( - self, side, idx, - ratio, space, space_orig, figure=False, - ): - """"Overwrite" the main figure gridspec to make room for a panel. The - `side` is the panel side, the `idx` is the slot you want the panel - to occupy, and the remaining args are the panel widths and spacings.""" - # Constants and stuff - # Insert spaces to the left of right panels or to the right of - # left panels. And note that since .insert() pushes everything in - # that column to the right, actually must insert 1 slot farther to - # the right when inserting left panels/spaces - s = side[0] - if s not in 'lrbt': - raise ValueError(f'Invalid side {side}.') - idx_space = idx - 1 * bool(s in 'br') - idx_offset = 1 * bool(s in 'tl') - if s in 'lr': - w, ncols = 'w', 'ncols' - else: - w, ncols = 'h', 'nrows' - - # Load arrays and test if we need to insert - subplots_kw = self._subplots_kw - subplots_orig_kw = self._subplots_orig_kw - panels = subplots_kw[w + 'panels'] - ratios = subplots_kw[w + 'ratios'] - spaces = subplots_kw[w + 'space'] - spaces_orig = subplots_orig_kw[w + 'space'] - - # Slot already exists - entry = ('f' if figure else s) - exists = (idx not in (-1, len(panels)) and panels[idx] == entry) - if exists: # already exists! - if spaces_orig[idx_space] is None: - spaces_orig[idx_space] = units(space_orig) - spaces[idx_space] = _notNone(spaces_orig[idx_space], space) - # Make room for new panel slot - else: - # Modify basic geometry - idx += idx_offset - idx_space += idx_offset - subplots_kw[ncols] += 1 - # Original space, ratio array, space array, panel toggles - spaces_orig.insert(idx_space, space_orig) - spaces.insert(idx_space, space) - ratios.insert(idx, ratio) - panels.insert(idx, entry) - # Reference ax location array - # TODO: For now do not need to increment, but need to double - # check algorithm for fixing axes aspect! - # ref = subplots_kw[x + 'ref'] - # ref[:] = [val + 1 if val >= idx else val for val in ref] - - # Update figure - figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) - self.set_size_inches(figsize, auto=True) - if exists: - gridspec = self._gridspec_main - gridspec.update(**gridspec_kw) - else: - # New gridspec - gridspec = GridSpec(self, **gridspec_kw) - self._gridspec_main = gridspec - # Reassign subplotspecs to all axes and update positions - # May seem inefficient but it literally just assigns a hidden, - # attribute, and the creation time for subpltospecs is tiny - axs = [iax for ax in self._iter_axes() - for iax in (ax, *ax.child_axes)] - for ax in axs: - # Get old index - # NOTE: Endpoints are inclusive, not exclusive! - if not hasattr(ax, 'get_subplotspec'): - continue - if s in 'lr': - inserts = (None, None, idx, idx) - else: - inserts = (idx, idx, None, None) - subplotspec = ax.get_subplotspec() - igridspec = subplotspec.get_gridspec() - topmost = subplotspec.get_topmost_subplotspec() - # Apply new subplotspec! - _, _, *coords = topmost.get_active_rows_columns() - for i in range(4): - # if inserts[i] is not None and coords[i] >= inserts[i]: - if inserts[i] is not None and coords[i] >= inserts[i]: - coords[i] += 1 - (row1, row2, col1, col2) = coords - subplotspec_new = gridspec[row1:row2 + 1, col1:col2 + 1] - if topmost is subplotspec: - ax.set_subplotspec(subplotspec_new) - elif topmost is igridspec._subplot_spec: - igridspec._subplot_spec = subplotspec_new - else: - raise ValueError( - f'Unexpected GridSpecFromSubplotSpec nesting.' - ) - # Update parent or child position - ax.update_params() - ax.set_position(ax.figbox) - - return gridspec - def _update_figtitle(self, title, **kwargs): """Assign the figure "super title" and update settings.""" if title is not None and self._suptitle.get_text() != title: @@ -1622,7 +1852,8 @@ def _update_figtitle(self, title, **kwargs): self._suptitle.update(kwargs) def _update_labels(self, ax, side, labels, **kwargs): - """Assign the side labels and update settings.""" + """Assign side labels and updates label settings. The labels are + aligned down the line by geometry_configurator.""" s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid label side {side!r}.') @@ -1647,15 +1878,159 @@ def _update_labels(self, ax, side, labels, **kwargs): if kwargs: obj.update(kwargs) - def add_subplot(self, *args, **kwargs): - """Issues warning for new users that try to call - `~matplotlib.figure.Figure.add_subplot` manually.""" - if not self._authorized_add_subplot: + def add_gridspec(self, *args, **kwargs): + """This docstring is replaced below.""" + return self.set_gridspec(*args, **kwargs) + + def add_subplot(self, *args, + proj=None, projection=None, basemap=False, + proj_kw=None, projection_kw=None, main=True, number=None, + sharex=None, sharey=None, + **kwargs): + """ + Add a subplot to the figure. + + Parameters + ---------- + *args + There are three options here. See the matplotlib + `~matplotlib.figure.add_subplot` documentation for details. + + * A `SubplotSpec` instance. Must be a child of the "main" + gridspec, and must be a ProPlot `SubplotSpec` instead of a native + matplotlib `~matplotlib.gridspec.SubplotSpec`. + * A 3-digit integer, e.g. ``121``. Geometry must be equivalent to + or divide the "main" gridspec geometry. + * A tuple indicating (nrows, ncols, index). Geometry must be + equivalent to or divide the "main" gridspec geometry. + proj, projection : str, `~mpl_toolkits.basemap.Basemap`, or `~cartopy.crs.CRS`, optional + A registered matplotlib projection name, a basemap or cartopy + map projection name, a `~mpl_toolkits.basemap.Basemap` instance, or + a `~cartpoy.crs.CRS` instance. Passed to `~proplot.projs.Proj`. See + `~proplot.projs.Proj` for a table of map projection names. + proj_kw, projection_kw : dict-like, optional + Dictionary of keyword args for the projection class. Passed to + `~proplot.projs.Proj`. + main : bool, optional + Used internally. Indicates whether this is a "main axes" rather + than a twin, panel, or inset axes. Default is ``True``. + number : int, optional + The subplot number, used for a-b-c labeling. See `~Axes.format` + for details. Note the first axes is ``1``, not ``0``. Ignored if + `main` is ``False``. + + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.figure.Figure.add_subplot`. This can also + include axes properties. + sharex, sharey + Ignored. ProPlot toggles axes sharing for the entire figure and + calculates which axes should be shared based on their gridspec + positions. See `Figure` for details. + """ # noqa + # Copied from matplotlib add_subplot + if not len(args): + args = (1, 1, 1) + if len(args) == 1 and isinstance(args[0], Integral): + if not 100 <= args[0] <= 999: + raise ValueError( + 'Integer subplot specification must be a ' + 'three-digit number, not {args[0]!r}.') + args = tuple(map(int, str(args[0]))) + if sharex is not None: _warn_proplot( - 'Using "fig.add_subplot()" with ProPlot figures may result in ' - 'unexpected behavior. Please use "proplot.subplots()" instead.' - ) - ax = super().add_subplot(*args, **kwargs) + f'Ignoring sharex={sharex!r}. To toggle axes sharing, ' + 'just pass sharex=num to figure() or subplots().') + if sharey is not None: + _warn_proplot( + f'Ignoring sharey={sharey!r}. To toggle axes sharing, ' + 'just pass sharey=num to figure() or subplots().') + + # Copied from SubplotBase __init__ + # Interpret positional args + gs = self._gridspec + ss = None + if len(args) == 1: + if isinstance(args[0], SubplotSpec): + ss = args[0] + elif isinstance(args[0], mgridspec.SubplotSpec): + raise ValueError( + f'Invalid subplotspec {args[0]!r}. ' + 'Figure.add_subplot() only accepts SubplotSpecs generated ' + 'by the ProPlot GridSpec class.') + else: + try: + s = str(int(args[0])) + nrows, ncols, num = map(int, s) + except ValueError: + raise ValueError( + f'Single argument to subplot must be a 3-digit ' + 'integer, not {args[0]!r}.') + elif len(args) == 3: + nrows, ncols, num = args + else: + raise ValueError(f'Illegal argument(s) to add_subplot: {args!r}') + + # Initialize gridspec and subplotspec + # Also enforce constant geometry + if ss is None: + nrows, ncols = int(nrows), int(ncols) + if isinstance(num, tuple) and len(num) == 2: + num = [int(n) for n in num] + else: + if num < 1 or num > nrows * ncols: + raise ValueError( + f'num must be 1 <= num <= {nrows*ncols}, not {num}') + if not isinstance(num, tuple): + num = (num, num) + if gs is None: + self._update_gridspec(nrows=nrows, ncols=ncols) + gs = self._gridspec + elif (nrows, ncols) != gs.get_geometry(): + raise ValueError( + f'Input arguments {args!r} conflict with existing ' + 'gridspec geometry of {nrows} rows, {ncols} columns.') + ss = gs[(num[0] - 1):num[1]] + else: + if gs is None: + nrows, ncols, *_ = ss.get_geometry() + gs = ss.get_gridspec() + self._update_gridspec(nrows=nrows, ncols=ncols) + elif ss.get_gridspec() is not gs: # covers geometry discrepancies + raise ValueError( + f'Invalid subplotspec {args[0]!r}. ' + 'Figure.add_subplot() only accepts SubplotSpec objects ' + 'whose parent is the main gridspec.') + gs.add_figure(self) + + # Impose projection + # TODO: Have Proj return all unused keyword args, with a + # map_projection = obj entry, and maybe hide the Proj constructor as + # an argument processing utility? + proj = _notNone( + proj, projection, 'cartesian', + names=('proj', 'projection') + ) + proj_kw = _notNone( + proj_kw, projection_kw, {}, + names=('proj_kw', 'projection_kw') + ) + if proj not in ('cartesian', 'polar'): + map_projection = projs.Proj(proj, basemap=basemap, **proj_kw) + if 'map_projection' in kwargs: + _warn_proplot( + f'Ignoring input "map_projection" ' + f'{kwargs["map_projection"]!r}.' + ) + kwargs['map_projection'] = map_projection + proj = 'basemap' if basemap else 'cartopy' + + # Return subplot + ax = super().add_subplot(ss, projection=proj, number=number, **kwargs) + if main: + ax.number = _notNone(number, len(self._mainaxes) + 1) + self._mainaxes.append(ax) return ax def colorbar( @@ -1892,6 +2267,29 @@ def set_aligny(self, value): self.stale = True self._aligny = bool(value) + def set_gridspec(self, *args, **kwargs): + """This docstring is replaced below.""" + # Create and apply the gridspec + if self._gridspec is not None: + raise RuntimeError( + 'The gridspec has already been declared and multiple ' + 'GridSpecs are not allowed. Call ' + 'Figure.get_gridspec() to retrieve it.') + if len(args) == 1 and isinstance(args[0], GridSpec): + gs = args[0] + elif len(args) == 1 and isinstance(args[0], mgridspec.GridSpec): + raise ValueError( + 'The gridspec must be a ProPlot GridSpec. Matplotlib ' + 'gridspecs are not allowed.') + else: + gs = GridSpec(*args, **kwargs) + gs.add_figure(self) + ncols, nrows = gs.get_geometry() + self._gridspec = gs + self._geometryconfig._init() + self.stale = True + return gs + def set_sharex(self, value): """Set the *x* axis sharing level.""" value = int(value) @@ -1934,7 +2332,7 @@ def set_spany(self, value): def gridspec(self): """The single `GridSpec` instance used for all subplots in the figure.""" - return self._gridspec_main + return self._gridspec @property def ref(self): @@ -1951,41 +2349,9 @@ def ref(self, ref): self.stale = True self._ref = ref - def _iter_axes(self): - """Iterates over all axes and panels in the figure belonging to the - `~proplot.axes.Axes` class. Excludes inset and twin axes.""" - axs = [] - for ax in (*self._axes_main, *self._lpanels, *self._rpanels, - *self._bpanels, *self._tpanels): - if not ax or not ax.get_visible(): - continue - axs.append(ax) - for ax in axs: - for s in 'lrbt': - for iax in getattr(ax, '_' + s + 'panels'): - if not iax or not iax.get_visible(): - continue - axs.append(iax) - return axs - - -def _journals(journal): - """Return the width and height corresponding to the given journal.""" - # Get dimensions for figure from common journals. - value = JOURNAL_SPECS.get(journal, None) - if value is None: - raise ValueError( - f'Unknown journal figure size specifier {journal!r}. ' - 'Current options are: ' - + ', '.join(map(repr, JOURNAL_SPECS.keys())) - ) - # Return width, and optionally also the height - width, height = None, None - try: - width, height = value - except (TypeError, ValueError): - width = value - return width, height + # Add documentation + add_gridspec.__doc__ = _gridspec_doc + set_gridspec.__doc__ = _gridspec_doc def _axes_dict(naxs, value, kw=False, default=None): @@ -2035,19 +2401,44 @@ def _axes_dict(naxs, value, kw=False, default=None): return kwargs -def subplots( - array=None, ncols=1, nrows=1, - ref=1, order='C', - aspect=1, figsize=None, - width=None, height=None, journal=None, - axwidth=None, axheight=None, - hspace=None, wspace=None, space=None, - hratios=None, wratios=None, - width_ratios=None, height_ratios=None, - left=None, bottom=None, right=None, top=None, - basemap=False, proj=None, projection=None, - proj_kw=None, projection_kw=None, +def _journal_figsize(journal): + """Return the dimensions associated with this journal string.""" + # Get dimensions for figure from common journals. + value = JOURNAL_SPECS.get(journal, None) + if value is None: + raise ValueError( + f'Unknown journal figure size specifier {journal!r}. Options are: ' + ', '.join(map(repr, JOURNAL_SPECS.keys())) + '.') + # Return width, and optionally also the height + width, height = None, None + try: + width, height = value + except (TypeError, ValueError): + width = value + return width, height + +# TODO: Figure out how to save subplots keyword args! +@docstring.dedent_interpd +def figure(**kwargs): + """ + Analogous to `matplotlib.pyplot.figure`, create an empty figure meant + to be filled with axes using `Figure.add_subplot`. + + Parameters + ---------- + %(figure_doc)s **kwargs + Passed to `Figure`. + """ + return plt.figure(FigureClass=Figure, **kwargs) + + +def subplots( + array=None, ncols=1, nrows=1, ref=1, order='C', + left=None, right=None, bottom=None, top=None, wspace=None, hspace=None, + hratios=None, wratios=None, width_ratios=None, height_ratios=None, + proj=None, projection=None, proj_kw=None, projection_kw=None, + basemap=False, **kwargs ): """ Create a figure with a single subplot or arbitrary grids of subplots, @@ -2074,108 +2465,41 @@ def subplots( (``'F'``) order. Analogous to `numpy.array` ordering. This controls the order axes appear in the `axs` list, and the order of subplot a-b-c labeling (see `~proplot.axes.Axes.format`). - figsize : length-2 tuple, optional - Tuple specifying the figure `(width, height)`. - width, height : float or str, optional - The figure width and height. Units are interpreted by - `~proplot.utils.units`. - journal : str, optional - String name corresponding to an academic journal standard that is used - to control the figure width (and height, if specified). See below - table. - - =========== ==================== ========================================================================================================================================================== - Key Size description Organization - =========== ==================== ========================================================================================================================================================== - ``'aaas1'`` 1-column `American Association for the Advancement of Science `__ (e.g. *Science*) - ``'aaas2'`` 2-column ” - ``'agu1'`` 1-column `American Geophysical Union `__ - ``'agu2'`` 2-column ” - ``'agu3'`` full height 1-column ” - ``'agu4'`` full height 2-column ” - ``'ams1'`` 1-column `American Meteorological Society `__ - ``'ams2'`` small 2-column ” - ``'ams3'`` medium 2-column ” - ``'ams4'`` full 2-column ” - ``'nat1'`` 1-column `Nature Research `__ - ``'nat2'`` 2-column ” - ``'pnas1'`` 1-column `Proceedings of the National Academy of Sciences `__ - ``'pnas2'`` 2-column ” - ``'pnas3'`` landscape page ” - =========== ==================== ========================================================================================================================================================== - - ref : int, optional - The reference axes number. The `axwidth`, `axheight`, and `aspect` - keyword args are applied to this axes, and aspect ratio is conserved - for this axes in tight layout adjustment. - axwidth, axheight : float or str, optional - Sets the average width, height of your axes. Units are interpreted by - `~proplot.utils.units`. Default is :rc:`subplots.axwidth`. - - These arguments are convenient where you don't care about the figure - dimensions and just want your axes to have enough "room". - aspect : float or length-2 list of floats, optional - The (average) axes aspect ratio, in numeric form (width divided by - height) or as (width, height) tuple. If you do not provide - the `hratios` or `wratios` keyword args, all axes will have - identical aspect ratios. - hratios, wratios - Aliases for `height_ratios`, `width_ratios`. - width_ratios, height_ratios : float or list thereof, optional - Passed to `GridSpec`, denotes the width - and height ratios for the subplot grid. Length of `width_ratios` - must match the number of rows, and length of `height_ratios` must - match the number of columns. - wspace, hspace, space : float or str or list thereof, optional - Passed to `GridSpec`, denotes the - spacing between grid columns, rows, and both, respectively. If float - or string, expanded into lists of length ``ncols-1`` (for `wspace`) - or length ``nrows-1`` (for `hspace`). - - Units are interpreted by `~proplot.utils.units` for each element of - the list. By default, these are determined by the "tight - layout" algorithm. - left, right, top, bottom : float or str, optional - Passed to `GridSpec`, denotes the width of padding between the - subplots and the figure edge. Units are interpreted by - `~proplot.utils.units`. By default, these are determined by the - "tight layout" algorithm. proj, projection : str or dict-like, optional - The map projection name. The argument is interpreted as follows. - - * If string, this projection is used for all subplots. For valid - names, see the `~proplot.projs.Proj` documentation. - * If list of string, these are the projections to use for each - subplot in their `array` order. - * If dict-like, keys are integers or tuple integers that indicate - the projection to use for each subplot. If a key is not provided, - that subplot will be a `~proplot.axes.XYAxes`. For example, - in a 4-subplot figure, ``proj={2:'merc', (3,4):'stere'}`` - draws a Cartesian axes for the first subplot, a Mercator - projection for the second subplot, and a Stereographic projection - for the second and third subplots. - + The map projection name(s), passed to `~proplot.projs.Proj`. The + argument is interpreted as follows. + + * If string, this projection is used for all subplots. See + `~proplot.projs.Proj` for a table of map projection names. + * If list of strings, these projections are used for each + subplot in the order specified by `array` or `order`. + * If dict-like, the keys are integers or tuples of integers + corresponding to subplot numbers, and the values are strings + indicating the projection. If a key is not provided, the subplot + will be `~proplot.axes.CartesianAxes`. + + For example, with ``ncols=4`` and ``proj={2:'merc', (3,4):'cyl'}``, + the first subplot is a normal axes, the second is a Mercator + projection, and the third and fourth are cylindrical projections. proj_kw, projection_kw : dict-like, optional - Keyword arguments passed to `~mpl_toolkits.basemap.Basemap` or - cartopy `~cartopy.crs.Projection` classes on instantiation. - If dictionary of properties, applies globally. If *dictionary of - dictionaries* of properties, applies to specific subplots, as - with `proj`. - - For example, with ``ncols=2`` and - ``proj_kw={1:{'lon_0':0}, 2:{'lon_0':180}}``, the projection in - the left subplot is centered on the prime meridian, and the projection - in the right subplot is centered on the international dateline. + Dictionary of keyword args for the projection class. Passed to + `~proplot.projs.Proj`. Can be set for specific subplots just like + `proj`. For example, with ``ncols=2`` and + ``proj_kw={1:dict(lon_0=0), 2:dict(lon_0=180)}``, the left subplot is + centered on the prime meridian and the right subplot is centered on + the international dateline. basemap : bool or dict-like, optional - Whether to use `~mpl_toolkits.basemap.Basemap` or - `~cartopy.crs.Projection` for map projections. Default is ``False``. - If boolean, applies to all subplots. If dictionary, values apply to - specific subplots, as with `proj`. + Whether to use basemap or cartopy for map projections. Default is + ``False``. Can be set for specific subplots just like `proj`. + For example, with ``basemap={1:False, 2:True}``, the left subplot is + a cartopy projection and the right subplot is a basemap projection. Other parameters ---------------- - **kwargs - Passed to `Figure`. + %(figure_doc)s + hratios, wratios, height_ratios, width_ratios + Passed to `GridSpec`. These describe the ratios between successive + rows and columns of the subplot grid. Returns ------- @@ -2224,15 +2548,10 @@ def subplots( 'one of {nums}.' ) nrows, ncols = array.shape - - # 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 + # Get axes ranges from array + axids = [np.where(array == i) for i in np.sort(np.unique(array)) if i > 0] xrange = np.array([[x.min(), x.max()] for _, x in axids]) yrange = np.array([[y.min(), y.max()] for y, _ in axids]) - xref = xrange[ref - 1, :] # range for reference axes - yref = yrange[ref - 1, :] # Get basemap.Basemap or cartopy.crs.Projection instances for map proj = _notNone(projection, proj, None, names=('projection', 'proj')) @@ -2241,93 +2560,16 @@ def subplots( proj = _axes_dict(naxs, proj, kw=False, default='xy') proj_kw = _axes_dict(naxs, proj_kw, kw=True) basemap = _axes_dict(naxs, basemap, kw=False, default=False) - axes_kw = {num: {} - for num in range(1, naxs + 1)} # stores add_subplot arguments - for num, name in proj.items(): - # The default is XYAxes - if name is None or name == 'xy': - axes_kw[num]['projection'] = 'xy' - # Builtin matplotlib polar axes, just use my overridden version - elif name == 'polar': - axes_kw[num]['projection'] = 'polar' - if num == ref: - aspect = 1 - # Custom Basemap and Cartopy axes - else: - package = 'basemap' if basemap[num] else 'geo' - m = projs.Proj( - name, basemap=basemap[num], **proj_kw[num] - ) - if num == ref: - if basemap[num]: - aspect = ( - (m.urcrnrx - m.llcrnrx) / (m.urcrnry - m.llcrnry) - ) - else: - aspect = ( - np.diff(m.x_limits) / np.diff(m.y_limits) - )[0] - axes_kw[num].update({'projection': package, 'map_projection': m}) - - # Figure and/or axes dimensions - names, values = (), () - if journal: - # if user passed width= , will use that journal size - figsize = _journals(journal) - spec = f'journal={journal!r}' - names = ('axwidth', 'axheight', 'width') - values = (axwidth, axheight, width) - width, height = figsize - elif figsize: - spec = f'figsize={figsize!r}' - names = ('axwidth', 'axheight', 'width', 'height') - values = (axwidth, axheight, width, height) - width, height = figsize - elif width is not None or height is not None: - spec = [] - if width is not None: - spec.append(f'width={width!r}') - if height is not None: - spec.append(f'height={height!r}') - spec = ', '.join(spec) - names = ('axwidth', 'axheight') - values = (axwidth, axheight) - # Raise warning - for name, value in zip(names, values): - if value is not None: - _warn_proplot( - f'You specified both {spec} and {name}={value!r}. ' - f'Ignoring {name!r}.' - ) - # Standardized dimensions - width, height = units(width), units(height) - axwidth, axheight = units(axwidth), units(axheight) - # Standardized user input border spaces - left, right = units(left), units(right) - bottom, top = units(bottom), units(top) - # Standardized user input spaces - wspace = np.atleast_1d(units(_notNone(wspace, space))) - hspace = np.atleast_1d(units(_notNone(hspace, space))) - if len(wspace) == 1: - wspace = np.repeat(wspace, (ncols - 1,)) - if len(wspace) != ncols - 1: - raise ValueError( - f'Require {ncols-1} width spacings for {ncols} columns, ' - 'got {len(wspace)}.' - ) - if len(hspace) == 1: - hspace = np.repeat(hspace, (nrows - 1,)) - if len(hspace) != nrows - 1: - raise ValueError( - f'Require {nrows-1} height spacings for {nrows} rows, ' - 'got {len(hspace)}.' - ) # Standardized user input ratios - wratios = np.atleast_1d(_notNone(width_ratios, wratios, 1, - names=('width_ratios', 'wratios'))) - hratios = np.atleast_1d(_notNone(height_ratios, hratios, 1, - names=('height_ratios', 'hratios'))) + wratios = np.atleast_1d(_notNone( + width_ratios, wratios, 1, + names=('width_ratios', 'wratios') + )) + hratios = np.atleast_1d(_notNone( + height_ratios, hratios, 1, + names=('height_ratios', 'hratios') + )) if len(wratios) == 1: wratios = np.repeat(wratios, (ncols,)) if len(hratios) == 1: @@ -2336,46 +2578,17 @@ def subplots( raise ValueError(f'Got {ncols} columns, but {len(wratios)} wratios.') if len(hratios) != nrows: raise ValueError(f'Got {nrows} rows, but {len(hratios)} hratios.') + wratios, hratios = wratios.tolist(), hratios.tolist() # also makes copy - # Fill subplots_orig_kw with user input values - # NOTE: 'Ratios' are only fixed for panel axes, but we store entire array - wspace, hspace = wspace.tolist(), hspace.tolist() - wratios, hratios = wratios.tolist(), hratios.tolist() - subplots_orig_kw = { - 'left': left, 'right': right, 'top': top, 'bottom': bottom, - 'wspace': wspace, 'hspace': hspace, - } - - # 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')) - top = _notNone(top, _get_space('top')) - wspace, hspace = np.array(wspace), np.array(hspace) # also copies! - wspace[wspace == None] = _get_space('wspace', sharex) # noqa - hspace[hspace == None] = _get_space('hspace', sharey) # noqa - wratios, hratios = list(wratios), list(hratios) - wspace, hspace = list(wspace), list(hspace) - - # Parse arguments, fix dimensions in light of desired aspect ratio - figsize, gridspec_kw, subplots_kw = _subplots_geometry( + # Generate figure and gridspec + # NOTE: This time we initialize the *gridspec* with user input values + # TODO: Repair _update_gridspec so it works! + fig = plt.figure(FigureClass=Figure, ref=ref, **kwargs) + gs = fig._update_gridspec( nrows=nrows, ncols=ncols, - aspect=aspect, xref=xref, yref=yref, left=left, right=right, bottom=bottom, top=top, - width=width, height=height, axwidth=axwidth, axheight=axheight, - wratios=wratios, hratios=hratios, wspace=wspace, hspace=hspace, - wpanels=[''] * ncols, hpanels=[''] * nrows, + wratios=wratios, hratios=hratios ) - fig = plt.figure( - FigureClass=Figure, figsize=figsize, ref=ref, - gridspec_kw=gridspec_kw, subplots_kw=subplots_kw, - subplots_orig_kw=subplots_orig_kw, - **kwargs - ) - gridspec = fig._gridspec_main # Draw main subplots axs = naxs * [None] # list of axes @@ -2385,12 +2598,11 @@ def subplots( x0, x1 = xrange[idx, 0], xrange[idx, 1] y0, y1 = yrange[idx, 0], yrange[idx, 1] # Draw subplot - subplotspec = gridspec[y0:y1 + 1, x0:x1 + 1] - with fig._authorize_add_subplot(): - axs[idx] = fig.add_subplot( - subplotspec, number=num, main=True, - **axes_kw[num] - ) + ss = gs[y0:y1 + 1, x0:x1 + 1] + axs[idx] = fig.add_subplot( + ss, number=num, main=True, + proj=proj[num], basemap=basemap[num], proj_kw=proj_kw[num] + ) # Shared axes setup # TODO: Figure out how to defer this to drawtime in #50