From fc9a23dd01b1a2701dadb728febcc8417fe097cd Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Tue, 17 Sep 2019 19:40:06 -0600 Subject: [PATCH 01/37] Process projection args in add_subplot and ensure matching geometry --- proplot/subplots.py | 78 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index 81803163e..8931f646a 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -1457,12 +1457,78 @@ def _update_suptitle(self, title, **kwargs): if kwargs: self._suptitle.update(kwargs) - def add_subplot(self, *args, **kwargs): - """Issues warning for new users that try to call - `~matplotlib.figure.Figure.add_subplot` manually.""" - if self._locked: - warnings.warn('Using "fig.add_subplot()" with ProPlot figures may result in unexpected behavior. Please use "proplot.subplots()" instead.') - ax = super().add_subplot(*args, **kwargs) + def add_subplot(self, *args, + proj=None, projection=None, basemap=False, + **kwargs): + """ + Adds subplot using the existing figure gridspec. + + Parameters + ---------- + *args + If `~matplotlib.gridspec.SubplotSpec` instance, must be a child + of the main subplot gridspec! + + If a 3-digit integer or a tuple indicating (nrows, ncols, index), + the geometry must match the geometry of the input gridspec! + proj, projection : str, optional + The registered matplotlib projection name, or a basemap or cartopy + map projection name. For valid map projection names, see the + :ref:`Table of projections`. + """ + # TODO: Consider permitting add_subplot? + # 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(f'Integer subplot specification must be a three-digit number, not {args[0]!r}.') + args = tuple(map(int, str(args[0]))) + # Copied from SubplotBase __init__ and modified to enforce restrictions + gridspec = self._gridspec_main + subplotspec = None + if len(args) == 1: + if isinstance(args[0], SubplotSpec): + subplotspec = args[0] + else: + try: + s = str(int(args[0])) + rows, cols, 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: + rows, cols, num = args + else: + raise ValueError(f'Illegal argument(s) to add_subplot: {args}') + if subplotspec is None: + rows = int(rows) + cols = int(cols) + if isinstance(num, tuple) and len(num) == 2: + num = [int(n) for n in num] + else: + if num < 1 or num > rows*cols: + raise ValueError(f'num must be 1 <= num <= {rows*cols}, not {num}') + if (rows, cols) != gridspec.get_active_geometry(): + raise ValueError(f'Input arguments {args!r} conflict with existing gridspec geometry of {rows} rows, {cols} columns.') + if not isinstance(num, tuple): + num = (num, num) + subplotspec = gridspec[(num[0] - 1):num[1]] + + # The default is CartesianAxes + proj = _notNone(proj, projection, 'cartesian', names=('proj','projection')) + # Builtin matplotlib polar axes, just use my overridden version + if name == 'polar': + proj = 'polar2' + # Custom Basemap and Cartopy axes + # 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? + elif proj != 'cartesian': + kwargs = projs.Proj(name, basemap=basemap, **kwargs) + proj = 'basemap' if basemap else 'cartopy' + + # Initialize + ax = super().add_subplot(subplotspec, projection=proj, **kwargs) return ax def colorbar(self, *args, From 0bbcfc451b9c63c94e7e2f9484d1ab139dc0fb81 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Tue, 17 Sep 2019 19:45:41 -0600 Subject: [PATCH 02/37] Remove projection processing from subplots(), update add_subplot() docs --- proplot/subplots.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index 8931f646a..4bb97474c 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -1475,6 +1475,12 @@ def add_subplot(self, *args, The registered matplotlib projection name, or a basemap or cartopy map projection name. For valid map projection names, see the :ref:`Table of projections`. + + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.figure.Figure.add_subplot`. Also passed + to `~proplot.proj.Proj` if this is a cartopy or basemap projection. """ # TODO: Consider permitting add_subplot? # Copied from matplotlib add_subplot @@ -2042,23 +2048,6 @@ def subplots(array=None, ncols=1, nrows=1, proj = _axes_dict(naxs, proj, kw=False, default='cartesian') 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 CartesianAxes - if name is None or name == 'cartesian': - axes_kw[num]['projection'] = 'cartesian' - # Builtin matplotlib polar axes, just use my overridden version - elif name == 'polar': - axes_kw[num]['projection'] = 'polar2' - if num == ref: - aspect = 1 - # Custom Basemap and Cartopy axes - else: - package = 'basemap' if basemap[num] else 'cartopy' - obj, iaspect = projs.Proj(name, basemap=basemap[num], **proj_kw[num]) - if num == ref: - aspect = iaspect - axes_kw[num].update({'projection':package, 'map_projection':obj}) #-------------------------------------------------------------------------# # Figure architecture @@ -2180,9 +2169,8 @@ def subplots(array=None, ncols=1, nrows=1, with fig._unlock(): axs[idx] = fig.add_subplot(subplotspec, number=num, spanx=spanx, spany=spany, alignx=alignx, aligny=aligny, - sharex=sharex, sharey=sharey, - main=True, - **axes_kw[num]) + sharex=sharex, sharey=sharey, main=True, + proj=proj[num], basemap=basemap[num], **proj_kw[num]) # Return figure and axes n = (ncols if order == 'C' else nrows) From 7c9419d3ca01554a9f2df97e856a43451c7fb168 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Tue, 17 Sep 2019 19:51:07 -0600 Subject: [PATCH 03/37] Update Proj, no longer returns aspect becuase that is fixed automatically by _adjust_aspect --- proplot/projs.py | 6 +----- proplot/subplots.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/proplot/projs.py b/proplot/projs.py index cd5e356aa..879083bd3 100644 --- a/proplot/projs.py +++ b/proplot/projs.py @@ -158,8 +158,6 @@ def Proj(name, basemap=False, **kwargs): kwproj.setdefault('rsphere', (6378137.00,6356752.3142)) reso = kwproj.pop('resolution', None) or kwproj.pop('reso', None) or 'c' proj = mbasemap.Basemap(projection=name, resolution=reso, **kwproj) - aspect = (proj.urcrnrx - proj.llcrnrx) / \ - (proj.urcrnry - proj.llcrnry) # Cartopy else: import cartopy.crs as ccrs # verify package is available @@ -172,9 +170,7 @@ def Proj(name, basemap=False, **kwargs): if crs is None: raise ValueError(f'Unknown projection "{name}". Options are: {", ".join(cartopy_projs.keys())}.') proj = crs(**kwargs) - aspect = (np.diff(proj.x_limits) / \ - np.diff(proj.y_limits))[0] - return proj, aspect + return proj # Various pseudo-rectangular projections # Inspired by source code for Mollweide implementation diff --git a/proplot/subplots.py b/proplot/subplots.py index 4bb97474c..2642b0edb 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -74,6 +74,8 @@ import matplotlib.figure as mfigure import matplotlib.transforms as mtransforms import matplotlib.gridspec as mgridspec +from numbers import Integral +from matplotlib.gridspec import SubplotSpec try: import matplotlib.backends.backend_macosx as mbackend except ImportError: @@ -1459,6 +1461,7 @@ def _update_suptitle(self, title, **kwargs): def add_subplot(self, *args, proj=None, projection=None, basemap=False, + proj_kw=None, projection_kw=None, **kwargs): """ Adds subplot using the existing figure gridspec. @@ -1521,16 +1524,20 @@ def add_subplot(self, *args, subplotspec = gridspec[(num[0] - 1):num[1]] # The default is CartesianAxes - proj = _notNone(proj, projection, 'cartesian', names=('proj','projection')) + proj = _notNone(proj, projection, 'cartesian', names=('proj', 'projection')) + proj_kw = _notNone(proj_kw, projection_kw, {}, names=('proj_kw', 'projection_kw')) # Builtin matplotlib polar axes, just use my overridden version - if name == 'polar': + if proj == 'polar': proj = 'polar2' # Custom Basemap and Cartopy axes # 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? elif proj != 'cartesian': - kwargs = projs.Proj(name, basemap=basemap, **kwargs) + map_projection = projs.Proj(proj, basemap=basemap, **proj_kw) + if 'map_projection' in kwargs: + warnings.warn(f'Ignoring input "map_projection" {kwargs["map_projection"]!r}.') + kwargs['map_projection'] = map_projection proj = 'basemap' if basemap else 'cartopy' # Initialize From e6694822e53d1316e7f5ecad7abfbbc9b509d4c3 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sun, 29 Sep 2019 23:19:32 +0300 Subject: [PATCH 04/37] Rename FlexibleGridSpec --> GridSpec (like Axes + Figure), update developer notes --- proplot/subplots.py | 98 +++++++++++++++------------------------------ 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index 0f32570ef..f16b170f8 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -3,66 +3,32 @@ The starting point for creating custom ProPlot figures and axes. The `subplots` function is all you'll need to directly use here. It returns a `Figure` instance and an `axes_grid` container of -`~proplot.axes.Axes` axes, whose positions are controlled by the -`FlexibleGridSpec` class. +`~proplot.axes.Axes` axes, whose positions are controlled by the new +`GridSpec` class. .. raw:: html

Developer notes

-Matplotlib permits arbitrarily many gridspecs per figure, and -encourages serial calls to `~matplotlib.figure.Figure.add_subplot`. By -contrast, ProPlot permits only *one* `~matplotlib.gridspec.GridSpec` per -figure, and forces the user to construct axes and figures with `subplots`. On -the whole, matplotlib's approach is fairly cumbersome, but necessary owing to -certain design limitations. The following describes how ProPlot addresses -these limitations. - -* Matplotlib's `~matplotlib.gridspec.GridSpec` can only implement *uniform* - spacing between rows and columns of subplots. This seems to be the main - reason the `~matplotlib.gridspec.GridSpecFromSubplotSpec` class was - invented. To get variable spacing, users have to build their own nested - `~matplotlib.gridspec.GridSpec` objects and manually pass - `~matplotlib.gridspec.SubplotSpec` objects to - `~matplotlib.figure.Figure.add_subplot`. - - ProPlot's `FlexibleGridSpec` class permits *variable* spacing between - rows and columns of subplots. This largely eliminates the need for - `~matplotlib.gridspec.GridSpecFromSubplotSpec`, and prevents users - from having to pass their own `~matplotlib.gridspec.SubplotSpec` objects - to `~matplotlib.figure.Figure.add_subplot`. - -* Matplotlib's `~matplotlib.pyplot.subplots` can only draw simple 2D - arrangements of subplots. To make complex arrangements, the user must - generate their own `~matplotlib.gridspec.SubplotSpec` instances, or serially - pass 3-digit integers or 3 positional arguments - `~matplotlib.figure.Figure.add_subplot`. And to simplify this workflow, the - gridspec geometry must be allowed to vary. For example, to draw a simple - grid with two subplots on the top and one subplot on the bottom, the user - can serially pass ``221``, ``222``, and ``212`` to - `~matplotlib.figure.Figure.add_subplot`. But if the gridspec geometry - was required to remain the same, instead of ``212``, the user would have to - use ``2, 2, (3,4)``, which is much more cumbersome. So, the figure - must support multiple gridspec geometries -- i.e. it must support multiple - gridspecs! - - With ProPlot, users can build complex subplot grids using the ``array`` - `subplots` argument. This means that serial calls to - `~matplotlib.figure.Figure.add_subplot` are no longer necessary, and we can - use just *one* gridspec for the whole figure without imposing any new - limitations. Among other things, this simplifies the "tight layout" - algorithm. - - -Also note that ProPlot's `subplots` returns an `axes_grid` of axes, while -matplotlib's `~matplotlib.pyplot.subplots` returns a 2D `~numpy.ndarray`, -a 1D `~numpy.ndarray`, or the axes itself. `axes_grid` was invented in -part because `subplots` can draw arbitrarily complex arrangements of -subplots, not just simple grids. `axes_grid` is a `list` subclass supporting -1D indexing (e.g. ``axs[0]``), but permits 2D indexing (e.g. ``axs[1,0]``) -*just in case* the user *happened* to draw a clean 2D matrix of subplots. -The `~axes_grid.__getattr__` override also means it no longer matters -whether you are calling a method on an axes or a singleton `axes_grid` of axes. +While matplotlib permits arbitrarily many gridspecs per figure, ProPlot +permits only *one*. When `subplots` is used, this is trivial to enforce. When +`~Figure.add_subplot` is used, the figure geometry is "locked" after the +first call -- although `~Figure.add_subplot` calls that divide into the +existing geometry are also acceptable (for example, two square subplots above +a longer rectangle subplots with the integers ``221``, ``222``, and ``212``). +This choice is not a major imposition on the user, and *considerably* +simplifies gridspec adjustments, e.g. the "tight layout" adjustments. + +While matplotlib's `~matplotlib.pyplot.subplots` returns a 2D `~numpy.ndarray`, +a 1D `~numpy.ndarray`, or the axes itself, ProPlot's `subplots` returns an +`axes_grid` of axes, meant to unify these three possible return values. +`axes_grid` is a `list` subclass supporting 1D indexing (e.g. ``axs[0]``), but +permits 2D indexing (e.g. ``axs[1,0]``) *just in case* the user *happened* +to draw a clean 2D matrix of subplots. The `~axes_grid.__getattr__` override +also means it no longer matters whether you are calling a method on an axes +or a singleton `axes_grid` of axes. Finally, `axes_grid` lets `subplots` +support complex arrangements of subplots -- just use 1D indexing when they +don't look like a 2D matrix. """ # NOTE: Importing backend causes issues with sphinx, and anyway not sure it's # always included, so make it optional @@ -84,7 +50,7 @@ from .utils import _notNone, _counter, units from . import projs, axes __all__ = [ - 'axes_grid', 'close', 'show', 'subplots', 'Figure', 'FlexibleGridSpec', + 'axes_grid', 'close', 'show', 'subplots', 'Figure', 'GridSpec', ] # Translation @@ -346,7 +312,7 @@ def get_active_rows_columns(self): col2 = col1 return nrows//2, ncols//2, row1//2, row2//2, col1//2, col2//2 -class FlexibleGridSpec(mgridspec.GridSpec): +class GridSpec(mgridspec.GridSpec): """ `~matplotlib.gridspec.GridSpec` generalization that allows for grids with *variable spacing* between successive rows and columns of axes. @@ -371,7 +337,7 @@ def __init__(self, figure, nrows=1, ncols=1, **kwargs): 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 - `FlexibleGridSpec` directly, values are scaled relative to + `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 @@ -446,7 +412,7 @@ def _spaces_as_ratios(self, hspace=None, wspace=None, # spacing between axes height_ratios=None, width_ratios=None, **kwargs): - """For keyword arg usage, see `FlexibleGridSpec`.""" + """For keyword arg usage, see `GridSpec`.""" # Parse flexible input nrows, ncols = self.get_active_geometry() hratios = np.atleast_1d(_notNone(height_ratios, 1)) @@ -508,7 +474,7 @@ def get_active_width_ratios(self): def get_active_geometry(self): """Returns the number of active rows and columns, i.e. the rows and - columns that aren't skipped by `~FlexibleGridSpec.__getitem__`.""" + columns that aren't skipped by `~GridSpec.__getitem__`.""" return self._nrows_active, self._ncols_active def update(self, **kwargs): @@ -814,7 +780,7 @@ def __init__(self, self._tpanels = [] self._lpanels = [] self._rpanels = [] - gridspec = FlexibleGridSpec(self, **(gridspec_kw or {})) + gridspec = GridSpec(self, **(gridspec_kw or {})) nrows, ncols = gridspec.get_active_geometry() self._barray = np.empty((0, ncols), dtype=bool) self._tarray = np.empty((0, ncols), dtype=bool) @@ -1308,7 +1274,7 @@ def _insert_row_column(self, side, idx, gridspec.update(**gridspec_kw) else: # New gridspec - gridspec = FlexibleGridSpec(self, **gridspec_kw) + 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, @@ -1873,12 +1839,12 @@ def subplots(array=None, ncols=1, nrows=1, hratios, wratios Aliases for `height_ratios`, `width_ratios`. width_ratios, height_ratios : float or list thereof, optional - Passed to `FlexibleGridSpec`. The width + Passed to `GridSpec`. 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 `FlexibleGridSpec`, denotes the + 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`). @@ -1887,9 +1853,9 @@ def subplots(array=None, ncols=1, nrows=1, the list. By default, these are determined by the "tight layout" algorithm. left, right, top, bottom : float or str, optional - Passed to `FlexibleGridSpec`, denote the width of padding between the + 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 + `~proplot.utils.units`. By default, padding is determined by the "tight layout" algorithm. sharex, sharey, share : {3, 2, 1, 0}, optional From 7dd3b3a444d029d81bff7c7c1c37e15716ce11b7 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 30 Sep 2019 00:09:36 +0300 Subject: [PATCH 05/37] Add figure_kwargs to docstring.interpd --- proplot/subplots.py | 110 +++++++++++++++++++++++--------------------- 1 file changed, 58 insertions(+), 52 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index f16b170f8..fe8875666 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -41,6 +41,7 @@ import matplotlib.transforms as mtransforms import matplotlib.gridspec as mgridspec from numbers import Integral +from matplotlib import docstring from matplotlib.gridspec import SubplotSpec try: import matplotlib.backends.backend_macosx as mbackend @@ -50,7 +51,7 @@ from .utils import _notNone, _counter, units from . import projs, axes __all__ = [ - 'axes_grid', 'close', 'show', 'subplots', 'Figure', 'GridSpec', + 'axes_grid', 'close', 'figure', 'show', 'subplots', 'Figure', 'GridSpec', ] # Translation @@ -704,56 +705,57 @@ def __exit__(self, *args): for label in self._labels: label.set_visible(True) +# TODO: obfuscate Figure call signature like with axes methods? +figure_kwargs = """ +tight : bool, optional + Toggles automatic tight layout adjustments. Default is + :rc:`tight`. +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``. +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. +""" +docstring.interpd.update(figure_kwargs=figure_kwargs.strip()) + 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, tight=None, pad=None, axpad=None, panelpad=None, includepanels=False, - autoformat=True, - ref=1, order='C', # documented in subplots but needed here + autoformat=True, ref_num=1, # ref_num should never change subplots_kw=None, gridspec_kw=None, subplots_orig_kw=None, tight_layout=None, constrained_layout=None, **kwargs): """ Parameters ---------- - tight : bool, optional - Toggles automatic tight layout adjustments. Default is - :rc:`tight`. - 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``. - 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. + %(figure_kwargs)s 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 - ---------------- - ref, order - Documented in `subplots`. tight_layout, constrained_layout Ignored, because ProPlot uses its own tight layout algorithm. **kwargs @@ -771,7 +773,6 @@ def __init__(self, self._auto_format = autoformat self._auto_tight_layout = _notNone(tight, rc['tight']) self._include_panels = includepanels - self._order = order # used for configuring panel axes_grids self._ref_num = ref self._axes_main = [] self._subplots_orig_kw = subplots_orig_kw @@ -1709,7 +1710,7 @@ def _iter_axes(self): return axs #-----------------------------------------------------------------------------# -# Primary plotting function, used to create figure/axes +# Primary functions used to create figures and axes #-----------------------------------------------------------------------------# def _journals(journal): """Journal sizes for figures.""" @@ -1766,6 +1767,26 @@ def _axes_dict(naxs, value, kw=False, default=None): raise ValueError(f'Have {naxs} axes, but {value} has properties for axes {", ".join(str(i) for i in sorted(kwargs.keys()))}.') return kwargs +# TODO: Figure out how to save subplots keyword args! +@docstring.dedent_interpd +def figure(**kwargs): + """ + Analogous to `matplotlib.pyplot.figure`, creates an empty figure meant + to be filled with axes using `Figure.add_subplot`. + + Parameters + ---------- + %(figure_kwargs)s + **kwargs + Passed to `~matplotlib.figure.Figure`. + """ + # TODO: Repair subplots-dependent behavior! Permit serial args! + kwargs['gridspec_kw'] = None + kwargs['subplots_kw'] = None + kwargs['subplots_orig_kw'] = None + return plt.figure(FigureClass=Figure, **kwargs) + +@docstring.dedent_interpd def subplots(array=None, ncols=1, nrows=1, ref=1, order='C', aspect=1, figsize=None, @@ -1922,23 +1943,7 @@ def subplots(array=None, ncols=1, nrows=1, Other parameters ---------------- - tight : bool, optional - Toggles automatic tight layout adjustments. Default is - :rc:`tight`. - - If you manually specify a spacing, 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. - pad, axpad, panelpad : float or str, optional - Padding for automatic tight layout adjustments. See `Figure` for - details. - includepanels : bool, optional - Whether to include panels when calculating the position of certain - spanning labels. See `Figure` for details. - autoformat : bool, optional - Whether to automatically format axes when special datasets are - passed to plotting commands. See `Figure` for details. + %(figure_kwargs)s Returns ------- @@ -2120,7 +2125,8 @@ def subplots(array=None, ncols=1, nrows=1, wratios=wratios, hratios=hratios, wspace=wspace, hspace=hspace, wpanels=['']*ncols, hpanels=['']*nrows, ) - fig = plt.figure(FigureClass=Figure, tight=tight, figsize=figsize, ref=ref, + fig = plt.figure(FigureClass=Figure, tight=tight, figsize=figsize, + ref_num=ref, pad=pad, axpad=axpad, panelpad=panelpad, autoformat=autoformat, includepanels=includepanels, subplots_orig_kw=subplots_orig_kw, subplots_kw=subplots_kw, From fe8607afd0c07b68e4bf244d683aa8ee711f40f0 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 30 Sep 2019 00:23:13 +0300 Subject: [PATCH 06/37] Fix add_subplot docstring --- proplot/subplots.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index fe8875666..e80a70d1c 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -1436,11 +1436,15 @@ def add_subplot(self, *args, Parameters ---------- *args - If `~matplotlib.gridspec.SubplotSpec` instance, must be a child - of the main subplot gridspec! - - If a 3-digit integer or a tuple indicating (nrows, ncols, index), - the geometry must match the geometry of the input gridspec! + There are three options here. See the matplotlib + `~matplotlib.figure.add_subplot` documentation for details. + + * A `~matplotlib.gridspec.SubplotSpec` instance. Must be a child of + the "main" gridspec. + * 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, optional The registered matplotlib projection name, or a basemap or cartopy map projection name. For valid map projection names, see the From 08f5cbb8f6d29b70eeb8c1649d0557876b6091f9 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 03:47:55 -0600 Subject: [PATCH 07/37] Colormap() converts input matplotlib cmaps to subclass --- proplot/styletools.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/proplot/styletools.py b/proplot/styletools.py index 61a4b1f92..67e8b32dd 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -1664,9 +1664,15 @@ def Colormap(*args, name=None, listmode='perceptual', ireverse = False if not np.iterable(reverse) else reverse[i] ileft = None if not np.iterable(left) else left[i] iright = None if not np.iterable(right) else right[i] - # Interpret existing colormap - if isinstance(cmap, mcolors.Colormap): + # Convert matplotlib colormaps to subclasses + if isinstance(cmap, (ListedColormap, LinearSegmentedColormap)): pass + elif type(cmap) is mcolors.LinearSegmentedColormap: + cmap = LinearSegmentedColormap( + cmap.name, cmap._segmentdata, cmap.N, cmap._gamma) + elif type(cmap) is mcolors.ListedColormap: + cmap = ListedColormap( + cmap.colors, cmap.name, cmap.N) # Dictionary of hue/sat/luminance values or 2-tuples representing linear transition elif isinstance(cmap, dict): cmap = PerceptuallyUniformColormap.from_hsl(tmp, **cmap) From 0bcf8a9cafac70c9147cfbbe8ef0304ed36aa09b Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 04:50:56 -0600 Subject: [PATCH 08/37] GridSpec tracks figures automatically, fix add_subplot bugs Tmp --- proplot/axes.py | 2 +- proplot/subplots.py | 213 +++++++++++++++++++++++++------------------- 2 files changed, 124 insertions(+), 91 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index 4c2af10a2..534186611 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -2452,7 +2452,7 @@ def twiny(self): class PolarAxes(Axes, mproj.PolarAxes): """Intermediate class, mixes `ProjectionAxes` with `~matplotlib.projections.polar.PolarAxes`.""" - name = 'polar2' + name = 'polar' """The registered projection name.""" def __init__(self, *args, **kwargs): """ diff --git a/proplot/subplots.py b/proplot/subplots.py index 296f95f58..80b079c5c 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -42,7 +42,6 @@ import matplotlib.gridspec as mgridspec from numbers import Integral from matplotlib import docstring -from matplotlib.gridspec import SubplotSpec try: import matplotlib.backends.backend_macosx as mbackend except ImportError: @@ -51,7 +50,10 @@ from .utils import _notNone, _counter, units from . import projs, axes __all__ = [ - 'axes_grid', 'close', 'figure', 'show', 'subplots', 'Figure', 'GridSpec', + 'axes_grid', 'close', 'figure', + 'Figure', 'GridSpec', + 'show', 'subplots', + 'SubplotSpec', ] # Translation @@ -79,23 +81,15 @@ 'aaas2': '12cm', # AAAS 2 column } - #-----------------------------------------------------------------------------# # Miscellaneous stuff #-----------------------------------------------------------------------------# # Wrapper functions, so user doesn't have to import pyplot -def close(): - """Alias for ``matplotlib.pyplot.close('all')``, included so you don't have - to import `~matplotlib.pyplot`. Closes all figures stored - in memory.""" - plt.close('all') # easy peasy - -def show(): - """Alias for ``matplotlib.pyplot.show()``, included so you don't have - to import `~matplotlib.pyplot`. Note this command should be - unnecessary if you are doing inline iPython notebook plotting and ran the - `~proplot.notebook.nbsetup` command.""" - plt.show() +def _fp_equal(num1, num2, digits=10): + """Tests equality of two floating point numbers out to `N` digits. Used + in a couple places.""" + hi, lo = 10**digits, 10**-digits + return round(num1*hi)*lo == round(num2*hi)*lo # Helper classes class axes_grid(list): @@ -286,7 +280,7 @@ def _iterator(*args, **kwargs): #-----------------------------------------------------------------------------# # Gridspec classes #-----------------------------------------------------------------------------# -class FlexibleSubplotSpec(mgridspec.SubplotSpec): +class SubplotSpec(mgridspec.SubplotSpec): """ Adds two helper methods to `~matplotlib.gridspec.SubplotSpec` that return the geometry *excluding* rows and columns allocated for spaces. @@ -311,7 +305,7 @@ def get_active_rows_columns(self): else: row2 = row1 col2 = col1 - return nrows//2, ncols//2, row1//2, row2//2, col1//2, col2//2 + return (nrows + 1)//2, (ncols + 1)//2, row1//2, row2//2, col1//2, col2//2 class GridSpec(mgridspec.GridSpec): """ @@ -325,15 +319,13 @@ class GridSpec(mgridspec.GridSpec): These "spaces" are then allowed to vary in width using the builtin `width_ratios` and `height_ratios` properties. """ - def __init__(self, figure, nrows=1, ncols=1, **kwargs): + def __init__(self, nrows=1, ncols=1, **kwargs): """ 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. + The number of rows and columns on the subplot grid. This is + applied automatically when the gridspec is passed. hspace, wspace : float or list of float The vertical and horizontal spacing between rows and columns of subplots, respectively. In `~proplot.subplots.subplots`, ``wspace`` @@ -355,8 +347,9 @@ def __init__(self, figure, nrows=1, ncols=1, **kwargs): **kwargs Passed to `~matplotlib.gridspec.GridSpec`. """ - self._nrows = nrows*2-1 # used with get_geometry - self._ncols = ncols*2-1 + self._figures = set() # figure tracker + 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) @@ -364,7 +357,6 @@ def __init__(self, figure, nrows=1, ncols=1, **kwargs): hspace=0, wspace=0, # we implement these as inactive rows/columns width_ratios=wratios, height_ratios=hratios, - figure=figure, **kwargs, ) @@ -385,7 +377,7 @@ def __getitem__(self, key): num1, num2 = np.ravel_multi_index((num1, num2), (nrows, ncols)) num1 = self._positem(num1) num2 = self._positem(num2) - return FlexibleSubplotSpec(self, num1, num2) + return SubplotSpec(self, num1, num2) @staticmethod def _positem(size): @@ -452,6 +444,17 @@ def _spaces_as_ratios(self, hratios_final[1::2] = [*hspace] return wratios_final, hratios_final, kwargs # bring extra kwargs back + def add_figure(self, fig): + """Adds `~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.""" + self._figures.add(fig) + + def remove_figure(self): + """Removes `~matplotlib.figure.Figure` from the list of figures that + are using this gridspec.""" + self._figures.discard(fig) + def get_margins(self): """Returns left, bottom, right, top values. Not sure why this method doesn't already exist on `~matplotlib.gridspec.GridSpec`.""" @@ -478,17 +481,17 @@ def get_active_geometry(self): columns that aren't skipped by `~GridSpec.__getitem__`.""" return self._nrows_active, self._ncols_active - def update(self, **kwargs): + def update(self, figure=None, **kwargs): """ - Updates the width ratios, height ratios, gridspec margins, and spacing + Updates the width and height ratios, gridspec margins, and spacing allocated between subplot rows and columns. 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. So, we explicitly require that the gridspec is dedicated to - a particular `~matplotlib.figure.Figure` instance, and just edit axes - positions for axes on that instance. + manager. ProPlot insists one gridspec per figure, tracks + the figures using this gridspec object, and applies updates to those + tracked figures. """ # Convert spaces to ratios wratios, hratios, kwargs = self._spaces_as_ratios(**kwargs) @@ -505,13 +508,13 @@ def update(self, **kwargs): 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 + # Apply to figure(s) and all axes + for fig in self._figures: + 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 #-----------------------------------------------------------------------------# # Helper funcs @@ -745,21 +748,25 @@ def __init__(self, tight=None, pad=None, axpad=None, panelpad=None, includepanels=False, autoformat=True, ref_num=1, # ref_num should never change - subplots_kw=None, gridspec_kw=None, subplots_orig_kw=None, + subplots_kw=None, subplots_orig_kw=None, tight_layout=None, constrained_layout=None, **kwargs): """ Parameters ---------- %(figure_kwargs)s - 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. - tight_layout, constrained_layout - Ignored, because ProPlot uses its own tight layout algorithm. + subplots_kw, subplots_orig_kw : dict-like, optional + Dictionaries storing the "current" figure geometry properties + and properties manually specified by the user. This is used + for the tight layout algorithm. **kwargs Passed to `matplotlib.figure.Figure`. + + Other parameters + ---------------- + tight_layout, constrained_layout + Ignored, because ProPlot uses its own tight layout algorithm. + A warning will be issued if these are set to ``True``. """ # Initialize first, because need to provide fully initialized figure # as argument to gridspec, because matplotlib tight_layout does that @@ -773,7 +780,7 @@ def __init__(self, self._auto_format = autoformat self._auto_tight_layout = _notNone(tight, rc['tight']) self._include_panels = includepanels - self._ref_num = ref + self._ref_num = ref_num self._axes_main = [] self._subplots_orig_kw = subplots_orig_kw self._subplots_kw = subplots_kw @@ -781,13 +788,18 @@ def __init__(self, self._tpanels = [] self._lpanels = [] self._rpanels = [] - gridspec = GridSpec(self, **(gridspec_kw or {})) + self._gridspec = None + + def _initialize_geometry(self, gridspec): + """Initializes figure geometry using the input gridspec.""" + # FIXME: Axes panels mess up the below arrays! Means we end up + # stacking panels that don't need to be stacked. nrows, ncols = gridspec.get_active_geometry() self._barray = np.empty((0, ncols), dtype=bool) self._tarray = np.empty((0, ncols), dtype=bool) self._larray = np.empty((0, nrows), dtype=bool) self._rarray = np.empty((0, nrows), dtype=bool) - self._gridspec_main = gridspec + self._gridspec = gridspec self.suptitle('') # add _suptitle attribute @_counter @@ -818,7 +830,7 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): iratio = (row1 - offset if s == 't' else row2 + offset) idx1 = iratio idx2 = slice(col1, col2 + 1) - gridspec_prev = self._gridspec_main + gridspec_prev = self._gridspec gridspec = self._insert_row_column(side, iratio, width, space, space_orig, figure=False, ) @@ -957,16 +969,13 @@ def _adjust_aspect(self): else: pass # matplotlib issues warning, forces aspect == 'auto' # Apply aspect - # Account for floating point errors - if aspect is not None: - aspect = round(aspect*1e10)*1e-10 - subplots_kw = self._subplots_kw - aspect_prev = round(subplots_kw['aspect']*1e10)*1e-10 - if aspect != aspect_prev: - subplots_kw['aspect'] = aspect - figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) - self.set_size_inches(figsize) - self._gridspec_main.update(**gridspec_kw) + # Account for floating point errors by rounding to 10 digits + subplots_kw = self._subplots_kw + if aspect is not None and not _fp_equal(aspect, subplots_kw['aspect']): + subplots_kw['aspect'] = aspect + figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) + self.set_size_inches(figsize) + self._gridspec.update(**gridspec_kw) def _adjust_tight_layout(self, renderer): """Applies tight layout scaling that permits flexible figure @@ -977,7 +986,7 @@ def _adjust_tight_layout(self, renderer): axs = self._iter_axes() obox = self.bbox_inches # original bbox bbox = self.get_tightbbox(renderer) - gridspec = self._gridspec_main + gridspec = self._gridspec 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: @@ -1077,7 +1086,7 @@ def _adjust_tight_layout(self, renderer): 'wspace':spaces[0], 'hspace':spaces[1], }) figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) - self._gridspec_main.update(**gridspec_kw) + self._gridspec.update(**gridspec_kw) self.set_size_inches(figsize) def _align_axislabels(self, b=True): @@ -1272,12 +1281,13 @@ def _insert_row_column(self, side, idx, figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) self.set_size_inches(figsize) if exists: - gridspec = self._gridspec_main + gridspec = self._gridspec gridspec.update(**gridspec_kw) else: # New gridspec - gridspec = GridSpec(self, **gridspec_kw) - self._gridspec_main = gridspec + self._gridspec.remove_figure(self) + gridspec = GridSpec(**gridspec_kw) + self._gridspec = 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 @@ -1438,8 +1448,9 @@ def add_subplot(self, *args, There are three options here. See the matplotlib `~matplotlib.figure.add_subplot` documentation for details. - * A `~matplotlib.gridspec.SubplotSpec` instance. Must be a child of - the "main" gridspec. + * 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 @@ -1455,7 +1466,6 @@ def add_subplot(self, *args, Passed to `~matplotlib.figure.Figure.add_subplot`. Also passed to `~proplot.proj.Proj` if this is a cartopy or basemap projection. """ - # TODO: Consider permitting add_subplot? # Copied from matplotlib add_subplot if not len(args): args = (1, 1, 1) @@ -1463,12 +1473,16 @@ def add_subplot(self, *args, if not 100 <= args[0] <= 999: raise ValueError(f'Integer subplot specification must be a three-digit number, not {args[0]!r}.') args = tuple(map(int, str(args[0]))) - # Copied from SubplotBase __init__ and modified to enforce restrictions - gridspec = self._gridspec_main + + # Copied from SubplotBase __init__ + # Interpret positional args + gridspec = self._gridspec subplotspec = None if len(args) == 1: if isinstance(args[0], SubplotSpec): subplotspec = args[0] + elif isinstance(args[0], mgridspec.SubplotSpec): + raise ValueError(f'Invalid subplotspec {args[0]!r}. Figure.add_subplot() only accepts SubplotSpec objects generated by the ProPlot GridSpec class.') else: try: s = str(int(args[0])) @@ -1478,32 +1492,42 @@ def add_subplot(self, *args, elif len(args) == 3: rows, cols, num = args else: - raise ValueError(f'Illegal argument(s) to add_subplot: {args}') + raise ValueError(f'Illegal argument(s) to add_subplot: {args!r}') + + # Initialize gridspec and subplotspec + # Also enforce constant geometry if subplotspec is None: - rows = int(rows) - cols = int(cols) + rows, cols = int(rows), int(cols) if isinstance(num, tuple) and len(num) == 2: num = [int(n) for n in num] else: if num < 1 or num > rows*cols: raise ValueError(f'num must be 1 <= num <= {rows*cols}, not {num}') + if not isinstance(num, tuple): + num = (num, num) + if gridspec is None: + gridspec = GridSpec(rows, cols) # use default params + self._initialize_geometry(gridspec) + subplotspec = gridspec[(num[0] - 1):num[1]] + else: + rows, cols, *_ = subplotspec.get_active_geometry() + if gridspec is None: + gridspec = subplotspec.get_gridspec() + self._initialize_geometry(gridspec) + if subplotspec.get_gridspec() is not gridspec: + raise ValueError(f'Invalid subplotspec {args[0]!r}. Figure.add_subplot() only accepts SubplotSpec objects whose parent is the main gridspec.') if (rows, cols) != gridspec.get_active_geometry(): raise ValueError(f'Input arguments {args!r} conflict with existing gridspec geometry of {rows} rows, {cols} columns.') - if not isinstance(num, tuple): - num = (num, num) - subplotspec = gridspec[(num[0] - 1):num[1]] + gridspec.add_figure(self) # The default is CartesianAxes proj = _notNone(proj, projection, 'cartesian', names=('proj', 'projection')) proj_kw = _notNone(proj_kw, projection_kw, {}, names=('proj_kw', 'projection_kw')) - # Builtin matplotlib polar axes, just use my overridden version - if proj == 'polar': - proj = 'polar2' # Custom Basemap and Cartopy axes # 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? - elif proj != 'cartesian': + if proj not in ('cartesian', 'polar'): map_projection = projs.Proj(proj, basemap=basemap, **proj_kw) if 'map_projection' in kwargs: warnings.warn(f'Ignoring input "map_projection" {kwargs["map_projection"]!r}.') @@ -1713,9 +1737,9 @@ def _iter_axes(self): return axs #-----------------------------------------------------------------------------# -# Primary functions used to create figures and axes +# Main user interface and helper funcs #-----------------------------------------------------------------------------# -def _journals(journal): +def _journal_figsize(journal): """Journal sizes for figures.""" # Get dimensions for figure from common journals. value = JOURNAL_SPECS.get(journal, None) @@ -1770,6 +1794,19 @@ def _axes_dict(naxs, value, kw=False, default=None): raise ValueError(f'Have {naxs} axes, but {value} has properties for axes {", ".join(str(i) for i in sorted(kwargs.keys()))}.') return kwargs +def close(): + """Alias for ``matplotlib.pyplot.close('all')``, included so you don't have + to import `~matplotlib.pyplot`. Closes all figures stored + in memory.""" + plt.close('all') + +def show(): + """Alias for ``matplotlib.pyplot.show()``, included so you don't have + to import `~matplotlib.pyplot`. Note this command should be + unnecessary if you are doing inline iPython notebook plotting and ran the + `~proplot.notebook.nbsetup` command.""" + plt.show() + # TODO: Figure out how to save subplots keyword args! @docstring.dedent_interpd def figure(**kwargs): @@ -2026,13 +2063,10 @@ def subplots(array=None, ncols=1, nrows=1, proj_kw = _axes_dict(naxs, proj_kw, kw=True) basemap = _axes_dict(naxs, basemap, kw=False, default=False) - #-------------------------------------------------------------------------# - # Figure architecture - #-------------------------------------------------------------------------# # Figure and/or axes dimensions names, values = (), () if journal: - figsize = _journals(journal) # if user passed width= , will use that journal size + figsize = _journal_figsize(journal) spec = f'journal={journal!r}' names = ('axwidth', 'axheight', 'width') values = (axwidth, axheight, width) @@ -2062,6 +2096,7 @@ def subplots(array=None, ncols=1, nrows=1, # 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))) @@ -2073,6 +2108,7 @@ def subplots(array=None, ncols=1, nrows=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'))) @@ -2124,17 +2160,14 @@ def subplots(array=None, ncols=1, nrows=1, wratios=wratios, hratios=hratios, wspace=wspace, hspace=hspace, wpanels=['']*ncols, hpanels=['']*nrows, ) + gridspec = GridSpec(**gridspec_kw) fig = plt.figure(FigureClass=Figure, tight=tight, figsize=figsize, ref_num=ref, pad=pad, axpad=axpad, panelpad=panelpad, autoformat=autoformat, includepanels=includepanels, subplots_orig_kw=subplots_orig_kw, subplots_kw=subplots_kw, - gridspec_kw=gridspec_kw) - gridspec = fig._gridspec_main + ) - #-------------------------------------------------------------------------# - # Draw on figure - #-------------------------------------------------------------------------# # Draw main subplots axs = naxs*[None] # list of axes for idx in range(naxs): From 81e40a56b7aa2b4b09936ce514c1f2bc9fe62b23 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 04:58:54 -0600 Subject: [PATCH 09/37] Misc bugfixes --- proplot/subplots.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index 80b079c5c..7bd57c65f 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -789,6 +789,7 @@ def __init__(self, self._lpanels = [] self._rpanels = [] self._gridspec = None + self.suptitle('') # add _suptitle attribute def _initialize_geometry(self, gridspec): """Initializes figure geometry using the input gridspec.""" @@ -800,7 +801,6 @@ def _initialize_geometry(self, gridspec): self._larray = np.empty((0, nrows), dtype=bool) self._rarray = np.empty((0, nrows), dtype=bool) self._gridspec = gridspec - self.suptitle('') # add _suptitle attribute @_counter def _add_axes_panel(self, ax, side, filled=False, **kwargs): @@ -1520,13 +1520,12 @@ def add_subplot(self, *args, raise ValueError(f'Input arguments {args!r} conflict with existing gridspec geometry of {rows} rows, {cols} columns.') gridspec.add_figure(self) - # The default is CartesianAxes - proj = _notNone(proj, projection, 'cartesian', names=('proj', 'projection')) - proj_kw = _notNone(proj_kw, projection_kw, {}, names=('proj_kw', 'projection_kw')) - # Custom Basemap and Cartopy axes + # 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: @@ -1821,7 +1820,6 @@ def figure(**kwargs): Passed to `~matplotlib.figure.Figure`. """ # TODO: Repair subplots-dependent behavior! Permit serial args! - kwargs['gridspec_kw'] = None kwargs['subplots_kw'] = None kwargs['subplots_orig_kw'] = None return plt.figure(FigureClass=Figure, **kwargs) From 3ae4f2b9b8f3aea3eab99c6e3848108ac0ac25e6 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 05:11:10 -0600 Subject: [PATCH 10/37] Remove unlocker, move Axes 'main' kwarg to add_subplot --- proplot/axes.py | 32 ++++++++++++++++------------- proplot/subplots.py | 50 ++++++++++++++++++--------------------------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index 534186611..c13bb8f4d 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -123,7 +123,6 @@ class Axes(maxes.Axes): def __init__(self, *args, number=None, sharex=0, sharey=0, spanx=None, spany=None, alignx=None, aligny=None, - main=False, **kwargs): """ Parameters @@ -140,9 +139,6 @@ def __init__(self, *args, number=None, alignx, aligny : bool, optional Boolean toggle for whether aligned axis labels are enabled for the *x* and *y* axes. See `~proplot.subplots.subplots` for details. - main : bool, optional - Used internally, indicates whether this is a "main axes" rather - than a twin, panel, or inset axes. See also -------- @@ -186,8 +182,6 @@ def __init__(self, *args, number=None, self._blabel = self.text(0.5, 0.05, '', va='top', ha='center', transform=coltransform) self._tlabel = self.text(0.5, 0.95, '', va='bottom', ha='center', transform=coltransform) # reasonable starting point # Shared and spanning axes - if main: - self.figure._axes_main.append(self) self._spanx_on = spanx self._spany_on = spany self._alignx_on = alignx @@ -996,10 +990,8 @@ def colorbar(self, *args, loc=None, pad=None, height_ratios=((1-length)/2, length, (1-length)/2), ) subplotspec = gridspec[1] - with self.figure._unlock(): - ax = self.figure.add_subplot(subplotspec, projection=None) - if ax is self: - raise ValueError(f'Uh oh.') + ax = self.figure.add_subplot(subplotspec, + main=False, projection=None) self.add_child_axes(ax) # Location @@ -1783,6 +1775,19 @@ def _hide_labels(self): # TODO: Document this? axis.set_minor_formatter(mticker.NullFormatter()) + def _make_twin_axes(self, *args, **kwargs): + """Makes a twin axes of self. This is used for twinx and twiny. Copied + from matplotlib in case the API changes.""" + # Typically, SubplotBase._make_twin_axes is called instead of this. + # There is also an override in axes_grid1/axes_divider.py. + if 'sharex' in kwargs and 'sharey' in kwargs: + raise ValueError('Twinned Axes may share only one axis.') + ax2 = self.figure.add_axes(self.get_position(True), *args, **kwargs) + self.set_adjustable('datalim') + ax2.set_adjustable('datalim') + self._twinned_axes.join(self, ax2) + return ax2 + def _sharex_setup(self, sharex, level): """Sets up shared axes. The input is the 'parent' axes, from which this one will draw its properties.""" @@ -2335,12 +2340,12 @@ def altx(self, *args, **kwargs): # Cannot wrap twiny() because we want to use CartesianAxes, not # matplotlib Axes. Instead use hidden method _make_twin_axes. # See https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py + # NOTE: _make_twin_axes uses self.add_axes if self._altx_child: raise RuntimeError('No more than *two* twin axes!') if self._altx_parent: raise RuntimeError('This *is* a twin axes!') - with self.figure._unlock(): - ax = self._make_twin_axes(sharey=self, projection='cartesian') + ax = self._make_twin_axes(sharey=self, projection='cartesian') ax.set_autoscaley_on(self.get_autoscaley_on()) # shared axes must have matching autoscale ax.grid(False) self._altx_child = ax @@ -2355,8 +2360,7 @@ def alty(self): raise RuntimeError('No more than *two* twin axes!') if self._alty_parent: raise RuntimeError('This *is* a twin axes!') - with self.figure._unlock(): - ax = self._make_twin_axes(sharex=self, projection='cartesian') + ax = self._make_twin_axes(sharex=self, projection='cartesian') ax.set_autoscalex_on(self.get_autoscalex_on()) # shared axes must have matching autoscale ax.grid(False) self._alty_child = ax diff --git a/proplot/subplots.py b/proplot/subplots.py index 7bd57c65f..b1fba0d78 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -686,16 +686,6 @@ def _subplots_geometry(**kwargs): #-----------------------------------------------------------------------------# # Figure class and helper classes #-----------------------------------------------------------------------------# -class _unlocker(object): - """Suppresses warning message when adding subplots, and cleanly resets - lock setting if exception raised.""" - def __init__(self, fig): - self._fig = fig - def __enter__(self): - self._fig._locked = False - def __exit__(self, *args): - self._fig._locked = True - class _hidelabels(object): """Hides objects temporarily so they are ignored by the tight bounding box algorithm.""" @@ -841,10 +831,9 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): idx2 += 1 # Draw and setup panel - with self._unlock(): - pax = self.add_subplot(gridspec[idx1,idx2], - sharex=ax._sharex_level, sharey=ax._sharey_level, - projection='cartesian') + pax = self.add_subplot(gridspec[idx1,idx2], main=False, + sharex=ax._sharex_level, sharey=ax._sharey_level, + projection='cartesian') getattr(ax, '_' + s + 'panels').append(pax) pax._panel_side = side pax._panel_share = share @@ -941,9 +930,8 @@ def _add_figure_panel(self, side, ) # Draw and setup panel - with self._unlock(): - pax = self.add_subplot(gridspec[idx1,idx2], - projection='cartesian') + 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 @@ -1370,11 +1358,6 @@ def _get_align_axes(self, side): ord = [ax._range_gridspec(y)[0] for ax in axs] return [ax for _,ax in sorted(zip(ord, axs)) if ax.get_visible()] - def _unlock(self): - """Prevents warning message when adding subplots one-by-one, used - internally.""" - return _unlocker(self) - def _update_axislabels(self, axis=None, **kwargs): """Applies axis labels to the relevant shared axis. If spanning labels are toggled, keeps the labels synced for all subplots in the @@ -1437,7 +1420,7 @@ def _update_suptitle(self, title, **kwargs): def add_subplot(self, *args, proj=None, projection=None, basemap=False, - proj_kw=None, projection_kw=None, + proj_kw=None, projection_kw=None, main=True, **kwargs): """ Adds subplot using the existing figure gridspec. @@ -1457,8 +1440,14 @@ def add_subplot(self, *args, equivalent to or divide the "main" gridspec geometry. proj, projection : str, optional The registered matplotlib projection name, or a basemap or cartopy - map projection name. For valid map projection names, see the - :ref:`Table of projections`. + map projection name. 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``. Other parameters ---------------- @@ -1535,6 +1524,8 @@ def add_subplot(self, *args, # Initialize ax = super().add_subplot(subplotspec, projection=proj, **kwargs) + if main: + self._axes_main.append(ax) return ax def colorbar(self, *args, @@ -2175,11 +2166,10 @@ def subplots(array=None, ncols=1, nrows=1, y0, y1 = yrange[idx,0], yrange[idx,1] # Draw subplot subplotspec = gridspec[y0:y1+1, x0:x1+1] - with fig._unlock(): - axs[idx] = fig.add_subplot(subplotspec, number=num, - spanx=spanx, spany=spany, alignx=alignx, aligny=aligny, - sharex=sharex, sharey=sharey, main=True, - proj=proj[num], basemap=basemap[num], **proj_kw[num]) + axs[idx] = fig.add_subplot(subplotspec, number=num, + spanx=spanx, spany=spany, alignx=alignx, aligny=aligny, + sharex=sharex, sharey=sharey, main=True, + proj=proj[num], basemap=basemap[num], **proj_kw[num]) # Return figure and axes n = (ncols if order == 'C' else nrows) From dcd918ed248f1d15ce416c12c86c125ab45f16de Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 05:13:38 -0600 Subject: [PATCH 11/37] Rename hidden func --- proplot/subplots.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index b1fba0d78..96158e181 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -686,9 +686,9 @@ def _subplots_geometry(**kwargs): #-----------------------------------------------------------------------------# # Figure class and helper classes #-----------------------------------------------------------------------------# -class _hidelabels(object): - """Hides objects temporarily so they are ignored by the tight bounding box - algorithm.""" +class _hide_labels(object): + """Hides objects temporarily so they are ignored by the tight bounding + box algorithm.""" def __init__(self, *args): self._labels = args def __enter__(self): @@ -1154,7 +1154,7 @@ def _align_suplabels(self, renderer): coords = [None]*len(axs) if s == 't' and suptitle_on: supaxs = axs - with _hidelabels(*labels): + with _hide_labels(*labels): for i,(ax,label) in enumerate(zip(axs,labels)): label_on = label.get_text().strip() if not label_on: From baf04d5b466ad6904dde0e8b40a773d34e8095e2 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 14:40:04 -0600 Subject: [PATCH 12/37] Docs cleanup, axes number, misc minor changes --- proplot/axes.py | 46 +-- proplot/projs.py | 7 +- proplot/styletools.py | 57 ++- proplot/subplots.py | 785 +++++++++++++++++++++++------------------- 4 files changed, 486 insertions(+), 409 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index c13bb8f4d..e439cd83e 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -128,8 +128,8 @@ def __init__(self, *args, number=None, Parameters ---------- number : int - The subplot number, used for a-b-c labelling (see - `~Axes.format`). + The subplot number, used for a-b-c labeling. See `~Axes.format` + for details. Note the first axes is ``1``, not ``0``. sharex, sharey : {3, 2, 1, 0}, optional The "axis sharing level" for the *x* axis, *y* axis, or both axes. See `~proplot.subplots.subplots` for details. @@ -147,7 +147,6 @@ def __init__(self, *args, number=None, # Call parent super().__init__(*args, **kwargs) # Properties - self._number = number # for abc numbering self._abc_loc = None self._abc_text = None self._titles_dict = {} # dictionary of title text objects and their locations @@ -189,6 +188,7 @@ def __init__(self, *args, number=None, self._sharex_level = sharex self._sharey_level = sharey self._share_setup() + self.number = number # for abc numbering self.format(mode=1) # mode == 1 applies the rcExtraParams def _draw_auto_legends_colorbars(self): @@ -306,6 +306,18 @@ def _get_title_props(self, abc=False, loc=None): obj.set_transform(self.transAxes) return loc, obj, kw + def _iter_panels(self, sides='lrbt'): + """Iterates over axes and child panel axes.""" + axs = [self] if self.get_visible() else [] + if not ({*sides} <= {*'lrbt'}): + raise ValueError(f'Invalid sides {sides!r}.') + for s in sides: + for ax in getattr(self, '_' + s + 'panels'): + if not ax or not ax.get_visible(): + continue + axs.append(ax) + return axs + @staticmethod def _loc_translate(loc, **kwargs): """Translates location string `loc` into a standardized form.""" @@ -1364,17 +1376,11 @@ def number(self): `~proplot.subplots.subplots`.""" return self._number - def _iter_panels(self, sides='lrbt'): - """Iterates over axes and child panel axes.""" - axs = [self] if self.get_visible() else [] - if not ({*sides} <= {*'lrbt'}): - raise ValueError(f'Invalid sides {sides!r}.') - for s in sides: - for ax in getattr(self, '_' + s + 'panels'): - if not ax or not ax.get_visible(): - continue - axs.append(ax) - return axs + @number.setter + def number(self, num): + if not isinstance(num, Integral) or num < 1: + raise ValueError(f'Invalid number {num!r}. Must be integer >=1.') + self._number = num # Wrapped by special functions # Also support redirecting to Basemap methods @@ -2438,12 +2444,6 @@ def twiny(self): 'x':'y', 'x1':'left', 'x2':'right', 'y':'x', 'y1':'bottom', 'y2':'top', } - dualx.__doc__ = dualxy_descrip % { - 'x':'x', 'args':', '.join(dualxy_kwargs) - } - dualy.__doc__ = dualxy_descrip % { - 'x':'y', 'args':', '.join(dualxy_kwargs) - } twinx.__doc__ = twinxy_descrip % { 'x':'y', 'x1':'left', 'x2':'right', 'y':'x', 'y1':'bottom', 'y2':'top', @@ -2452,6 +2452,12 @@ def twiny(self): 'x':'x', 'x1':'bottom', 'x2':'top', 'y':'y', 'y1':'left', 'y2':'right', } + dualx.__doc__ = dualxy_descrip % { + 'x':'x', 'args':', '.join(dualxy_kwargs) + } + dualy.__doc__ = dualxy_descrip % { + 'x':'y', 'args':', '.join(dualxy_kwargs) + } class PolarAxes(Axes, mproj.PolarAxes): """Intermediate class, mixes `ProjectionAxes` with diff --git a/proplot/projs.py b/proplot/projs.py index d666277af..026fb3247 100644 --- a/proplot/projs.py +++ b/proplot/projs.py @@ -157,14 +157,17 @@ def Proj(name, basemap=False, **kwargs): # Cartopy else: import cartopy.crs as ccrs # verify package is available - kwargs = {CARTOPY_CRS_TRANSLATE.get(key, key): value for key,value in kwargs.items()} + kwargs = { + CARTOPY_CRS_TRANSLATE.get(key, key): value + for key,value in kwargs.items() + } crs = cartopy_projs.get(name, None) if name == 'geos': # fix common mistake kwargs.pop('central_latitude', None) if 'boundinglat' in kwargs: raise ValueError(f'"boundinglat" must be passed to the ax.format() command for cartopy axes.') if crs is None: - raise ValueError(f'Unknown projection "{name}". Options are: {", ".join(cartopy_projs.keys())}.') + raise ValueError(f'Unknown projection {name!r}. Options are: {", ".join(map(repr, cartopy_projs.keys()))}.') proj = crs(**kwargs) return proj diff --git a/proplot/styletools.py b/proplot/styletools.py index 67e8b32dd..b27c9f2d0 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -232,13 +232,7 @@ } # Docstring fragments -cyclic_doc = """ -cyclic : bool, optional - Whether this colormap is cyclic. This affects how colors at either - end of the colorbar are scaled, and which `extend` settings other - than ``'neither'`` are allowed. -""" -gamma_doc = """ +_gamma_params = """ gamma1 : float, optional If >1, makes low saturation colors more prominent. If <1, makes high saturation colors more prominent. Similar to the @@ -250,10 +244,9 @@ `HCLWizard `_ option. See `make_mapping_array` for details. gamma : float, optional - Use this to identically set `gamma1` and `gamma2` at once. + Sets `gamma1` and `gamma2` to this identical value. """ -docstring.interpd.update(gamma_doc=gamma_doc) -docstring.interpd.update(cyclic_doc=cyclic_doc) +docstring.interpd.update(gamma_params=_gamma_params) #-----------------------------------------------------------------------------# # Color manipulation functions @@ -321,7 +314,7 @@ def to_rgb(color, space='rgb', cycle=None): cycle = mcm.cmap_d[cycle].colors except (KeyError, AttributeError): cycles = sorted(name for name,cmap in mcm.cmap_d.items() if isinstance(cmap, ListedColormap)) - raise ValueError(f'Invalid cycle name "{cycle}". Options are: {", ".join(cycles)}') + raise ValueError(f'Invalid cycle name {cycle!r}. Options are: {", ".join(map(repr, cycles))}') elif cycle is None: cycle = rcParams['axes.prop_cycle'].by_key() if 'color' not in cycle: @@ -329,14 +322,14 @@ def to_rgb(color, space='rgb', cycle=None): else: cycle = cycle['color'] elif not np.iterable(cycle): - raise ValueError(f'Invalid cycle "{cycle}".') + raise ValueError(f'Invalid cycle specifier {cycle!r}.') color = cycle[int(color[-1]) % len(cycle)] # Translate RGB strings and (cmap,index) tuples if isinstance(color, str) or (np.iterable(color) and len(color) == 2): try: color = mcolors.to_rgb(color) # ensure is valid color except (ValueError, TypeError): - raise ValueError(f'Invalid RGB argument "{color}".') + raise ValueError(f'Invalid RGB argument {color!r}.') elif space == 'rgb': color = color[:3] # trim alpha try: @@ -578,7 +571,7 @@ def _get_data(self, ext): data = [rgb(color) for color in colors] data = '\n'.join(','.join(str(num) for num in line) for line in data) else: - raise ValueError(f'Invalid extension {ext!r}. Options are "hex", "txt", "rgb", or "rgba".') + raise ValueError(f'Invalid extension {ext!r}. Options are: "hex", "txt", "rgb", "rgba".') return data def _parse_path(self, path, dirname='.', ext=''): @@ -614,12 +607,14 @@ def __repr__(self): string += f' {key!r}: [{data[0][2]:.3f}, ..., {data[-1][1]:.3f}],\n' return type(self).__name__ + '({\n' + string + '})' - @docstring.dedent_interpd def __init__(self, *args, cyclic=False, **kwargs): """ Parameters ---------- - %(cyclic_doc)s + cyclic : bool, optional + Whether this colormap is cyclic. This affects how colors at either + end of the colorbar are scaled, and which `extend` settings other + than ``'neither'`` are allowed. *args, **kwargs Passed to `~matplotlib.colors.LinearSegmentedColormap`. """ @@ -738,7 +733,7 @@ def new(self, name=None, segmentdata=None, N=None, name : str The colormap name. Default is ``self.name + '_new'``. segmentdata, N, gamma, cyclic : optional - See `LinearSegmentedColormap`. If not provided, + See `LinearSegmentedColormap` for details. If not provided, these are copied from the current colormap. """ if name is None: @@ -1019,8 +1014,8 @@ def new(self, colors=None, name=None, N=None): name : str The colormap name. Default is ``self.name + '_new'``. colors, N : optional - See `~matplotlib.colors.ListedColormap`. If not provided, - these are copied from the current colormap. + See `~matplotlib.colors.ListedColormap` for details. If not + provided, these are copied from the current colormap. """ if name is None: name = self.name + '_new' @@ -1142,8 +1137,10 @@ def __init__(self, clip : bool, optional Whether to "clip" impossible colors, i.e. truncate HCL colors with RGB channels with values >1, or mask them out as gray. - %(gamma_doc)s - %(cyclic_doc)s + cyclic : bool, optional + Whether this colormap is cyclic. See `LinearSegmentedColormap` + for details. + %(gamma_params)s Example ------- @@ -1182,7 +1179,7 @@ def __init__(self, segmentdata[key][i] = xyy # Initialize N = N or rcParams['image.lut'] - super().__init__(name, segmentdata, N, gamma=1.0) + super().__init__(name, segmentdata, N, gamma=1.0, cyclic=cyclic) def _init(self): """As with `~matplotlib.colors.LinearSegmentedColormap`, but converts @@ -1222,7 +1219,7 @@ def new(self, name=None, segmentdata=None, N=None, space=None, name : str The colormap name. Default is ``self.name + '_new'``. segmentdata, N, space, clip, gamma, gamma1, gamma2, cyclic : optional - See `PerceptuallyUniformColormap`. If not provided, + See `PerceptuallyUniformColormap` for details. If not provided, these are copied from the current colormap. """ if name is None: @@ -1379,7 +1376,7 @@ def set_gamma(self, gamma=None, gamma1=None, gamma2=None): Parameters ---------- - %(gamma_doc)s + %(gamma_params)s """ gamma1 = _notNone(gamma1, gamma) gamma2 = _notNone(gamma2, gamma) @@ -1651,7 +1648,7 @@ def Colormap(*args, name=None, listmode='perceptual', if not args: raise ValueError(f'Colormap() requires at least one positional argument.') if listmode not in ('listed', 'linear', 'perceptual'): - raise ValueError(f'Invalid listmode={listmode!r}. Options are "listed", "linear", and "perceptual".') + raise ValueError(f'Invalid listmode={listmode!r}. Options are: "listed", "linear", "perceptual".') cmaps = [] tmp = '_no_name' # name required, but we only care about name of final merged map for i,cmap in enumerate(args): @@ -1692,10 +1689,10 @@ def Colormap(*args, name=None, listmode='perceptual', try: color = to_rgb(cmap, cycle=cycle) except (ValueError, TypeError): - msg = f'Invalid cmap, cycle, or color "{cmap}".' + msg = f'Invalid cmap, cycle, or color {cmap!r}.' if isinstance(cmap, str): - msg += (f'\nValid cmap and cycle names: {", ".join(sorted(mcm.cmap_d))}.' - f'\nValid color names: {", ".join(sorted(mcolors.colorConverter.colors.keys()))}.') + msg += (f'\nValid cmap and cycle names: {", ".join(map(repr, sorted(mcm.cmap_d)))}.' + f'\nValid color names: {", ".join(map(repr, sorted(mcolors.colorConverter.colors)))}.') raise ValueError(msg) cmap = PerceptuallyUniformColormap.from_color(tmp, color, fade) # Transform colormap by clipping colors or reversing @@ -1937,11 +1934,11 @@ def Norm(norm, levels=None, **kwargs): # Get class norm_out = normalizers.get(norm, None) if norm_out is None: - raise ValueError(f'Unknown normalizer "{norm}". Options are {", ".join(normalizers.keys())}.') + raise ValueError(f'Unknown normalizer {norm!r}. Options are: {", ".join(map(repr, normalizers.keys()))}.') # Instantiate class if norm_out is LinearSegmentedNorm: if not np.iterable(levels): - raise ValueError(f'Need levels for normalizer "{norm}". Received levels={levels}.') + raise ValueError(f'Need levels for normalizer {norm!r}. Received levels={levels!r}.') kwargs.update({'levels':levels}) norm_out = norm_out(**kwargs) # initialize else: diff --git a/proplot/subplots.py b/proplot/subplots.py index b323236e6..9a64c3d2e 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -82,16 +82,20 @@ } #-----------------------------------------------------------------------------# -# Miscellaneous stuff +# Helper classes #-----------------------------------------------------------------------------# -# Wrapper functions, so user doesn't have to import pyplot -def _fp_equal(num1, num2, digits=10): - """Tests equality of two floating point numbers out to `N` digits. Used - in a couple places.""" - hi, lo = 10**digits, 10**-digits - return round(num1*hi)*lo == round(num2*hi)*lo +class _hide_labels(object): + """Hides objects temporarily so they are ignored by the tight bounding + box algorithm.""" + 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) -# Helper classes class axes_grid(list): """List subclass and pseudo-2D array that is used as a container for the list of axes returned by `subplots`, lists of figure panels, and lists of @@ -258,9 +262,6 @@ def _iterator(*args, **kwargs): # Mixed raise AttributeError(f'Found mixed types for attribute {attr!r}.') -#-----------------------------------------------------------------------------# -# Gridspec classes -#-----------------------------------------------------------------------------# class SubplotSpec(mgridspec.SubplotSpec): """ Adds two helper methods to `~matplotlib.gridspec.SubplotSpec` that return @@ -431,7 +432,7 @@ def add_figure(self, fig): `~Figure.add_subplot` with a subplotspec generated by this gridspec.""" self._figures.add(fig) - def remove_figure(self): + def remove_figure(self, fig): """Removes `~matplotlib.figure.Figure` from the list of figures that are using this gridspec.""" self._figures.discard(fig) @@ -500,6 +501,12 @@ def update(self, figure=None, **kwargs): #-----------------------------------------------------------------------------# # Helper funcs #-----------------------------------------------------------------------------# +def _fp_equal(num1, num2, digits=10): + """Tests equality of two floating point numbers out to `N` digits. Used + in a couple places.""" + hi, lo = 10**digits, 10**-digits + return round(num1*hi)*lo == round(num2*hi)*lo + def _panels_kwargs(side, share=None, width=None, space=None, filled=False, figure=False): @@ -524,7 +531,59 @@ def _panels_kwargs(side, else 'inner' if figure else 'panel') + 'space'])) return share, width, space, space_orig -def _subplots_geometry(**kwargs): +def _size_edge_kwargs(axwidth=None, axheight=None, + width=None, height=None, figsize=None, journal=None, + left=None, right=None, bottom=None, top=None, + **kwargs): + """Interprets arguments passed to `subplots` and `figure`.""" + # Figure and/or axes dimensions + names, values = (), () + if journal: + figsize = _journal_figsize(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: + warnings.warn(f'You specified both {spec} and {name}={value!r}. Ignoring {name!r}.') + # Standardize dimensions + width, height = units(width), units(height) + axwidth, axheight = units(axwidth), units(axheight) + + # Input border spacing + left, right = units(left), units(right) + bottom, top = units(bottom), units(top) + space_kw = { + 'left':left, 'right':right, 'bottom':bottom, 'top':top + } + # Default border spacing + left = _notNone(left, units(rc['subplots.ylabspace'])) + right = _notNone(right, units(rc['subplots.innerspace'])) + top = _notNone(top, units(rc['subplots.titlespace'])) + bottom = _notNone(bottom, units(rc['subplots.xlabspace'])) + figure_kw = { + 'axwidth':axwidth, 'axheight':axheight, 'width':width, 'height':height, + 'left':left, 'right':right, 'bottom':bottom, 'top':top + } + return figure_kw, space_kw, kwargs + +def _figure_geometry(**kwargs): """Saves 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 @@ -541,9 +600,8 @@ def _subplots_geometry(**kwargs): 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) + # Panel string toggles, lists containing empty strings '' 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 @@ -667,34 +725,15 @@ def _subplots_geometry(**kwargs): #-----------------------------------------------------------------------------# # Figure class and helper classes #-----------------------------------------------------------------------------# -class _hide_labels(object): - """Hides objects temporarily so they are ignored by the tight bounding - box algorithm.""" - 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) - -# TODO: obfuscate Figure call signature like with axes methods? -figure_kwargs = """ +_figure_params = """ tight : bool, optional Toggles automatic tight layout adjustments. Default is :rc:`tight`. -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`. +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`. includepanels : bool, optional Whether to include panels when centering *x* axis labels, *y* axis labels, and figure "super titles" along the edge of the @@ -705,8 +744,80 @@ def __exit__(self, *args): labels when a `~pandas.Series`, `~pandas.DataFrame` or `~xarray.DataArray` with relevant metadata is passed to a plotting command. +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``. +""" +_subplot_params = """ +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 + =========== ==================== ========================================================================================================================================================== + ``'pnas1'`` 1-column `Proceedings of the National Academy of Sciences `__ + ``'pnas2'`` 2-column ” + ``'pnas3'`` landscape page ” + ``'ams1'`` 1-column `American Meteorological Society `__ + ``'ams2'`` small 2-column ” + ``'ams3'`` medium 2-column ” + ``'ams4'`` full 2-column ” + ``'agu1'`` 1-column `American Geophysical Union `__ + ``'agu2'`` 2-column ” + ``'agu3'`` full height 1-column ” + ``'agu4'`` full height 2-column ” + =========== ==================== ========================================================================================================================================================== + +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. """ -docstring.interpd.update(figure_kwargs=figure_kwargs.strip()) +docstring.interpd.update( + subplot_params=_subplot_params.strip(), + figure_params=_figure_params.strip() + ) class Figure(mfigure.Figure): """The `~matplotlib.figure.Figure` class returned by `subplots`. At @@ -714,22 +825,75 @@ class Figure(mfigure.Figure): 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, - tight=None, - pad=None, axpad=None, panelpad=None, includepanels=False, - autoformat=True, ref_num=1, # ref_num should never change - subplots_kw=None, subplots_orig_kw=None, + tight=None, pad=None, axpad=None, panelpad=None, includepanels=False, + autoformat=True, + span=None, spanx=None, spany=None, + align=None, alignx=None, aligny=None, + share=None, sharex=None, sharey=None, + ref=1, subplots_kw=None, space_kw=None, tight_layout=None, constrained_layout=None, **kwargs): """ Parameters ---------- - %(figure_kwargs)s - subplots_kw, subplots_orig_kw : dict-like, optional + tight : bool, optional + Toggles automatic tight layout adjustments. 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`. + 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. + 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``. + 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. + subplots_kw, space_kw : dict-like, optional Dictionaries storing the "current" figure geometry properties - and properties manually specified by the user. This is used - for the tight layout algorithm. + and gridspec spacing settings manually specified by the user, + respectively. This is used for the tight layout algorithm. **kwargs Passed to `matplotlib.figure.Figure`. @@ -751,16 +915,35 @@ def __init__(self, self._auto_format = autoformat self._auto_tight_layout = _notNone(tight, rc['tight']) self._include_panels = includepanels - self._ref_num = ref_num self._axes_main = [] - self._subplots_orig_kw = subplots_orig_kw + self._space_kw = space_kw self._subplots_kw = subplots_kw self._bpanels = [] self._tpanels = [] self._lpanels = [] self._rpanels = [] self._gridspec = None + self.ref = ref self.suptitle('') # add _suptitle attribute + # Axes sharing and spanning settings + sharex = int(_notNone(sharex, share, rc['share'])) + sharey = int(_notNone(sharey, share, rc['share'])) + if sharex not in range(4) or sharey not in range(4): + raise ValueError(f'Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels). Got sharex={sharex} and sharey={sharey}.') + spanx = _notNone(spanx, span, 0 if sharex == 0 else None, rc['span']) + spany = _notNone(spany, span, 0 if sharey == 0 else None, rc['span']) + alignx = _notNone(alignx, align) + aligny = _notNone(aligny, align) + if (spanx and alignx) or (spany and aligny): + warnings.warn(f'The "alignx" and "aligny" args have no effect when "spanx" and "spany" are True.') + alignx = _notNone(alignx, rc['align']) + aligny = _notNone(alignx, rc['align']) + self._alignx = alignx + self._aligny = aligny + self._sharex = sharex + self._sharey = sharey + self._spanx = spanx + self._spany = spany def _initialize_geometry(self, gridspec): """Initializes figure geometry using the input gridspec.""" @@ -926,7 +1109,7 @@ def _adjust_aspect(self): # Get aspect ratio if not self._axes_main: return - ax = self._axes_main[self._ref_num-1] + ax = self._axes_main[self.ref - 1] mode = ax.get_aspect() aspect = None if mode == 'equal': @@ -942,7 +1125,7 @@ def _adjust_aspect(self): subplots_kw = self._subplots_kw if aspect is not None and not _fp_equal(aspect, subplots_kw['aspect']): subplots_kw['aspect'] = aspect - figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) + figsize, gridspec_kw, _ = _figure_geometry(**subplots_kw) self.set_size_inches(figsize) self._gridspec.update(**gridspec_kw) @@ -956,9 +1139,9 @@ def _adjust_tight_layout(self, renderer): obox = self.bbox_inches # original bbox bbox = self.get_tightbbox(renderer) gridspec = self._gridspec + space_kw = self._space_kw # tight layout overrides 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: + if not axs or not subplots_kw or not space_kw: return # Tight box *around* figure @@ -974,7 +1157,7 @@ def _adjust_tight_layout(self, renderer): ('left','right','top','bottom'), (left,right,top,bottom) ): - previous = subplots_orig_kw[key] + previous = space_kw[key] current = subplots_kw[key] subplots_kw[key] = _notNone(previous, current - offset + pad) @@ -983,8 +1166,8 @@ def _adjust_tight_layout(self, renderer): panelpad = self._panelpad nrows, ncols = gridspec.get_active_geometry() wspace, hspace = subplots_kw['wspace'], subplots_kw['hspace'] - wspace_orig = subplots_orig_kw['wspace'] - hspace_orig = subplots_orig_kw['hspace'] + wspace_orig = space_kw['wspace'] + hspace_orig = space_kw['hspace'] # Get new subplot spacings, axes panel spacing, figure panel spacing spaces = [] for (w, x, y, nacross, @@ -1054,7 +1237,7 @@ def _adjust_tight_layout(self, renderer): subplots_kw.update({ 'wspace':spaces[0], 'hspace':spaces[1], }) - figsize, gridspec_kw, _ = _subplots_geometry(**subplots_kw) + figsize, gridspec_kw, _ = _figure_geometry(**subplots_kw) self._gridspec.update(**gridspec_kw) self.set_size_inches(figsize) @@ -1192,6 +1375,52 @@ def _align_suplabels(self, renderer): 'transform':self.transFigure} suptitle.update(kw) + def _get_align_coord(self, side, axs): + """Returns figure coordinate for spanning labels and super title. The + `x` can be ``'x'`` or ``'y'``.""" + # Get position in figure relative coordinates + s = side[0] + x = ('y' if s in 'lr' else 'x') + extra = ('tb' if s in 'lr' else 'lr') + if self._include_panels: + axs = [iax for ax in axs for iax in ax._iter_panels(extra)] + ranges = np.array([ax._range_gridspec(x) for ax in axs]) + min_, max_ = ranges[:,0].min(), ranges[:,1].max() + axlo = axs[np.where(ranges[:,0] == min_)[0][0]] + axhi = axs[np.where(ranges[:,1] == max_)[0][0]] + lobox = axlo.get_subplotspec().get_position(self) + hibox = axhi.get_subplotspec().get_position(self) + if x == 'x': + pos = (lobox.x0 + hibox.x1)/2 + else: + pos = (lobox.y1 + hibox.y0)/2 # 'lo' is actually on top, highest up in gridspec + # Return axis suitable for spanning position + spanax = axs[(np.argmin(ranges[:,0]) + np.argmax(ranges[:,1]))//2] + spanax = spanax._panel_parent or spanax + return pos, spanax + + def _get_align_axes(self, side): + """Returns main axes along the left, right, bottom, or top sides + of the figure.""" + # Initial stuff + s = side[0] + idx = (0 if s in 'lt' else 1) + if s in 'lr': + x, y = 'x', 'y' + else: + x, y = 'y', 'x' + # Get edge index + axs = self._axes_main + 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)[idx] == edge] + 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, ): @@ -1216,38 +1445,30 @@ def _insert_row_column(self, side, idx, # Load arrays and test if we need to insert subplots_kw = self._subplots_kw - subplots_orig_kw = self._subplots_orig_kw + space_kw = self._space_kw panels = subplots_kw[w + 'panels'] ratios = subplots_kw[w + 'ratios'] spaces = subplots_kw[w + 'space'] - spaces_orig = subplots_orig_kw[w + 'space'] + spaces_orig = space_kw[w + 'space'] - # Slot already exists + # Test if panel 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) + figsize, gridspec_kw, _ = _figure_geometry(**subplots_kw) self.set_size_inches(figsize) if exists: gridspec = self._gridspec @@ -1293,51 +1514,22 @@ def _insert_row_column(self, side, idx, return gridspec - def _get_align_coord(self, side, axs): - """Returns figure coordinate for spanning labels and super title. The - `x` can be ``'x'`` or ``'y'``.""" - # Get position in figure relative coordinates - s = side[0] - x = ('y' if s in 'lr' else 'x') - extra = ('tb' if s in 'lr' else 'lr') - if self._include_panels: - axs = [iax for ax in axs for iax in ax._iter_panels(extra)] - ranges = np.array([ax._range_gridspec(x) for ax in axs]) - min_, max_ = ranges[:,0].min(), ranges[:,1].max() - axlo = axs[np.where(ranges[:,0] == min_)[0][0]] - axhi = axs[np.where(ranges[:,1] == max_)[0][0]] - lobox = axlo.get_subplotspec().get_position(self) - hibox = axhi.get_subplotspec().get_position(self) - if x == 'x': - pos = (lobox.x0 + hibox.x1)/2 - else: - pos = (lobox.y1 + hibox.y0)/2 # 'lo' is actually on top, highest up in gridspec - # Return axis suitable for spanning position - spanax = axs[(np.argmin(ranges[:,0]) + np.argmax(ranges[:,1]))//2] - spanax = spanax._panel_parent or spanax - return pos, spanax - - def _get_align_axes(self, side): - """Returns main axes along the left, right, bottom, or top sides - of the figure.""" - # Initial stuff - s = side[0] - idx = (0 if s in 'lt' else 1) - if s in 'lr': - x, y = 'x', 'y' - else: - x, y = 'y', 'x' - # Get edge index - axs = self._axes_main - 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)[idx] == edge] - ord = [ax._range_gridspec(y)[0] for ax in axs] - return [ax for _,ax in sorted(zip(ord, axs)) if ax.get_visible()] + 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 _update_axislabels(self, axis=None, **kwargs): """Applies axis labels to the relevant shared axis. If spanning @@ -1401,7 +1593,8 @@ def _update_suptitle(self, title, **kwargs): def add_subplot(self, *args, proj=None, projection=None, basemap=False, - proj_kw=None, projection_kw=None, main=True, + proj_kw=None, projection_kw=None, main=True, number=None, + sharex=None, sharey=None, **kwargs): """ Adds subplot using the existing figure gridspec. @@ -1429,12 +1622,20 @@ def add_subplot(self, *args, 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`. Also passed - to `~proplot.proj.Proj` if this is a cartopy or basemap projection. + 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. """ # Copied from matplotlib add_subplot if not len(args): @@ -1443,6 +1644,10 @@ def add_subplot(self, *args, if not 100 <= args[0] <= 999: raise ValueError(f'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: + warnings.warn(f'Ignoring sharex={sharex!r}. To toggle axes sharing, just pass sharex=num to figure() or subplots().') + if sharey is not None: + warnings.warn(f'Ignoring sharey={sharey!r}. To toggle axes sharing, just pass sharey=num to figure() or subplots().') # Copied from SubplotBase __init__ # Interpret positional args @@ -1503,9 +1708,12 @@ def add_subplot(self, *args, kwargs['map_projection'] = map_projection proj = 'basemap' if basemap else 'cartopy' - # Initialize - ax = super().add_subplot(subplotspec, projection=proj, **kwargs) + # Return subplot + ax = super().add_subplot(subplotspec, + projection=proj, number=number, + **kwargs) if main: + ax.number = _notNone(number, len(self._axes_main) + 1) self._axes_main.append(ax) return ax @@ -1687,44 +1895,25 @@ def savefig(self, filename, **kwargs): super().savefig(filename, **kwargs) save = savefig - """Alias for `~Figure.savefig`, because calling ``fig.savefig`` + """Alias for `~Figure.savefig`, because calling ``fig.savefig()`` is sort of redundant.""" - 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 + @property + def ref(self): + """The reference axes number. The `axwidth`, `axheight`, and `aspect` + `subplots` and `figure` arguments are applied to this axes, and aspect + ratio is conserved for this axes in tight layout adjustment.""" + return self._ref + + @ref.setter + def ref(self, ref): + if not isinstance(ref, Integral) or ref < 1: + raise ValueError(f'Invalid axes number {ref!r}. Must be integer >=1.') + self._ref = ref #-----------------------------------------------------------------------------# # Main user interface and helper funcs #-----------------------------------------------------------------------------# -def _journal_figsize(journal): - """Journal sizes for figures.""" - # 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(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 - def _axes_dict(naxs, value, kw=False, default=None): """Build a dictionary that looks like ``{1:value1, 2:value2, ...}`` or ``{1:{key1:value1, ...}, 2:{key2:value2, ...}, ...}`` for storing @@ -1762,9 +1951,24 @@ def _axes_dict(naxs, value, kw=False, default=None): kwargs[num] = default # Verify numbers if {*range(1, naxs+1)} != {*kwargs.keys()}: - raise ValueError(f'Have {naxs} axes, but {value} has properties for axes {", ".join(str(i) for i in sorted(kwargs.keys()))}.') + raise ValueError(f'Have {naxs} axes, but {value!r} has properties for axes {", ".join(map(repr, sorted(kwargs)))}.') return kwargs +def _journal_figsize(journal): + """Journal sizes for figures.""" + # 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(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 + def close(): """Alias for ``matplotlib.pyplot.close('all')``, included so you don't have to import `~matplotlib.pyplot`. Closes all figures stored @@ -1787,13 +1991,18 @@ def figure(**kwargs): Parameters ---------- - %(figure_kwargs)s + %(subplot_params)s + + Other parameters + ---------------- **kwargs Passed to `~matplotlib.figure.Figure`. """ - # TODO: Repair subplots-dependent behavior! Permit serial args! - kwargs['subplots_kw'] = None - kwargs['subplots_orig_kw'] = None + # Interpret figure size and border keyword args + subplots_kw, space_kw, kwargs = _size_edge_kwargs(**kwargs) + # Default ratios and optional scalar spacing + kwargs['subplots_kw'] = subplots_kw + kwargs['space_kw'] = space_kw return plt.figure(FigureClass=Figure, **kwargs) @docstring.dedent_interpd @@ -1806,12 +2015,8 @@ def subplots(array=None, ncols=1, nrows=1, width_ratios=None, height_ratios=None, flush=None, wflush=None, hflush=None, left=None, bottom=None, right=None, top=None, - tight=None, pad=None, axpad=None, panelpad=None, - span=None, spanx=None, spany=None, - align=None, alignx=None, aligny=None, - share=None, sharex=None, sharey=None, - basemap=False, proj=None, projection=None, proj_kw=None, projection_kw=None, - autoformat=True, includepanels=False, + proj=None, projection=None, proj_kw=None, projection_kw=None, + basemap=False, **kwargs ): """ Analogous to `matplotlib.pyplot.subplots`, creates a figure with a single @@ -1842,47 +2047,7 @@ def subplots(array=None, ncols=1, nrows=1, (``'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 - =========== ==================== ========================================================================================================================================================== - ``'pnas1'`` 1-column `Proceedings of the National Academy of Sciences `__ - ``'pnas2'`` 2-column ” - ``'pnas3'`` landscape page ” - ``'ams1'`` 1-column `American Meteorological Society `__ - ``'ams2'`` small 2-column ” - ``'ams3'`` medium 2-column ” - ``'ams4'`` full 2-column ” - ``'agu1'`` 1-column `American Geophysical Union `__ - ``'agu2'`` 2-column ” - ``'agu3'`` full height 1-column ” - ``'agu4'`` full height 2-column ” - =========== ==================== ========================================================================================================================================================== - - 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. + %(subplot_params)s hratios, wratios Aliases for `height_ratios`, `width_ratios`. width_ratios, height_ratios : float or list thereof, optional @@ -1905,71 +2070,40 @@ def subplots(array=None, ncols=1, nrows=1, `~proplot.utils.units`. By default, padding is determined by the "tight layout" algorithm. - sharex, sharey, share : {3, 2, 1, 0}, optional - The "axis sharing level" for the *x* axis, *y* axis, or both axes. - Default is ``3``. This can considerably 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 - Default is ``False`` if `sharex`, `sharey`, or `share` are ``0``, - ``True`` otherwise. Toggles "spanning" axis labels for the *x* axis, - *y* axis, or both axes. 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``. 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.CartesianAxes`. 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 ---------------- - %(figure_kwargs)s + **kwargs + Passed to `Figure`. Returns ------- @@ -1978,8 +2112,8 @@ def subplots(array=None, ncols=1, nrows=1, axs : `axes_grid` A special list of axes instances. See `axes_grid`. """ - rc._getitem_mode = 0 # ensure still zero; might be non-zero if had error in 'with context' block # Build array + rc._getitem_mode = 0 # ensure still zero; might be non-zero if had error in 'with context' block if order not in ('C','F'): # better error message raise ValueError(f'Invalid order {order!r}. Choose from "C" (row-major, default) and "F" (column-major).') if array is None: @@ -2004,20 +2138,9 @@ def subplots(array=None, ncols=1, nrows=1, raise ValueError(f'Invalid reference number {ref!r}. For array {array!r}, must be one of {nums}.') nrows, ncols = array.shape - # Figure out rows and columns "spanned" by each axes in list, for - # axis sharing and axis label spanning settings - sharex = int(_notNone(sharex, share, rc['share'])) - sharey = int(_notNone(sharey, share, rc['share'])) - if sharex not in range(4) or sharey not in range(4): - raise ValueError(f'Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels). Got sharex={sharex} and sharey={sharey}.') - spanx = _notNone(spanx, span, 0 if sharex == 0 else None, rc['span']) - spany = _notNone(spany, span, 0 if sharey == 0 else None, rc['span']) - alignx = _notNone(alignx, align) - aligny = _notNone(aligny, align) - if (spanx and alignx) or (spany and aligny): - warnings.warn(f'The "alignx" and "aligny" args have no effect when "spanx" and "spany" are True.') - alignx = _notNone(alignx, rc['align']) - aligny = _notNone(alignx, rc['align']) + # Interpret figure sizing and border arguments + subplots_kw, space_kw, kwargs = _size_edge_kwargs(**kwargs) + # 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 @@ -2033,40 +2156,6 @@ def subplots(array=None, ncols=1, nrows=1, proj_kw = _axes_dict(naxs, proj_kw, kw=True) basemap = _axes_dict(naxs, basemap, kw=False, default=False) - # Figure and/or axes dimensions - names, values = (), () - if journal: - figsize = _journal_figsize(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: - warnings.warn(f'You specified both {spec} and {name}={value!r}. 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))) @@ -2078,37 +2167,10 @@ def subplots(array=None, ncols=1, nrows=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'))) - if len(wratios) == 1: - wratios = np.repeat(wratios, (ncols,)) - if len(hratios) == 1: - hratios = np.repeat(hratios, (nrows,)) - if len(wratios) != ncols: - raise ValueError(f'Got {ncols} columns, but {len(wratios)} wratios.') - if len(hratios) != nrows: - raise ValueError(f'Got {nrows} rows, but {len(hratios)} hratios.') - - # 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, - } + space_kw.update(wspace=wspace, hspace=hspace) - # Default border spaces - left = _notNone(left, units(rc['subplots.ylabspace'])) - right = _notNone(right, units(rc['subplots.innerspace'])) - top = _notNone(top, units(rc['subplots.titlespace'])) - bottom = _notNone(bottom, units(rc['subplots.xlabspace'])) # Default spaces between axes - wratios, hratios = [*wratios], [*hratios] # copies wspace, hspace = np.array(wspace), np.array(hspace) # also copies! wspace[wspace==None] = ( units(rc['subplots.innerspace']) if sharey == 3 @@ -2121,22 +2183,32 @@ def subplots(array=None, ncols=1, nrows=1, ) wspace, hspace = wspace.tolist(), hspace.tolist() + # 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'))) + if len(wratios) == 1: + wratios = np.repeat(wratios, (ncols,)) + if len(hratios) == 1: + hratios = np.repeat(hratios, (nrows,)) + if len(wratios) != ncols: + 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 + # Parse arguments, fix dimensions in light of desired aspect ratio - figsize, gridspec_kw, subplots_kw = _subplots_geometry( + figsize, gridspec_kw, subplots_kw = _figure_geometry( 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, wspace=wspace, hspace=hspace, + **subplots_kw) + fig = plt.figure(FigureClass=Figure, figsize=figsize, ref=ref, + subplots_kw=subplots_kw, space_kw=space_kw, + **kwargs) gridspec = GridSpec(**gridspec_kw) - fig = plt.figure(FigureClass=Figure, tight=tight, figsize=figsize, - ref_num=ref, - pad=pad, axpad=axpad, panelpad=panelpad, autoformat=autoformat, - includepanels=includepanels, - subplots_orig_kw=subplots_orig_kw, subplots_kw=subplots_kw, - ) # Draw main subplots axs = naxs*[None] # list of axes @@ -2148,9 +2220,8 @@ def subplots(array=None, ncols=1, nrows=1, # Draw subplot subplotspec = gridspec[y0:y1+1, x0:x1+1] axs[idx] = fig.add_subplot(subplotspec, number=num, - spanx=spanx, spany=spany, alignx=alignx, aligny=aligny, - sharex=sharex, sharey=sharey, main=True, - proj=proj[num], basemap=basemap[num], **proj_kw[num]) + main=True, proj=proj[num], basemap=basemap[num], + proj_kw=proj_kw[num]) # Return figure and axes n = (ncols if order == 'C' else nrows) From c2519f6610150ba7101b812cc60436399e6043bf Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 16:43:27 -0600 Subject: [PATCH 13/37] Implement _subplots_geometry as Figure method, major refactor --- proplot/subplots.py | 828 ++++++++++++++++++++------------------------ 1 file changed, 384 insertions(+), 444 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index 9a64c3d2e..807ec4efc 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -531,294 +531,9 @@ def _panels_kwargs(side, else 'inner' if figure else 'panel') + 'space'])) return share, width, space, space_orig -def _size_edge_kwargs(axwidth=None, axheight=None, - width=None, height=None, figsize=None, journal=None, - left=None, right=None, bottom=None, top=None, - **kwargs): - """Interprets arguments passed to `subplots` and `figure`.""" - # Figure and/or axes dimensions - names, values = (), () - if journal: - figsize = _journal_figsize(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: - warnings.warn(f'You specified both {spec} and {name}={value!r}. Ignoring {name!r}.') - # Standardize dimensions - width, height = units(width), units(height) - axwidth, axheight = units(axwidth), units(axheight) - - # Input border spacing - left, right = units(left), units(right) - bottom, top = units(bottom), units(top) - space_kw = { - 'left':left, 'right':right, 'bottom':bottom, 'top':top - } - # Default border spacing - left = _notNone(left, units(rc['subplots.ylabspace'])) - right = _notNone(right, units(rc['subplots.innerspace'])) - top = _notNone(top, units(rc['subplots.titlespace'])) - bottom = _notNone(bottom, units(rc['subplots.xlabspace'])) - figure_kw = { - 'axwidth':axwidth, 'axheight':axheight, 'width':width, 'height':height, - 'left':left, 'right':right, 'bottom':bottom, 'top':top - } - return figure_kw, space_kw, kwargs - -def _figure_geometry(**kwargs): - """Saves 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 '' 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, got {len(hratios)}.') - if len(wratios) != ncols: - raise ValueError(f'Expected {ncols} width ratios for {ncols} columns, got {len(wratios)}.') - if len(hspace) != nrows - 1: - raise ValueError(f'Expected {nrows - 1} hspaces for {nrows} rows, got {len(hspace)}.') - if len(wspace) != ncols - 1: - raise ValueError(f'Expected {ncols - 1} wspaces for {ncols} columns, got {len(wspace)}.') - if len(hpanels) != nrows: - raise ValueError(f'Expected {nrows} hpanel toggles for {nrows} rows, got {len(hpanels)}.') - if len(wpanels) != ncols: - raise ValueError(f'Expected {ncols} wpanel toggles for {ncols} columns, got {len(wpanels)}.') - - # 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 is 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} 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 - 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) - 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 - #-----------------------------------------------------------------------------# -# Figure class and helper classes +# Figure class and helper funcs #-----------------------------------------------------------------------------# -_figure_params = """ -tight : bool, optional - Toggles automatic tight layout adjustments. 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`. -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. -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``. -""" -_subplot_params = """ -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 - =========== ==================== ========================================================================================================================================================== - ``'pnas1'`` 1-column `Proceedings of the National Academy of Sciences `__ - ``'pnas2'`` 2-column ” - ``'pnas3'`` landscape page ” - ``'ams1'`` 1-column `American Meteorological Society `__ - ``'ams2'`` small 2-column ” - ``'ams3'`` medium 2-column ” - ``'ams4'`` full 2-column ” - ``'agu1'`` 1-column `American Geophysical Union `__ - ``'agu2'`` 2-column ” - ``'agu3'`` full height 1-column ” - ``'agu4'`` full height 2-column ” - =========== ==================== ========================================================================================================================================================== - -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. -""" -docstring.interpd.update( - subplot_params=_subplot_params.strip(), - figure_params=_figure_params.strip() - ) - class Figure(mfigure.Figure): """The `~matplotlib.figure.Figure` class returned by `subplots`. At draw-time, an improved tight layout algorithm is employed, and @@ -826,17 +541,56 @@ class Figure(mfigure.Figure): panels is changed to accommodate subplot content. Figure dimensions may be automatically scaled to preserve subplot aspect ratios.""" def __init__(self, - tight=None, pad=None, axpad=None, panelpad=None, includepanels=False, - autoformat=True, + 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, + share=None, sharex=None, sharey=None, span=None, spanx=None, spany=None, align=None, alignx=None, aligny=None, - share=None, sharex=None, sharey=None, - ref=1, subplots_kw=None, space_kw=None, + includepanels=False, autoformat=True, ref=1, tight_layout=None, constrained_layout=None, **kwargs): """ Parameters ---------- + 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 + =========== ==================== ========================================================================================================================================================== + ``'pnas1'`` 1-column `Proceedings of the National Academy of Sciences `__ + ``'pnas2'`` 2-column ” + ``'pnas3'`` landscape page ” + ``'ams1'`` 1-column `American Meteorological Society `__ + ``'ams2'`` small 2-column ” + ``'ams3'`` medium 2-column ” + ``'ams4'`` full 2-column ” + ``'agu1'`` 1-column `American Geophysical Union `__ + ``'agu2'`` 2-column ” + ``'agu3'`` full height 1-column ” + ``'agu4'`` full height 2-column ” + =========== ==================== ========================================================================================================================================================== + + 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. tight : bool, optional Toggles automatic tight layout adjustments. Default is :rc:`tight`. @@ -846,16 +600,20 @@ def __init__(self, "stacked" panels. Units are interpreted by `~proplot.utils.units`. Defaults are :rc:`subplots.pad`, :rc:`subplots.axpad`, and :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``. - 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. + 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, padding is determined by the + "tight layout" algorithm. + 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. 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 @@ -886,14 +644,20 @@ def __init__(self, `__ 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. 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. - subplots_kw, space_kw : dict-like, optional - Dictionaries storing the "current" figure geometry properties - and gridspec spacing settings manually specified by the user, - respectively. This is used for the tight layout algorithm. **kwargs Passed to `matplotlib.figure.Figure`. @@ -903,28 +667,11 @@ def __init__(self, Ignored, because ProPlot uses its own tight layout algorithm. A warning will be issued if these are set to ``True``. """ - # Initialize first, because need to provide fully initialized figure - # as argument to gridspec, because matplotlib tight_layout does that + # Initialize first if tight_layout or constrained_layout: warnings.warn(f'Ignoring tight_layout={tight_layout} and contrained_layout={constrained_layout}. ProPlot uses its own tight layout algorithm, activated by default or with tight=True.') super().__init__(**kwargs) - self._locked = False - 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._include_panels = includepanels - self._axes_main = [] - self._space_kw = space_kw - self._subplots_kw = subplots_kw - self._bpanels = [] - self._tpanels = [] - self._lpanels = [] - self._rpanels = [] - self._gridspec = None - self.ref = ref - self.suptitle('') # add _suptitle attribute + # Axes sharing and spanning settings sharex = int(_notNone(sharex, share, rc['share'])) sharey = int(_notNone(sharey, share, rc['share'])) @@ -945,16 +692,73 @@ def __init__(self, self._spanx = spanx self._spany = spany - def _initialize_geometry(self, gridspec): - """Initializes figure geometry using the input gridspec.""" - # FIXME: Axes panels mess up the below arrays! Means we end up - # stacking panels that don't need to be stacked. - nrows, ncols = gridspec.get_active_geometry() - self._barray = np.empty((0, ncols), dtype=bool) - self._tarray = np.empty((0, ncols), dtype=bool) - self._larray = np.empty((0, nrows), dtype=bool) - self._rarray = np.empty((0, nrows), dtype=bool) - self._gridspec = gridspec + # Figure and/or axes dimensions + names, values = (), () + if journal: + figsize = _journal_figsize(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: + warnings.warn(f'You specified both {spec} and {name}={value!r}. Ignoring {name!r}.') + # Input border spacing + left, right = units(left), units(right) + bottom, top = units(bottom), units(top) + self._left_orig, self._right_orig = left, right + self._bottom_orig, self._top_orig = bottom, top + self._wspace_orig, self._hspace_orig = wspace, hspace + # Default border spacing + left = _notNone(left, units(rc['subplots.ylabspace'])) + right = _notNone(right, units(rc['subplots.innerspace'])) + top = _notNone(top, units(rc['subplots.titlespace'])) + bottom = _notNone(bottom, units(rc['subplots.xlabspace'])) + width, height = units(width), units(height) + axwidth, axheight = units(axwidth), units(axheight) + self._ref_aspect = aspect + self._width, self._height = width, height + self._ref_width, self._ref_height = axwidth, axheight + self._left, self._right = left, right + self._bottom, self._top = bottom, top + self._wspace, self._hspace = wspace, hspace + + # Various constants + 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._include_panels = includepanels + self._axes_main = [] + 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 @_counter def _add_axes_panel(self, ax, side, filled=False, **kwargs): @@ -1036,11 +840,10 @@ def _add_figure_panel(self, side, 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'] + panels, nacross = self._wpanels, self._ncols else: - panels, nacross = subplots_kw['wpanels'], subplots_kw['nrows'] + panels, nacross = self._hpanels, self._nrows array = getattr(self, '_' + s + 'array') npanels, nalong = array.shape @@ -1107,9 +910,11 @@ def _adjust_aspect(self): 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: + axs = self._axes_main + ref = self.ref + if not axs or ref > len(axs): return - ax = self._axes_main[self.ref - 1] + ax = axs[ref - 1] mode = ax.get_aspect() aspect = None if mode == 'equal': @@ -1122,12 +927,9 @@ def _adjust_aspect(self): pass # matplotlib issues warning, forces aspect == 'auto' # Apply aspect # Account for floating point errors by rounding to 10 digits - subplots_kw = self._subplots_kw - if aspect is not None and not _fp_equal(aspect, subplots_kw['aspect']): - subplots_kw['aspect'] = aspect - figsize, gridspec_kw, _ = _figure_geometry(**subplots_kw) - self.set_size_inches(figsize) - self._gridspec.update(**gridspec_kw) + if aspect is not None and not _fp_equal(aspect, self._ref_aspect): + self._ref_aspect = aspect + self._update_geometry() def _adjust_tight_layout(self, renderer): """Applies tight layout scaling that permits flexible figure @@ -1139,9 +941,7 @@ def _adjust_tight_layout(self, renderer): obox = self.bbox_inches # original bbox bbox = self.get_tightbbox(renderer) gridspec = self._gridspec - space_kw = self._space_kw # tight layout overrides - subplots_kw = self._subplots_kw - if not axs or not subplots_kw or not space_kw: + if not axs or not gridspec: return # Tight box *around* figure @@ -1153,21 +953,18 @@ def _adjust_tight_layout(self, renderer): 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 = space_kw[key] - current = subplots_kw[key] - subplots_kw[key] = _notNone(previous, current - offset + pad) + self._left = _notNone(self._left_orig, self._left - offset + pad) + self._right = _notNone(self._right_orig, self._right - offset + pad) + self._bottom = _notNone(self._bottom_orig, self._bottom - offset + pad) + self._top = _notNone(self._top_orig, self._top - offset + pad) # Get arrays storing gridspec spacing args axpad = self._axpad panelpad = self._panelpad nrows, ncols = gridspec.get_active_geometry() - wspace, hspace = subplots_kw['wspace'], subplots_kw['hspace'] - wspace_orig = space_kw['wspace'] - hspace_orig = space_kw['hspace'] + wspace, hspace = self._wspace, self._hspace + wspace_orig = self._wspace_orig + hspace_orig = self._hspace_orig # Get new subplot spacings, axes panel spacing, figure panel spacing spaces = [] for (w, x, y, nacross, @@ -1175,7 +972,7 @@ def _adjust_tight_layout(self, renderer): (wspace,hspace), (wspace_orig,hspace_orig), ): # 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]) @@ -1232,14 +1029,12 @@ def _adjust_tight_layout(self, renderer): space = _notNone(space_orig, space) # only if user did not provide original space!!! jspace[i] = space spaces.append(jspace) + # Update dictionary + self._wspace = spaces[0] + self._hspace = spaces[1] - # Apply new spaces - subplots_kw.update({ - 'wspace':spaces[0], 'hspace':spaces[1], - }) - figsize, gridspec_kw, _ = _figure_geometry(**subplots_kw) - self._gridspec.update(**gridspec_kw) - self.set_size_inches(figsize) + # Update geometry + self._update_geometry() def _align_axislabels(self, b=True): """Aligns spanning *x* and *y* axis labels, accounting for figure @@ -1444,43 +1239,39 @@ def _insert_row_column(self, side, idx, w, ncols = 'h', 'nrows' # Load arrays and test if we need to insert - subplots_kw = self._subplots_kw - space_kw = self._space_kw - panels = subplots_kw[w + 'panels'] - ratios = subplots_kw[w + 'ratios'] - spaces = subplots_kw[w + 'space'] - spaces_orig = space_kw[w + 'space'] + if not self._gridspec: + raise RuntimeError(f'Figure gridspec has not been initialized yet.') + panels = getattr(self, '_' + w + 'panels') + ratios = getattr(self, '_' + w + 'ratios') + spaces = getattr(self, '_' + w + 'space') + spaces_orig = getattr(self, '_' + w + 'space_orig') # Test if panel slot already exists - entry = ('f' if figure else s) - exists = (idx not in (-1, len(panels)) and panels[idx] == entry) - if exists: # already exists! + slot_name = ('f' if figure else s) + slot_exists = (idx not in (-1, len(panels)) and panels[idx] == slot_name) + if slot_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) else: idx += idx_offset idx_space += idx_offset - subplots_kw[ncols] += 1 + 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, entry) - - # Update figure - figsize, gridspec_kw, _ = _figure_geometry(**subplots_kw) - self.set_size_inches(figsize) - if exists: - gridspec = self._gridspec - gridspec.update(**gridspec_kw) - else: - # New gridspec + panels.insert(idx, slot_name) + + # Update geometry + if not slot_exists: + self._gridspec = None # reset self._gridspec.remove_figure(self) - gridspec = GridSpec(**gridspec_kw) - self._gridspec = 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 + gridspec = self._update_geometry() # 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 not slot_exists: axs = [iax for ax in self._iter_axes() for iax in (ax, *ax.child_axes)] for ax in axs: # Get old index @@ -1497,10 +1288,9 @@ def _insert_row_column(self, side, idx, # Apply new subplotspec! nrows, ncols, *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 + row1, row2, col1, col2 = coords subplotspec_new = gridspec[row1:row2+1, col1:col2+1] if topmost is subplotspec: ax.set_subplotspec(subplotspec_new) @@ -1561,6 +1351,181 @@ def _update_axislabels(self, axis=None, **kwargs): if pax is not None: # apply to panel? getattr(pax, x + 'axis').label.update(kwargs) + def _update_geometry(self, **kwargs): + """Saves 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 + kw = self._geometry_kw + kw.update(kwargs) + nrows, ncols = kw['nrows'], kw['ncols'] + aspect, xref, yref = kw['aspect'], kw['xref'], kw['yref'] + width, height = kw['width'], kw['height'] + axwidth, axheight = kw['axwidth'], kw['axheight'] + # Gridspec settings + wspace, hspace = kw['wspace'], kw['hspace'] + wratios, hratios = kw['wratios'], kw['hratios'] + left, bottom = kw['left'], kw['bottom'] + right, top = kw['right'], kw['top'] + + # Initialize some settings if this is a new gridspec + # Panel string toggles, lists containing empty strings '' or one of 'l', + # 'r', 'b', 't' (indicating axes panels) or 'f' (indicating figure panels) + if not self._gridspec: + 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 + wpanels = self._wpanels + hpanels = self._hpanels + + # Unfurl vectors + wratios = np.atleast_1d(wratios).tolist() + if len(wratios) == 1: + wratios = wratios * ncols + hratios = np.atleast_1d(hratios).tolist() + if len(hratios) == 1: + hratios = hratios * nrows + wspace = np.atleast_1d(wspace).tolist() + if len(wspace) == 1: + wspace = wspace * (ncols - 1) + hspace = np.atleast_1d(hspace).tolist() + if len(hspace) == 1: + hspace = hspace * (nrows - 1) + + # Checks, important now that we modify gridspec geometry + if len(hratios) != nrows: + raise ValueError(f'Expected {nrows} width ratios for {nrows} rows, got {len(hratios)}.') + if len(wratios) != ncols: + raise ValueError(f'Expected {ncols} width ratios for {ncols} columns, got {len(wratios)}.') + if len(hspace) != nrows - 1: + raise ValueError(f'Expected {nrows - 1} hspaces for {nrows} rows, got {len(hspace)}.') + if len(wspace) != ncols - 1: + raise ValueError(f'Expected {ncols - 1} wspaces for {ncols} columns, got {len(wspace)}.') + if len(hpanels) != nrows: + raise ValueError(f'Expected {nrows} hpanel toggles for {nrows} rows, got {len(hpanels)}.') + if len(wpanels) != ncols: + raise ValueError(f'Expected {ncols} wpanel toggles for {ncols} columns, got {len(wpanels)}.') + + # 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 is 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} 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 + 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) + 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 + + # Update figure size and gridspec + self.set_size_inches((width, height)) + 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, + ) + else: + 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, + ) + return self._gridspec + def _update_suplabels(self, ax, side, labels, **kwargs): """Assigns side labels, updates label settings.""" s = side[0] @@ -1661,38 +1626,40 @@ def add_subplot(self, *args, else: try: s = str(int(args[0])) - rows, cols, num = map(int, s) + 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: - rows, cols, num = args + 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 subplotspec is None: - rows, cols = int(rows), int(cols) + 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 > rows*cols: - raise ValueError(f'num must be 1 <= num <= {rows*cols}, not {num}') + 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 gridspec is None: - gridspec = GridSpec(rows, cols) # use default params - self._initialize_geometry(gridspec) + self._initialize_geometry(nrows, ncols) + self._update_geometry(nrows=nrows, ncols=ncols) + gridspec = self._gridspec + elif (nrows, ncols) != gridspec.get_active_geometry(): + raise ValueError(f'Input arguments {args!r} conflict with existing gridspec geometry of {nrows} rows, {ncols} columns.') subplotspec = gridspec[(num[0] - 1):num[1]] else: - rows, cols, *_ = subplotspec.get_active_geometry() if gridspec is None: + nrows, ncols, *_ = subplotspec.get_active_geometry() gridspec = subplotspec.get_gridspec() - self._initialize_geometry(gridspec) - if subplotspec.get_gridspec() is not gridspec: - raise ValueError(f'Invalid subplotspec {args[0]!r}. Figure.add_subplot() only accepts SubplotSpec objects whose parent is the main gridspec.') - if (rows, cols) != gridspec.get_active_geometry(): - raise ValueError(f'Input arguments {args!r} conflict with existing gridspec geometry of {rows} rows, {cols} columns.') + self._initialize_geometry(nrows, ncols) + self._update_geometry(nrows=nrows, ncols=ncols) + elif subplotspec.get_gridspec() is not gridspec: # also covers geometry discrepancies + raise ValueError(f'Invalid subplotspec {args[0]!r}. Figure.add_subplot() only accepts SubplotSpec objects whose parent is the main gridspec.') gridspec.add_figure(self) # Impose projection @@ -1909,6 +1876,7 @@ def ref(self): def ref(self, ref): if not isinstance(ref, Integral) or ref < 1: raise ValueError(f'Invalid axes number {ref!r}. Must be integer >=1.') + self.stale = True self._ref = ref #-----------------------------------------------------------------------------# @@ -1998,23 +1966,13 @@ def figure(**kwargs): **kwargs Passed to `~matplotlib.figure.Figure`. """ - # Interpret figure size and border keyword args - subplots_kw, space_kw, kwargs = _size_edge_kwargs(**kwargs) - # Default ratios and optional scalar spacing - kwargs['subplots_kw'] = subplots_kw - kwargs['space_kw'] = space_kw return plt.figure(FigureClass=Figure, **kwargs) @docstring.dedent_interpd def subplots(array=None, ncols=1, nrows=1, ref=1, order='C', - aspect=1, figsize=None, - width=None, height=None, axwidth=None, axheight=None, journal=None, hspace=None, wspace=None, space=None, - hratios=None, wratios=None, - width_ratios=None, height_ratios=None, - flush=None, wflush=None, hflush=None, - left=None, bottom=None, right=None, top=None, + hratios=None, wratios=None, width_ratios=None, height_ratios=None, proj=None, projection=None, proj_kw=None, projection_kw=None, basemap=False, **kwargs ): @@ -2055,21 +2013,6 @@ def subplots(array=None, ncols=1, nrows=1, 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, padding is determined by the - "tight layout" algorithm. - proj, projection : str or dict-like, optional The map projection name(s), passed to `~proplot.projs.Proj`. The argument is interpreted as follows. @@ -2138,9 +2081,6 @@ def subplots(array=None, ncols=1, nrows=1, raise ValueError(f'Invalid reference number {ref!r}. For array {array!r}, must be one of {nums}.') nrows, ncols = array.shape - # Interpret figure sizing and border arguments - subplots_kw, space_kw, kwargs = _size_edge_kwargs(**kwargs) - # 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 @@ -2168,7 +2108,7 @@ def subplots(array=None, ncols=1, nrows=1, if len(hspace) != nrows-1: raise ValueError(f'Require {nrows-1} height spacings for {nrows} rows, got {len(hspace)}.') wspace, hspace = wspace.tolist(), hspace.tolist() - space_kw.update(wspace=wspace, hspace=hspace) + wspace_orig, hspace_orig = wspace, hspace # Default spaces between axes wspace, hspace = np.array(wspace), np.array(hspace) # also copies! @@ -2199,16 +2139,16 @@ def subplots(array=None, ncols=1, nrows=1, wratios, hratios = wratios.tolist(), hratios.tolist() # also makes copy # Parse arguments, fix dimensions in light of desired aspect ratio - figsize, gridspec_kw, subplots_kw = _figure_geometry( - nrows=nrows, ncols=ncols, - aspect=aspect, xref=xref, yref=yref, - wpanels=['']*ncols, hpanels=['']*nrows, + # TODO: Fix xref and yref so update geometry gets them automatically + # from the reference axes! Then user can also change the reference axes + # which is pretty neat! + fig = plt.figure(FigureClass=Figure, figsize=figsize, ref=ref, **kwargs) + fig._wspace_orig = wspace_orig + fig._hspace_orig = hspace_orig + gridspec = fig._update_geometry(nrows=nrows, ncols=ncols, + xref=xref, yref=yref, wratios=wratios, hratios=hratios, wspace=wspace, hspace=hspace, - **subplots_kw) - fig = plt.figure(FigureClass=Figure, figsize=figsize, ref=ref, - subplots_kw=subplots_kw, space_kw=space_kw, - **kwargs) - gridspec = GridSpec(**gridspec_kw) + ) # Draw main subplots axs = naxs*[None] # list of axes From 106f00bd087646a8439ab7bdff00ce36c7497b26 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 31 Oct 2019 18:01:05 -0600 Subject: [PATCH 14/37] Fix GridSpec docs --- proplot/subplots.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index 807ec4efc..38e77ff96 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -308,24 +308,24 @@ def __init__(self, nrows=1, ncols=1, **kwargs): nrows, ncols : int, optional The number of rows and columns on the subplot grid. This is applied automatically when the gridspec is passed. - hspace, wspace : float or list of float + 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. + subplots, respectively. These are specified in physical units. + Units are interpreted by `~proplot.utils.units`. - If float, the spacing is identical between all rows and columns. If - list of float, the length of the lists must equal ``nrows-1`` + If float or string, the spacing is identical between all rows and + columns. If a list, this sets arbitrary spacing between different + rows and columns. The length of the list must equal ``nrows-1`` and ``ncols-1``, respectively. - height_ratios, width_ratios : list of float + height_ratios, width_ratios : list of float, optional 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. + left, right, top, bottom : float or str, optional + Denotes the margin *widths* in physical units. These are *not* + the margin coordinates. Units are interpreted by + `~proplot.utils.units`. **kwargs Passed to `~matplotlib.gridspec.GridSpec`. """ From 707d377483e7d82f6791366c422876c528270df3 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sun, 3 Nov 2019 18:14:49 -0700 Subject: [PATCH 15/37] Let Proj accept objects, misc minor changes --- proplot/projs.py | 34 +++++++++++----- proplot/styletools.py | 12 +++--- proplot/subplots.py | 94 +++++++++++++++++++++++-------------------- 3 files changed, 81 insertions(+), 59 deletions(-) diff --git a/proplot/projs.py b/proplot/projs.py index 026fb3247..e4762cd10 100644 --- a/proplot/projs.py +++ b/proplot/projs.py @@ -27,15 +27,18 @@ 'WinkelTripel', ] try: - from cartopy.crs import (_WarpedRectangularProjection, + from mpl_toolkits.basemap import Basemap +except: + Basemap = object +try: + from cartopy.crs import (CRS, _WarpedRectangularProjection, LambertAzimuthalEqualArea, AzimuthalEquidistant, Gnomonic) - _cartopy_installed = True except ModuleNotFoundError: + CRS = object _WarpedRectangularProjection = object LambertAzimuthalEqualArea = object AzimuthalEquidistant = object Gnomonic = object - _cartopy_installed = False def Circle(N=100): """Returns a circle `~matplotlib.path.Path` used as the outline @@ -58,10 +61,13 @@ def Proj(name, basemap=False, **kwargs): Parameters ---------- - name : str - The projection name. Like basemap, we use the PROJ.4 shorthands. The - following table lists the valid projection names, their full names - (with links to the relevant `PROJ.4 documentation + name : str, `~mpl_toolkits.basemap.Basemap`, or `cartopy.crs.Projection` + The projection name or projection class instance. If the latter, it + is simply returned. If the former, it must correspond to one of the + PROJ.4 projection name shorthands, like in basemap. + + The following table lists the valid projection name shorthands, their + full names (with links to the relevant `PROJ.4 documentation `__), and whether they are available in the cartopy and basemap packages. "``(added)``" indicates a cartopy projection that ProPlot has @@ -122,8 +128,8 @@ def Proj(name, basemap=False, **kwargs): ==================================== =========================================================================================== ========= ======= basemap : bool, optional - Whether to use the basemap or cartopy package. Default is - ``False``. + Whether to use the basemap package as opposed to the cartopy package. + Default is ``False``. **kwargs Passed to the `~mpl_toolkits.basemap.Basemap` or cartopy `~cartopy.crs.Projection` class. @@ -141,8 +147,14 @@ def Proj(name, basemap=False, **kwargs): -------- `~proplot.axes.CartopyProjectionAxes`, `~proplot.axes.BasemapProjectionAxes` """ + # Class instances + if ((CRS is not object and isinstance(name, CRS)) + or (Basemap is not object and isinstance(name, Basemap))): + proj = name + elif not isinstance(proj, str): + raise ValueError(f'Unexpected Proj() argument {proj!r}. Must be name, mpl_toolkits.basemap.Basemap instance, or cartopy.crs.CRS instance.') # Basemap - if basemap: + elif basemap: import mpl_toolkits.basemap as mbasemap # verify package is available name = BASEMAP_TRANSLATE.get(name, name) kwproj = basemap_rc.get(name, {}) @@ -313,7 +325,7 @@ def __init__(self, central_longitude=0.0, globe=None): cartopy_projs = {} """Mapping of "projection names" to cartopy `~cartopy.crs.Projection` classes.""" -if _cartopy_installed: +if CRS is not object: # Custom ones, these are always present import cartopy.crs as ccrs # verify package is available cartopy_projs = { # interpret string, create cartopy projection diff --git a/proplot/styletools.py b/proplot/styletools.py index b27c9f2d0..8eed86897 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -1429,13 +1429,15 @@ def __setitem__(self, key, item): a matplotlib `~matplotlib.colors.ListedColormap` or `~matplotlib.colors.LinearSegmentedColormap`, it is converted to the ProPlot `ListedColormap` or `LinearSegmentedColormap` subclass.""" - if type(item) is mcolors.LinearSegmentedColormap: + if isinstance(item, (ListedColormap, LinearSegmentedColormap)): + pass + elif isinstance(item, mcolors.LinearSegmentedColormap): item = LinearSegmentedColormap( item.name, item._segmentdata, item.N, item._gamma) - elif type(item) is mcolors.ListedColormap: + elif isinstance(item, mcolors.ListedColormap): item = ListedColormap( item.colors, item.name, item.N) - elif not isinstance(item, (ListedColormap, LinearSegmentedColormap)): + else: raise ValueError(f'Invalid colormap {item}. Must be instance of matplotlib.colors.ListedColormap or matplotlib.colors.LinearSegmentedColormap.') key = self._sanitize_key(key, mirror=False) return super().__setitem__(key, item) @@ -1664,10 +1666,10 @@ def Colormap(*args, name=None, listmode='perceptual', # Convert matplotlib colormaps to subclasses if isinstance(cmap, (ListedColormap, LinearSegmentedColormap)): pass - elif type(cmap) is mcolors.LinearSegmentedColormap: + elif isinstance(cmap, mcolors.LinearSegmentedColormap): cmap = LinearSegmentedColormap( cmap.name, cmap._segmentdata, cmap.N, cmap._gamma) - elif type(cmap) is mcolors.ListedColormap: + elif isinstance(cmap, mcolors.ListedColormap): cmap = ListedColormap( cmap.colors, cmap.name, cmap.N) # Dictionary of hue/sat/luminance values or 2-tuples representing linear transition diff --git a/proplot/subplots.py b/proplot/subplots.py index 38e77ff96..930a7e792 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -323,9 +323,11 @@ def __init__(self, nrows=1, ncols=1, **kwargs): 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, optional - Denotes the margin *widths* in physical units. These are *not* - the margin coordinates. Units are interpreted by - `~proplot.utils.units`. + 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. **kwargs Passed to `~matplotlib.gridspec.GridSpec`. """ @@ -385,34 +387,37 @@ def _normalize(key, size): def _spaces_as_ratios(self, hspace=None, wspace=None, # spacing between axes + hratios=None, wratios=None, 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)) + hratios = np.atleast_1d(_notNone(hratios, height_ratios, 1, + names=('hratios', 'height_ratios'))) + wratios = np.atleast_1d(_notNone(wratios, width_ratios, 1, + names=('wratios', 'width_ratios'))) hspace = np.atleast_1d(_notNone(hspace, np.mean(hratios)*0.10)) # this is relative to axes 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,)) + 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,)) # 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.') + raise ValueError(f'Got {nrows} rows, but {len(hratios)} height_ratios.') if len(wratios) != ncols: - raise ValueError(f'Got {ncols} columns, but {len(wratios)} wratios.') + raise ValueError(f'Got {ncols} columns, but {len(wratios)} width_ratios.') if len(wspace) != ncols-1: - raise ValueError(f'Require {ncols-1} width spacings for {ncols} columns, got {len(wspace)}.') + raise ValueError(f'Require length {ncols-1} wspace vector for {ncols} columns, but got {len(wspace)}.') if len(hspace) != nrows-1: - raise ValueError(f'Require {nrows-1} height spacings for {nrows} rows, got {len(hspace)}.') + raise ValueError(f'Require length {nrows-1} hspace vector for {nrows} rows, but got {len(hspace)}.') # Assign spacing as ratios nrows, ncols = self.get_geometry() @@ -426,16 +431,11 @@ def _spaces_as_ratios(self, hratios_final[1::2] = [*hspace] return wratios_final, hratios_final, kwargs # bring extra kwargs back - def add_figure(self, fig): + def add_figure(self, figure): """Adds `~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.""" - self._figures.add(fig) - - def remove_figure(self, fig): - """Removes `~matplotlib.figure.Figure` from the list of figures that - are using this gridspec.""" - self._figures.discard(fig) + self._figures.add(figure) def get_margins(self): """Returns left, bottom, right, top values. Not sure why this method @@ -463,6 +463,16 @@ def get_active_geometry(self): columns that aren't skipped by `~GridSpec.__getitem__`.""" return self._nrows_active, self._ncols_active + def remove_figure(self, figure): + """Removes `~matplotlib.figure.Figure` from the list of figures that + are using this gridspec.""" + self._figures.discard(figure) + + def tight_layout(self): + """Method is disabled because ProPlot has its own simplified + tight layout algorithm.""" + raise RuntimeError(f'Native matplotlib tight layout is disabled.') + def update(self, figure=None, **kwargs): """ Updates the width and height ratios, gridspec margins, and spacing @@ -491,12 +501,12 @@ def update(self, figure=None, **kwargs): raise ValueError(f'Unknown keyword arg(s): {kwargs}.') # Apply to figure(s) and all axes - for fig in self._figures: - fig.subplotpars.update(self.left, self.bottom, self.right, self.top) - for ax in fig.axes: + for figure in self._figures: + figure.subplotpars.update(self.left, self.bottom, self.right, self.top) + for ax in figure.axes: ax.update_params() ax.set_position(ax.figbox) - fig.stale = True + figure.stale = True #-----------------------------------------------------------------------------# # Helper funcs @@ -545,6 +555,7 @@ def __init__(self, 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, @@ -714,10 +725,15 @@ def __init__(self, spec = ', '.join(spec) names = ('axwidth', 'axheight') values = (axwidth, axheight) - # Raise warning + # Save dimensions for name,value in zip(names,values): if value is not None: warnings.warn(f'You specified both {spec} and {name}={value!r}. Ignoring {name!r}.') + width, height = units(width), units(height) + axwidth, axheight = units(axwidth), units(axheight) + self._width, self._height = width, height + self._ref_width, self._ref_height = axwidth, axheight + # Input border spacing left, right = units(left), units(right) bottom, top = units(bottom), units(top) @@ -729,11 +745,7 @@ def __init__(self, right = _notNone(right, units(rc['subplots.innerspace'])) top = _notNone(top, units(rc['subplots.titlespace'])) bottom = _notNone(bottom, units(rc['subplots.xlabspace'])) - width, height = units(width), units(height) - axwidth, axheight = units(axwidth), units(axheight) self._ref_aspect = aspect - self._width, self._height = width, height - self._ref_width, self._ref_height = axwidth, axheight self._left, self._right = left, right self._bottom, self._top = bottom, top self._wspace, self._hspace = wspace, hspace @@ -938,6 +950,7 @@ def _adjust_tight_layout(self, renderer): instance.""" # Initial stuff axs = self._iter_axes() + pad = self._pad obox = self.bbox_inches # original bbox bbox = self.get_tightbbox(renderer) gridspec = self._gridspec @@ -945,18 +958,12 @@ def _adjust_tight_layout(self, renderer): return # Tight box *around* figure - # Get bounds from old bounding box - pad = self._pad - 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? - self._left = _notNone(self._left_orig, self._left - offset + pad) - self._right = _notNone(self._right_orig, self._right - offset + pad) - self._bottom = _notNone(self._bottom_orig, self._bottom - offset + pad) - self._top = _notNone(self._top_orig, self._top - offset + pad) + self._left = _notNone(self._left_orig, self._left - bbox.xmin + pad) + self._right = _notNone(self._right_orig, self._right - bbox.ymin + pad) + self._bottom = _notNone(self._bottom_orig, self._bottom - (obox.xmax - bbox.xmax) + pad) + self._top = _notNone(self._top_orig, self._top - (obox.ymax - bbox.ymax) + pad) # Get arrays storing gridspec spacing args axpad = self._axpad @@ -1577,9 +1584,10 @@ def add_subplot(self, *args, 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, optional - The registered matplotlib projection name, or a basemap or cartopy - map projection name. Passed to `~proplot.projs.Proj`. See + 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 @@ -2142,7 +2150,7 @@ def subplots(array=None, ncols=1, nrows=1, # TODO: Fix xref and yref so update geometry gets them automatically # from the reference axes! Then user can also change the reference axes # which is pretty neat! - fig = plt.figure(FigureClass=Figure, figsize=figsize, ref=ref, **kwargs) + fig = plt.figure(FigureClass=Figure, ref=ref, **kwargs) fig._wspace_orig = wspace_orig fig._hspace_orig = hspace_orig gridspec = fig._update_geometry(nrows=nrows, ncols=ncols, From 05b1f7a3a0baff1638c9bc4061ef90a2f61f557b Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 4 Nov 2019 06:02:36 -0700 Subject: [PATCH 16/37] Massive overhaul of rctools, related changes --- proplot/__init__.py | 22 - proplot/axes.py | 154 +++--- proplot/cycles/538.hex | 1 + proplot/cycles/Contrast.hex | 1 + proplot/cycles/Cool.hex | 1 + proplot/cycles/FlatUI.hex | 1 + proplot/cycles/Floral.hex | 1 + proplot/cycles/Hot.hex | 1 + proplot/cycles/Sharp.hex | 1 + proplot/cycles/Warm.hex | 1 + proplot/cycles/colorblind.hex | 1 + proplot/cycles/colorblind10.hex | 1 + proplot/cycles/default.hex | 1 + proplot/cycles/ggplot.hex | 1 + proplot/rctools.py | 907 ++++++++++++++++++-------------- proplot/styletools.py | 447 +++++++--------- proplot/subplots.py | 32 -- proplot/utils.py | 92 ++-- 18 files changed, 828 insertions(+), 838 deletions(-) create mode 100644 proplot/cycles/538.hex create mode 100644 proplot/cycles/Contrast.hex create mode 100644 proplot/cycles/Cool.hex create mode 100644 proplot/cycles/FlatUI.hex create mode 100644 proplot/cycles/Floral.hex create mode 100644 proplot/cycles/Hot.hex create mode 100644 proplot/cycles/Sharp.hex create mode 100644 proplot/cycles/Warm.hex create mode 100644 proplot/cycles/colorblind.hex create mode 100644 proplot/cycles/colorblind10.hex create mode 100644 proplot/cycles/default.hex create mode 100644 proplot/cycles/ggplot.hex diff --git a/proplot/__init__.py b/proplot/__init__.py index 40fc4bbfe..313e4b66b 100644 --- a/proplot/__init__.py +++ b/proplot/__init__.py @@ -31,28 +31,6 @@ def _warning_proplot(message, category, filename, lineno, line=None): if warnings.formatwarning is not _warning_proplot: warnings.formatwarning = _warning_proplot -# Initialize customization folders and files -import os -_rc_folder = os.path.join(os.path.expanduser('~'), '.proplot') -if not os.path.isdir(_rc_folder): - os.mkdir(_rc_folder) -for _rc_sub in ('cmaps', 'cycles', 'colors', 'fonts'): - _rc_sub = os.path.join(_rc_folder, _rc_sub) - if not os.path.isdir(_rc_sub): - os.mkdir(_rc_sub) -_rc_file = os.path.join(os.path.expanduser('~'), '.proplotrc') -_rc_file_default = os.path.join(os.path.dirname(__file__), '.proplotrc') -if not os.path.isfile(_rc_file): - with open(_rc_file_default) as f: - lines = ''.join( - '# ' + line if line.strip() and line[0] != '#' else line - for line in f.readlines() - ) - with open(_rc_file, 'x') as f: - f.write('# User default settings\n' - + '# See https://proplot.readthedocs.io/en/latest/rctools.html\n' - + lines) - # Import stuff # WARNING: Import order is meaningful! Loads modules that are dependencies # of other modules last, and loads styletools early so we can try to update diff --git a/proplot/axes.py b/proplot/axes.py index de156fe0d..60da5f4d2 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -4,16 +4,16 @@ `~proplot.subplots.subplots` and their various method wrappers. You should start with the documentation on the following methods. -* `Axes.format` -* `Axes.context` * `CartesianAxes.format` +* `PolarAxes.format` * `ProjectionAxes.format` +* `Axes.format` -`Axes.format` and `Axes.context` are both called by -`CartesianAxes.format` and `ProjectionAxes.format`. ``format`` is your -**one-stop-shop for changing axes settings** like *x* and *y* axis limits, -axis labels, tick locations, tick labels grid lines, axis scales, titles, -a-b-c labelling, adding geographic features, and much more. +`CartesianAxes.format`, `PolarAxes.format`, and `ProjectionAxes.format` all +call `Axes.format`. Call ``ax.format(...)`` to change a variety of axes +settings like *x* and *y* axis limits, axis labels, tick locations, tick +labels grid lines, axis scales, titles, a-b-c labelling, adding geographic +features, and much more. """ import numpy as np import warnings @@ -117,6 +117,19 @@ def _wrapper(self, *args, **kwargs): #-----------------------------------------------------------------------------# # Generalized custom axes class #-----------------------------------------------------------------------------# +def _parse_kwargs(self, *, mode=2, rc_kw=None, **kwargs): + """Separate rc setting keyword arguments from format command keyword + arguments.""" + kw = {} + rc_kw = rc_kw or {} + for key,value in kwargs.items(): + key_fixed = RC_NODOTSNAMES.get(key, None) + if key_fixed is None: + kw[key] = value + else: + rc_kw[key_fixed] = value + return rc_kw, rc_mode, kw + class Axes(maxes.Axes): """Lowest-level axes subclass. Handles titles and axis sharing. Adds several new methods and overrides existing ones.""" @@ -319,7 +332,7 @@ def _iter_panels(self, sides='lrbt'): return axs @staticmethod - def _loc_translate(loc, **kwargs): + def _loc_translate(loc): """Translates location string `loc` into a standardized form.""" if loc is True: loc = None @@ -539,63 +552,6 @@ def _update_title(self, obj, **kwargs): y = _notNone(kwargs.pop('y', y), pos[1]) return self.text(x, y, text, **kwextra) - def context(self, *, mode=2, rc_kw=None, **kwargs): - """ - For internal use. Sets up temporary `~proplot.rctools.rc` settings by - returning the result of `~proplot.rctools.rc_configurator.context`. - - Parameters - ---------- - rc_kw : dict, optional - A dictionary containing "rc" configuration settings that will - be applied to this axes. Temporarily updates the - `~proplot.rctools.rc` object. - **kwargs - Any of three options: - - * A keyword arg for `Axes.format`, `CartesianAxes.format`, - or `ProjectionAxes.format`. - * A global "rc" keyword arg, like ``linewidth`` or ``color``. - * A standard "rc" keyword arg **with the dots omitted**, - like ``landcolor`` instead of ``land.color``. - - The latter two options update the `~proplot.rctools.rc` - object, just like `rc_kw`. - - Other parameters - ---------------- - mode : int, optional - The "getitem mode". This is used under-the-hood -- you shouldn't - have to use it directly. Determines whether queries to the - `~proplot.rctools.rc` object will ignore `rcParams `__. - This can help prevent a massive number of unnecessary lookups - when the settings haven't been changed by the user. - See `~proplot.rctools.rc_configurator` for details. - - Returns - ------- - `~proplot.rctools.rc_configurator` - The `proplot.rctools.rc` object primed for use in a "with" - statement. - dict - Dictionary of keyword arguments that are not `~proplot.rctools.rc` - properties, to be passed to the ``format`` methods. - """ - # Figure out which kwargs are valid rc settings - # TODO: Support for 'small', 'large', etc. font - kw = {} # for format - rc_kw = rc_kw or {} - for key,value in kwargs.items(): - key_fixed = RC_NODOTSNAMES.get(key, None) - if key_fixed is None: - kw[key] = value - else: - rc_kw[key_fixed] = value - rc._getitem_mode = 0 # might still be non-zero if had error - # Return "context object", which is just the configurator itself - # primed for use in a "with" statement - return rc.context(rc_kw, mode=mode), kw - def format(self, *, title=None, top=None, figtitle=None, suptitle=None, rowlabels=None, collabels=None, leftlabels=None, rightlabels=None, toplabels=None, bottomlabels=None, @@ -930,7 +886,7 @@ def colorbar(self, *args, loc=None, pad=None, pad : float or str, optional The space between the axes edge and the colorbar. For inset colorbars only. Units are interpreted by `~proplot.utils.units`. - Default is :rc:`colorbar.axespad`. + Default is :rc:`colorbar.insetpad`. length : float or str, optional The colorbar length. For outer colorbars, units are relative to the axes width or height. Default is :rc:`colorbar.length`. For inset @@ -938,16 +894,17 @@ def colorbar(self, *args, loc=None, pad=None, is :rc:`colorbar.insetlength`. width : float or str, optional The colorbar width. Units are interpreted by `~proplot.utils.units`. - Default is :rc:`colorbar.width` or :rc:`colorbar.insetwidth`. + For outer colorbars, default is :rc:`colorbar.width`. For inset + colorbars, default is :rc:`colorbar.insetwidth`. space : float or str, optional The space between the colorbar and the main axes. For outer colorbars only. Units are interpreted by `~proplot.utils.units`. When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, defaut is :rc:`subplots.panelspace`. + Otherwise, the default is :rc:`subplots.panelspace`. frame, frameon : bool, optional - Whether to draw a frame around inset colorbars, just like - `~matplotlib.axes.Axes.legend`. - Default is :rc:`colorbar.frameon`. + For inset colorbars, indicates whether to draw a "frame", just + like `~matplotlib.axes.Axes.legend`. Default is + :rc:`colorbar.frameon`. alpha, linewidth, edgecolor, facecolor : optional Transparency, edge width, edge color, and face color for the frame around the inset colorbar. Default is @@ -959,12 +916,11 @@ def colorbar(self, *args, loc=None, pad=None, """ # TODO: add option to pad inset away from axes edge! kwargs.update({'edgecolor':edgecolor, 'linewidth':linewidth}) - loc = _notNone(loc, rc['colorbar.loc']) - loc = self._loc_translate(loc) - if loc == 'best': # a white lie - loc = 'lower right' + loc = self._loc_translate(_notNone(loc, rc['colorbar.loc'])) if not isinstance(loc, str): # e.g. 2-tuple or ndarray raise ValueError(f'Invalid colorbar location {loc!r}.') + if loc == 'best': # white lie + loc = 'lower right' # Generate panel if loc in ('left','right','top','bottom'): @@ -1036,7 +992,7 @@ def colorbar(self, *args, loc=None, pad=None, extend = units(_notNone(kwargs.get('extendsize',None), rc['colorbar.insetextend'])) cbwidth = units(_notNone(cbwidth, rc['colorbar.insetwidth']))/height cblength = units(_notNone(cblength, rc['colorbar.insetlength']))/width - pad = units(_notNone(pad, rc['colorbar.axespad'])) + pad = units(_notNone(pad, rc['colorbar.insetpad'])) xpad, ypad = pad/width, pad/height # Get location in axes-relative coordinates @@ -1142,14 +1098,14 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): The space between the axes and the legend for outer legends. Units are interpreted by `~proplot.utils.units`. When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, defaut is :rc:`subplots.panelspace`. + Otherwise, the default is :rc:`subplots.panelspace`. Other parameters ---------------- *args, **kwargs Passed to `~proplot.wrappers.legend_wrapper`. """ - loc = self._loc_translate(loc, width=width, space=space) + loc = self._loc_translate(_notNone(loc, rc['legend.loc'])) if isinstance(loc, np.ndarray): loc = loc.tolist() @@ -1346,7 +1302,7 @@ 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, defaut is :rc:`subplots.panelspace`. + Otherwise, the default is :rc:`subplots.panelspace`. 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. @@ -1971,8 +1927,14 @@ def format(self, *, Keyword arguments used to update the background patch object. You can use this, for example, to set background hatching with ``patch_kw={'hatch':'xxx'}``. + rc_kw : dict, optional + Dictionary containing `~proplot.rctools.rc` settings applied to + this axes using `~proplot.rctools.rc_configurator.context`. **kwargs - Passed to `Axes.format` and `Axes.context`. + Passed to `Axes.format` or passed to + `~proplot.rctools.rc_configurator.context` and used to update + axes `~proplot.rctools.rc` settings. For example, + ``axestitlesize=15`` modifies the :rcraw:`axes.titlesize` setting. Note ---- @@ -1987,8 +1949,8 @@ def format(self, *, `~proplot.axistools.Scale`, `~proplot.axistools.Locator`, `~proplot.axistools.Formatter` """ - context, kwargs = self.context(**kwargs) - with context: + rc_kw, rc_mode, kwargs = _parse_kwargs(**kwargs) + with rc.context(rc_kw, mode=rc_mode): # Background basics self.patch.set_clip_on(False) self.patch.set_zorder(-1) @@ -2537,11 +2499,17 @@ def format(self, *args, thetaformatter_kw, rformatter_kw : dict-like, optional The azimuthal and radial label formatter settings. Passed to `~proplot.axistools.Formatter`. + rc_kw : dict, optional + Dictionary containing `~proplot.rctools.rc` settings applied to + this axes using `~proplot.rctools.rc_configurator.context`. **kwargs - Passed to `Axes.format` and `Axes.context` + Passed to `Axes.format` or passed to + `~proplot.rctools.rc_configurator.context` and used to update + axes `~proplot.rctools.rc` settings. For example, + ``axestitlesize=15`` modifies the :rcraw:`axes.titlesize` setting. """ - context, kwargs = self.context(**kwargs) - with context: + rc_kw, rc_mode, kwargs = _parse_kwargs(**kwargs) + with rc.context(rc_kw, mode=rc_mode): # Not mutable default args thetalocator_kw = thetalocator_kw or {} thetaformatter_kw = thetaformatter_kw or {} @@ -2764,13 +2732,19 @@ def format(self, *, Keyword arguments used to update the background patch object. You can use this, for example, to set background hatching with ``patch_kw={'hatch':'xxx'}``. + rc_kw : dict, optional + Dictionary containing `~proplot.rctools.rc` settings applied to + this axes using `~proplot.rctools.rc_configurator.context`. **kwargs - Passed to `Axes.format` and `Axes.context`. + Passed to `Axes.format` or passed to + `~proplot.rctools.rc_configurator.context` and used to update + axes `~proplot.rctools.rc` settings. For example, + ``axestitlesize=15`` modifies the :rcraw:`axes.titlesize` setting. """ - # Parse alternative keyword args - # TODO: Why isn't default latmax 80 respected sometimes? - context, kwargs = self.context(**kwargs) - with context: + rc_kw, rc_mode, kwargs = _parse_kwargs(**kwargs) + with rc.context(rc_kw, mode=rc_mode): + # Parse alternative keyword args + # TODO: Why isn't default latmax 80 respected sometimes? lonlines = _notNone(lonlines, lonlocator, rc['geogrid.lonstep'], names=('lonlines', 'lonlocator')) latlines = _notNone(latlines, latlocator, rc['geogrid.latstep'], names=('latlines', 'latlocator')) latmax = _notNone(latmax, rc['geogrid.latmax']) diff --git a/proplot/cycles/538.hex b/proplot/cycles/538.hex new file mode 100644 index 000000000..52f7cec0b --- /dev/null +++ b/proplot/cycles/538.hex @@ -0,0 +1 @@ +'#008fd5', '#fc4f30', '#e5ae38', '#6d904f', '#8b8b8b', '#810f7c', diff --git a/proplot/cycles/Contrast.hex b/proplot/cycles/Contrast.hex new file mode 100644 index 000000000..11232d974 --- /dev/null +++ b/proplot/cycles/Contrast.hex @@ -0,0 +1 @@ +"#2B4162", "#FA9F42", "#E0E0E2", "#A21817", "#0B6E4F", diff --git a/proplot/cycles/Cool.hex b/proplot/cycles/Cool.hex new file mode 100644 index 000000000..cbafcd43c --- /dev/null +++ b/proplot/cycles/Cool.hex @@ -0,0 +1 @@ +"#6C464F", "#9E768F", "#9FA4C4", "#B3CDD1", "#C7F0BD", diff --git a/proplot/cycles/FlatUI.hex b/proplot/cycles/FlatUI.hex new file mode 100644 index 000000000..d35ce690a --- /dev/null +++ b/proplot/cycles/FlatUI.hex @@ -0,0 +1 @@ +"#3498db", "#e74c3c", "#95a5a6", "#34495e", "#2ecc71", "#9b59b6", diff --git a/proplot/cycles/Floral.hex b/proplot/cycles/Floral.hex new file mode 100644 index 000000000..731923aca --- /dev/null +++ b/proplot/cycles/Floral.hex @@ -0,0 +1 @@ +"#23395B", "#D81E5B", "#FFFD98", "#B9E3C6", "#59C9A5", diff --git a/proplot/cycles/Hot.hex b/proplot/cycles/Hot.hex new file mode 100644 index 000000000..b7f06cd14 --- /dev/null +++ b/proplot/cycles/Hot.hex @@ -0,0 +1 @@ +"#0D3B66", "#F95738", "#F4D35E", "#FAF0CA", "#EE964B", diff --git a/proplot/cycles/Sharp.hex b/proplot/cycles/Sharp.hex new file mode 100644 index 000000000..9b71dfd6d --- /dev/null +++ b/proplot/cycles/Sharp.hex @@ -0,0 +1 @@ +"#007EA7", "#D81159", "#B3CDD1", "#FFBC42", "#0496FF", diff --git a/proplot/cycles/Warm.hex b/proplot/cycles/Warm.hex new file mode 100644 index 000000000..5aabebf86 --- /dev/null +++ b/proplot/cycles/Warm.hex @@ -0,0 +1 @@ +'#335c67', '#9e2a2b', '#fff3b0', '#e09f3e', '#540b0e' diff --git a/proplot/cycles/colorblind.hex b/proplot/cycles/colorblind.hex new file mode 100644 index 000000000..7d7218dd4 --- /dev/null +++ b/proplot/cycles/colorblind.hex @@ -0,0 +1 @@ +'#0072B2', '#D55E00', '#009E73', '#CC79A7', '#F0E442', '#56B4E9', diff --git a/proplot/cycles/colorblind10.hex b/proplot/cycles/colorblind10.hex new file mode 100644 index 000000000..9f4cac178 --- /dev/null +++ b/proplot/cycles/colorblind10.hex @@ -0,0 +1 @@ +"#0173B2", "#DE8F05", "#029E73", "#D55E00", "#CC78BC", "#CA9161", "#FBAFE4", "#949494", "#ECE133", "#56B4E9", # versions with more colors diff --git a/proplot/cycles/default.hex b/proplot/cycles/default.hex new file mode 100644 index 000000000..9d8b2e450 --- /dev/null +++ b/proplot/cycles/default.hex @@ -0,0 +1 @@ +'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', diff --git a/proplot/cycles/ggplot.hex b/proplot/cycles/ggplot.hex new file mode 100644 index 000000000..61a5f1fe3 --- /dev/null +++ b/proplot/cycles/ggplot.hex @@ -0,0 +1 @@ +'#E24A33', '#348ABD', '#988ED5', '#777777', '#FBC15E', '#8EBA42', '#FFB5B8', diff --git a/proplot/rctools.py b/proplot/rctools.py index a407c6a87..57248acd9 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -7,7 +7,7 @@ 1. Builtin matplotlib `rcParams `__ settings. These have the format ``x.y`` or ``x.y.z``. -2. ProPlot :ref:`rcParamsCustom` settings. These also have the format ``x.y`` +2. ProPlot :ref:`rcParamsLong` settings. These also have the format ``x.y`` (see below). 3. ProPlot :ref:`rcParamsShort` settings. These have no dots (see below). @@ -25,7 +25,7 @@ * ``ax.format(rc_kw={'name':value})`` In all of these examples, if the setting name ``name`` contains -any dots, you can simply **omit the dots**. For example, to change the +any dots, you can simply omit the dots. For example, to change the :rcraw:`title.loc` property, use ``plot.rc.titleloc = value``, ``plot.rc.update(titleloc=value)``, or ``ax.format(titleloc=value)``. @@ -81,9 +81,9 @@ ``rivers`` Boolean, toggles river lines on and off. ================ ==================================================================================================================================================================================================================================== -############## -rcParamsCustom -############## +############ +rcParamsLong +############ The ``subplots`` category controls the default layout for figures and axes. The ``abc``, ``title``, and ``tick`` categories control a-b-c label, title, and axis tick label settings. The @@ -124,7 +124,7 @@ ``colorbar.insetlength`` Length of inset colorbars. Units are interpreted by `~proplot.utils.units`. ``colorbar.width`` Width of outer colorbars. Units are interpreted by `~proplot.utils.units`. ``colorbar.insetwidth`` Width of inset colorbars. Units are interpreted by `~proplot.utils.units`. -``colorbar.axespad`` Padding between axes edge and inset colorbars. Units are interpreted by `~proplot.utils.units`. +``colorbar.insetpad`` Padding between axes edge and inset colorbars. Units are interpreted by `~proplot.utils.units`. ``colorbar.extend`` Length of rectangular or triangular "extensions" for panel colorbars. Units are interpreted by `~proplot.utils.units`. ``colorbar.insetextend`` Length of rectangular or triangular "extensions" for inset colorbars. Units are interpreted by `~proplot.utils.units`. ``geoaxes.facecolor``, ``geoaxes.edgecolor``, ``geoaxes.linewidth`` Face color, edge color, and edge width for the map outline patch. @@ -202,9 +202,213 @@ __all__ = ['rc', 'rc_configurator', 'nb_setup'] # Initialize -from matplotlib import rcParams as rcParams -rcParamsShort = {} -rcParamsCustom = {} +from matplotlib import rcParams, RcParams +class RcParamsShort(dict): + """Class for holding short-name settings. So far just `dict`.""" + pass +class RcParamsLong(dict): + """Class for holding custom settings. So far just `dict`.""" + pass +defaultParamsShort = RcParamsShort({ + 'nbsetup': True, + 'format': 'retina', + 'autosave': 30, + 'autoreload': 2, + 'abc': False, + 'share': 3, + 'align': False, + 'span': True, + 'tight': True, + 'fontname': 'Helvetica Neue', + 'cmap': 'fire', + 'lut': 256, + 'cycle': 'colorblind', + 'rgbcycle': False, + 'color': 'k', + 'alpha': 1, + 'facecolor': 'w', + 'small': 8, + 'large': 9, + 'linewidth': 0.6, + 'margin': 0.0, + 'grid': True, + 'gridminor': False, + 'ticklen': 4.0, + 'tickdir': 'out', + 'tickpad': 2.0, + 'tickratio': 0.8, + 'ticklenratio': 0.5, + 'gridratio': 0.5, + 'reso': 'lo', + 'geogrid': True, + 'land': False, + 'ocean': False, + 'coast': False, + 'rivers': False, + 'lakes': False, + 'borders': False, + 'innerborders': False, + }) +defaultParamsLong = RcParamsLong({ + 'abc.loc': 'l', # left side above the axes + 'title.loc': 'c', # centered above the axes + 'title.pad': 3.0, # copy + 'abc.style': 'a', + 'abc.size': None, # = large + 'abc.color': 'k', + 'abc.weight': 'bold', + 'abc.border': True, + 'abc.linewidth': 1.5, + 'tick.labelsize': None, # = small + 'tick.labelcolor': None, # = color + 'tick.labelweight': 'normal', + 'title.size': None, # = large + 'title.color': 'k', + 'title.weight': 'normal', + 'title.border': True, + 'title.linewidth': 1.5, + 'suptitle.size': None, # = large + 'suptitle.color': 'k', + 'suptitle.weight': 'bold', + 'leftlabel.size': None, # = large + 'leftlabel.weight': 'bold', + 'leftlabel.color': 'k', + 'toplabel.size': None, # = large + 'toplabel.weight': 'bold', + 'toplabel.color': 'k', + 'rightlabel.size': None, # = large + 'rightlabel.weight': 'bold', + 'rightlabel.color': 'k', + 'bottomlabel.size': None, # = large + 'bottomlabel.weight': 'bold', + 'bottomlabel.color': 'k', + 'image.edgefix': True, + 'image.levels': 11, + 'axes.alpha': None, # if empty, depends on 'savefig.transparent' setting + 'axes.formatter.zerotrim': True, + 'axes.formatter.timerotation': 90, + 'axes.gridminor': True, + 'axes.geogrid': True, + 'gridminor.alpha': None, # = grid.alpha + 'gridminor.color': None, # = grid.color + 'gridminor.linestyle': None, # = grid.linewidth + 'gridminor.linewidth': None, # = grid.linewidth x gridratio + 'geogrid.labels': False, + 'geogrid.labelsize': None, # = small + 'geogrid.latmax': 90, + 'geogrid.lonstep': 30, + 'geogrid.latstep': 20, + 'geogrid.alpha': 0.5, + 'geogrid.color': 'k', + 'geogrid.linewidth': 1.0, + 'geogrid.linestyle': ': ', + 'geoaxes.linewidth': None, # = linewidth + 'geoaxes.facecolor': None, # = facecolor + 'geoaxes.edgecolor': None, # = color + 'land.color': 'k', + 'ocean.color': 'w', + 'lakes.color': 'w', + 'coast.color': 'k', + 'coast.linewidth': 0.6, + 'borders.color': 'k', + 'borders.linewidth': 0.6, + 'innerborders.color': 'k', + 'innerborders.linewidth': 0.6, + 'rivers.color': 'k', + 'rivers.linewidth': 0.6, + 'colorbar.loc': 'right', + 'colorbar.grid': False, + 'colorbar.frameon': True, + 'colorbar.framealpha': 0.8, + 'colorbar.insetpad': '0.5em', + 'colorbar.extend': '1.3em', + 'colorbar.insetextend': '1em', + 'colorbar.length': 1, + 'colorbar.insetlength': '8em', + 'colorbar.width': '1.5em', + 'colorbar.insetwidth': '1.2em', + 'subplots.axwidth': '18em', + 'subplots.panelwidth': '4em', + 'subplots.pad': '0.5em', + 'subplots.axpad': '1em', + 'subplots.panelpad': '0.5em', + 'subplots.panelspace': '1em', + 'subplots.innerspace': '1.5em', + 'subplots.ylabspace': '5.5em', + 'subplots.xlabspace': '4em', + 'subplots.titlespace': '2em', + }) +defaultParams = RcParams({ + 'figure.dpi': 90, + 'figure.facecolor': '#f2f2f2', + 'figure.autolayout': False, + 'figure.titleweight': 'bold', + 'figure.max_open_warning': 0, + 'savefig.directory': '', + 'savefig.dpi': 300, + 'savefig.facecolor': 'white', + 'savefig.transparent': True, + 'savefig.format': 'pdf', + 'savefig.bbox': 'standard', + 'savefig.pad_inches': 0.0, + 'axes.titleweight': 'normal', + 'axes.xmargin': 0.0, + 'axes.ymargin': 0.0, + 'axes.grid': True, + 'axes.labelpad': 3.0, + 'axes.titlepad': 3.0, + 'grid.color': 'k', + 'grid.alpha': 0.1, + 'grid.linewidth': 0.6, + 'grid.linestyle': '-', + 'hatch.color': 'k', + 'hatch.linewidth': 0.6, + 'lines.linewidth': 1.3, + 'lines.markersize': 3.0, + 'legend.frameon': True, + 'legend.framealpha': 0.8, + 'legend.fancybox': False, + 'legend.labelspacing': 0.5, + 'legend.handletextpad': 0.5, + 'legend.handlelength': 1.5, + 'legend.columnspacing': 1.0, + 'legend.borderpad': 0.5, + 'legend.borderaxespad': 0, + 'xtick.minor.visible': True, + 'ytick.minor.visible': True, + 'mathtext.bf': 'sans:bold', + 'mathtext.it': 'sans:it', + 'mathtext.default': 'regular', + }) +rcParamsShort = RcParamsShort({}) +rcParamsLong = RcParamsLong({}) + +# Initialize user file +_rc_file = os.path.join(os.path.expanduser('~'), '.proplotrc') +if not os.path.isfile(_rc_file): + def _tabulate(rcdict): + string = '' + maxlen = max(map(len, rcdict)) + for key,value in rcdict.items(): + value = '' if value is None else repr(value) + space = ' ' * (maxlen - len(key) + 1) * int(bool(value)) + string += f'# {key}:{space}{value}\n' + with open(_rc_file, 'x') as f: + f.write(f""" +#------------------------------------------------------ +# Use this file to customize your settings +# For descriptions of each key name see: +# https://proplot.readthedocs.io/en/latest/rctools.html +#------------------------------------------------------ +# ProPlot short name settings +{_tabulate(defaultParamsShort)} +# +# ProPlot long name settings +{_tabulate(defaultParamsLong)} +# +# Matplotlib settings +{_tabulate(defaultParams)} +""") # "Global" settings and the lower-level settings they change # NOTE: This whole section, declaring dictionaries and sets, takes 1ms @@ -222,98 +426,37 @@ 'grid': ('axes.grid',), 'gridminor': ('axes.gridminor',), 'geogrid': ('axes.geogrid',), - 'ticklen' : ('xtick.major.size', 'ytick.major.size'), + 'ticklen': ('xtick.major.size', 'ytick.major.size'), 'tickdir': ('xtick.direction', 'ytick.direction'), 'tickpad': ('xtick.major.pad', 'xtick.minor.pad', 'ytick.major.pad', 'ytick.minor.pad'), } -# Names of the new settings -RC_PARAMNAMES = {*rcParams.keys()} -RC_SHORTNAMES = { - 'abc', 'span', 'share', 'align', 'tight', 'fontname', 'cmap', 'lut', - 'cycle', 'rgbcycle', 'alpha', 'facecolor', 'color', 'small', 'large', - 'linewidth', 'margin', 'grid', 'gridminor', 'geogrid', - 'ticklen' , 'tickdir', 'tickpad', 'tickratio', 'ticklenratio', 'gridratio', - 'reso', 'land', 'ocean', 'lakes', 'coast', 'borders', 'innerborders', 'rivers', - 'nbsetup', 'format', 'autosave', 'autoreload' - } -RC_CUSTOMNAMES = { - 'axes.formatter.zerotrim', 'axes.formatter.timerotation', - 'axes.gridminor', 'axes.geogrid', 'axes.alpha', - 'image.levels', 'image.edgefix', - 'geoaxes.linewidth', 'geoaxes.facecolor', 'geoaxes.edgecolor', - 'land.color', 'ocean.color', 'lakes.color', 'coast.color', 'coast.linewidth', - 'borders.color', 'borders.linewidth', 'innerborders.color', 'innerborders.linewidth', 'rivers.color', 'rivers.linewidth', - 'abc.size', 'abc.weight', 'abc.color', 'abc.loc', 'abc.style', 'abc.border', 'abc.linewidth', - 'title.loc', 'title.pad', 'title.color', 'title.border', 'title.linewidth', 'title.weight', 'title.size', - 'suptitle.size', 'suptitle.weight', 'suptitle.color', - 'leftlabel.size', 'leftlabel.weight', 'leftlabel.color', - 'rightlabel.size', 'rightlabel.weight', 'rightlabel.color', - 'toplabel.size', 'toplabel.weight', 'toplabel.color', - 'bottomlabel.size', 'bottomlabel.weight', 'bottomlabel.color', - 'gridminor.alpha', 'gridminor.color', 'gridminor.linestyle', 'gridminor.linewidth', - 'geogrid.labels', 'geogrid.alpha', 'geogrid.color', 'geogrid.labelsize', 'geogrid.linewidth', 'geogrid.linestyle', 'geogrid.latmax', 'geogrid.lonstep', 'geogrid.latstep', - 'tick.labelweight', 'tick.labelcolor', 'tick.labelsize', - 'subplots.pad', 'subplots.axpad', 'subplots.panelpad', - 'subplots.ylabspace', 'subplots.xlabspace', 'subplots.innerspace', 'subplots.titlespace', - 'subplots.axwidth', 'subplots.panelwidth', 'subplots.panelspace', - 'colorbar.grid', 'colorbar.frameon', 'colorbar.framealpha', - 'colorbar.loc', 'colorbar.length', 'colorbar.width', 'colorbar.insetlength', 'colorbar.insetwidth', - 'colorbar.extend', 'colorbar.insetextend', 'colorbar.axespad', - } # Used by Axes.format, allows user to pass rc settings as keyword args, # way less verbose. For example, landcolor='b' vs. rc_kw={'land.color':'b'}. -RC_NODOTSNAMES = { # useful for passing these as kwargs - name.replace('.', ''):name for names in - (RC_CUSTOMNAMES, RC_PARAMNAMES, RC_SHORTNAMES) - for name in names +RC_NODOTS = { # useful for passing these as kwargs + name.replace('.', ''): name for names + in (rcParams, rcParamsLong) for name in names } # Categories for returning dict of subcategory properties RC_CATEGORIES = { - *(re.sub('\.[^.]*$', '', name) for names in (RC_CUSTOMNAMES, RC_PARAMNAMES) for name in names), - *(re.sub('\..*$', '', name) for names in (RC_CUSTOMNAMES, RC_PARAMNAMES) for name in names) + *(re.sub('\.[^.]*$', '', name) for names in (rcParams, rcParamsLong) for name in names), + *(re.sub('\..*$', '', name) for names in (rcParams, rcParamsLong) for name in names) } -# Unit conversion -# See: https://matplotlib.org/users/customizing.html, all props matching -# the below strings use the units 'points', and my special categories are inches! -def _convert_units(key, value): +# Helper funcs +def _to_points(key, value): """Converts certain keys to the units "points". If "key" is passed, tests that key against possible keys that accept physical units.""" - # WARNING: Must keep colorbar and subplots units alive, so when user - # requests em units, values change with respect to font size. The points - # thing is a conveniene feature so not as important for them. + # See: https://matplotlib.org/users/customizing.html, all props matching + # the below strings use the units 'points', and custom categories are in if (isinstance(value,str) and key.split('.')[0] not in ('colorbar','subplots') and re.match('^.*(width|space|size|pad|len|small|large)$', key)): value = utils.units(value, 'pt') return value -def _set_cycler(name): - """Sets the default color cycler.""" - # Draw from dictionary - try: - colors = mcm.cmap_d[name].colors - except (KeyError, AttributeError): - cycles = sorted(name for name,cmap in mcm.cmap_d.items() if isinstance(cmap, mcolors.ListedColormap)) - raise ValueError(f'Invalid cycle name {name!r}. Options are: {", ".join(cycles)}') - # Apply color name definitions - if rcParamsShort['rgbcycle'] and name.lower() == 'colorblind': - regcolors = colors + [(0.1, 0.1, 0.1)] - elif mcolors.to_rgb('r') != (1.0,0.0,0.0): # reset - regcolors = [(0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.75, 0.75, 0.0), (0.75, 0.75, 0.0), (0.0, 0.75, 0.75), (0.0, 0.0, 0.0)] - else: - regcolors = [] # no reset necessary - for code,color in zip('brgmyck', regcolors): - rgb = mcolors.to_rgb(color) - mcolors.colorConverter.colors[code] = rgb - mcolors.colorConverter.cache[code] = rgb - # Pass to cycle constructor - rcParams['patch.facecolor'] = colors[0] - rcParams['axes.prop_cycle'] = cycler.cycler('color', colors) - def _get_config_paths(): """Returns configuration file paths.""" - # Get paths + # Local configuration idir = os.getcwd() paths = [] while idir: # not empty string @@ -329,15 +472,11 @@ def _get_config_paths(): ipath = os.path.join(os.path.expanduser('~'), '.proplotrc') if os.path.exists(ipath) and ipath not in paths: paths.insert(0, ipath) - # Global configuration - ipath = os.path.join(os.path.dirname(__file__), '.proplotrc') - if ipath not in paths: - paths.insert(0, ipath) return paths def _get_synced_params(key=None, value=None): """Returns dictionaries for updating "child" properties in - `rcParams` and `rcParamsCustom` with global property.""" + `rcParams` and `rcParamsLong` with global property.""" kw = {} # builtin properties that global setting applies to kw_custom = {} # custom properties that global setting applies to if key is not None and value is not None: @@ -345,76 +484,171 @@ def _get_synced_params(key=None, value=None): else: items = rcParamsShort.items() for key,value in items: + # Cycler + if key in ('cycle', 'rgbcycle'): + if key == 'rgbcycle': + cycle, rgbcycle = rcParamsShort['cycle'], value + else: + cycle, rgbcycle = value, rcParamsShort['rgbcycle'] + try: + colors = mcm.cmap_d[cycle].colors + except (KeyError, AttributeError): + cycles = sorted(name for name,cmap in mcm.cmap_d.items() if isinstance(cmap, mcolors.ListedColormap)) + raise ValueError(f'Invalid cycle name {cycle!r}. Options are: {", ".join(map(repr, cycles))}') + if rgbcycle and cycle.lower() == 'colorblind': + regcolors = colors + [(0.1, 0.1, 0.1)] + elif mcolors.to_rgb('r') != (1.0,0.0,0.0): # reset + regcolors = [(0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.75, 0.75, 0.0), (0.75, 0.75, 0.0), (0.0, 0.75, 0.75), (0.0, 0.0, 0.0)] + else: + regcolors = [] # no reset necessary + for code,color in zip('brgmyck', regcolors): + rgb = mcolors.to_rgb(color) + mcolors.colorConverter.colors[code] = rgb + mcolors.colorConverter.cache[code] = rgb + kw['patch.facecolor'] = colors[0] + kw['axes.prop_cycle'] = cycler.cycler('color', colors) + + # Zero linewidth almost always means zero tick length + elif key == 'linewidth' and _to_points(key, value) == 0: + ikw, ikw_custom = _get_synced_params('ticklen', 0) + kw.update(ikw) + kw_custom.update(ikw_custom) + # Tick length/major-minor tick length ratio - if key in ('ticklen', 'ticklenratio'): + elif key in ('ticklen', 'ticklenratio'): if key == 'ticklen': - ticklen = _convert_units(key, value) + ticklen = _to_points(key, value) ratio = rcParamsShort['ticklenratio'] else: ticklen = rcParamsShort['ticklen'] ratio = value kw['xtick.minor.size'] = ticklen*ratio kw['ytick.minor.size'] = ticklen*ratio + # Spine width/major-minor tick width ratio elif key in ('linewidth', 'tickratio'): if key == 'linewidth': - tickwidth = _convert_units(key, value) + tickwidth = _to_points(key, value) ratio = rcParamsShort['tickratio'] else: tickwidth = rcParamsShort['linewidth'] ratio = value kw['xtick.minor.width'] = tickwidth*ratio kw['ytick.minor.width'] = tickwidth*ratio - # Grid line + + # Gridline width elif key in ('grid.linewidth', 'gridratio'): if key == 'grid.linewidth': - gridwidth = _convert_units(key, value) + gridwidth = _to_points(key, value) ratio = rcParamsShort['gridratio'] else: gridwidth = rcParams['grid.linewidth'] ratio = value kw_custom['gridminor.linewidth'] = gridwidth*ratio + + # Gridline toggling, complicated because of the clunky way this is + # implemented in matplotlib. There should be a gridminor setting! + elif key in ('grid', 'gridminor'): + ovalue = rcParams['axes.grid'] + owhich = rcParams['axes.grid.which'] + # Instruction is to turn off gridlines + if not value: + # Gridlines are already off, or they are on for the particular + # ones that we want to turn off. Instruct to turn both off. + if not ovalue or (key == 'grid' and owhich == 'major') or (key == 'gridminor' and owhich == 'minor'): + which = 'both' # disable both sides + # Gridlines are currently on for major and minor ticks, so we instruct + # to turn on gridlines for the one we *don't* want off + elif owhich == 'both': # and ovalue is True, as we already tested + value = True + which = 'major' if key == 'gridminor' else 'minor' # if gridminor=False, enable major, and vice versa + # Gridlines are on for the ones that we *didn't* instruct to turn + # off, and off for the ones we do want to turn off. This just + # re-asserts the ones that are already on. + else: + value = True + which = owhich + # Instruction is to turn on gridlines + else: + # Gridlines are already both on, or they are off only for the ones + # that we want to turn on. Turn on gridlines for both. + if owhich == 'both' or (key == 'grid' and owhich == 'minor') or (key == 'gridminor' and owhich == 'major'): + which = 'both' + # Gridlines are off for both, or off for the ones that we + # don't want to turn on. We can just turn on these ones. + else: + which = owhich + kw['axes.grid'] = value + kw['axes.grid.which'] = which + # Now update linked settings - val = None + value = _to_points(key, value) for name in RC_CHILDREN.get(key, ()): - val = _convert_units(key, value) - if name in rcParamsCustom: - kw_custom[name] = val + if name in rcParamsLong: + kw_custom[name] = value else: - kw[name] = val - if key == 'linewidth' and val == 0: - ikw, ikw_custom = _get_synced_params('ticklen', 0) - kw.update(ikw) - kw_custom.update(ikw_custom) + kw[name] = value return kw, kw_custom +#-----------------------------------------------------------------------------# +# Class +#-----------------------------------------------------------------------------# +class _mode_mod(object): + """Helper class that temporarily modifies the getitem mode.""" + def __init__(self, mode): + self._mode = mode + def __enter__(self): + if self._mode is not None: + self._mode_prev = rc._mode + object.__setattr__(rc, '_mode', self._mode) + def __exit__(self, *args): + if self._mode is not None: + object.__setattr__(rc, '_mode', self._mode_prev) + class rc_configurator(object): - _public_api = ( - 'get', 'fill', 'category', 'reset', 'context', 'update' - ) # getattr and setattr will not look for these items + """ + Magical abstract class for managing matplotlib `rcParams + `__ and additional + ProPlot :ref:`rcParamsLong` and :ref:`rcParamsShort` settings. When + initialized, this loads defaults settings plus any user overrides in the + ``~/.proplotrc`` file. See the `~proplot.rctools` documentation for + details. + """ def __str__(self): return type(rcParams).__str__(rcParamsShort) # just show globals def __repr__(self): return type(rcParams).__repr__(rcParamsShort) def __contains__(self, key): - return (key in RC_SHORTNAMES or key in RC_CUSTOMNAMES or key in - RC_PARAMNAMES or key in RC_NODOTSNAMES) # query biggest lists last + return key in rcParamsShort or key in rcParamsLong or key in rcParams @_counter # about 0.05s - def __init__(self): - """Magical abstract class for managing matplotlib `rcParams - `__ settings, ProPlot - :ref:`rcParamsCustom` settings, and :ref:`rcParamsShort` "global" settings. - When initialized, this loads defaults settings plus any user overrides - in the ``~/.proplotrc`` file. See the `~proplot.rctools` documentation - for details.""" - # Set the default style. Note that after first figure made, backend - # is 'sticky', never changes! See: https://stackoverflow.com/a/48322150/4970632 + def __init__(self, local=True): + """ + Parameters + ---------- + local : bool, optional + Whether to load overrides from local and user ``.proplotrc`` + file(s). Default is ``True``. + """ + # Attributes and style + object.__setattr__(self, '_mode', 0) + object.__setattr__(self, '_context', []) plt.style.use('default') - # Load the defaults from file + # Update from defaults + rcParamsLong.clear() + rcParamsLong.update(defaultParamsLong) + rcParamsShort.clear() + rcParamsShort.update(defaultParamsShort) + for key,value in rcParamsShort.items(): + rc, rc_long = _get_synced_params(key, value) + rcParams.update(rc) + rcParamsLong.update(rc_long) + + # Update from files + if not local: + return for i,file in enumerate(_get_config_paths()): - # Load if not os.path.exists(file): continue with open(file) as f: @@ -423,76 +657,83 @@ def __init__(self): except yaml.YAMLError as err: print('{file!r} has invalid YAML syntax.') raise err - # Special duplicate keys - if data is None: - continue - # Add keys to dictionaries - gkeys, ckeys = {*()}, {*()} - for key,value in data.items(): - if key in RC_SHORTNAMES: - rcParamsShort[key] = value - if i == 0: - gkeys.add(key) - elif key in RC_CUSTOMNAMES: - value = _convert_units(key, value) - rcParamsCustom[key] = value - if i == 0: - ckeys.add(key) - elif key in RC_PARAMNAMES: - value = _convert_units(key, value) - rcParams[key] = value - else: + for key,value in (data or {}).items(): + try: + self[key] = value + except KeyError: raise RuntimeError(f'{file!r} has invalid key {key!r}.') - # Make sure we did not miss anything - if i == 0: - if gkeys != RC_SHORTNAMES: - raise RuntimeError(f'{file!r} has incomplete or invalid global keys {RC_SHORTNAMES - gkeys}.') - if ckeys != RC_CUSTOMNAMES: - raise RuntimeError(f'{file!r} has incomplete or invalid custom keys {RC_CUSTOMNAMES - ckeys}.') - - # Apply *global settings* to children settings - rcParams['axes.titlepad'] = rcParamsCustom['title.pad'] - _set_cycler(rcParamsShort['cycle']) - rc, rc_new = _get_synced_params() - for key,value in rc.items(): - rcParams[key] = value - for key,value in rc_new.items(): - rcParamsCustom[key] = value - - # Caching stuff - self._init = True - self._getitem_mode = 0 - self._context = {} - self._cache = {} - self._cache_orig = {} - self._cache_restore = {} + + def __enter__(self): + """Applies settings from the most recent context object.""" + # Get context information + _, mode, kwargs, cache, restore = self._context[-1] # missing arg is previous mode + object.__setattr__(self, '_mode', mode) + def _set_item(rcdict, key, value): + restore[key] = rcdict[key] + cache[key] = rcdict[key] = value + + # Apply settings + for key,value in kwargs.items(): + if key in rcParamsShort: + rc, rc_long = _get_synced_params(key, value) + _set_item(rcParamsShort, key, value) + for ikey, ivalue in rc_long.items(): + _set_item(rcParamsLong, ikey, ivalue) + for ikey, ivalue in rc.items(): + _set_item(rcParams, ikey, ivalue) + elif key in rcParamsLong: + _set_item(rcParamsLong, ikey, ivalue) + elif key in rcParams: + _set_item(rcParams, ikey, ivalue) + + def __exit__(self, *args): + """Restores configurator cache to initial state.""" + mode, _, _, _, restore = self._context[-1] + for key,value in restore.items(): + self[key] = value + del self._context[-1] + object.__setattr__(self, '_mode', mode) + + def __delitem__(self, *args): + """Pseudo-immutability.""" + raise RuntimeError('rc settings cannot be deleted.') + + def __delattr__(self, *args): + """Pseudo-immutability.""" + raise RuntimeError('rc settings cannot be deleted.') + + def __getattr__(self, attr): + """Invokes `~rc_configurator.__getitem__`.""" + return self[attr] def __getitem__(self, key): """Returns `rcParams `__, - :ref:`rcParamsCustom`, and :ref:`rcParamsShort` settings. If we are in a + :ref:`rcParamsLong`, and :ref:`rcParamsShort` settings. If we are in a `~rc_configurator.context` block, may return ``None`` if the setting is not cached (i.e. if it was not changed by the user).""" # Can get a whole bunch of different things # Get full dictionary e.g. for rc[None] if not key: - return {**rcParams, **rcParamsCustom} + return {**rcParams, **rcParamsLong} # Standardize # NOTE: If key is invalid, raise error down the line. if '.' not in key and key not in rcParamsShort: - key = RC_NODOTSNAMES.get(key, key) + key = RC_NODOTS.get(key, key) # Allow for special time-saving modes where we *ignore rcParams* - # or even *ignore rcParamsCustom*. - mode = self._getitem_mode + # or even *ignore rcParamsLong*. + mode = self._mode if mode == 0: - kws = (self._cache, rcParamsShort, rcParamsCustom, rcParams) + kws = (rcParamsShort, rcParamsLong, rcParams) elif mode == 1: - kws = (self._cache, rcParamsShort, rcParamsCustom) # custom only! + kws = (rcParamsShort, rcParamsLong) # custom only! elif mode == 2: - kws = (self._cache,) # changed only! + kws = () else: raise KeyError(f'Invalid caching mode {mode!r}.') + if self._context: + kws = (self._context[-1][-2], *kws) # Get individual property. Will successively index a few different dicts # Try to return the value @@ -503,157 +744,76 @@ def __getitem__(self, key): continue # If we were in one of the exclusive modes, return None if mode == 0: - raise KeyError(f'Invalid prop name {key!r}.') + raise KeyError(f'Invalid property name {key!r}.') else: return None + def __setattr__(self, attr, value): + """Invokes `~rc_configurator.__setitem__`.""" + self[attr] = value + def __setitem__(self, key, value): """Sets `rcParams `__, - :ref:`rcParamsCustom`, and :ref:`rcParamsShort` settings.""" - # Check whether we are in context block - # NOTE: Do not add key to cache until we are sure it is a valid key - cache = self._cache - context = bool(self._context) # test if context dict is non-empty - if context: - restore = self._cache_restore - - # Standardize - # NOTE: If key is invalid, raise error down the line. + :ref:`rcParamsLong`, and :ref:`rcParamsShort` settings.""" if '.' not in key and key not in rcParamsShort: - key = RC_NODOTSNAMES.get(key, key) - - # Special keys + key = RC_NODOTS.get(key, key) if key == 'title.pad': key = 'axes.titlepad' - if key == 'rgbcycle': # if must re-apply cycler afterward - cache[key] = value - rcParamsShort[key] = value - key, value = 'cycle', rcParamsShort['cycle'] - - # Set the default cycler - if key == 'cycle': - cache[key] = value - if context: - restore[key] = rcParamsShort[key] - restore['axes.prop_cycle'] = rcParams['axes.prop_cycle'] - restore['patch.facecolor'] = rcParams['patch.facecolor'] - _set_cycler(value) - - # Gridline toggling, complicated because of the clunky way this is - # implemented in matplotlib. There should be a gridminor setting! - elif key in ('grid', 'gridminor'): - cache[key] = value - ovalue = rcParams['axes.grid'] - owhich = rcParams['axes.grid.which'] - if context: - restore[key] = rcParamsShort[key] - restore['axes.grid'] = ovalue - restore['axes.grid.which'] = owhich - # Instruction is to turn off gridlines - if not value: - # Gridlines are already off, or they are on for the particular - # ones that we want to turn off. Instruct to turn both off. - if not ovalue or (key == 'grid' and owhich == 'major') or (key == 'gridminor' and owhich == 'minor'): - which = 'both' # disable both sides - # Gridlines are currently on for major and minor ticks, so we instruct - # to turn on gridlines for the one we *don't* want off - elif owhich == 'both': # and ovalue is True, as we already tested - value = True - which = 'major' if key == 'gridminor' else 'minor' # if gridminor=False, enable major, and vice versa - # Gridlines are on for the ones that we *didn't* instruct to turn - # off, and off for the ones we do want to turn off. This just - # re-asserts the ones that are already on. - else: - value = True - which = owhich - # Instruction is to turn on gridlines - else: - # Gridlines are already both on, or they are off only for the ones - # that we want to turn on. Turn on gridlines for both. - if owhich == 'both' or (key == 'grid' and owhich == 'minor') or (key == 'gridminor' and owhich == 'major'): - which = 'both' - # Gridlines are off for both, or off for the ones that we - # don't want to turn on. We can just turn on these ones. - else: - which = owhich - cache.update({'axes.grid':value, 'axes.grid.which':which}) - rcParams.update({'axes.grid':value, 'axes.grid.which':which}) - - # Ordinary settings - elif key in rcParamsShort: - # Update global setting - cache[key] = value - if context: - restore[key] = rcParamsShort[key] + if key in rcParamsShort: + rc, rc_long = _get_synced_params(key, value) rcParamsShort[key] = value - # Update children of setting - rc, rc_new = _get_synced_params(key, value) - cache.update(rc) - cache.update(rc_new) - if context: - restore.update({key:rcParams[key] for key in rc}) - restore.update({key:rcParamsCustom[key] for key in rc_new}) - rcParams.update(rc) - rcParamsCustom.update(rc_new) - # Update normal settings - elif key in RC_CUSTOMNAMES: - value = _convert_units(key, value) - cache[key] = value - if context: - restore[key] = rcParamsCustom[key] - rcParamsCustom[key] = value - elif key in RC_PARAMNAMES: - value = _convert_units(key, value) - cache[key] = value - if context: - restore[key] = rcParams[key] - rcParams[key] = value # rcParams dict has key validation + for ikey, ivalue in rc.items(): + rcParams[ikey] = ivalue + for ikey, ivalue in rc_long.items(): + rcParamsLong[ikey] = ivalue + elif key in rcParamsLong: + rcParamsLong[key] = _to_points(key, value) + elif key in rcParams: + rcParams[key] = _to_points(key, value) else: raise KeyError(f'Invalid key {key!r}.') - self._init = False # setitem was successful, we are no longer in initial state - # Attributes same as items - def __getattribute__(self, attr): - """Invokes `~rc_configurator.__getitem__`.""" - if attr[:1] == '_' or attr in self._public_api: - return object.__getattribute__(self, attr) - else: - return self[attr] + def _setattr(self, attr, value): + """Helper function that sets attribute.""" + object.__setattr__(self, attr, value) - def __setattr__(self, attr, value): - """Invokes `~rc_configurator.__setitem__`.""" - if attr[:1] == '_': - object.__setattr__(self, attr, value) - else: - self[attr] = value - - # Immutability - def __delitem__(self, *args): - """Pseudo-immutability.""" - raise RuntimeError('rc settings cannot be deleted.') - - def __delattr__(self, *args): - """Pseudo-immutability.""" - raise RuntimeError('rc settings cannot be deleted.') + def category(self, cat, cache=True): + """ + Returns a dictionary of settings belonging to the indicated category, + i.e. settings beginning with the substring ``cat + '.'``. - # Context tools - def __enter__(self): - """Apply settings from configurator cache.""" - self._cache_orig = rc._cache.copy() - self._cache_restore = {} # shouldn't be necessary but just in case - self._cache = {} - for key,value in self._context.items(): - self[key] = value # applies globally linked and individual settings + Parameters + ---------- + cat : str, optional + The `rc` settings category. + cache : bool, optional + If ``False``, the `~rc_configurator.__getitem__` mode is temporarily + set to ``0`` (see `~rc_configurator.context`). + """ + # Check input mode + if cat not in RC_CATEGORIES: + raise ValueError(f'rc category {cat!r} does not exist. Valid categories are {", ".join(map(repr, RC_CATEGORIES))}.') + mode = 0 if not cache else self._mode + if mode == 0: + kws = (rcParamsLong, rcParams) + elif mode == 1: + kws = (rcParamsLong,) + elif mode == 2: + kws = () + else: + raise KeyError(f'Invalid caching mode {mode}.') + if self._context: + kws = (self._context[-1][-2], *kws) - def __exit__(self, *args): - """Restore configurator cache to initial state.""" - self._context = {} - self._getitem_mode = 0 - for key,value in self._cache_restore.items(): - self[key] = value - self._cache = self._cache_orig - self._cache_restore = {} - self._cache_orig = {} + # Return params dictionary + output = {} + for kw in kws: + for category,value in kw.items(): + if re.search(f'^{cat}\.', category): + subcategory = re.sub(f'^{cat}\.', '', category) + if subcategory and '.' not in subcategory: + output[subcategory] = value + return output def context(self, *args, mode=0, **kwargs): """ @@ -670,28 +830,26 @@ def context(self, *args, mode=0, **kwargs): Parameters ---------- *args - Dictionaries of setting names and values. + Dictionaries of `rc` names and values. **kwargs - Setting names and values passed as keyword arguments. + `rc` names and values passed as keyword arguments. If the + name has dots, simply omit them. Other parameters ---------------- mode : {0,1,2}, optional - The `~rc_configurator.__getitem__` mode. - Dictates the behavior of the `rc` object within a ``with...as`` - block when settings are requested with e.g. :rcraw:`setting`. If - you are using `~rc_configurator.context` manually, the `mode` is - automatically set to ``0`` -- other input is ignored. Internally, - ProPlot uses all of the three available modes. + The `~rc_configurator.__getitem__` mode. Dictates the behavior of + the `rc` object within a ``with...as`` block when settings are + requested. 0. All settings (`rcParams `__, - :ref:`rcParamsCustom`, and :ref:`rcParamsShort`) are returned, whether - or not `~rc_configurator.context` has changed them. + :ref:`rcParamsLong`, and :ref:`rcParamsShort`) are returned, + whether or not `~rc_configurator.context` has changed them. 1. Unchanged `rcParams `__ - return ``None``. :ref:`rcParamsCustom` and :ref:`rcParamsShort` are - returned whether or not `~rc_configurator.context` has changed them. - This is used in the `~proplot.axes.Axes.__init__` call to - `~proplot.axes.Axes.format`. When a setting lookup returns + return ``None``. :ref:`rcParamsLong` and :ref:`rcParamsShort` + are returned whether or not `~rc_configurator.context` has + changed them. This is used in the `~proplot.axes.Axes.__init__` + call to `~proplot.axes.Axes.format`. When a lookup returns ``None``, `~proplot.axes.Axes.format` does not apply it. 2. All unchanged settings return ``None``. This is used during user calls to `~proplot.axes.Axes.format`. @@ -706,16 +864,13 @@ def context(self, *args, mode=0, **kwargs): """ if mode not in range(3): - raise ValueError(f'Invalid _getitem_mode {mode}.') + raise ValueError(f'Invalid mode {mode!r}.') for arg in args: if not isinstance(arg, dict): - raise ValueError('Non-dictionary argument.') + raise ValueError('Non-dictionary argument {arg!r}.') kwargs.update(arg) - self._context = kwargs # could be empty - self._getitem_mode = mode - return self + self._context.append((self._mode, mode, kwargs, {}, {})) - # Other tools def get(self, key, cache=False): """ Returns a setting. @@ -728,13 +883,9 @@ def get(self, key, cache=False): If ``False``, the `~rc_configurator.__getitem__` mode is temporarily set to ``0`` (see `~rc_configurator.context`). """ - if not cache: - orig = self._getitem_mode - self._getitem_mode = 0 - item = self[key] - if not cache: - self._getitem_mode = orig - return item + mode = 0 if not cache else None + with _mode_mod(mode): + return self[key] def fill(self, props, cache=True): """ @@ -754,75 +905,33 @@ def fill(self, props, cache=True): lookup returns ``None``, the setting is omitted from the output dictionary. """ - if not cache: - orig = self._getitem_mode - self._getitem_mode = 0 - props_out = {} - for key,value in props.items(): - item = self[value] - if item is not None: - props_out[key] = item - if not cache: - self._getitem_mode = orig - return props_out - - def category(self, cat, cache=True): - """ - Returns a dictionary of settings belonging to the indicated category, - i.e. settings beginning with the substring ``cat + '.'``. - - Parameters - ---------- - cat : str, optional - The `rc` settings category. - cache : bool, optional - If ``False``, the `~rc_configurator.__getitem__` mode is temporarily - set to ``0`` (see `~rc_configurator.context`). - """ - # Check - if cat not in RC_CATEGORIES: - raise ValueError(f'RC category {cat} does not exist. Valid categories are {", ".join(RC_CATEGORIES)}.') - if not cache: - mode = 0 - else: - mode = self._getitem_mode - - # Allow for special time-saving modes where we *ignore rcParams* - # or even *ignore rcParamsCustom*. - if mode == 0: - kws = (self._cache, rcParamsShort, rcParamsCustom, rcParams) - elif mode == 1: - kws = (self._cache, rcParamsShort, rcParamsCustom) - elif mode == 2: - kws = (self._cache, rcParamsShort) - else: - raise KeyError(f'Invalid caching mode {mode}.') - - # Return params dictionary - params = {} - for kw in kws: - for category,value in kw.items(): - if re.search(f'^{cat}\.', category): - subcategory = re.sub(f'^{cat}\.', '', category) - if subcategory and '.' not in subcategory: - params[subcategory] = value - return params + mode = 0 if not cache else None + output = {} + with _mode_mod(mode): + for key,value in props.items(): + item = self[value] + if item is not None: + output[key] = item + return output def update(self, *args, **kwargs): """ - Bulk updates settings, usage is similar to python `dict` objects. + Bulk updates settings. Parameters ---------- *args : str, dict, or (str, dict) - Positional arguments can be a dictionary of `rc` settings and/or - a "category" string name. If a category name is passed, all settings - in the dictionary (if it was passed) and all keyword arg names - (if they were passed) are prepended with the string ``cat + '.'``. - For example, ``rc.update('axes', labelsize=20, titlesize=20)`` - changes the ``axes.labelsize`` and ``axes.titlesize`` properties. + The first argument can optionally be a "category" string name, + in which case all other setting names passed to this function are + prepended with the string ``cat + '.'``. For example, + ``rc.update('axes', labelsize=20, titlesize=20)`` changes the + :rcraw:`axes.labelsize` and :rcraw:`axes.titlesize` properties. + + The first or second argument can also be a dictionary of `rc` + names and values. **kwargs - `rc` settings passed as keyword args. + `rc` names and values passed as keyword arguments. If the + name has dots, simply omit them. """ # Parse args kw = {} @@ -844,16 +953,21 @@ def update(self, *args, **kwargs): for key,value in kw.items(): self[prefix + key] = value - def reset(self): - """Restores settings to the initial state -- ProPlot defaults, plus - any user overrides in the ``~/.proplotrc`` file.""" - if not self._init: # save resources if rc is unchanged! - return self.__init__() + def reset(self, **kwargs): + """ + Resets the configurator to its initial state. + + Parameters + ---------- + **kwargs + Passed to `rc_configurator`. + """ + self.__init__(**kwargs) # Declare rc object # WARNING: Must be instantiated after ipython notebook setup! The default # backend may change some rc settings! -rc = rc_configurator() +rc = rc_configurator(True) """Instance of `rc_configurator`. This is used to change global settings. See the `~proplot.rctools` documentation for details.""" @@ -884,7 +998,6 @@ def nb_setup(): # Initialize with default 'inline' settings # Reset rc object afterwards - rc._init = False try: # For notebooks ipython.magic("matplotlib inline") diff --git a/proplot/styletools.py b/proplot/styletools.py index 8eed86897..8a5ad67d8 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -45,7 +45,7 @@ ] # Colormap stuff -CMAPS_CATEGORIES = { +CMAPS_TABLE = { # Assorted origin, but these belong together 'Grayscale': ( 'Grays', 'Mono', 'GrayCycle', @@ -128,17 +128,9 @@ 'binary', 'bwr', 'brg', # appear to be custom matplotlib, very simple construction 'cubehelix', 'wistia', 'CMRmap', # individually released 'seismic', 'terrain', 'nipy_spectral', # origin ambiguous - ), + 'tab10', 'tab20', 'tab20b', 'tab20c', # merged colormap cycles + ) } -CMAPS_DELETE = ( - 'binary', 'gist_yarg', 'gist_gray', 'gray', 'bone', 'pink', - 'spring', 'summer', 'autumn', 'winter', 'cool', 'wistia', - 'afmhot', 'gist_heat', 'copper', - 'seismic', 'bwr', 'brg', - 'flag', 'prism', 'ocean', 'gist_earth', 'terrain', 'gist_stern', - 'gnuplot', 'gnuplot2', 'cmrmap', 'hsv', 'hot', 'rainbow', - 'gist_rainbow', 'jet', 'nipy_spectral', 'gist_ncar', 'cubehelix', - ) CMAPS_DIVERGING = tuple( (key1.lower(), key2.lower()) for key1,key2 in ( ('PiYG', 'GYPi'), @@ -156,37 +148,18 @@ ('DryWet', 'WetDry') )) -# Color cycle stuff -CYCLES_PRESET = { - # Default matplotlib v2 - 'default': ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'], - # From stylesheets - '538': ['#008fd5', '#fc4f30', '#e5ae38', '#6d904f', '#8b8b8b', '#810f7c'], - 'ggplot': ['#E24A33', '#348ABD', '#988ED5', '#777777', '#FBC15E', '#8EBA42', '#FFB5B8'], - # The two nice-looking seaborn color cycles - 'ColorBlind': ['#0072B2', '#D55E00', '#009E73', '#CC79A7', '#F0E442', '#56B4E9'], - 'ColorBlind10': ["#0173B2", "#DE8F05", "#029E73", "#D55E00", "#CC78BC", "#CA9161", "#FBAFE4", "#949494", "#ECE133", "#56B4E9"], # versions with more colors - # Created with iwanthue and coolers - 'FlatUI': ["#3498db", "#e74c3c", "#95a5a6", "#34495e", "#2ecc71", "#9b59b6"], - 'Warm': [(51,92,103), (158,42,43), (255,243,176), (224,159,62), (84,11,14)], - 'Cool': ["#6C464F", "#9E768F", "#9FA4C4", "#B3CDD1", "#C7F0BD"], - 'Sharp': ["#007EA7", "#D81159", "#B3CDD1", "#FFBC42", "#0496FF"], - 'Hot': ["#0D3B66", "#F95738", "#F4D35E", "#FAF0CA", "#EE964B"], - 'Contrast': ["#2B4162", "#FA9F42", "#E0E0E2", "#A21817", "#0B6E4F"], - 'Floral': ["#23395B", "#D81E5B", "#FFFD98", "#B9E3C6", "#59C9A5"], - } -CYCLES_DELETE = ( - 'tab10', 'tab20', 'tab20b', 'tab20c', - 'paired', 'pastel1', 'pastel2', 'dark2', - ) # unappealing cycles, and cycles that are just merged monochrome colormaps -CYCLES_RENAME = ( - ('Accent','Set1'), - ) # rename existing cycles - # Named color filter props -FILTER_SPACE = 'hcl' # dist 'distinct-ness' of colors using this colorspace -FILTER_THRESH = 0.10 # bigger number equals fewer colors -FILTER_TRANSLATIONS = tuple((re.compile(regex), sub) for regex,sub in ( +FILTER_SPACE_NAME = 'hcl' # dist 'distinct-ness' of colors using this colorspace +FILTER_SPACE_THRESH = 0.10 # bigger number equals fewer colors +FILTER_IGNORE = re.compile('(' + '|'.join(( + 'shit', 'poop', 'poo', 'pee', 'piss', 'puke', 'vomit', 'snot', 'booger', 'bile', 'diarrhea', + )) + ')') # filter these out, let's try to be professional here... +FILTER_OVERRIDE = ( + 'charcoal', 'sky blue', 'eggshell', 'sea blue', 'coral', 'aqua', 'tomato red', 'brick red', 'crimson', + 'red orange', 'yellow orange', 'yellow green', 'blue green', + 'blue violet', 'red violet', + ) # common names that should always be included +FILTER_TRANS = tuple((re.compile(regex), sub) for regex,sub in ( ('/', ' '), ("'s", ''), ('grey', 'gray'), ('pinky', 'pink'), @@ -205,14 +178,6 @@ ('grayblue', 'gray blue'), ('lightblue', 'light blue') )) # prevent registering similar-sounding names -FILTER_ADD = ( - 'charcoal', 'sky blue', 'eggshell', 'sea blue', 'coral', 'aqua', 'tomato red', 'brick red', 'crimson', - 'red orange', 'yellow orange', 'yellow green', 'blue green', - 'blue violet', 'red violet', - ) # common names that should always be included -FILTER_BAD = re.compile('(' + '|'.join(( - 'shit', 'poop', 'poo', 'pee', 'piss', 'puke', 'vomit', 'snot', 'booger', 'bile', 'diarrhea', - )) + ')') # filter these out, let's try to be professional here... # Named color stuff OPEN_COLORS = ( @@ -220,7 +185,7 @@ 'indigo', 'blue', 'cyan', 'teal', 'green', 'lime', 'yellow', 'orange', 'gray' ) -BASE_COLORS_FULL = { +BASE_COLORS = { 'blue': (0, 0, 1), 'green': (0, 0.5, 0), 'red': (1, 0, 0), @@ -1352,7 +1317,6 @@ def from_list(name, colors, ratios=None, **kwargs): Passed to `PerceptuallyUniformColormap`. """ # Translate colors - # TODO: Allow alpha cdict = {} space = kwargs.get('space', 'hsl') # use the builtin default colors = [to_xyz(color, space) for color in colors] @@ -1437,6 +1401,8 @@ def __setitem__(self, key, item): elif isinstance(item, mcolors.ListedColormap): item = ListedColormap( item.colors, item.name, item.N) + elif item is None: + return else: raise ValueError(f'Invalid colormap {item}. Must be instance of matplotlib.colors.ListedColormap or matplotlib.colors.LinearSegmentedColormap.') key = self._sanitize_key(key, mirror=False) @@ -1536,15 +1502,6 @@ def __getitem__(self, key): return rgba return super().__getitem__((rgb, alpha)) -# Apply monkey patches to top level modules -if not isinstance(mcm.cmap_d, CmapDict): - mcm.cmap_d = CmapDict(mcm.cmap_d) -if not isinstance(mcolors._colors_full_map, _ColorMappingOverride): - _map = _ColorMappingOverride(mcolors._colors_full_map) - mcolors._colors_full_map = _map - mcolors.colorConverter.cache = _map.cache # re-instantiate - mcolors.colorConverter.colors = _map # re-instantiate - #-----------------------------------------------------------------------------# # Colormap and cycle constructor functions #-----------------------------------------------------------------------------# @@ -1647,6 +1604,8 @@ def Colormap(*args, name=None, listmode='perceptual', `~matplotlib.colors.ListedColormap` instance. """ # Initial stuff + # TODO: Play with using "qualitative" colormaps in realistic examples, + # how to make colormaps cyclic. if not args: raise ValueError(f'Colormap() requires at least one positional argument.') if listmode not in ('listed', 'linear', 'perceptual'): @@ -1740,7 +1699,7 @@ def Colormap(*args, name=None, listmode='perceptual', cmap.save(**save_kw) return cmap -def Cycle(*args, samples=None, name=None, +def Cycle(*args, N=None, name=None, marker=None, alpha=None, dashes=None, linestyle=None, linewidth=None, markersize=None, markeredgewidth=None, markeredgecolor=None, markerfacecolor=None, save=False, save_kw=None, @@ -1772,11 +1731,11 @@ def Cycle(*args, samples=None, name=None, is looked up and its ``colors`` attribute is used. See `cycles`. * Otherwise, the argument is passed to `Colormap`, and colors from the resulting `~matplotlib.colors.LinearSegmentedColormap` - are used. See the `samples` argument. + are used. See the `N` argument. - If the last positional argument is numeric, it is used for the `samples` + If the last positional argument is numeric, it is used for the `N` keyword argument. - samples : float or list of float, optional + N : float or list of float, optional For `~matplotlib.colors.ListedColormap`\ s, this is the number of colors to select. For example, ``Cycle('538', 4)`` returns the first 4 colors of the ``'538'`` color cycle. @@ -1784,7 +1743,7 @@ def Cycle(*args, samples=None, name=None, For `~matplotlib.colors.LinearSegmentedColormap`\ s, this is either a list of sample coordinates used to draw colors from the map, or an integer number of colors to draw. If the latter, the sample coordinates - are ``np.linspace(0, 1, samples)``. For example, ``Cycle('Reds', 5)`` + are ``np.linspace(0, 1, N)``. For example, ``Cycle('Reds', 5)`` divides the ``'Reds'`` colormap into five evenly spaced colors. name : str, optional Name of the resulting `~matplotlib.colors.ListedColormap` used to @@ -1854,23 +1813,22 @@ def Cycle(*args, samples=None, name=None, else: # Collect samples if args and isinstance(args[-1], Number): - args, samples = args[:-1], args[-1] # means we want to sample existing colormaps or cycles + args, N = args[:-1], args[-1] # means we want to sample existing colormaps or cycles kwargs.setdefault('fade', 90) kwargs.setdefault('listmode', 'listed') cmap = Colormap(*args, **kwargs) # the cmap object itself if isinstance(cmap, ListedColormap): - N = samples - colors = cmap.colors[:N] # if samples is None, does nothing + colors = cmap.colors[:N] # if N is None, does nothing else: - samples = _notNone(samples, 10) - if isinstance(samples, Integral): - samples = np.linspace(0, 1, samples) # from edge to edge - elif np.iterable(samples) and all(isinstance(item,Number) for item in samples): - samples = np.array(samples) + N = _notNone(N, 10) + if isinstance(N, Integral): + x = np.linspace(0, 1, N) # from edge to edge + elif np.iterable(N) and all(isinstance(item,Number) for item in N): + x = np.array(N) else: - raise ValueError(f'Invalid samples "{samples}".') - N = len(samples) - colors = cmap(samples) + raise ValueError(f'Invalid samples {N!r}.') + N = len(x) + colors = cmap(x) # Register and save the samples as a ListedColormap name = name or '_no_name' @@ -1884,6 +1842,7 @@ def Cycle(*args, samples=None, name=None, nprops = max(nprops, len(colors)) props['color'] = [tuple(color) if not isinstance(color,str) else color for color in cmap.colors] # save the tupled version! + # Build cycler, make sure lengths are the same for key,value in props.items(): if len(value) < nprops: @@ -1975,17 +1934,17 @@ class BinNorm(mcolors.BoundaryNorm): colormap coordinates of ``[0, 0.25, 0.5, 0.75, 1]``. 3. Out-of-bounds coordinates are added. These depend on the value of the `extend` keyword argument. For `extend` equal to ``'neither'``, - the coordinates including out-of-bounds values are ``[0, 0, 0.25, 0.5, 0.75, 1, 1]`` -- + the coordinates including out-of-bounds values are + ``[0, 0, 0.25, 0.5, 0.75, 1, 1]`` -- out-of-bounds values have the same color as the nearest in-bounds values. - For `extend` equal to ``'both'``, the bins are ``[0, 0.16, 0.33, 0.5, 0.66, 0.83, 1]`` -- + For `extend` equal to ``'both'``, the bins are + ``[0, 0.16, 0.33, 0.5, 0.66, 0.83, 1]`` -- out-of-bounds values are given distinct colors. This makes sure your colorbar always shows the **full range of colors** in the colormap. 4. Whenever `BinNorm.__call__` is invoked, the input value normalized by `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. - - The input parameters are as follows. """ def __init__(self, levels, norm=None, clip=False, step=1.0, extend='neither'): """ @@ -2010,8 +1969,8 @@ def __init__(self, levels, norm=None, clip=False, step=1.0, extend='neither'): Note ---- If you are using a diverging colormap with ``extend='max'`` or - ``extend='min'``, the center will get messed up. But that is very strange - usage anyway... so please just don't do that :) + ``extend='min'``, the center will get messed up. But that is very + strange usage anyway... so please just don't do that :) """ # Declare boundaries, vmin, vmax in True coordinates. # Notes: @@ -2085,8 +2044,8 @@ def __call__(self, xq, clip=None): return ma.array(yq, mask=mask) def inverse(self, yq): - """Raises error -- inversion after discretization is impossible.""" - raise ValueError('BinNorm is not invertible.') + """Raises RuntimeError. Inversion after discretization is impossible.""" + raise RuntimeError('BinNorm is not invertible.') #-----------------------------------------------------------------------------# # Normalizers intended to *pre-scale* levels passed to BinNorm @@ -2229,7 +2188,7 @@ def inverse(self, yq, clip=None): return ma.array(xq, mask=mask) #-----------------------------------------------------------------------------# -# Load data +# Functions for loading and visualizing stuff #-----------------------------------------------------------------------------# def _get_data_paths(dirname): """Returns configuration file paths.""" @@ -2248,9 +2207,8 @@ def _read_cmap_cycle_data(filename): """ Helper function that reads generalized colormap and color cycle files. """ - empty = (None, None, None) if os.path.isdir(filename): # no warning - return empty + return None, None, None # Directly read segmentdata json file # NOTE: This is special case! Immediately return name and cmap @@ -2272,7 +2230,7 @@ def _read_cmap_cycle_data(filename): return name, None, cmap # Read .rgb, .rgba, .xrgb, and .xrgba files - elif ext in ('txt', 'rgb', 'xrgb', 'rgba', 'xrgba'): + if ext in ('txt', 'rgb', 'xrgb', 'rgba', 'xrgba'): # Load # NOTE: This appears to be biggest import time bottleneck! Increases # time from 0.05s to 0.2s, with numpy loadtxt or with this regex thing. @@ -2282,12 +2240,12 @@ def _read_cmap_cycle_data(filename): data = [[float(num) for num in line] for line in data] except ValueError: warnings.warn(f'Failed to load "{filename}". Expected a table of comma or space-separated values.') - return empty + return None, None, None # Build x-coordinates and standardize shape data = np.array(data) if data.shape[1] != len(ext): warnings.warn(f'Failed to load "{filename}". Got {data.shape[1]} columns, but expected {len(ext)}.') - return empty + return None, None, None if ext[0] != 'x': # i.e. no x-coordinates specified explicitly x = np.linspace(0, 1, data.shape[0]) else: @@ -2300,16 +2258,16 @@ def _read_cmap_cycle_data(filename): xmldoc = etree.parse(filename) except IOError: warnings.warn(f'Failed to load "{filename}".') - return empty + return None, None, None x, data = [], [] for s in xmldoc.getroot().findall('.//Point'): # Verify keys if any(key not in s.attrib for key in 'xrgb'): warnings.warn(f'Failed to load "{filename}". Missing an x, r, g, or b specification inside one or more tags.') - return empty + return None, None, None if 'o' in s.attrib and 'a' in s.attrib: warnings.warn(f'Failed to load "{filename}". Contains ambiguous opacity key.') - return empty + return None, None, None # Get data color = [] for key in 'rgbao': # o for opacity @@ -2320,8 +2278,8 @@ def _read_cmap_cycle_data(filename): data.append(color) # Convert to array if not all(len(data[0]) == len(color) for color in data): - warnings.warn(f'File {filename} has some points with alpha channel specified, some without.') - return empty + warnings.warn(f'File {filename} has some points with alpha channel specified, some without.') + return None, None, None # Read hex strings elif ext == 'hex': @@ -2330,13 +2288,13 @@ def _read_cmap_cycle_data(filename): data = re.findall('#[0-9a-fA-F]{6}', string) # list of strings if len(data) < 2: warnings.warn(f'Failed to load "{filename}".') - return empty + return None, None, None # Convert to array x = np.linspace(0, 1, len(data)) data = [to_rgb(color) for color in data] else: warnings.warn(f'Colormap or cycle file {filename!r} has unknown extension.') - return empty + return None, None, None # Standardize and reverse if necessary to cmap x, data = np.array(x), np.array(data) @@ -2364,31 +2322,13 @@ def register_cmaps(): ===================== ============================================================================================================================================================================================================= Extension Description ===================== ============================================================================================================================================================================================================= - ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes). + ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': ``.xml`` XML files with ```` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values. ``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values. ``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate. ``.rgba``, ``.xrgba`` As with ``.rgb``, ``.xrgb``, but with a trailing opacity (or "alpha") column. ===================== ============================================================================================================================================================================================================= """ - # Turn original matplotlib maps from ListedColormaps to LinearSegmentedColormaps - # It makes zero sense to me that they are stored as ListedColormaps - for name in CMAPS_CATEGORIES['Matplotlib Originals']: # initialize as empty lists - cmap = mcm.cmap_d.get(name, None) - if cmap and isinstance(cmap, ListedColormap): - mcm.cmap_d[name] = LinearSegmentedColormap.from_list(name, cmap.colors) - - # Misc tasks - cmap = mcm.cmap_d.pop('Greys', None) - if cmap is not None: - mcm.cmap_d['Grays'] = cmap # to be consistent with registered color names (also 'Murica) - for name in ('Spectral',): - mcm.cmap_d[name] = mcm.cmap_d[name].reversed(name=name) # make spectral go from 'cold' to 'hot' - - # Remove gross cmaps (strong-arm user into using the better ones) - for name in CMAPS_DELETE: - mcm.cmap_d.pop(name, None) - # Fill initial user-accessible cmap list with the colormaps we will keep cmaps.clear() cmaps[:] = [ @@ -2398,24 +2338,21 @@ def register_cmaps(): # Add colormaps from ProPlot and user directories N = rcParams['image.lut'] # query this when register function is called - for path in _get_data_paths('cmaps'): + for i,path in enumerate(_get_data_paths('cmaps')): for filename in sorted(glob.glob(os.path.join(path, '*'))): - name, x, data = _read_cmap_cycle_data(filename) + name, x, cmap = _read_cmap_cycle_data(filename) if name is None: continue - if isinstance(data, LinearSegmentedColormap): - cmap = data - else: - data = [(x,color) for x,color in zip(x,data)] - cmap = LinearSegmentedColormap.from_list(name, data, N=N) + if not isinstance(cmap, LinearSegmentedColormap): + cmap = [(ix,color) for ix,color in zip(x,cmap)] + cmap = LinearSegmentedColormap.from_list(name, cmap, N=N) + if i == 0 and name in ('phase', 'graycycle'): + cmap._cyclic = True mcm.cmap_d[name] = cmap cmaps.append(name) - # Add cyclic attribute - for name,cmap in mcm.cmap_d.items(): - cmap._cyclic = (name.lower() in ('twilight', 'twilight_shifted', 'phase', 'graycycle')) # add hidden attribute used by BinNorm # Sort - cmaps[:] = sorted(cmaps) + cmaps[:] = sorted(cmaps, key = lambda s: s.lower()) @_timer def register_cycles(): @@ -2430,37 +2367,28 @@ def register_cycles(): """ # Empty out user-accessible cycle list cycles.clear() - - # Remove gross cycles, change the names of some others - for name in CYCLES_DELETE: - mcm.cmap_d.pop(name, None) - for (name1,name2) in CYCLES_RENAME: - cycle = mcm.cmap_d.pop(name1, None) - if cycle: - mcm.cmap_d[name2] = cycle - cycles.append(name2) + cycles[:] = [ + name for name,cmap in mcm.cmap_d.items() + if isinstance(cmap, ListedColormap) + ] # Read cycles from directories - icycles = {} for path in _get_data_paths('cycles'): for filename in sorted(glob.glob(os.path.join(path, '*'))): - name, _, data = _read_cmap_cycle_data(filename) + name, _, cycle = _read_cmap_cycle_data(filename) if name is None: continue - if isinstance(data, LinearSegmentedColormap): + if isinstance(cycle, LinearSegmentedColormap): + cycle = colors() warnings.warn(f'Failed to load {filename!r} as color cycle.') continue - icycles[name] = data - - # Register cycles as ListedColormaps - for name,colors in {**CYCLES_PRESET, **icycles}.items(): - cmap = ListedColormap(colors, name=name) - cmap.colors = [to_rgb(color) for color in cmap.colors] # sanitize - mcm.cmap_d[name] = cmap - cycles.append(name) + cmap = ListedColormap(colors, name=name) + cmap.colors = [to_rgb(color) for color in cmap.colors] # sanitize + mcm.cmap_d[name] = cmap + cycles.append(name) # Sort - cycles[:] = sorted([*cycles, 'Set2', 'Set3'], key=lambda s: s.lower()) + cycles[:] = sorted(cycles, key = lambda s: s.lower()) @_timer def register_colors(nmax=np.inf): @@ -2473,18 +2401,17 @@ def register_colors(nmax=np.inf): # Reset native colors dictionary and add some default groups # Add in CSS4 so no surprises for user, but we will not encourage this # usage and will omit CSS4 colors from the demo table. - scale = (360, 100, 100) base = {} + scale = (360, 100, 100) colordict.clear() base.update(mcolors.BASE_COLORS) - base.update(BASE_COLORS_FULL) + base.update(BASE_COLORS) # full names mcolors.colorConverter.colors.clear() # clean out! mcolors.colorConverter.cache.clear() # clean out! for name,dict_ in (('base',base), ('css',mcolors.CSS4_COLORS)): colordict.update({name:dict_}) # Load colors from file and get their HCL values - # TODO: Cleanup! seen = {*base} # never overwrite base names, e.g. 'blue' and 'b'! hcls = np.empty((0,3)) pairs = [] @@ -2508,13 +2435,13 @@ def register_colors(nmax=np.inf): for name,color in data: # is list of name, color tuples if i >= nmax: # e.g. for xkcd colors break - for regex,sub in FILTER_TRANSLATIONS: + for regex,sub in FILTER_TRANS: name = regex.sub(sub, name) - if name in seen or FILTER_BAD.search(name): + if name in seen or FILTER_IGNORE.search(name): continue seen.add(name) pairs.append((cat, name)) # save the category name pair - ihcls.append(to_xyz(color, space=FILTER_SPACE)) + ihcls.append(to_xyz(color, space=FILTER_SPACE_NAME)) dict_[name] = color # save the color i += 1 _colordict_unfiltered[cat] = dict_ @@ -2524,10 +2451,10 @@ def register_colors(nmax=np.inf): # WARNING: Unique axis argument requires numpy version >=1.13 deleted = 0 hcls = hcls/np.array(scale) - hcls = np.round(hcls/FILTER_THRESH).astype(np.int64) + hcls = np.round(hcls/FILTER_SPACE_THRESH).astype(np.int64) _, idxs, _ = np.unique(hcls, return_index=True, return_counts=True, axis=0) # get unique rows for idx,(cat,name) in enumerate(pairs): - if name not in FILTER_ADD and idx not in idxs: + if name not in FILTER_OVERRIDE and idx not in idxs: deleted += 1 else: colordict[cat][name] = _colordict_unfiltered[cat][name] @@ -2597,46 +2524,6 @@ def register_fonts(): }) fonts[:] = sorted((*fonts_system, *fonts_proplot)) -#-----------------------------------------------------------------------------# -# Register stuff and define variables -#-----------------------------------------------------------------------------# -cmaps = [] # track *downloaded* colormaps, user can then check this list -"""List of new registered colormap names.""" -cycles = [] # track *all* color cycles -"""List of registered color cycle names.""" -_colordict_unfiltered = {} # downloaded colors categorized by filename -colordict = {} # limit to 'sufficiently unique' color names -"""Registered color names by category.""" -fonts_proplot = [] -"""Names of fonts added by ProPlot.""" -fonts_system = [] -"""Names of fonts provided by matplotlib or your operating system.""" -fonts = [] -"""All registered font names.""" - -register_colors() -register_cmaps() -register_cycles() -register_fonts() - -# Dictionary of normalizers; note BinNorm is inaccessible for users -normalizers = { - 'none': mcolors.NoNorm, - 'null': mcolors.NoNorm, - 'zero': MidpointNorm, - 'midpoint': MidpointNorm, - 'segments': LinearSegmentedNorm, - 'segmented': LinearSegmentedNorm, - 'log': mcolors.LogNorm, - 'linear': mcolors.Normalize, - 'power': mcolors.PowerNorm, - 'symlog': mcolors.SymLogNorm, - } -"""Dictionary of possible normalizers. See `Norm` for a table.""" - -#-----------------------------------------------------------------------------# -# Demos -#-----------------------------------------------------------------------------# def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, width=100, aspect=1, axwidth=1.7): """ @@ -2670,7 +2557,7 @@ def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, width=100, Returns ------- `~proplot.subplots.Figure` - The figure instance. + The figure. """ # Figure and plot from . import subplots @@ -2776,7 +2663,7 @@ def show_colorspaces(luminance=None, saturation=None, hue=None): Returns ------- `~proplot.subplots.Figure` - The figure instance. + The figure. """ # Get colorspace properties hues = np.linspace(0, 360, 361) @@ -2834,14 +2721,14 @@ def show_colorspaces(luminance=None, saturation=None, hue=None): title=space.upper(), titleweight='bold') return fig -def show_colors(nbreak=17, minsat=0.2): +def show_colors(nhues=17, minsat=0.2): """ Visualizes the registered color names in two figures. Adapted from `this example `_. Parameters ---------- - nbreak : int, optional + nhues : int, optional The number of breaks between hues for grouping "like colors" in the color table. minsat : float, optional @@ -2851,7 +2738,7 @@ def show_colors(nbreak=17, minsat=0.2): Returns ------- figs : list of `~proplot.subplots.Figure` - The figure instances. + The figure. """ # Get colors explicitly defined in colorConverter, or the default # components of that map @@ -2867,55 +2754,57 @@ def show_colors(nbreak=17, minsat=0.2): for name in group: icolors.update(colordict[name]) # add category dictionary - # Group colors together by discrete range of hue, then sort by value - # For opencolors this is not necessary + # Group opencolor names together + # names = [[name + str(i) for i in range(nrows)] for name in OPEN_COLORS] if open_colors: wscale = 0.5 swatch = 1.5 - nrows, ncols = 10, len(OPEN_COLORS) # rows and columns - plot_names = [[name + str(i) for i in range(nrows)] for name in OPEN_COLORS] - nrows = nrows*2 - ncols = (ncols+1)//2 - plot_names = np.array(plot_names, order='C') - plot_names.resize((ncols, nrows)) - plot_names = plot_names.tolist() - # Get colors in perceptally uniform space, then group based on hue thresholds + nrows, ncols = 10, 13 + names = np.reshape([*icolors.keys()], (ncols,nrows)) + names = np.array(names, order='C') + names.resize(((ncols + 1)//2, nrows*2)) # fill in with blanks + # Group colors together by discrete range of hue, then sort by value else: # Transform to HCL space ncols = 4 wscale = 1 swatch = 1 colors_hcl = { - key: [c/s for c,s in zip(to_xyz(value, FILTER_SPACE), scale)] + key: [c/s for c,s in zip(to_xyz(value, FILTER_SPACE_NAME), scale)] for key,value in icolors.items() } # Separate into columns and roughly sort by brightness in these columns - breakpoints = np.linspace(0,1,nbreak) # group in blocks of 20 hues - plot_names = [] # initialize + names = [] # initialize + hues = np.linspace(0, 1, nhues) # group in blocks of 20 hues sat_test = (lambda x: x < minsat) # test saturation for 'grays' - for n in range(nbreak): + for i in range(nhues): # 'Grays' column - if n == 0: - hue_colors = [(name,hcl) for name,hcl in colors_hcl.items() if sat_test(hcl[1])] - # Column for nth color + if i == 0: + hue_colors = [ + (name,hcl) for name,hcl in colors_hcl.items() + if sat_test(hcl[1]) + ] + # Nth color column else: - b1, b2 = breakpoints[n-1], breakpoints[n] - hue_test = ((lambda x: b1 <= x <= b2) if b2 - is breakpoints[-1] else (lambda x: b1 <= x < b2)) - hue_colors = [(name,hcl) for name,hcl + b1, b2 = hues[i-1], hues[i] + hue_test = ( + (lambda x: b1 <= x <= b2) if b2 is hues[-1] + else (lambda x: b1 <= x < b2) + ) + hue_colors = [ + (name,hcl) for name,hcl in colors_hcl.items() if hue_test(hcl[0]) - and not sat_test(hcl[1])] # grays have separate category + and not sat_test(hcl[1]) + ] # grays have separate category # Get indices to build sorted list, then append sorted list - sorted_index = np.argsort([pair[1][2] for pair in hue_colors]) - plot_names.append([hue_colors[i][0] for i in sorted_index]) - # Concatenate those columns so get nice rectangle - names = [i for sublist in plot_names for i in sublist] - plot_names = [[]] - nrows = len(names)//ncols+1 - for i,name in enumerate(names): - if ((i + 1) % nrows) == 0: - plot_names.append([]) # add new empty list - plot_names[-1].append(name) + idx_sorted = np.argsort([pair[1][2] for pair in hue_colors]) + names.append([hue_colors[i][0] for i in idx_sorted]) + # Concatenate the columns rather than plot single column for + # each hue, so get nice grid + names = [i for sublist in names for i in sublist] + nrows = len(names)//ncols + 1 + names = np.array(names, order='C') + names.resize((ncols, nrows)) # fill in with blanks # Create plot by iterating over columns fig, ax = subplots( @@ -2924,7 +2813,7 @@ def show_colors(nbreak=17, minsat=0.2): ) X, Y = fig.get_dpi()*fig.get_size_inches() # size in *dots*; make these axes units hsep, wsep = Y/(nrows+1), X/ncols # height and width of row/column in *dots* - for col,huelist in enumerate(plot_names): + for col,huelist in enumerate(names): for row,name in enumerate(huelist): # list of colors in hue category if not name: # empty slot continue @@ -2968,24 +2857,24 @@ def show_cmaps(*args, N=256, length=4.0, width=0.2, unknown='User'): Returns ------- `~proplot.subplots.Figure` - The figure instance. + The figure. """ # Have colormaps separated into categories if args: - imaps = [Colormap(cmap, N=N).name for cmap in args] + inames = [Colormap(cmap, N=N).name for cmap in args] else: - imaps = [ + inames = [ name for name in mcm.cmap_d.keys() if name not in ('vega', 'greys', 'no_name') and isinstance(mcm.cmap_d[name], LinearSegmentedColormap) ] # Get dictionary of registered colormaps and their categories - imaps = [name.lower() for name in imaps] - cats = {cat:names for cat,names in CMAPS_CATEGORIES.items()} - cats_plot = {cat:[name for name in names if name.lower() in imaps] for cat,names in cats.items()} + inames = list(map(str.lower, inames)) + cats = {cat:names for cat,names in CMAPS_TABLE.items()} + cats_plot = {cat:[name for name in names if name.lower() in inames] for cat,names in cats.items()} # Distinguish known from unknown (i.e. user) maps, add as a new category - imaps_known = [name.lower() for cat,names in cats.items() for name in names if name.lower() in imaps] - imaps_unknown = [name for name in imaps if name not in imaps_known] + imaps_known = [name.lower() for cat,names in cats.items() for name in names if name.lower() in inames] + imaps_unknown = [name for name in inames if name not in imaps_known] # Remove categories with no known maps and put user at start cats_plot = {unknown:imaps_unknown, **cats_plot} cats_plot = {cat:maps for cat,maps in cats_plot.items() if maps} @@ -3016,7 +2905,7 @@ def show_cmaps(*args, N=256, length=4.0, width=0.2, unknown='User'): iax += 1 ax.set_visible(False) ax = axs[iax] - if name not in mcm.cmap_d or name.lower() not in imaps: # i.e. the expected builtin colormap is missing + if name not in mcm.cmap_d or name.lower() not in inames: # i.e. the expected builtin colormap is missing ax.set_visible(False) # empty space continue ax.imshow(a, cmap=name, origin='lower', aspect='auto', levels=N) @@ -3045,7 +2934,7 @@ def show_cycles(*args, axwidth=1.5): Returns ------- `~proplot.subplots.Figure` - The figure instance. + The figure. """ # Get the list of cycles if args: @@ -3089,12 +2978,78 @@ def show_fonts(fonts=None, size=12): letters = 'the quick brown fox jumps over a lazy dog\nTHE QUICK BROWN FOX JUMPS OVER A LAZY DOG' # letters = 'Aa Bb Cc Dd Ee Ff Gg Hh Ii Jj Kk Ll Mm Nn Oo Pp Qq Rr Ss Tt Uu Vv Ww Xx Yy Zz' for weight in ('normal',): - f, axs = subplots(ncols=1, nrows=len(fonts), space=0, axwidth=4.5, axheight=5.5*size/72) + fig, axs = subplots(ncols=1, nrows=len(fonts), space=0, axwidth=4.5, axheight=5.5*size/72) axs.format(xloc='neither', yloc='neither', xlocator='null', ylocator='null', alpha=0) axs[0].format(title='Fonts demo', titlesize=size, titleloc='l', titleweight='bold') for i,ax in enumerate(axs): font = fonts[i] ax.text(0, 0.5, f'{font}: {letters}\n{math}\n{greek}', fontfamily=font, fontsize=size, weight=weight, ha='left', va='center') - return f + return fig +#-----------------------------------------------------------------------------# +# Load stuff +#-----------------------------------------------------------------------------# +# Apply custom changes +mcm.cmap_d['Grays'] = mcm.cmap_d.pop('Greys', None) # 'Murica, and consistency with registered color names +mcm.cmap_d['Spectral'] = mcm.cmap_d['Spectral'].reversed(name='Spectral') # make spectral go from 'cold' to 'hot' +for _name in CMAPS_TABLE['Matplotlib Originals']: # initialize as empty lists + _cmap = mcm.cmap_d.get(_name, None) + if _cmap and isinstance(_cmap, mcolors.ListedColormap): + mcm.cmap_d[_name] = LinearSegmentedColormap.from_list(_name, _cmap.colors, cyclic=('twilight' in _name)) +for _cat in ('MATLAB', 'GNUplot', 'GIST', 'Miscellaneous'): + for _name in CMAPS_TABLE[_cat]: + mcm.cmap_d.pop(_name, None) + +# Apply monkey patches to top level modules +if not isinstance(mcm.cmap_d, CmapDict): + mcm.cmap_d = CmapDict(mcm.cmap_d) +if not isinstance(mcolors._colors_full_map, _ColorMappingOverride): + _map = _ColorMappingOverride(mcolors._colors_full_map) + mcolors._colors_full_map = _map + mcolors.colorConverter.cache = _map.cache # re-instantiate + mcolors.colorConverter.colors = _map # re-instantiate + + +# Initialize customization folders and files +_rc_folder = os.path.join(os.path.expanduser('~'), '.proplot') +if not os.path.isdir(_rc_folder): + os.mkdir(_rc_folder) +for _rc_sub in ('cmaps', 'cycles', 'colors', 'fonts'): + _rc_sub = os.path.join(_rc_folder, _rc_sub) + if not os.path.isdir(_rc_sub): + os.mkdir(_rc_sub) + +# Fill lists and dictionaries +cmaps = [] # track *downloaded* colormaps, user can then check this list +"""List of new registered colormap names.""" +cycles = [] # track *all* color cycles +"""List of registered color cycle names.""" +_colordict_unfiltered = {} # downloaded colors categorized by filename +colordict = {} # limit to 'sufficiently unique' color names +"""Registered color names by category.""" +fonts_proplot = [] +"""Names of fonts added by ProPlot.""" +fonts_system = [] +"""Names of fonts provided by matplotlib or your operating system.""" +fonts = [] +"""All registered font names.""" +register_colors() +register_cmaps() +register_cycles() +register_fonts() + +# Dictionary of normalizers +normalizers = { + 'none': mcolors.NoNorm, + 'null': mcolors.NoNorm, + 'zero': MidpointNorm, + 'midpoint': MidpointNorm, + 'segments': LinearSegmentedNorm, + 'segmented': LinearSegmentedNorm, + 'log': mcolors.LogNorm, + 'linear': mcolors.Normalize, + 'power': mcolors.PowerNorm, + 'symlog': mcolors.SymLogNorm, + } +"""Dictionary of possible normalizers. See `Norm` for a table.""" diff --git a/proplot/subplots.py b/proplot/subplots.py index 930a7e792..2de12ce07 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -5,30 +5,6 @@ It returns a `Figure` instance and an `axes_grid` container of `~proplot.axes.Axes` axes, whose positions are controlled by the new `GridSpec` class. - -.. raw:: html - -

Developer notes

- -While matplotlib permits arbitrarily many gridspecs per figure, ProPlot -permits only *one*. When `subplots` is used, this is trivial to enforce. When -`~Figure.add_subplot` is used, the figure geometry is "locked" after the -first call -- although `~Figure.add_subplot` calls that divide into the -existing geometry are also acceptable (for example, two square subplots above -a longer rectangle subplots with the integers ``221``, ``222``, and ``212``). -This choice is not a major imposition on the user, and *considerably* -simplifies gridspec adjustments, e.g. the "tight layout" adjustments. - -While matplotlib's `~matplotlib.pyplot.subplots` returns a 2D `~numpy.ndarray`, -a 1D `~numpy.ndarray`, or the axes itself, ProPlot's `subplots` returns an -`axes_grid` of axes, meant to unify these three possible return values. -`axes_grid` is a `list` subclass supporting 1D indexing (e.g. ``axs[0]``), but -permits 2D indexing (e.g. ``axs[1,0]``) *just in case* the user *happened* -to draw a clean 2D matrix of subplots. The `~axes_grid.__getattr__` override -also means it no longer matters whether you are calling a method on an axes -or a singleton `axes_grid` of axes. Finally, `axes_grid` lets `subplots` -support complex arrangements of subplots -- just use 1D indexing when they -don't look like a 2D matrix. """ # NOTE: Importing backend causes issues with sphinx, and anyway not sure it's # always included, so make it optional @@ -1959,7 +1935,6 @@ def show(): plt.show() # TODO: Figure out how to save subplots keyword args! -@docstring.dedent_interpd def figure(**kwargs): """ Analogous to `matplotlib.pyplot.figure`, creates an empty figure meant @@ -1967,16 +1942,11 @@ def figure(**kwargs): Parameters ---------- - %(subplot_params)s - - Other parameters - ---------------- **kwargs Passed to `~matplotlib.figure.Figure`. """ return plt.figure(FigureClass=Figure, **kwargs) -@docstring.dedent_interpd def subplots(array=None, ncols=1, nrows=1, ref=1, order='C', hspace=None, wspace=None, space=None, @@ -2013,7 +1983,6 @@ def subplots(array=None, ncols=1, nrows=1, (``'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`). - %(subplot_params)s hratios, wratios Aliases for `height_ratios`, `width_ratios`. width_ratios, height_ratios : float or list thereof, optional @@ -2050,7 +2019,6 @@ def subplots(array=None, ncols=1, nrows=1, 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 diff --git a/proplot/utils.py b/proplot/utils.py index 767cf379c..88b39609b 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -169,23 +169,23 @@ def edges(array, axis=-1): array = np.swapaxes(array, axis, -1) return array -def units(value, numeric='in'): +def units(value, output='in'): """ - Flexible units -- this function is used internally all over ProPlot, so - that you don't have to use "inches" or "points" for all sizing arguments. - See `this link `_ - for info on the em square units. + Converts values and lists of values between arbitrary physical units. This + function is used internally all over ProPlot, permitting flexible units + for various keyword arguments. Parameters ---------- value : float or str or list thereof - A size "unit" or *list thereof*. If numeric, assumed unit is `numeric`. - If string, we look for the format ``'123.456unit'``, where the - number is the value and ``'unit'`` is one of the following. + A size specifier or *list thereof*. If numeric, nothing is done. + If string, it is converted to `output` units. The string should look + like ``'123.456unit'``, where the number is the magnitude and + ``'unit'`` is one of the following. - ====== =================================================================== + ====== ========================================================================================== Key Description - ====== =================================================================== + ====== ========================================================================================== ``m`` Meters ``cm`` Centimeters ``mm`` Millimeters @@ -194,29 +194,19 @@ def units(value, numeric='in'): ``pt`` Points (1/72 inches) ``px`` Pixels on screen, uses dpi of ``rc['figure.dpi']`` ``pp`` Pixels once printed, uses dpi of ``rc['savefig.dpi']`` - ``em`` Em-square for ``rc['font.size']`` - ``ex`` Ex-square for ``rc['font.size']`` - ``Em`` Em-square for ``rc['axes.titlesize']`` - ``Ex`` Ex-square for ``rc['axes.titlesize']`` - ====== =================================================================== + ``em`` `Em square `__ for ``rc['font.size']`` + ``en`` `En square `__ for ``rc['font.size']`` + ``Em`` `Em square `__ for ``rc['axes.titlesize']`` + ``En`` `En square `__ for ``rc['axes.titlesize']`` + ====== ========================================================================================== - numeric : str, optional - The assumed unit for numeric arguments, and the output unit. Default - is **inches**, i.e. ``'in'``. + output : str, optional + The output units. Default is inches, i.e. ``'in'``. """ - # Loop through arbitrary list, or return None if input was None (this - # is the exception). - if not np.iterable(value) or isinstance(value, str): - singleton = True - values = (value,) - else: - singleton = False - values = value - # Font unit scales # NOTE: Delay font_manager import, because want to avoid rebuilding font - # cache, which means import must come after TTFPATH added to environ, - # i.e. inside styletools.register_fonts()! + # cache, which means import must come after TTFPATH added to environ + # by styletools.register_fonts()! small = rcParams['font.size'] # must be absolute large = rcParams['axes.titlesize'] if isinstance(large, str): @@ -224,46 +214,46 @@ def units(value, numeric='in'): scale = mfonts.font_scalings.get(large, 1) # error will be raised somewhere else if string name is invalid! large = small*scale - # Dict of possible units + # Scales for converting physical units to inches unit_dict = { - # Physical units - 'in': 1.0, # already in inches + 'in': 1.0, 'm': 39.37, 'ft': 12.0, 'cm': 0.3937, 'mm': 0.03937, 'pt': 1/72.0, - # Font units 'em': small/72.0, - 'ex': 0.5*small/72.0, # more or less; see URL - 'Em': large/72.0, # for large text - 'Ex': 0.5*large/72.0, + 'en': 0.5*small/72.0, + 'Em': large/72.0, + 'En': 0.5*large/72.0, } - # Display units + # Scales for converting display units to inches # WARNING: In ipython shell these take the value 'figure' if not isinstance(rcParams['figure.dpi'], str): - unit_dict['px'] = 1/rcParams['figure.dpi'] # on screen + unit_dict['px'] = 1/rcParams['figure.dpi'] # once generated by backend if not isinstance(rcParams['savefig.dpi'], str): - unit_dict['pp'] = 1/rcParams['savefig.dpi'] # once 'printed', i.e. saved - - # Iterate + unit_dict['pp'] = 1/rcParams['savefig.dpi'] # once 'printed' i.e. saved + # Scale for converting inches to arbitrary other unit try: - scale = unit_dict[numeric] + scale = unit_dict[output] except KeyError: - raise ValueError(f'Invalid numeric unit {numeric}. Valid units are {", ".join(unit_dict.keys())}.') + raise ValueError(f'Invalid numeric unit {output!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') + + # Convert units for each value in list result = [] - for value in values: - if value is None or isinstance(value, Number): - result.append(value) + singleton = (not np.iterable(value) or isinstance(value, str)) + for val in ((value,) if singleton else value): + if val is None or isinstance(val, Number): + result.append(val) continue - elif not isinstance(value, str): - raise ValueError(f'Size spec must be string or number or list thereof, received {values}.') - regex = re.match('^([-+]?[0-9.]*)(.*)$', value) + elif not isinstance(val, str): + raise ValueError(f'Size spec must be string or number or list thereof. Got {value!r}.') + regex = re.match('^([-+]?[0-9.]*)(.*)$', val) num, unit = regex.groups() try: - result.append(float(num)*unit_dict[unit]/scale) + result.append(float(num) * (unit_dict[unit]/scale if unit else 1)) except (KeyError, ValueError): - raise ValueError(f'Invalid size spec {value}. Valid units are {", ".join(unit_dict.keys())}.') + raise ValueError(f'Invalid size spec {val!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') if singleton: result = result[0] return result From 79e5ce66c10bb6f3ba245b45b01e2ac9515857b9 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 4 Nov 2019 06:07:08 -0700 Subject: [PATCH 17/37] Fix import errors and remove proplotrc --- proplot/.proplotrc | 190 ------------------------------------------ proplot/axes.py | 4 +- proplot/rctools.py | 3 +- proplot/styletools.py | 6 +- 4 files changed, 6 insertions(+), 197 deletions(-) delete mode 100644 proplot/.proplotrc diff --git a/proplot/.proplotrc b/proplot/.proplotrc deleted file mode 100644 index cecbbd0db..000000000 --- a/proplot/.proplotrc +++ /dev/null @@ -1,190 +0,0 @@ -#------------------------ -# ProPlot global settings -#------------------------ -# Notebooks -nbsetup: True -format: retina -autosave: 30 -autoreload: 2 -# Style -abc: False -share: 3 -align: False -span: True -tight: True -fontname: 'Helvetica Neue' -cmap: fire -lut: 256 -cycle: colorblind -rgbcycle: False -color: k -alpha: 1 -facecolor: w -small: 8 -large: 9 -linewidth: 0.6 -margin: 0.0 -grid: True -gridminor: False -ticklen: 4.0 -tickdir: out -tickpad: 2.0 -tickratio: 0.8 -ticklenratio: 0.5 -gridratio: 0.5 -# Geography -reso: lo -geogrid: True -land: False -ocean: False -coast: False -rivers: False -lakes: False -borders: False -innerborders: False - -#--------------------------------------------------- -# rcParams defaults -# See: https://matplotlib.org/users/customizing.html -#--------------------------------------------------- -figure.dpi: 90 -figure.facecolor: '#f2f2f2' -figure.autolayout: False -figure.titleweight: bold -figure.max_open_warning: 0 -savefig.directory: -savefig.dpi: 300 -savefig.facecolor: white -savefig.transparent: True -savefig.format: pdf -savefig.bbox: standard -savefig.pad_inches: 0.0 -axes.titleweight: normal -axes.xmargin: 0.0 -axes.ymargin: 0.0 -axes.grid: True -axes.labelpad: 3.0 -axes.titlepad: 3.0 -grid.color: 'k' -grid.alpha: 0.1 -grid.linewidth: 0.6 -grid.linestyle: '-' -hatch.color: k -hatch.linewidth: 0.6 -lines.linewidth: 1.3 -lines.markersize: 3.0 -legend.frameon: True -legend.framealpha: 0.8 -legend.fancybox: False -legend.labelspacing: 0.5 -legend.handletextpad: 0.5 -legend.handlelength: 1.5 -legend.columnspacing: 1.0 -legend.borderpad: 0.5 -legend.borderaxespad: 0 -xtick.minor.visible: True -ytick.minor.visible: True -mathtext.bf: 'sans:bold' -mathtext.it: 'sans:it' -mathtext.default: regular - -#----------------------- -# rcExtraParams defaults -#----------------------- -# Extra label settings -abc.loc: l # left side above the axes -title.loc: c # centered above the axes -title.pad: 3.0 # copy -abc.style: a -abc.size: # filled by 'large' -abc.color: k -abc.weight: bold -abc.border: True -abc.linewidth: 1.5 -tick.labelsize: # filled by 'small' -tick.labelcolor: # filled by 'color' -tick.labelweight: normal -title.size: # filled by 'large' -title.color: k -title.weight: normal -title.border: True -title.linewidth: 1.5 -suptitle.size: # filled by 'large' -suptitle.color: k -suptitle.weight: bold -leftlabel.size: # filled by 'large' -leftlabel.weight: bold -leftlabel.color: k -toplabel.size: # filled by 'large' -toplabel.weight: bold -toplabel.color: k -rightlabel.size: # filled by 'large' -rightlabel.weight: bold -rightlabel.color: k -bottomlabel.size: # filled by 'large' -bottomlabel.weight: bold -bottomlabel.color: k -# Extra image settings -image.edgefix: True -image.levels: 11 -# Extra axes settings -axes.alpha: # if empty, depends on 'savefig.transparent' setting -axes.formatter.zerotrim: True -axes.formatter.timerotation: 90 -axes.gridminor: True -axes.geogrid: True -# Minor tick gridlines -gridminor.alpha: # filled by "grid.alpha" -gridminor.color: # filled by "grid.color" -gridminor.linestyle: # filled by "grid.linewidth" -gridminor.linewidth: # filled by "grid.linewidth" x "gridratio" -# Parallels and meridians -geogrid.labels: False -geogrid.labelsize: # filled by "small" -geogrid.latmax: 90 -geogrid.lonstep: 30 -geogrid.latstep: 20 -geogrid.alpha: 0.5 -geogrid.color: k -geogrid.linewidth: 1.0 -geogrid.linestyle: ':' -# Geographic content -geoaxes.linewidth: # filled by "linewidth" -geoaxes.facecolor: # filled by "facecolor" -geoaxes.edgecolor: # filled by "color" -land.color: k -ocean.color: w -lakes.color: w -coast.color: k -coast.linewidth: 0.6 -borders.color: k -borders.linewidth: 0.6 -innerborders.color: k -innerborders.linewidth: 0.6 -rivers.color: k -rivers.linewidth: 0.6 -# Colorbar settings -colorbar.loc: right -colorbar.grid: False -colorbar.frameon: True -colorbar.framealpha: 0.8 -colorbar.axespad: 0.5em -colorbar.extend: 1.3em -colorbar.insetextend: 1em -colorbar.length: 1 -colorbar.insetlength: 8em -colorbar.width: 1.5em # 0.17 inches -colorbar.insetwidth: 1.2em -# Subplot properties -# Use font relative units for everything because this is common way user -# might want to increase figure resolution -subplots.axwidth: 18em # 2 inches -subplots.panelwidth: 4em # 0.45 inches -subplots.pad: 0.5em -subplots.axpad: 1em -subplots.panelpad: 0.5em -subplots.panelspace: 1em -subplots.innerspace: 1.5em -subplots.ylabspace: 5.5em -subplots.xlabspace: 4em -subplots.titlespace: 2em diff --git a/proplot/axes.py b/proplot/axes.py index 60da5f4d2..eb5de6b7e 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -31,7 +31,7 @@ import matplotlib.collections as mcollections from . import utils, projs, axistools from .utils import _notNone, units -from .rctools import rc, RC_NODOTSNAMES +from .rctools import rc, RC_NODOTS from .wrappers import ( _get_transform, _norecurse, _redirect, _add_errorbars, _bar_wrapper, _barh_wrapper, _boxplot_wrapper, @@ -123,7 +123,7 @@ def _parse_kwargs(self, *, mode=2, rc_kw=None, **kwargs): kw = {} rc_kw = rc_kw or {} for key,value in kwargs.items(): - key_fixed = RC_NODOTSNAMES.get(key, None) + key_fixed = RC_NODOTS.get(key, None) if key_fixed is None: kw[key] = value else: diff --git a/proplot/rctools.py b/proplot/rctools.py index 57248acd9..d8587347f 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -393,6 +393,7 @@ def _tabulate(rcdict): value = '' if value is None else repr(value) space = ' ' * (maxlen - len(key) + 1) * int(bool(value)) string += f'# {key}:{space}{value}\n' + return string.strip() with open(_rc_file, 'x') as f: f.write(f""" #------------------------------------------------------ @@ -408,7 +409,7 @@ def _tabulate(rcdict): # # Matplotlib settings {_tabulate(defaultParams)} -""") +""".strip()) # "Global" settings and the lower-level settings they change # NOTE: This whole section, declaring dictionaries and sets, takes 1ms diff --git a/proplot/styletools.py b/proplot/styletools.py index 8a5ad67d8..d498ec8a9 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -2379,10 +2379,8 @@ def register_cycles(): if name is None: continue if isinstance(cycle, LinearSegmentedColormap): - cycle = colors() - warnings.warn(f'Failed to load {filename!r} as color cycle.') - continue - cmap = ListedColormap(colors, name=name) + cycle = colors(cycle) + cmap = ListedColormap(cycle, name=name) cmap.colors = [to_rgb(color) for color in cmap.colors] # sanitize mcm.cmap_d[name] = cmap cycles.append(name) From b87ec258b3f75122f57e8973b0bf7f707a7820a1 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 4 Nov 2019 16:46:44 -0700 Subject: [PATCH 18/37] Considerably simplify rc.contex, use cache only with get(), fill(), category() --- proplot/axes.py | 240 +++++++++++++++++++------------------- proplot/axistools.py | 4 +- proplot/rctools.py | 261 +++++++++++++++++++++--------------------- proplot/styletools.py | 51 +++++---- proplot/utils.py | 18 +-- proplot/wrappers.py | 19 ++- 6 files changed, 305 insertions(+), 288 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index eb5de6b7e..79450801e 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -18,7 +18,7 @@ import numpy as np import warnings import functools -from numbers import Integral +from numbers import Integral, Number import matplotlib.projections as mproj import matplotlib.axes as maxes import matplotlib.dates as mdates @@ -63,7 +63,6 @@ 't': 'top', } LOC_TRANSLATE = { - None: None, 'inset': 'best', 'i': 'best', 0: 'best', @@ -117,9 +116,9 @@ def _wrapper(self, *args, **kwargs): #-----------------------------------------------------------------------------# # Generalized custom axes class #-----------------------------------------------------------------------------# -def _parse_kwargs(self, *, mode=2, rc_kw=None, **kwargs): - """Separate rc setting keyword arguments from format command keyword - arguments.""" +def _parse_kwargs(mode=2, rc_kw=None, **kwargs): + """Separates `~proplot.rctools.rc` setting name value pairs from + `~Axes.format` keyword arguments.""" kw = {} rc_kw = rc_kw or {} for key,value in kwargs.items(): @@ -128,7 +127,7 @@ def _parse_kwargs(self, *, mode=2, rc_kw=None, **kwargs): kw[key] = value else: rc_kw[key_fixed] = value - return rc_kw, rc_mode, kw + return rc_kw, mode, kw class Axes(maxes.Axes): """Lowest-level axes subclass. Handles titles and axis @@ -164,7 +163,7 @@ def __init__(self, *args, number=None, self._abc_text = None self._titles_dict = {} # dictionary of title text objects and their locations self._title_loc = None # location of main title - self._title_pad = rc.get('axes.titlepad') # so we can copy to top panel + self._title_pad = rc['axes.titlepad'] # so we can copy to top panel self._title_above_panel = True # TODO: add rc prop? # Children and related properties self._bpanels = [] @@ -256,67 +255,71 @@ def _get_title_props(self, abc=False, loc=None): """Returns standardized location name, position keyword arguments, and setting keyword arguments for the relevant title or a-b-c label at location `loc`.""" - # Props - # NOTE: Sometimes we load all properties from rc object, sometimes - # just changed ones. This is important if e.g. user calls in two - # lines ax.format(titleweight='bold') then ax.format(title='text'), - # don't want to override custom setting with rc default setting. - props = lambda cache: rc.fill({ - 'fontsize': f'{prefix}.size', - 'weight': f'{prefix}.weight', - 'color': f'{prefix}.color', - 'border': f'{prefix}.border', - 'linewidth': f'{prefix}.linewidth', - 'fontfamily': 'font.family', - }, cache=cache) - # Location string and position coordinates cache = True prefix = 'abc' if abc else 'title' - loc = _notNone(loc, rc[f'{prefix}.loc']) - iloc = getattr(self, '_' + ('abc' if abc else 'title') + '_loc') # old + loc = _notNone(loc, rc.get(f'{prefix}.loc', True)) + loc_prev = getattr(self, '_' + ('abc' if abc else 'title') + '_loc') # old if loc is None: - loc = iloc - elif iloc is not None and loc != iloc: + loc = loc_prev + elif loc_prev is not None and loc != loc_prev: cache = False + try: + loc = self._loc_translate(loc) + except KeyError: + raise ValueError(f'Invalid title or abc loc {loc!r}.') + else: + if loc in ('top','bottom','best') or not isinstance(loc, str): + raise ValueError(f'Invalid title or abc loc {loc!r}.') - # Above axes - loc = LOC_TRANSLATE.get(loc, loc) - if loc in ('top','bottom'): - raise ValueError(f'Invalid title location {loc!r}.') - elif loc in ('left','right','center'): - kw = props(cache) - kw.pop('border', None) # no border for titles outside axes - kw.pop('linewidth', None) + # Existing object + if loc in ('left','right','center'): if loc == 'center': obj = self.title else: obj = getattr(self, '_' + loc + '_title') - # Inside axes elif loc in self._titles_dict: - kw = props(cache) obj = self._titles_dict[loc] + # New object else: - kw = props(False) + cache = False width, height = self.get_size_inches() - if loc in ('upper center','lower center'): + if loc in ('upper center', 'lower center'): x, ha = 0.5, 'center' - elif loc in ('upper left','lower left'): - xpad = rc.get('axes.titlepad')/(72*width) + elif loc in ('upper left', 'lower left'): + xpad = rc['axes.titlepad']/(72*width) x, ha = 1.5*xpad, 'left' - elif loc in ('upper right','lower right'): - xpad = rc.get('axes.titlepad')/(72*width) + elif loc in ('upper right', 'lower right'): + xpad = rc['axes.titlepad']/(72*width) x, ha = 1 - 1.5*xpad, 'right' else: - raise ValueError(f'Invalid title or abc "loc" {loc}.') - if loc in ('upper left','upper right','upper center'): - ypad = rc.get('axes.titlepad')/(72*height) + raise RuntimeError # should be impossible + if loc in ('upper left', 'upper right', 'upper center'): + ypad = rc['axes.titlepad']/(72*height) y, va = 1 - 1.5*ypad, 'top' - elif loc in ('lower left','lower right','lower center'): - ypad = rc.get('axes.titlepad')/(72*height) + elif loc in ('lower left', 'lower right', 'lower center'): + ypad = rc['axes.titlepad']/(72*height) y, va = 1.5*ypad, 'bottom' + else: + raise RuntimeError # should be impossible obj = self.text(x, y, '', ha=ha, va=va, transform=self.transAxes) obj.set_transform(self.transAxes) + + # Return location, object, and settings + # NOTE: Sometimes we load all properties from rc object, sometimes + # just changed ones. This is important if e.g. user calls in two + # lines ax.format(titleweight='bold') then ax.format(title='text') + kw = rc.fill({ + 'fontsize': f'{prefix}.size', + 'weight': f'{prefix}.weight', + 'color': f'{prefix}.color', + 'border': f'{prefix}.border', + 'linewidth': f'{prefix}.linewidth', + 'fontfamily': 'font.family', + }, cache) + if loc in ('left', 'right', 'center'): + kw.pop('border', None) + kw.pop('linewidth', None) return loc, obj, kw def _iter_panels(self, sides='lrbt'): @@ -332,12 +335,19 @@ def _iter_panels(self, sides='lrbt'): return axs @staticmethod - def _loc_translate(loc): + def _loc_translate(loc, default=None): """Translates location string `loc` into a standardized form.""" - if loc is True: - loc = None + if loc in (None, True): + loc = default elif isinstance(loc, (str, Integral)): - loc = LOC_TRANSLATE.get(loc, loc) + try: + loc = LOC_TRANSLATE[loc] + except KeyError: + raise KeyError(f'Invalid location {loc!r}.') + elif np.iterable(loc) and len(loc) == 2 and all(isinstance(l, Number) for l in loc): + loc = np.array(loc) + else: + raise KeyError(f'Invalid location {loc!r}.') return loc def _make_inset_locator(self, bounds, trans): @@ -633,11 +643,11 @@ def format(self, *, title=None, top=None, """ # Figure patch (for some reason needs to be re-asserted even if # declared before figure is drawn) - kw = rc.fill({'facecolor':'figure.facecolor'}) + kw = rc.fill({'facecolor':'figure.facecolor'}, True) self.figure.patch.update(kw) if top is not None: self._title_above_panel = top - pad = rc['axes.titlepad'] + pad = rc.get('axes.titlepad', True) if pad is not None: self._set_title_offset_trans(pad) self._title_pad = pad @@ -654,7 +664,7 @@ def format(self, *, title=None, top=None, 'weight': 'suptitle.weight', 'color': 'suptitle.color', 'fontfamily': 'font.family' - }) + }, True) if suptitle or kw: fig._update_suptitle(suptitle, **kw) # Labels @@ -679,10 +689,10 @@ def format(self, *, title=None, top=None, titles_dict = self._titles_dict if not self._panel_side: # Location and text - abcstyle = rc['abc.style'] # changed or running format first time? + abcstyle = rc.get('abc.style', True) # changed or running format first time? if 'abcformat' in kwargs: # super sophisticated deprecation system abcstyle = kwargs.pop('abcformat') - warnings.warn(f'rc setting "abcformat" is deprecated. Please use "abcstyle".') + warnings.warn(f'The "abcformat" setting is deprecated. Please use "abcstyle".') if abcstyle and self.number is not None: if not isinstance(abcstyle, str) or (abcstyle.count('a') != 1 and abcstyle.count('A') != 1): @@ -707,7 +717,7 @@ def format(self, *, title=None, top=None, # Toggle visibility # NOTE: If abc is a matplotlib 'title' attribute, making it # invisible messes stuff up. Just set text to empty. - abc = rc['abc'] + abc = rc.get('abc', True) if abc is not None: obj.set_text(self._abc_text if bool(abc) else '') @@ -916,7 +926,7 @@ def colorbar(self, *args, loc=None, pad=None, """ # TODO: add option to pad inset away from axes edge! kwargs.update({'edgecolor':edgecolor, 'linewidth':linewidth}) - loc = self._loc_translate(_notNone(loc, rc['colorbar.loc'])) + loc = self._loc_translate(loc, rc['colorbar.loc']) if not isinstance(loc, str): # e.g. 2-tuple or ndarray raise ValueError(f'Invalid colorbar location {loc!r}.') if loc == 'best': # white lie @@ -989,7 +999,7 @@ def colorbar(self, *args, loc=None, pad=None, # Default props cbwidth, cblength = width, length width, height = self.get_size_inches() - extend = units(_notNone(kwargs.get('extendsize',None), rc['colorbar.insetextend'])) + extend = units(_notNone(kwargs.get('extendsize', None), rc['colorbar.insetextend'])) cbwidth = units(_notNone(cbwidth, rc['colorbar.insetwidth']))/height cblength = units(_notNone(cblength, rc['colorbar.insetlength']))/width pad = units(_notNone(pad, rc['colorbar.insetpad'])) @@ -1024,17 +1034,18 @@ def colorbar(self, *args, loc=None, pad=None, # Also keep zorder same as with legend. frameon = _notNone(frame, frameon, rc['colorbar.frameon'], names=('frame','frameon')) if frameon: - # Make patch object xmin, ymin, width, height = fbounds patch = mpatches.Rectangle((xmin,ymin), width, height, snap=True, zorder=4.5, transform=self.transAxes) - # Update patch props alpha = _notNone(alpha, rc['colorbar.framealpha']) linewidth = _notNone(linewidth, rc['axes.linewidth']) edgecolor = _notNone(edgecolor, rc['axes.edgecolor']) facecolor = _notNone(facecolor, rc['axes.facecolor']) - patch.update({'alpha':alpha, 'linewidth':linewidth, - 'edgecolor':edgecolor, 'facecolor':facecolor}) + patch.update({ + 'alpha':alpha, + 'linewidth':linewidth, + 'edgecolor':edgecolor, + 'facecolor':facecolor}) self.add_artist(patch) # Make axes @@ -1105,7 +1116,7 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): *args, **kwargs Passed to `~proplot.wrappers.legend_wrapper`. """ - loc = self._loc_translate(_notNone(loc, rc['legend.loc'])) + loc = self._loc_translate(loc, rc['legend.loc']) if isinstance(loc, np.ndarray): loc = loc.tolist() @@ -1551,8 +1562,8 @@ def _rcloc_to_stringloc(x, string): # figures out string location Might be ``None`` if settings are unchanged.""" # For x axes if x == 'x': - top = rc[f'{string}.top'] - bottom = rc[f'{string}.bottom'] + top = rc.get(f'{string}.top', True) + bottom = rc.get(f'{string}.bottom', True) if top is None and bottom is None: return None elif top and bottom: @@ -1565,8 +1576,8 @@ def _rcloc_to_stringloc(x, string): # figures out string location return 'neither' # For y axes else: - left = rc[f'{string}.left'] - right = rc[f'{string}.right'] + left = rc.get(f'{string}.left', True) + right = rc.get(f'{string}.right', True) if left is None and right is None: return None elif left and right: @@ -1651,7 +1662,7 @@ def _datex_rotate(self): return rotation = rc['axes.formatter.timerotation'] kw = {'rotation':rotation} - if rotation not in (0,90,-90): + if rotation not in (0, 90, -90): kw['ha'] = ('right' if rotation > 0 else 'left') for label in self.xaxis.get_ticklabels(): label.update(kw) @@ -1957,7 +1968,7 @@ def format(self, *, kw_face = rc.fill({ 'facecolor': 'axes.facecolor', 'alpha': 'axes.alpha' - }) + }, True) patch_kw = patch_kw or {} kw_face.update(patch_kw) self.patch.update(kw_face) @@ -1974,12 +1985,12 @@ def format(self, *, xminorlocator_kw = xminorlocator_kw or {} yminorlocator_kw = yminorlocator_kw or {} # Flexible keyword args, declare defaults - xmargin = _notNone(xmargin, rc['axes.xmargin']) - ymargin = _notNone(ymargin, rc['axes.ymargin']) - xtickdir = _notNone(xtickdir, rc['xtick.direction']) - ytickdir = _notNone(ytickdir, rc['ytick.direction']) - xtickminor = _notNone(xtickminor, rc['xtick.minor.visible']) - ytickminor = _notNone(ytickminor, rc['ytick.minor.visible']) + xmargin = _notNone(xmargin, rc.get('axes.xmargin', True)) + ymargin = _notNone(ymargin, rc.get('axes.ymargin', True)) + xtickdir = _notNone(xtickdir, rc.get('xtick.direction', True)) + ytickdir = _notNone(ytickdir, rc.get('ytick.direction', True)) + xtickminor = _notNone(xtickminor, rc.get('xtick.minor.visible', True)) + ytickminor = _notNone(ytickminor, rc.get('ytick.minor.visible', True)) xformatter = _notNone(xticklabels, xformatter, None, names=('xticklabels', 'xformatter')) yformatter = _notNone(yticklabels, yformatter, None, names=('yticklabels', 'yformatter')) xlocator = _notNone(xticks, xlocator, None, names=('xticks', 'xlocator')) @@ -1987,13 +1998,14 @@ def format(self, *, xminorlocator = _notNone(xminorticks, xminorlocator, None, names=('xminorticks', 'xminorlocator')) yminorlocator = _notNone(yminorticks, yminorlocator, None, names=('yminorticks', 'yminorlocator')) # Grid defaults are more complicated - axis = rc.get('axes.grid.axis') # always need this property - grid, which = rc['axes.grid'], rc['axes.grid.which'] + grid = rc.get('axes.grid', True) + which = rc.get('axes.grid.which', True) if which is not None or grid is not None: # if *one* was changed + axis = rc['axes.grid.axis'] # always need this property if grid is None: - grid = rc.get('axes.grid') + grid = rc['axes.grid'] elif which is None: - which = rc.get('axes.grid.which') + which = rc['axes.grid.which'] xgrid = _notNone(xgrid, grid and axis in ('x','both') and which in ('major','both')) ygrid = _notNone(ygrid, grid @@ -2085,7 +2097,7 @@ def format(self, *, kw = rc.fill({ 'linewidth': 'axes.linewidth', 'color': 'axes.edgecolor', - }) + }, True) if color is not None: kw['color'] = color sides = ('bottom','top') if x == 'x' else ('left','right') @@ -2124,7 +2136,7 @@ def format(self, *, # Tick and grid settings for major and minor ticks separately # Override is just a "new default", but user can override this - grid_dict = lambda grid: { + _grid_dict = lambda grid: { 'grid_color': grid + '.color', 'grid_alpha': grid + '.alpha', 'grid_linewidth': grid + '.linewidth', @@ -2132,7 +2144,7 @@ def format(self, *, } for which,igrid in zip(('major', 'minor'), (grid,gridminor)): # Tick properties - kw_ticks = rc.category(x + 'tick.' + which) + kw_ticks = rc.category(x + 'tick.' + which, True) if kw_ticks is None: kw_ticks = {} else: @@ -2141,15 +2153,15 @@ def format(self, *, if which == 'major': kw_ticks['size'] = utils.units(ticklen, 'pt') else: - kw_ticks['size'] = utils.units(ticklen, 'pt') * rc.get('ticklenratio') + kw_ticks['size'] = utils.units(ticklen, 'pt') * rc['ticklenratio'] # Grid style and toggling if igrid is not None: axis.grid(igrid, which=which) # toggle with special global props if which == 'major': - kw_grid = rc.fill(grid_dict('grid')) + kw_grid = rc.fill(_grid_dict('grid'), True) else: kw_major = kw_grid - kw_grid = rc.fill(grid_dict('gridminor')) + kw_grid = rc.fill(_grid_dict('gridminor'), True) kw_grid.update({key:value for key,value in kw_major.items() if key not in kw_grid}) # Changed rc settings axis.set_tick_params(which=which, **kw_grid, **kw_ticks) @@ -2196,7 +2208,7 @@ def format(self, *, 'labelcolor': 'tick.labelcolor', # new props 'labelsize': 'tick.labelsize', 'color': x + 'tick.color', - }) + }, True) if color: kw['color'] = color kw['labelcolor'] = color @@ -2205,10 +2217,8 @@ def format(self, *, kw['pad'] = 1 # ticklabels should be much closer if ticklabeldir == 'in': # put tick labels inside the plot tickdir = 'in' - pad = (rc.get(x + 'tick.major.size') - + rc.get(x + 'tick.major.pad') - + rc.get(x + 'tick.labelsize')) - kw['pad'] = -pad + kw['pad'] = -1*sum(rc[f'{x}tick.{key}'] + for key in ('major.size', 'major.pad', 'labelsize')) if tickdir is not None: kw['direction'] = tickdir axis.set_tick_params(which='both', **kw) @@ -2218,7 +2228,7 @@ def format(self, *, kw = rc.fill({ 'fontfamily': 'font.family', 'weight': 'tick.labelweight' - }) + }, True) if rotation is not None: kw = {'rotation':rotation} if x == 'x': @@ -2239,7 +2249,7 @@ def format(self, *, 'fontsize': 'axes.labelsize', 'weight': 'axes.labelweight', 'fontfamily': 'font.family', - }) + }, True) if label is not None: kw['text'] = label if color: @@ -2570,7 +2580,7 @@ def format(self, *args, kw = rc.fill({ 'linewidth': 'axes.linewidth', 'color': 'axes.edgecolor', - }) + }, True) sides = ('inner','polar') if r == 'r' else ('start','end') spines = [self.spines[s] for s in sides] for spine,side in zip(spines,sides): @@ -2586,13 +2596,13 @@ def format(self, *args, 'grid_alpha': 'grid.alpha', 'grid_linewidth': 'grid.linewidth', 'grid_linestyle': 'grid.linestyle', - }) + }, True) axis.set_tick_params(which='both', **kw) # Label settings that can't be controlled with set_tick_params kw = rc.fill({ 'fontfamily': 'font.family', 'weight': 'tick.labelweight' - }) + }, True) for t in axis.get_ticklabels(): t.update(kw) @@ -2745,11 +2755,11 @@ def format(self, *, with rc.context(rc_kw, mode=rc_mode): # Parse alternative keyword args # TODO: Why isn't default latmax 80 respected sometimes? - lonlines = _notNone(lonlines, lonlocator, rc['geogrid.lonstep'], names=('lonlines', 'lonlocator')) - latlines = _notNone(latlines, latlocator, rc['geogrid.latstep'], names=('latlines', 'latlocator')) - latmax = _notNone(latmax, rc['geogrid.latmax']) - labels = _notNone(labels, rc['geogrid.labels']) - grid = _notNone(grid, rc['geogrid']) + lonlines = _notNone(lonlines, lonlocator, rc.get('geogrid.lonstep', True), names=('lonlines', 'lonlocator')) + latlines = _notNone(latlines, latlocator, rc.get('geogrid.latstep', True), names=('latlines', 'latlocator')) + latmax = _notNone(latmax, rc.get('geogrid.latmax', True)) + labels = _notNone(labels, rc.get('geogrid.labels', True)) + grid = _notNone(grid, rc.get('geogrid', True)) if labels: lonlabels = _notNone(lonlabels, 1) latlabels = _notNone(latlabels, 1) @@ -2782,8 +2792,8 @@ def format(self, *, if latlines is not None or latmax is not None: # Fill defaults if latlines is None: - latlines = _notNone(self._latlines_values, rc.get('geogrid.latstep')) - ilatmax = _notNone(latmax, self._latmax, rc.get('geogrid.latmax')) + latlines = _notNone(self._latlines_values, rc['geogrid.latstep']) + ilatmax = _notNone(latmax, self._latmax, rc['geogrid.latmax']) # Get tick locations if not np.iterable(latlines): if (ilatmax % latlines) == (-ilatmax % latlines): @@ -3018,7 +3028,7 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, 'color': 'geogrid.color', 'linewidth': 'geogrid.linewidth', 'linestyle': 'geogrid.linestyle', - }) # cached changes + }, True) gl.collection_kwargs.update(kw) # Grid locations # TODO: Check eps @@ -3068,9 +3078,9 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # NOTE: The e.g. cfeature.COASTLINE features are just for convenience, # hi res versions. Use cfeature.COASTLINE.name to see how it can be looked # up with NaturalEarthFeature. - reso = rc.get('reso') + reso = rc['reso'] if reso not in ('lo','med','hi'): - raise ValueError(f'Invalid resolution {reso}.') + raise ValueError(f'Invalid resolution {reso!r}.') reso = { 'lo': '110m', 'med': '50m', @@ -3087,14 +3097,14 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, } for name,args in features.items(): # Get feature - if not rc.get(name): # toggled + if not rc[name]: # toggled continue if getattr(self, '_' + name, None): # already drawn continue feat = cfeature.NaturalEarthFeature(*args, reso) # For 'lines', need to specify edgecolor and facecolor # See: https://github.com/SciTools/cartopy/issues/803 - kw = rc.category(name, cache=False) + kw = rc.category(name) # do not omit uncached props if name in ('coast', 'rivers', 'borders', 'innerborders'): kw['edgecolor'] = kw.pop('color') kw['facecolor'] = 'none' @@ -3108,13 +3118,13 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # Update patch kw_face = rc.fill({ 'facecolor': 'geoaxes.facecolor' - }) + }, True) kw_face.update(patch_kw) self.background_patch.update(kw_face) kw_edge = rc.fill({ 'edgecolor': 'geoaxes.edgecolor', 'linewidth': 'geoaxes.linewidth' - }) + }, True) self.outline_patch.update(kw_edge) def _hide_labels(self): @@ -3279,10 +3289,10 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, kw_edge = rc.fill({ 'linewidth': 'geoaxes.linewidth', 'edgecolor': 'geoaxes.edgecolor' - }) + }, True) kw_face = rc.fill({ 'facecolor': 'geoaxes.facecolor' - }) + }, True) patch_kw = patch_kw or {} kw_face.update(patch_kw) self.axesPatch = self.patch # bugfix or something @@ -3309,11 +3319,11 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, 'color': 'geogrid.color', 'linewidth': 'geogrid.linewidth', 'linestyle': 'geogrid.linestyle', - }, cache=False) + }) # always apply tkw = rc.fill({ 'color': 'geogrid.color', 'fontsize': 'geogrid.labelsize', - }, cache=False) + }) # Change from left/right/bottom/top to left/right/top/bottom if lonarray is not None: lonarray[2:] = lonarray[2:][::-1] @@ -3372,11 +3382,11 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, 'innerborders': 'drawstates', } for name, method in features.items(): - if not rc.get(name): # toggled + if not rc[name]: # toggled continue if getattr(self, f'_{name}', None): # already drawn continue - kw = rc.category(name, cache=False) + kw = rc.category(name) feat = getattr(self.projection, method)(ax=self) if isinstance(feat, (list,tuple)): # can return single artist or list of artists for obj in feat: diff --git a/proplot/axistools.py b/proplot/axistools.py index 44e7781e7..ca07361ca 100644 --- a/proplot/axistools.py +++ b/proplot/axistools.py @@ -355,7 +355,7 @@ def __init__(self, *args, """ tickrange = tickrange or (-np.inf, np.inf) super().__init__(*args, **kwargs) - zerotrim = _notNone(zerotrim, rc.get('axes.formatter.zerotrim')) + zerotrim = _notNone(zerotrim, rc['axes.formatter.zerotrim']) self._maxprecision = precision self._zerotrim = zerotrim self._tickrange = tickrange @@ -546,7 +546,7 @@ def set_default_locators_and_formatters(self, axis, only_if_default=False): if (not only_if_default or axis.isDefault_minloc or isinstance(axis.get_minor_locator(), mticker.AutoMinorLocator)): name = axis.axis_name if axis.axis_name in 'xy' else 'x' - minor = 'minor' if rc.get(name + 'tick.minor.visible') else 'null' + minor = 'minor' if rc[name + 'tick.minor.visible'] else 'null' axis.set_minor_locator( getattr(self, '_minor_locator', None) or Locator(minor) ) diff --git a/proplot/rctools.py b/proplot/rctools.py index d8587347f..43a2ef417 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -202,14 +202,8 @@ __all__ = ['rc', 'rc_configurator', 'nb_setup'] # Initialize -from matplotlib import rcParams, RcParams -class RcParamsShort(dict): - """Class for holding short-name settings. So far just `dict`.""" - pass -class RcParamsLong(dict): - """Class for holding custom settings. So far just `dict`.""" - pass -defaultParamsShort = RcParamsShort({ +from matplotlib import rcParams +defaultParamsShort = { 'nbsetup': True, 'format': 'retina', 'autosave': 30, @@ -248,8 +242,8 @@ class RcParamsLong(dict): 'lakes': False, 'borders': False, 'innerborders': False, - }) -defaultParamsLong = RcParamsLong({ + } +defaultParamsLong = { 'abc.loc': 'l', # left side above the axes 'title.loc': 'c', # centered above the axes 'title.pad': 3.0, # copy @@ -337,8 +331,8 @@ class RcParamsLong(dict): 'subplots.ylabspace': '5.5em', 'subplots.xlabspace': '4em', 'subplots.titlespace': '2em', - }) -defaultParams = RcParams({ + } +defaultParams = { 'figure.dpi': 90, 'figure.facecolor': '#f2f2f2', 'figure.autolayout': False, @@ -379,9 +373,9 @@ class RcParamsLong(dict): 'mathtext.bf': 'sans:bold', 'mathtext.it': 'sans:it', 'mathtext.default': 'regular', - }) -rcParamsShort = RcParamsShort({}) -rcParamsLong = RcParamsLong({}) + } +rcParamsShort = {} +rcParamsLong = {} # Initialize user file _rc_file = os.path.join(os.path.expanduser('~'), '.proplotrc') @@ -397,7 +391,7 @@ def _tabulate(rcdict): with open(_rc_file, 'x') as f: f.write(f""" #------------------------------------------------------ -# Use this file to customize your settings +# Use this file to customize settings # For descriptions of each key name see: # https://proplot.readthedocs.io/en/latest/rctools.html #------------------------------------------------------ @@ -431,7 +425,6 @@ def _tabulate(rcdict): 'tickdir': ('xtick.direction', 'ytick.direction'), 'tickpad': ('xtick.major.pad', 'xtick.minor.pad', 'ytick.major.pad', 'ytick.minor.pad'), } - # Used by Axes.format, allows user to pass rc settings as keyword args, # way less verbose. For example, landcolor='b' vs. rc_kw={'land.color':'b'}. RC_NODOTS = { # useful for passing these as kwargs @@ -592,19 +585,15 @@ def _get_synced_params(key=None, value=None): return kw, kw_custom #-----------------------------------------------------------------------------# -# Class +# Main class #-----------------------------------------------------------------------------# -class _mode_mod(object): - """Helper class that temporarily modifies the getitem mode.""" - def __init__(self, mode): - self._mode = mode - def __enter__(self): - if self._mode is not None: - self._mode_prev = rc._mode - object.__setattr__(rc, '_mode', self._mode) - def __exit__(self, *args): - if self._mode is not None: - object.__setattr__(rc, '_mode', self._mode_prev) +def _sanitize_key(key): + """Converts the key to a palatable value.""" + if not isinstance(key, str): + raise KeyError(f'Invalid key {key!r}. Must be string.') + if '.' not in key and key not in rcParamsShort: + key = RC_NODOTS.get(key, key) + return key.lower() class rc_configurator(object): """ @@ -615,12 +604,20 @@ class rc_configurator(object): ``~/.proplotrc`` file. See the `~proplot.rctools` documentation for details. """ - def __str__(self): - return type(rcParams).__str__(rcParamsShort) # just show globals - def __repr__(self): - return type(rcParams).__repr__(rcParamsShort) def __contains__(self, key): return key in rcParamsShort or key in rcParamsLong or key in rcParams + def __iter__(self): + for key in sorted((*rcParamsShort, *rcParamsLong, *rcParams)): + yield key + def __repr__(self): + rcdict = type('rc', (dict,), {})(rcParamsShort) + string = type(rcParams).__repr__(rcdict) + indent = ' ' * 4 # indent is rc({ + return string.strip('})') + f'\n{indent}... (rcParams) ...\n{indent}}})' + def __str__(self): # encapsulate params in temporary class whose name is used by rcParams.__str__ + rcdict = type('rc', (dict,), {})(rcParamsShort) + string = type(rcParams).__str__(rcdict) + return string + '\n... (rcParams) ...' @_counter # about 0.05s def __init__(self, local=True): @@ -632,7 +629,6 @@ def __init__(self, local=True): file(s). Default is ``True``. """ # Attributes and style - object.__setattr__(self, '_mode', 0) object.__setattr__(self, '_context', []) plt.style.use('default') @@ -667,8 +663,7 @@ def __init__(self, local=True): def __enter__(self): """Applies settings from the most recent context object.""" # Get context information - _, mode, kwargs, cache, restore = self._context[-1] # missing arg is previous mode - object.__setattr__(self, '_mode', mode) + *_, kwargs, cache, restore = self._context[-1] # missing arg is previous mode def _set_item(rcdict, key, value): restore[key] = rcdict[key] cache[key] = rcdict[key] = value @@ -689,11 +684,10 @@ def _set_item(rcdict, key, value): def __exit__(self, *args): """Restores configurator cache to initial state.""" - mode, _, _, _, restore = self._context[-1] + *_, restore = self._context[-1] for key,value in restore.items(): self[key] = value del self._context[-1] - object.__setattr__(self, '_mode', mode) def __delitem__(self, *args): """Pseudo-immutability.""" @@ -709,45 +703,14 @@ def __getattr__(self, attr): def __getitem__(self, key): """Returns `rcParams `__, - :ref:`rcParamsLong`, and :ref:`rcParamsShort` settings. If we are in a - `~rc_configurator.context` block, may return ``None`` if the setting - is not cached (i.e. if it was not changed by the user).""" - # Can get a whole bunch of different things - # Get full dictionary e.g. for rc[None] - if not key: - return {**rcParams, **rcParamsLong} - - # Standardize - # NOTE: If key is invalid, raise error down the line. - if '.' not in key and key not in rcParamsShort: - key = RC_NODOTS.get(key, key) - - # Allow for special time-saving modes where we *ignore rcParams* - # or even *ignore rcParamsLong*. - mode = self._mode - if mode == 0: - kws = (rcParamsShort, rcParamsLong, rcParams) - elif mode == 1: - kws = (rcParamsShort, rcParamsLong) # custom only! - elif mode == 2: - kws = () - else: - raise KeyError(f'Invalid caching mode {mode!r}.') - if self._context: - kws = (self._context[-1][-2], *kws) - - # Get individual property. Will successively index a few different dicts - # Try to return the value - for kw in kws: + :ref:`rcParamsLong`, and :ref:`rcParamsShort` settings.""" + key = _sanitize_key(key) + for kw in (rcParamsShort, rcParamsLong, rcParams): try: return kw[key] except KeyError: continue - # If we were in one of the exclusive modes, return None - if mode == 0: - raise KeyError(f'Invalid property name {key!r}.') - else: - return None + raise KeyError(f'Invalid property name {key!r}.') def __setattr__(self, attr, value): """Invokes `~rc_configurator.__setitem__`.""" @@ -774,11 +737,34 @@ def __setitem__(self, key, value): else: raise KeyError(f'Invalid key {key!r}.') - def _setattr(self, attr, value): - """Helper function that sets attribute.""" - object.__setattr__(self, attr, value) + def _get_item(self, key, mode=None): + """Ax with `~rc_configurator.__getitem__`, but limits the search + based on the context mode, and returns ``None`` if the key is not + found in the searched dictionaries.""" + if mode is None: + mode = min((context[0] for context in self._context), default=0) + caches = (context[2] for context in self._context) + if mode == 0: + rcdicts = (*caches, rcParamsShort, rcParamsLong, rcParams) + elif mode == 1: + rcdicts = (*caches, rcParamsShort, rcParamsLong) # custom only! + elif mode == 2: + rcdicts = (*caches,) + else: + raise KeyError(f'Invalid caching mode {mode!r}.') + for rcdict in rcdicts: + if not rcdict: + continue + try: + return rcdict[key] + except KeyError: + continue + if mode == 0: + raise KeyError(f'Invalid property name {key!r}.') + else: + return None - def category(self, cat, cache=True): + def category(self, cat, context=False): """ Returns a dictionary of settings belonging to the indicated category, i.e. settings beginning with the substring ``cat + '.'``. @@ -787,34 +773,24 @@ def category(self, cat, cache=True): ---------- cat : str, optional The `rc` settings category. - cache : bool, optional - If ``False``, the `~rc_configurator.__getitem__` mode is temporarily - set to ``0`` (see `~rc_configurator.context`). + context : bool, optional + If ``True``, then each category setting that is not found in the + context mode dictionaries is omitted from the output dictionary. + See `~rc_configurator.context`. """ - # Check input mode if cat not in RC_CATEGORIES: - raise ValueError(f'rc category {cat!r} does not exist. Valid categories are {", ".join(map(repr, RC_CATEGORIES))}.') - mode = 0 if not cache else self._mode - if mode == 0: - kws = (rcParamsLong, rcParams) - elif mode == 1: - kws = (rcParamsLong,) - elif mode == 2: - kws = () - else: - raise KeyError(f'Invalid caching mode {mode}.') - if self._context: - kws = (self._context[-1][-2], *kws) - - # Return params dictionary - output = {} - for kw in kws: - for category,value in kw.items(): - if re.search(f'^{cat}\.', category): - subcategory = re.sub(f'^{cat}\.', '', category) - if subcategory and '.' not in subcategory: - output[subcategory] = value - return output + raise ValueError(f'Invalid rc category {cat!r}. Valid categories are {", ".join(map(repr, RC_CATEGORIES))}.') + kw = {} + mode = 0 if not context else None + for rcdict in (rcParamsLong, rcParams): + for key in rcdict: + if not re.search(f'^{cat}[.][^.]+$', key): + continue + value = self._get_item(key, mode) + if value is None: + continue + kw[key] = value + return kw def context(self, *args, mode=0, **kwargs): """ @@ -839,9 +815,10 @@ def context(self, *args, mode=0, **kwargs): Other parameters ---------------- mode : {0,1,2}, optional - The `~rc_configurator.__getitem__` mode. Dictates the behavior of - the `rc` object within a ``with...as`` block when settings are - requested. + The context mode. Dictates the behavior of `~rc_configurator.get`, + `~rc_configurator.fill`, and `~rc_configurator.category` within a + "with as" block when called with ``context=True``. The options are + as follows. 0. All settings (`rcParams `__, :ref:`rcParamsLong`, and :ref:`rcParamsShort`) are returned, @@ -870,9 +847,19 @@ def context(self, *args, mode=0, **kwargs): if not isinstance(arg, dict): raise ValueError('Non-dictionary argument {arg!r}.') kwargs.update(arg) - self._context.append((self._mode, mode, kwargs, {}, {})) + self._context.append((mode, kwargs, {}, {})) + return self - def get(self, key, cache=False): + def dict(self): + """ + Returns a dictionary of all settings. + """ + output = {} + for key in sorted((*rcParamsShort, *rcParamsLong, *rcParams)): + output[key] = self[key] + return output + + def get(self, key, context=False): """ Returns a setting. @@ -880,15 +867,14 @@ def get(self, key, cache=False): ---------- key : str The setting name. - cache : bool, optional - If ``False``, the `~rc_configurator.__getitem__` mode is temporarily - set to ``0`` (see `~rc_configurator.context`). + context : bool, optional + If ``True``, then ``None`` is returned if the setting is not found + in the context mode dictionaries. See `~rc_configurator.context`. """ - mode = 0 if not cache else None - with _mode_mod(mode): - return self[key] + mode = 0 if not context else None + return self._get_item(key, mode) - def fill(self, props, cache=True): + def fill(self, props, context=False): """ Returns a dictionary filled with `rc` settings, used internally to build dictionaries for updating `~matplotlib.artist.Artist` instances. @@ -900,20 +886,32 @@ def fill(self, props, cache=True): are replaced with the corresponding property only if `~rc_configurator.__getitem__` does not return ``None``. Otherwise, that key, value pair is omitted from the output dictionary. - cache : bool, optional - If ``False``, the `~rc_configurator.__getitem__` mode is temporarily - set to ``0`` (see `~rc_configurator.context`). Otherwise, if an `rc` - lookup returns ``None``, the setting is omitted from the output - dictionary. + context : bool, optional + If ``True``, then each setting that is not found in the + context mode dictionaries is omitted from the output dictionary. + See `~rc_configurator.context`. """ - mode = 0 if not cache else None - output = {} - with _mode_mod(mode): - for key,value in props.items(): - item = self[value] - if item is not None: - output[key] = item - return output + kw = {} + mode = 0 if not context else None + for key,value in props.items(): + item = self._get_item(value, mode) + if item is not None: + kw[key] = item + return kw + + def items(self): + """ + Iterates over all setting names and values. Same as `dict.items`. + """ + for key in self: + yield key, self[key] + + def keys(self): + """ + Iterates over all setting names. Same as `dict.keys`. + """ + for key in self: + yield key def update(self, *args, **kwargs): """ @@ -965,6 +963,13 @@ def reset(self, **kwargs): """ self.__init__(**kwargs) + def values(self): + """ + Iterates over all setting values. Same as `dict.values`. + """ + for key in self: + yield self[key] + # Declare rc object # WARNING: Must be instantiated after ipython notebook setup! The default # backend may change some rc settings! diff --git a/proplot/styletools.py b/proplot/styletools.py index d498ec8a9..a79bdeffc 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -20,7 +20,7 @@ from collections.abc import Sized from lxml import etree from numbers import Number, Integral -from matplotlib import docstring, rcParams +from matplotlib import rcParams import numpy as np import numpy.ma as ma import matplotlib.colors as mcolors @@ -196,23 +196,6 @@ 'white': (1, 1, 1), } -# Docstring fragments -_gamma_params = """ -gamma1 : float, optional - If >1, makes low saturation colors more prominent. If <1, - makes high saturation colors more prominent. Similar to the - `HCLWizard `_ option. - See `make_mapping_array` for details. -gamma2 : float, optional - If >1, makes high luminance colors more prominent. If <1, - makes low luminance colors more prominent. Similar to the - `HCLWizard `_ option. - See `make_mapping_array` for details. -gamma : float, optional - Sets `gamma1` and `gamma2` to this identical value. -""" -docstring.interpd.update(gamma_params=_gamma_params) - #-----------------------------------------------------------------------------# # Color manipulation functions #-----------------------------------------------------------------------------# @@ -1069,7 +1052,6 @@ class PerceptuallyUniformColormap(LinearSegmentedColormap, _Colormap): """Similar to `~matplotlib.colors.LinearSegmentedColormap`, but instead of varying the RGB channels, we vary hue, saturation, and luminance in either the HCL colorspace or the HSLuv or HPLuv scalings of HCL.""" - @docstring.dedent_interpd def __init__(self, name, segmentdata, N=None, space=None, clip=True, gamma=None, gamma1=None, gamma2=None, cyclic=False, @@ -1105,7 +1087,18 @@ def __init__(self, cyclic : bool, optional Whether this colormap is cyclic. See `LinearSegmentedColormap` for details. - %(gamma_params)s + gamma : float, optional + Sets `gamma1` and `gamma2` to this identical value. + gamma1 : float, optional + If >1, makes low saturation colors more prominent. If <1, + makes high saturation colors more prominent. Similar to the + `HCLWizard `_ option. + See `make_mapping_array` for details. + gamma2 : float, optional + If >1, makes high luminance colors more prominent. If <1, + makes low luminance colors more prominent. Similar to the + `HCLWizard `_ option. + See `make_mapping_array` for details. Example ------- @@ -1333,14 +1326,24 @@ def from_list(name, colors, ratios=None, **kwargs): cdict[key] = _make_segmentdata_array(channel, ratios, **kwargs) return PerceptuallyUniformColormap(name, cdict, **kwargs) - @docstring.dedent_interpd def set_gamma(self, gamma=None, gamma1=None, gamma2=None): """ - Set new gamma value(s) and regenerates the colormap. + Sets new gamma value(s) and regenerates the colormap. Parameters ---------- - %(gamma_params)s + gamma : float, optional + Sets `gamma1` and `gamma2` to this identical value. + gamma1 : float, optional + If >1, makes low saturation colors more prominent. If <1, + makes high saturation colors more prominent. Similar to the + `HCLWizard `_ option. + See `make_mapping_array` for details. + gamma2 : float, optional + If >1, makes high luminance colors more prominent. If <1, + makes low luminance colors more prominent. Similar to the + `HCLWizard `_ option. + See `make_mapping_array` for details. """ gamma1 = _notNone(gamma1, gamma) gamma2 = _notNone(gamma2, gamma) @@ -2560,7 +2563,7 @@ def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, width=100, # Figure and plot from . import subplots if not args: - args = (rcParams['image.cmap'],) + raise ValueError(f'At least one positional argument required.') array = [[1,1,2,2,3,3]] labels = ('Hue', 'Chroma', 'Luminance') if scalings: diff --git a/proplot/utils.py b/proplot/utils.py index 88b39609b..9b2d7053f 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -183,22 +183,22 @@ def units(value, output='in'): like ``'123.456unit'``, where the number is the magnitude and ``'unit'`` is one of the following. - ====== ========================================================================================== + ====== ========================================================================================= Key Description - ====== ========================================================================================== + ====== ========================================================================================= ``m`` Meters ``cm`` Centimeters ``mm`` Millimeters ``ft`` Feet ``in`` Inches ``pt`` Points (1/72 inches) - ``px`` Pixels on screen, uses dpi of ``rc['figure.dpi']`` - ``pp`` Pixels once printed, uses dpi of ``rc['savefig.dpi']`` - ``em`` `Em square `__ for ``rc['font.size']`` - ``en`` `En square `__ for ``rc['font.size']`` - ``Em`` `Em square `__ for ``rc['axes.titlesize']`` - ``En`` `En square `__ for ``rc['axes.titlesize']`` - ====== ========================================================================================== + ``px`` Pixels on screen, uses dpi of :rcraw:`figure.dpi` + ``pp`` Pixels once printed, uses dpi of :rcraw:`savefig.dpi` + ``em`` `Em square `__ for :rcraw:`font.size` + ``en`` `En square `__ for :rcraw:`font.size` + ``Em`` `Em square `__ for :rcraw:`axes.titlesize` + ``En`` `En square `__ for :rcraw:`axes.titlesize` + ====== ========================================================================================= output : str, optional The output units. Default is inches, i.e. ``'in'``. diff --git a/proplot/wrappers.py b/proplot/wrappers.py index 03e3232e1..4d085c581 100644 --- a/proplot/wrappers.py +++ b/proplot/wrappers.py @@ -1224,7 +1224,7 @@ def text_wrapper(self, func, size = _notNone(fontsize, size, None, names=('fontsize', 'size')) if size is not None: kwargs['fontsize'] = utils.units(size, 'pt') - kwargs.setdefault('color', rc.get('text.color')) # text.color is ignored sometimes unless we apply this + kwargs.setdefault('color', rc['text.color']) # text.color is ignored sometimes unless we apply this obj = func(self, x, y, text, transform=transform, **kwargs) # Optionally draw border around text @@ -1871,7 +1871,7 @@ def cmap_changer(self, func, *args, cmap=None, cmap_kw=None, # See: https://stackoverflow.com/a/20998634/4970632 elif 'pcolor' in name: obj.update_scalarmappable() # populates the _facecolors attribute, initially filled with just a single color - labels_kw_ = {'size':rc['small'], 'ha':'center', 'va':'center'} + labels_kw_ = {'size': rc['small'], 'ha': 'center', 'va': 'center'} labels_kw_.update(labels_kw) array = obj.get_array() paths = obj.get_paths() @@ -2174,11 +2174,11 @@ def legend_wrapper(self, # Also apply override settings kw_handle = {} outline = rc.fill({ - 'linewidth':'axes.linewidth', - 'edgecolor':'axes.edgecolor', - 'facecolor':'axes.facecolor', - 'alpha':'legend.framealpha', - }, cache=False) + 'linewidth': 'axes.linewidth', + 'edgecolor': 'axes.edgecolor', + 'facecolor': 'axes.facecolor', + 'alpha': 'legend.framealpha', + }) for key in (*outline,): if key != 'linewidth': if kwargs.get(key, None): @@ -2517,11 +2517,11 @@ def colorbar_wrapper(self, if orientation == 'horizontal': scale = 3 # em squares alotted for labels length = width*abs(self.get_position().width) - fontsize = kw_ticklabels.get('size', rc.get('xtick.labelsize')) + fontsize = kw_ticklabels.get('size', rc['xtick.labelsize']) else: scale = 1 length = height*abs(self.get_position().height) - fontsize = kw_ticklabels.get('size', rc.get('ytick.labelsize')) + fontsize = kw_ticklabels.get('size', rc['ytick.labelsize']) maxn = _notNone(maxn, int(length/(scale*fontsize/72))) maxn_minor = _notNone(maxn_minor, int(length/(0.5*fontsize/72))) # Get locator @@ -2685,7 +2685,6 @@ def colorbar_wrapper(self, cb.outline.update(kw_outline) if cb.dividers is not None: cb.dividers.update(kw_outline) - # cb.dividers.update(rc.category('grid', cache=False)) # Label and tick label settings # WARNING: Must use colorbar set_label to set text, calling set_text on # the axis will do nothing! From 1d300b5451d0d490b6cf376250027f7bf8238088 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 4 Nov 2019 18:43:19 -0700 Subject: [PATCH 19/37] rc cleanup, more accurate default spacing when tight=False --- proplot/axes.py | 3 - proplot/rctools.py | 321 +++++++++++++++++++++++--------------------- proplot/subplots.py | 156 ++++++++++++--------- 3 files changed, 258 insertions(+), 222 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index 79450801e..f4a018db0 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -910,7 +910,6 @@ def colorbar(self, *args, loc=None, pad=None, The space between the colorbar and the main axes. For outer colorbars only. Units are interpreted by `~proplot.utils.units`. When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelspace`. frame, frameon : bool, optional For inset colorbars, indicates whether to draw a "frame", just like `~matplotlib.axes.Axes.legend`. Default is @@ -1109,7 +1108,6 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): The space between the axes and the legend for outer legends. Units are interpreted by `~proplot.utils.units`. When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelspace`. Other parameters ---------------- @@ -1313,7 +1311,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.panelspace`. 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. diff --git a/proplot/rctools.py b/proplot/rctools.py index 43a2ef417..b3476dc49 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -148,11 +148,6 @@ ``subplots.pad`` Padding around figure edge. Units are interpreted by `~proplot.utils.units`. ``subplots.axpad`` Padding between adjacent subplots. Units are interpreted by `~proplot.utils.units`. ``subplots.panelpad`` Padding between subplots and panels, and between stacked panels. Units are interpreted by `~proplot.utils.units`. -``subplots.titlespace`` Vertical space for titles. Units are interpreted by `~proplot.utils.units`. -``subplots.ylabspace`` Horizontal space between subplots alotted for *y*-labels. Units are interpreted by `~proplot.utils.units`. -``subplots.xlabspace`` Vertical space between subplots alotted for *x*-labels. Units are interpreted by `~proplot.utils.units`. -``subplots.innerspace`` Space between subplots alotted for tick marks. Units are interpreted by `~proplot.utils.units`. -``subplots.panelspace`` Purely empty space between main axes and side panels. Units are interpreted by `~proplot.utils.units`. ``suptitle.color``, ``suptitle.size``, ``suptitle.weight`` Font color, size, and weight for the figure title. ``tick.labelcolor``, ``tick.labelsize``, ``tick.labelweight`` Font color, size, and weight for axis tick labels. These mirror the ``axes.labelcolor``, ``axes.labelsize``, and ``axes.labelweight`` `~matplotlib.rcParams` settings used for axes labels. ``title.loc`` Title position. For options, see `~proplot.axes.Axes.format`. @@ -326,11 +321,6 @@ 'subplots.pad': '0.5em', 'subplots.axpad': '1em', 'subplots.panelpad': '0.5em', - 'subplots.panelspace': '1em', - 'subplots.innerspace': '1.5em', - 'subplots.ylabspace': '5.5em', - 'subplots.xlabspace': '4em', - 'subplots.titlespace': '2em', } defaultParams = { 'figure.dpi': 90, @@ -408,22 +398,23 @@ def _tabulate(rcdict): # "Global" settings and the lower-level settings they change # NOTE: This whole section, declaring dictionaries and sets, takes 1ms RC_CHILDREN = { - 'fontname': ('font.family',), - 'cmap': ('image.cmap',), - 'lut': ('image.lut',), - 'alpha': ('axes.alpha',), # this is a custom setting - 'facecolor': ('axes.facecolor', 'geoaxes.facecolor'), - 'color': ('axes.edgecolor', 'geoaxes.edgecolor', 'axes.labelcolor', 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color'), # change the 'color' of an axes - 'small': ('font.size', 'tick.labelsize', 'xtick.labelsize', 'ytick.labelsize', 'axes.labelsize', 'legend.fontsize', 'geogrid.labelsize'), # the 'small' fonts - 'large': ('abc.size', 'figure.titlesize', 'axes.titlesize', 'suptitle.size', 'title.size', 'leftlabel.size', 'toplabel.size', 'rightlabel.size', 'bottomlabel.size'), # the 'large' fonts - 'linewidth': ('axes.linewidth', 'geoaxes.linewidth', 'hatch.linewidth', 'xtick.major.width', 'ytick.major.width'), - 'margin': ('axes.xmargin', 'axes.ymargin'), - 'grid': ('axes.grid',), - 'gridminor': ('axes.gridminor',), - 'geogrid': ('axes.geogrid',), - 'ticklen': ('xtick.major.size', 'ytick.major.size'), - 'tickdir': ('xtick.direction', 'ytick.direction'), - 'tickpad': ('xtick.major.pad', 'xtick.minor.pad', 'ytick.major.pad', 'ytick.minor.pad'), + 'fontname': ('font.family',), + 'cmap': ('image.cmap',), + 'lut': ('image.lut',), + 'alpha': ('axes.alpha',), # this is a custom setting + 'facecolor': ('axes.facecolor', 'geoaxes.facecolor'), + 'color': ('axes.edgecolor', 'geoaxes.edgecolor', 'axes.labelcolor', 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color'), # change the 'color' of an axes + 'small': ('font.size', 'tick.labelsize', 'xtick.labelsize', 'ytick.labelsize', 'axes.labelsize', 'legend.fontsize', 'geogrid.labelsize'), # the 'small' fonts + 'large': ('abc.size', 'figure.titlesize', 'axes.titlesize', 'suptitle.size', 'title.size', 'leftlabel.size', 'toplabel.size', 'rightlabel.size', 'bottomlabel.size'), # the 'large' fonts + 'linewidth': ('axes.linewidth', 'geoaxes.linewidth', 'hatch.linewidth', 'xtick.major.width', 'ytick.major.width'), + 'margin': ('axes.xmargin', 'axes.ymargin'), + 'grid': ('axes.grid',), + 'gridminor': ('axes.gridminor',), + 'geogrid': ('axes.geogrid',), + 'ticklen': ('xtick.major.size', 'ytick.major.size'), + 'tickdir': ('xtick.direction', 'ytick.direction'), + 'tickpad': ('xtick.major.pad', 'xtick.minor.pad', 'ytick.major.pad', 'ytick.minor.pad'), + 'title.pad': ('axes.titlepad',), } # Used by Axes.format, allows user to pass rc settings as keyword args, # way less verbose. For example, landcolor='b' vs. rc_kw={'land.color':'b'}. @@ -468,121 +459,128 @@ def _get_config_paths(): paths.insert(0, ipath) return paths -def _get_synced_params(key=None, value=None): +def _get_synced_params(key, value): """Returns dictionaries for updating "child" properties in `rcParams` and `rcParamsLong` with global property.""" kw = {} # builtin properties that global setting applies to - kw_custom = {} # custom properties that global setting applies to - if key is not None and value is not None: - items = [(key,value)] - else: - items = rcParamsShort.items() - for key,value in items: - # Cycler - if key in ('cycle', 'rgbcycle'): - if key == 'rgbcycle': - cycle, rgbcycle = rcParamsShort['cycle'], value - else: - cycle, rgbcycle = value, rcParamsShort['rgbcycle'] - try: - colors = mcm.cmap_d[cycle].colors - except (KeyError, AttributeError): - cycles = sorted(name for name,cmap in mcm.cmap_d.items() if isinstance(cmap, mcolors.ListedColormap)) - raise ValueError(f'Invalid cycle name {cycle!r}. Options are: {", ".join(map(repr, cycles))}') - if rgbcycle and cycle.lower() == 'colorblind': - regcolors = colors + [(0.1, 0.1, 0.1)] - elif mcolors.to_rgb('r') != (1.0,0.0,0.0): # reset - regcolors = [(0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.75, 0.75, 0.0), (0.75, 0.75, 0.0), (0.0, 0.75, 0.75), (0.0, 0.0, 0.0)] - else: - regcolors = [] # no reset necessary - for code,color in zip('brgmyck', regcolors): - rgb = mcolors.to_rgb(color) - mcolors.colorConverter.colors[code] = rgb - mcolors.colorConverter.cache[code] = rgb - kw['patch.facecolor'] = colors[0] - kw['axes.prop_cycle'] = cycler.cycler('color', colors) - - # Zero linewidth almost always means zero tick length - elif key == 'linewidth' and _to_points(key, value) == 0: - ikw, ikw_custom = _get_synced_params('ticklen', 0) - kw.update(ikw) - kw_custom.update(ikw_custom) - - # Tick length/major-minor tick length ratio - elif key in ('ticklen', 'ticklenratio'): - if key == 'ticklen': - ticklen = _to_points(key, value) - ratio = rcParamsShort['ticklenratio'] - else: - ticklen = rcParamsShort['ticklen'] - ratio = value - kw['xtick.minor.size'] = ticklen*ratio - kw['ytick.minor.size'] = ticklen*ratio - - # Spine width/major-minor tick width ratio - elif key in ('linewidth', 'tickratio'): - if key == 'linewidth': - tickwidth = _to_points(key, value) - ratio = rcParamsShort['tickratio'] - else: - tickwidth = rcParamsShort['linewidth'] - ratio = value - kw['xtick.minor.width'] = tickwidth*ratio - kw['ytick.minor.width'] = tickwidth*ratio - - # Gridline width - elif key in ('grid.linewidth', 'gridratio'): - if key == 'grid.linewidth': - gridwidth = _to_points(key, value) - ratio = rcParamsShort['gridratio'] - else: - gridwidth = rcParams['grid.linewidth'] - ratio = value - kw_custom['gridminor.linewidth'] = gridwidth*ratio - - # Gridline toggling, complicated because of the clunky way this is - # implemented in matplotlib. There should be a gridminor setting! - elif key in ('grid', 'gridminor'): - ovalue = rcParams['axes.grid'] - owhich = rcParams['axes.grid.which'] - # Instruction is to turn off gridlines - if not value: - # Gridlines are already off, or they are on for the particular - # ones that we want to turn off. Instruct to turn both off. - if not ovalue or (key == 'grid' and owhich == 'major') or (key == 'gridminor' and owhich == 'minor'): - which = 'both' # disable both sides - # Gridlines are currently on for major and minor ticks, so we instruct - # to turn on gridlines for the one we *don't* want off - elif owhich == 'both': # and ovalue is True, as we already tested - value = True - which = 'major' if key == 'gridminor' else 'minor' # if gridminor=False, enable major, and vice versa - # Gridlines are on for the ones that we *didn't* instruct to turn - # off, and off for the ones we do want to turn off. This just - # re-asserts the ones that are already on. - else: - value = True - which = owhich - # Instruction is to turn on gridlines + kw_long = {} # custom properties that global setting applies to + kw_short = {} # short name properties + if '.' not in key and key not in rcParamsShort: + key = RC_NODOTS.get(key, key) + # Skip full name keys + if '.' in key: + pass + # Cycler + elif key in ('cycle', 'rgbcycle'): + if key == 'rgbcycle': + cycle, rgbcycle = rcParamsShort['cycle'], value + else: + cycle, rgbcycle = value, rcParamsShort['rgbcycle'] + try: + colors = mcm.cmap_d[cycle].colors + except (KeyError, AttributeError): + cycles = sorted(name for name,cmap in mcm.cmap_d.items() if isinstance(cmap, mcolors.ListedColormap)) + raise ValueError(f'Invalid cycle name {cycle!r}. Options are: {", ".join(map(repr, cycles))}') + if rgbcycle and cycle.lower() == 'colorblind': + regcolors = colors + [(0.1, 0.1, 0.1)] + elif mcolors.to_rgb('r') != (1.0,0.0,0.0): # reset + regcolors = [(0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.75, 0.75, 0.0), (0.75, 0.75, 0.0), (0.0, 0.75, 0.75), (0.0, 0.0, 0.0)] + else: + regcolors = [] # no reset necessary + for code,color in zip('brgmyck', regcolors): + rgb = mcolors.to_rgb(color) + mcolors.colorConverter.colors[code] = rgb + mcolors.colorConverter.cache[code] = rgb + kw['patch.facecolor'] = colors[0] + kw['axes.prop_cycle'] = cycler.cycler('color', colors) + + # Zero linewidth almost always means zero tick length + elif key == 'linewidth' and _to_points(key, value) == 0: + _, ikw_long, ikw = _get_synced_params('ticklen', 0) + kw.update(ikw) + kw_long.update(ikw_long) + + # Tick length/major-minor tick length ratio + elif key in ('ticklen', 'ticklenratio'): + if key == 'ticklen': + ticklen = _to_points(key, value) + ratio = rcParamsShort['ticklenratio'] + else: + ticklen = rcParamsShort['ticklen'] + ratio = value + kw['xtick.minor.size'] = ticklen*ratio + kw['ytick.minor.size'] = ticklen*ratio + + # Spine width/major-minor tick width ratio + elif key in ('linewidth', 'tickratio'): + if key == 'linewidth': + tickwidth = _to_points(key, value) + ratio = rcParamsShort['tickratio'] + else: + tickwidth = rcParamsShort['linewidth'] + ratio = value + kw['xtick.minor.width'] = tickwidth*ratio + kw['ytick.minor.width'] = tickwidth*ratio + + # Gridline width + elif key in ('grid.linewidth', 'gridratio'): + if key == 'grid.linewidth': + gridwidth = _to_points(key, value) + ratio = rcParamsShort['gridratio'] + else: + gridwidth = rcParams['grid.linewidth'] + ratio = value + kw_long['gridminor.linewidth'] = gridwidth*ratio + + # Gridline toggling, complicated because of the clunky way this is + # implemented in matplotlib. There should be a gridminor setting! + elif key in ('grid', 'gridminor'): + ovalue = rcParams['axes.grid'] + owhich = rcParams['axes.grid.which'] + # Instruction is to turn off gridlines + if not value: + # Gridlines are already off, or they are on for the particular + # ones that we want to turn off. Instruct to turn both off. + if not ovalue or (key == 'grid' and owhich == 'major') or (key == 'gridminor' and owhich == 'minor'): + which = 'both' # disable both sides + # Gridlines are currently on for major and minor ticks, so we instruct + # to turn on gridlines for the one we *don't* want off + elif owhich == 'both': # and ovalue is True, as we already tested + value = True + which = 'major' if key == 'gridminor' else 'minor' # if gridminor=False, enable major, and vice versa + # Gridlines are on for the ones that we *didn't* instruct to turn + # off, and off for the ones we do want to turn off. This just + # re-asserts the ones that are already on. else: - # Gridlines are already both on, or they are off only for the ones - # that we want to turn on. Turn on gridlines for both. - if owhich == 'both' or (key == 'grid' and owhich == 'minor') or (key == 'gridminor' and owhich == 'major'): - which = 'both' - # Gridlines are off for both, or off for the ones that we - # don't want to turn on. We can just turn on these ones. - else: - which = owhich - kw['axes.grid'] = value - kw['axes.grid.which'] = which - - # Now update linked settings - value = _to_points(key, value) - for name in RC_CHILDREN.get(key, ()): - if name in rcParamsLong: - kw_custom[name] = value + value = True + which = owhich + # Instruction is to turn on gridlines + else: + # Gridlines are already both on, or they are off only for the ones + # that we want to turn on. Turn on gridlines for both. + if owhich == 'both' or (key == 'grid' and owhich == 'minor') or (key == 'gridminor' and owhich == 'major'): + which = 'both' + # Gridlines are off for both, or off for the ones that we + # don't want to turn on. We can just turn on these ones. else: - kw[name] = value - return kw, kw_custom + which = owhich + kw['axes.grid'] = value + kw['axes.grid.which'] = which + + # Now update linked settings + value = _to_points(key, value) + if key in rcParamsShort: + kw_short[key] = value + elif key in rcParamsLong: + kw_long[key] = value + elif key in rcParams: + kw[key] = value + for name in RC_CHILDREN.get(key, ()): + if name in rcParamsLong: + kw_long[name] = value + else: + kw[name] = value + return kw_short, kw_long, kw #-----------------------------------------------------------------------------# # Main class @@ -637,10 +635,11 @@ def __init__(self, local=True): rcParamsLong.update(defaultParamsLong) rcParamsShort.clear() rcParamsShort.update(defaultParamsShort) - for key,value in rcParamsShort.items(): - rc, rc_long = _get_synced_params(key, value) - rcParams.update(rc) - rcParamsLong.update(rc_long) + for rcdict in (rcParamsShort, rcParamsLong): + for key,value in rcdict.items(): + _, rc_long, rc = _get_synced_params(key, value) + rcParamsLong.update(rc_long) + rcParams.update(rc) # Update from files if not local: @@ -662,24 +661,17 @@ def __init__(self, local=True): def __enter__(self): """Applies settings from the most recent context object.""" - # Get context information *_, kwargs, cache, restore = self._context[-1] # missing arg is previous mode def _set_item(rcdict, key, value): restore[key] = rcdict[key] - cache[key] = rcdict[key] = value - - # Apply settings + rcdict[key] = cache[key] = value for key,value in kwargs.items(): - if key in rcParamsShort: - rc, rc_long = _get_synced_params(key, value) + rc_short, rc_long, rc = _get_synced_params(key, value) + if ikey,ivalue in rc_short.items(): _set_item(rcParamsShort, key, value) - for ikey, ivalue in rc_long.items(): - _set_item(rcParamsLong, ikey, ivalue) - for ikey, ivalue in rc.items(): - _set_item(rcParams, ikey, ivalue) - elif key in rcParamsLong: + for ikey, ivalue in rc_long.items(): _set_item(rcParamsLong, ikey, ivalue) - elif key in rcParams: + for ikey, ivalue in rc.items(): _set_item(rcParams, ikey, ivalue) def __exit__(self, *args): @@ -719,6 +711,16 @@ def __setattr__(self, attr, value): def __setitem__(self, key, value): """Sets `rcParams `__, :ref:`rcParamsLong`, and :ref:`rcParamsShort` settings.""" + rc_short, rc_long, rc = _get_synced_params(key, value) + for ikey, ivalue in rc_short.items(): + rcParamsShort[ikey] = ivalue + for ikey, ivalue in rc_long.items(): + rcParamsLong[ikey] = ivalue + for ikey, ivalue in rc.items(): + rcParams[ikey] = ivalue + + + if '.' not in key and key not in rcParamsShort: key = RC_NODOTS.get(key, key) if key == 'title.pad': @@ -794,7 +796,7 @@ def category(self, cat, context=False): def context(self, *args, mode=0, **kwargs): """ - Temporarily modifies settings in a ``with...as`` block, + Temporarily modifies settings in a "with as" block, used by ProPlot internally but may also be useful for power users. This function was invented to prevent successive calls to @@ -802,7 +804,9 @@ def context(self, *args, mode=0, **kwargs): re-applying unchanged settings. Testing showed that these gratuitous `rcParams `__ lookups and artist updates increased runtime by seconds, even for - relatively simple plots. + relatively simple plots. It also resulted in overwriting previous + rc changes with the default values on successive calls to + `~proplot.axes.Axes.format`. Parameters ---------- @@ -834,12 +838,21 @@ def context(self, *args, mode=0, **kwargs): Example ------- + The below applies settings to axes in a specific figure using + `~rc_configurator.context`. >>> import proplot as plot >>> with plot.rc.context(linewidth=2, ticklen=5): ... f, ax = plot.subplots() ... ax.plot(data) + By contrast, the below applies settings to a specific axes using + `~proplot.axes.Axes.format`. + + >>> import proplot as plot + >>> f, ax = plot.subplots() + >>> ax.format(linewidth=2, ticklen=5) + """ if mode not in range(3): raise ValueError(f'Invalid mode {mode!r}.') diff --git a/proplot/subplots.py b/proplot/subplots.py index 2de12ce07..cd63dfcfb 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -413,11 +413,6 @@ def add_figure(self, figure): `~Figure.add_subplot` with a subplotspec generated by this gridspec.""" self._figures.add(figure) - def get_margins(self): - """Returns left, bottom, right, top values. Not sure why this method - doesn't already exist on `~matplotlib.gridspec.GridSpec`.""" - 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] @@ -426,6 +421,11 @@ def get_wspace(self): """Returns column ratios allocated for spaces.""" return self.get_width_ratios()[1::2] + def get_margins(self): + """Returns left, bottom, right, top values. Not sure why this method + doesn't already exist on `~matplotlib.gridspec.GridSpec`.""" + return self.left, self.bottom, self.right, self.top + def get_active_height_ratios(self): """Returns height ratios excluding slots allocated for spaces.""" return self.get_height_ratios()[::2] @@ -485,41 +485,68 @@ def update(self, figure=None, **kwargs): figure.stale = True #-----------------------------------------------------------------------------# -# Helper funcs +# Figure class and helper funcs #-----------------------------------------------------------------------------# -def _fp_equal(num1, num2, digits=10): - """Tests equality of two floating point numbers out to `N` digits. Used - in a couple places.""" +def _approx_equal(num1, num2, digits=10): + """Tests 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 _panels_kwargs(side, +def _panel_kwargs(side, share=None, width=None, space=None, filled=False, figure=False): - """Converts global keywords like `space` and `width` to side-local - keywords like `lspace` and `lwidth`, and applies default settings.""" - # Return values - # NOTE: Make default legend width same as default colorbar width, in - # case user draws legend and colorbar panel in same row or column! + """Returns default properties for new axes and figure panels.""" s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid panel spec {side!r}.') space_orig = units(space) if filled: - default = rc['colorbar.width'] + default = rc['colorbar.width'] # default 'legend' width same as colorbar else: default = rc['subplots.panelwidth'] - share = _notNone(share, (not filled)) + share = _notNone(share, not filled) width = units(_notNone(width, default)) - space = _notNone(units(space), units(rc['subplots.' + ('panel' if share - and not figure - else 'xlab' if s == 'b' else 'ylab' if s == 'l' - else 'inner' if figure else 'panel') + 'space'])) + key = ('wspace' if s in 'lr' else 'hspace') + pad = (rc['axpad'] if figure else rc['panelpad']) + space = _default_space(key, space, share, pad=pad) return share, width, space, space_orig -#-----------------------------------------------------------------------------# -# Figure class and helper funcs -#-----------------------------------------------------------------------------# +def _default_space(key, space, share=0, pad=None): + """Returns suitable default spacing given a shared axes setting.""" + if space is not None: + space = units(space) + elif key == 'left': + space = units(_notNone(pad, rc['subplots.pad'])) + ( + rc['ytick.major.size'] + rc['ytick.labelsize'] + + rc['ytick.major.pad'] + rc['axes.labelsize'])/72 + elif key == 'right': + space = units(_notNone(pad, rc['subplots.pad'])) + elif key == 'bottom': + space = units(_notNone(pad, rc['subplots.pad'])) + ( + rc['xtick.major.size'] + rc['xtick.labelsize'] + + rc['xtick.major.pad'] + rc['axes.labelsize'])/72 + elif key == 'top': + space = units(_notNone(pad, rc['subplots.pad'])) + ( + rc['axes.titlepad'] + rc['axes.titlesize'])/72 + elif key == 'wspace': + space = (units(_notNone(pad, rc['subplots.axpad'])) + + rc['ytick.major.size']/72) + if share < 3: + space += (rc['ytick.labelsize'] + rc['ytick.major.pad'])/72 + if share < 1: + space += rc['axes.labelsize']/72 + elif key == 'hspace': + space = units(_notNone(pad, rc['subplots.axpad'])) + ( + rc['axes.titlepad'] + rc['axes.titlesize'] + + rc['xtick.major.size'])/72 + if share < 3: + space += (rc['xtick.labelsize'] + rc['xtick.major.pad'])/72 + if share < 0: + space += rc['axes.labelsize']/72 + else: + raise KeyError(f'Invalid space key {key!r}.') + return space + class Figure(mfigure.Figure): """The `~matplotlib.figure.Figure` class returned by `subplots`. At draw-time, an improved tight layout algorithm is employed, and @@ -713,18 +740,15 @@ def __init__(self, # Input border spacing left, right = units(left), units(right) bottom, top = units(bottom), units(top) - self._left_orig, self._right_orig = left, right - self._bottom_orig, self._top_orig = bottom, top - self._wspace_orig, self._hspace_orig = wspace, hspace + self._subplotpars_orig = (left, bottom, right, top, wspace, hspace) # Default border spacing - left = _notNone(left, units(rc['subplots.ylabspace'])) - right = _notNone(right, units(rc['subplots.innerspace'])) - top = _notNone(top, units(rc['subplots.titlespace'])) - bottom = _notNone(bottom, units(rc['subplots.xlabspace'])) - self._ref_aspect = aspect - self._left, self._right = left, right - self._bottom, self._top = bottom, top - self._wspace, self._hspace = wspace, hspace + left = _default_space('left', left) + right = _default_space('right', right) + top = _default_space('top', top) + bottom = _default_space('bottom', bottom) + wspace = _default_space('wspace', wspace, sharey) + hspace = _default_space('hspace', hspace, sharex) + self.subplotpars.update(left, bottom, right, top, wspace, hspace) # Various constants self._pad = units(_notNone(pad, rc['subplots.pad'])) @@ -760,7 +784,7 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): 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 = _panels_kwargs(s, + share, width, space, space_orig = _panel_kwargs(s, filled=filled, figure=False, **kwargs) # Get gridspec and subplotspec indices @@ -814,7 +838,7 @@ def _add_figure_panel(self, side, if s not in 'lrbt': raise ValueError(f'Invalid side {side!r}.') side = SIDE_TRANSLATE[s] - _, width, space, space_orig = _panels_kwargs(s, + _, width, space, space_orig = _panel_kwargs(s, filled=True, figure=True, **kwargs) if s in 'lr': for key,value in (('col',col),('cols',cols)): @@ -915,7 +939,7 @@ def _adjust_aspect(self): pass # matplotlib issues warning, forces aspect == 'auto' # Apply aspect # Account for floating point errors by rounding to 10 digits - if aspect is not None and not _fp_equal(aspect, self._ref_aspect): + if aspect is not None and not _approx_equal(aspect, self._ref_aspect): self._ref_aspect = aspect self._update_geometry() @@ -926,28 +950,38 @@ def _adjust_tight_layout(self, renderer): instance.""" # Initial stuff axs = self._iter_axes() - pad = self._pad - obox = self.bbox_inches # original bbox - bbox = self.get_tightbbox(renderer) gridspec = self._gridspec if not axs or not gridspec: return + bbox = self.get_tightbbox(renderer) + bbox_orig = self.bbox_inches # original bbox + pad = self._pad + axpad = self._axpad + panelpad = self._panelpad + nrows, ncols = gridspec.get_active_geometry() + + # Get subplot params + subplotpars = self.subplotpars + (left_orig, right_orig, bottom_orig, top_orig, + wspace_orig, hspace_orig) = self._subplotpars_orig + (left, right, bottom, top, wspace, hspace) = ( + subplotpars.left, subplotpars.right, + subplotpars.bottom, subplotpars.top, + subplotpars.wspace, subplotpars.hspace, + ) # Tight box *around* figure # Apply new bounds, permitting user overrides # TODO: Account for bounding box NaNs? - self._left = _notNone(self._left_orig, self._left - bbox.xmin + pad) - self._right = _notNone(self._right_orig, self._right - bbox.ymin + pad) - self._bottom = _notNone(self._bottom_orig, self._bottom - (obox.xmax - bbox.xmax) + pad) - self._top = _notNone(self._top_orig, self._top - (obox.ymax - bbox.ymax) + pad) + subplotpars.left = _notNone(left_orig, + left - bbox.xmin + pad) + subplotpars.right = _notNone(right_orig, + right - bbox.ymin + pad) + subplotpars.bottom = _notNone(bottom_orig, + bottom - (bbox_orig.xmax - bbox.xmax) + pad) + subplotpars.top = _notNone(top_orig, + top - (bbox_orig.ymax - bbox.ymax) + pad) - # Get arrays storing gridspec spacing args - axpad = self._axpad - panelpad = self._panelpad - nrows, ncols = gridspec.get_active_geometry() - wspace, hspace = self._wspace, self._hspace - wspace_orig = self._wspace_orig - hspace_orig = self._hspace_orig # Get new subplot spacings, axes panel spacing, figure panel spacing spaces = [] for (w, x, y, nacross, @@ -1012,9 +1046,9 @@ def _adjust_tight_layout(self, renderer): space = _notNone(space_orig, space) # only if user did not provide original space!!! jspace[i] = space spaces.append(jspace) - # Update dictionary - self._wspace = spaces[0] - self._hspace = spaces[1] + # Update subplotpars + subplotpars.wspace = spaces[0] + subplotpars.hspace = spaces[1] # Update geometry self._update_geometry() @@ -2032,7 +2066,6 @@ def subplots(array=None, ncols=1, nrows=1, A special list of axes instances. See `axes_grid`. """ # Build array - rc._getitem_mode = 0 # ensure still zero; might be non-zero if had error in 'with context' block if order not in ('C','F'): # better error message raise ValueError(f'Invalid order {order!r}. Choose from "C" (row-major, default) and "F" (column-major).') if array is None: @@ -2088,15 +2121,8 @@ def subplots(array=None, ncols=1, nrows=1, # Default spaces between axes wspace, hspace = np.array(wspace), np.array(hspace) # also copies! - wspace[wspace==None] = ( - units(rc['subplots.innerspace']) if sharey == 3 - else units(rc['subplots.ylabspace']) - units(rc['subplots.titlespace']) if sharey in (1,2) # space for tick labels only - else units(rc['subplots.ylabspace'])) - hspace[hspace==None] = ( - units(rc['subplots.titlespace']) + units(rc['subplots.innerspace']) if sharex == 3 - else units(rc['subplots.xlabspace']) if sharex in (1,2) # space for tick labels and title - else units(rc['subplots.titlespace']) + units(rc['subplots.xlabspace']) - ) + wspace[wspace==None] = _default_space('wspace', None, sharey) + hspace[hspace==None] = _default_space('hspace', None, sharex) wspace, hspace = wspace.tolist(), hspace.tolist() # Standardized user input ratios From 7ae38c5bd546516847cefae0493eedbbc4b0fd98 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Tue, 5 Nov 2019 01:35:27 -0700 Subject: [PATCH 20/37] Major GridSpec refactor, make share/span/align figure-wide, altx/y fixes Major GridSpec refactor, make share/span/align figure-wide, altx/y fixes --- proplot/axes.py | 250 +++++++++-------- proplot/rctools.py | 2 +- proplot/subplots.py | 661 ++++++++++++++++++++++++++------------------ proplot/utils.py | 51 ++-- 4 files changed, 556 insertions(+), 408 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index f4a018db0..0ba44172c 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -43,7 +43,7 @@ colorbar_wrapper, legend_wrapper, ) try: - from cartopy.mpl.geoaxes import GeoAxes + from cartopy.mpl.geoaxes import GeoAxes, CRS except ModuleNotFoundError: GeoAxes = object @@ -116,7 +116,7 @@ def _wrapper(self, *args, **kwargs): #-----------------------------------------------------------------------------# # Generalized custom axes class #-----------------------------------------------------------------------------# -def _parse_kwargs(mode=2, rc_kw=None, **kwargs): +def _parse_format(mode=2, rc_kw=None, **kwargs): """Separates `~proplot.rctools.rc` setting name value pairs from `~Axes.format` keyword arguments.""" kw = {} @@ -132,25 +132,15 @@ def _parse_kwargs(mode=2, rc_kw=None, **kwargs): class Axes(maxes.Axes): """Lowest-level axes subclass. Handles titles and axis sharing. Adds several new methods and overrides existing ones.""" - def __init__(self, *args, number=None, - sharex=0, sharey=0, - spanx=None, spany=None, alignx=None, aligny=None, - **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``. - sharex, sharey : {3, 2, 1, 0}, optional - The "axis sharing level" for the *x* axis, *y* axis, or both - axes. See `~proplot.subplots.subplots` for details. - spanx, spany : bool, optional - Boolean toggle for whether spanning labels are enabled for the - *x* and *y* axes. See `~proplot.subplots.subplots` for details. - alignx, aligny : bool, optional - Boolean toggle for whether aligned axis labels are enabled for the - *x* and *y* axes. See `~proplot.subplots.subplots` for details. + *args, **kwargs + Passed to `~matplotlib.axes.Axes`. See also -------- @@ -163,9 +153,8 @@ def __init__(self, *args, number=None, self._abc_text = None self._titles_dict = {} # dictionary of title text objects and their locations self._title_loc = None # location of main title - self._title_pad = rc['axes.titlepad'] # so we can copy to top panel + self._title_pad = rc['axes.titlepad'] # can be overwritten by format() self._title_above_panel = True # TODO: add rc prop? - # Children and related properties self._bpanels = [] self._tpanels = [] self._lpanels = [] @@ -184,21 +173,12 @@ def __init__(self, *args, number=None, self._altx_parent = None self._auto_colorbar = {} # stores handles and kwargs for auto colorbar self._auto_legend = {} - # Text labels - # TODO: Add text labels as panels instead of as axes children? coltransform = mtransforms.blended_transform_factory(self.transAxes, self.figure.transFigure) rowtransform = mtransforms.blended_transform_factory(self.figure.transFigure, self.transAxes) - self._llabel = self.text(0.05, 0.5, '', va='center', ha='right', transform=rowtransform) - self._rlabel = self.text(0.95, 0.5, '', va='center', ha='left', transform=rowtransform) + self._llabel = self.text(0.05, 0.5, '', va='center', ha='right', transform=rowtransform) + self._rlabel = self.text(0.95, 0.5, '', va='center', ha='left', transform=rowtransform) self._blabel = self.text(0.5, 0.05, '', va='top', ha='center', transform=coltransform) - self._tlabel = self.text(0.5, 0.95, '', va='bottom', ha='center', transform=coltransform) # reasonable starting point - # Shared and spanning axes - self._spanx_on = spanx - self._spany_on = spany - self._alignx_on = alignx - self._aligny_on = aligny - self._sharex_level = sharex - self._sharey_level = sharey + self._tlabel = self.text(0.5, 0.95, '', va='bottom', ha='center', transform=coltransform) # reasonable starting point self._share_setup() self.number = number # for abc numbering self.format(mode=1) # mode == 1 applies the rcExtraParams @@ -364,10 +344,10 @@ def _range_gridspec(self, x): """Gets the column or row range for the axes.""" subplotspec = self.get_subplotspec() if x == 'x': - _, _, _, _, col1, col2 = subplotspec.get_active_rows_columns() + _, _, _, _, col1, col2 = subplotspec.get_rows_columns() return col1, col2 else: - _, _, row1, row2, _, _ = subplotspec.get_active_rows_columns() + _, _, row1, row2, _, _ = subplotspec.get_rows_columns() return row1, row2 def _range_tightbbox(self, x): @@ -452,11 +432,12 @@ def _reassign_title(self): pad = tax.xaxis.get_tick_padding() tax._set_title_offset_trans(self._title_pad + pad) - def _sharex_setup(self, sharex, level): + def _sharex_setup(self, sharex, level=None): """Sets up panel axis sharing.""" + if level is None: + level = self.figure._sharex if level not in range(4): raise ValueError('Level can be 1 (do not share limits, just hide axis labels), 2 (share limits, but do not hide tick labels), or 3 (share limits and hide tick labels).') - self._sharex_level = max(self._sharex_level, level) # enforce, e.g. if doing panel sharing self._share_short_axis(sharex, 'l', level) self._share_short_axis(sharex, 'r', level) self._share_long_axis(sharex, 'b', level) @@ -464,9 +445,10 @@ def _sharex_setup(self, sharex, level): def _sharey_setup(self, sharey, level): """Sets up panel axis sharing.""" + if level is None: + level = self.figure._sharey if level not in range(4): raise ValueError('Level can be 1 (do not share limits, just hide axis labels), 2 (share limits, but do not hide tick labels), or 3 (share limits and hide tick labels).') - self._sharey_level = max(self._sharey_level, level) self._share_short_axis(sharey, 'b', level) self._share_short_axis(sharey, 't', level) self._share_long_axis(sharey, 'l', level) @@ -476,9 +458,9 @@ def _share_setup(self): """Applies axis sharing for axes that share the same horizontal or vertical extent, and for their panels.""" # Panel axes sharing, between main subplot and its panels - # Top and bottom shared = lambda paxs: [pax for pax in paxs if not pax._panel_filled and pax._panel_share] if not self._panel_side: # this is a main axes + # Top and bottom bottom = self paxs = shared(self._bpanels) if paxs: @@ -500,16 +482,15 @@ def _share_setup(self): iax._sharey_setup(left, 3) # Main axes, sometimes overrides panel axes sharing - # TODO: This can get very repetitive, but probably minimal impact - # on performance? + # TODO: This can get very repetitive, but probably minimal impact? # Share x axes parent, *children = self._get_extent_axes('x') for child in children: - child._sharex_setup(parent, parent._sharex_level) + child._sharex_setup(parent) # Share y axes parent, *children = self._get_extent_axes('y') for child in children: - child._sharey_setup(parent, parent._sharey_level) + child._sharey_setup(parent) def _share_short_axis(self, share, side, level): """Share the "short" axes of panels along a main subplot with panels @@ -947,7 +928,9 @@ def colorbar(self, *args, loc=None, pad=None, 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() @@ -1443,14 +1426,14 @@ def number(self, num): #-----------------------------------------------------------------------------# # Axes subclasses #-----------------------------------------------------------------------------# -dualxy_kwargs = ( +_twin_kwargs = ( 'label', 'locator', 'formatter', 'ticks', 'ticklabels', 'minorlocator', 'minorticks', 'tickminor', 'ticklen', 'tickrange', 'tickdir', 'ticklabeldir', 'tickrotation', - 'bounds', 'margin', 'color', 'grid', 'gridminor', 'gridcolor', + 'bounds', 'margin', 'color', 'linewidth', 'grid', 'gridminor', 'gridcolor', ) -dualxy_descrip = """ +_dual_doc = """ Makes a secondary *%(x)s* axis for denoting equivalent *%(x)s* coordinates in *alternate units*. @@ -1480,14 +1463,22 @@ def number(self, num): Ignored if `scale` is ``None``. Passed to `~proplot.axistools.Scale`. %(args)s : optional - Prepended with ``'y'`` and passed to `Axes.format`. + Prepended with ``'%(x)s'`` and passed to `Axes.format`. """ -altxy_descrip = """ +_alt_doc = """ Alias and more intuitive name for `~CartesianAxes.twin%(y)s`. The matplotlib `~matplotlib.axes.Axes.twiny` function generates two *x* axes with a shared ("twin") *y* axis. -Enforces the following settings. + +Parameters +---------- +%(args)s : optional + Prepended with ``'%(x)s'`` and passed to `Axes.format`. + +Note +---- +This function enforces the following settngs. * Places the old *%(x)s* axis on the %(x1)s and the new *%(x)s* axis on the %(x2)s. @@ -1497,11 +1488,20 @@ def number(self, num): according to the visible spine positions. * Locks the old and new *%(y)s* axis limits and scales, and makes the new %(y)s axis labels invisible. + """ -twinxy_descrip = """ +_twin_doc = """ Mimics matplotlib's `~matplotlib.axes.Axes.twin%(y)s`. -Enforces the following settings. + +Parameters +---------- +%(args)s : optional + Prepended with ``'%(x)s'`` and passed to `Axes.format`. + +Note +---- +This function enforces the following settngs. * Places the old *%(x)s* axis on the %(x1)s and the new *%(x)s* axis on the %(x2)s. @@ -1513,12 +1513,26 @@ def number(self, num): %(y)s axis labels invisible. """ -def _parse_dualxy_args(x, transform, transform_kw, kwargs): - """Interprets the dualx and dualy transform and various keyword - arguments. Returns a list of forward transform, inverse transform, and - overrides for default locators and formatters.""" - # Transform using input functions - # TODO: Also support transforms? Probably not -- transforms are a huge +def _parse_alt(x, kwargs): + """Interprets keyword args passed to all "twin axis" methods so they + can be passed to Axes.format.""" + kw_bad, kw_out = {}, {} + for key,value in kwargs.items(): + if key in _twin_kwargs: + kw_out[x + key] = value + elif key[0] == x and key[1:] in _twin_kwargs: + warnings.warn(f'Twin axis keyword arg {key!r} is deprecated. Use {key[1:]!r} instead.') + kw_out[key] = value + else: + kw_bad[key] = value + if kw_bad: + raise TypeError(f'Unexpected keyword argument(s): {kw_bad!r}') + return kwargs + +def _parse_transform(transform, transform_kw): + """Interprets the dualx and dualy transform. Returns the forward and + inverse transform functions and keyword args passed to the FuncScale.""" + # NOTE: Do not support arbitrary transforms, because transforms are a huge # group that include ND and non-invertable transformations, but transforms # used for axis scales are subset of invertible 1D functions funcscale_kw = {} @@ -1538,26 +1552,11 @@ def _parse_dualxy_args(x, transform, transform_kw, kwargs): funcscale_funcs = (transform, lambda x: x) else: raise ValueError(f'Invalid transform {transform!r}. Must be function, tuple of two functions, or scale name.') - # Parse keyword args intended for format() command - kwargs_bad = {} - for key in (*kwargs.keys(),): - value = kwargs.pop(key) - if key[0] == x and key[1:] in dualxy_kwargs: - warnings.warn(f'dual{x}() keyword arg {key!r} is deprecated. Use {key[1:]!r} instead.') - kwargs[key] = value - elif key in dualxy_kwargs: - kwargs[x + key] = value - else: - kwargs_bad[key] = value - if kwargs_bad: - raise TypeError(f'dual{x}() got unexpected keyword argument(s): {kwargs_bad}') - return funcscale_funcs, funcscale_kw, kwargs - -def _rcloc_to_stringloc(x, string): # figures out string location - """Gets *location string* from the *boolean* "left", "right", "top", and - "bottom" rc settings, e.g. :rc:`axes.spines.left` or :rc:`ytick.left`. - Might be ``None`` if settings are unchanged.""" - # For x axes + return funcscale_funcs, funcscale_kw + +def _parse_rcloc(x, string): # figures out string location + """Converts *boolean* "left", "right", "top", and "bottom" rc settings to + location *string*. Will return ``None`` if settings are unchanged.""" if x == 'x': top = rc.get(f'{string}.top', True) bottom = rc.get(f'{string}.bottom', True) @@ -1571,7 +1570,6 @@ def _rcloc_to_stringloc(x, string): # figures out string location return 'bottom' else: return 'neither' - # For y axes else: left = rc.get(f'{string}.left', True) right = rc.get(f'{string}.right', True) @@ -1918,6 +1916,10 @@ def format(self, *, Color for the *x* and *y* axis spines, ticks, tick labels, and axis labels. Default is :rc:`color`. Use e.g. ``ax.format(color='red')`` to set for both axes. + xlinewidth, ylinewidth : color-spec, optional + Line width for the *x* and *y* axis spines and major ticks. + Default is :rc:`linewidth`. Use e.g. ``ax.format(linewidth=2)`` + to set for both axes. xticklen, yticklen : float or str, optional Tick lengths for the *x* and *y* axis. Units are interpreted by `~proplot.utils.units`, with "points" as the numeric unit. Default @@ -1957,7 +1959,7 @@ def format(self, *, `~proplot.axistools.Scale`, `~proplot.axistools.Locator`, `~proplot.axistools.Formatter` """ - rc_kw, rc_mode, kwargs = _parse_kwargs(**kwargs) + rc_kw, rc_mode, kwargs = _parse_format(**kwargs) with rc.context(rc_kw, mode=rc_mode): # Background basics self.patch.set_clip_on(False) @@ -2017,10 +2019,10 @@ def format(self, *, # want this sometimes! Same goes for spines! xspineloc = _notNone(xloc, xspineloc, None, names=('xloc', 'xspineloc')) yspineloc = _notNone(yloc, yspineloc, None, names=('yloc', 'yspineloc')) - xtickloc = _notNone(xtickloc, xspineloc, _rcloc_to_stringloc('x', 'xtick')) - ytickloc = _notNone(ytickloc, yspineloc, _rcloc_to_stringloc('y', 'ytick')) - xspineloc = _notNone(xspineloc, _rcloc_to_stringloc('x', 'axes.spines')) - yspineloc = _notNone(yspineloc, _rcloc_to_stringloc('y', 'axes.spines')) + xtickloc = _notNone(xtickloc, xspineloc, _parse_rcloc('x', 'xtick')) + ytickloc = _notNone(ytickloc, yspineloc, _parse_rcloc('y', 'ytick')) + xspineloc = _notNone(xspineloc, _parse_rcloc('x', 'axes.spines')) + yspineloc = _notNone(yspineloc, _parse_rcloc('y', 'axes.spines')) if xtickloc != 'both': xticklabelloc = _notNone(xticklabelloc, xtickloc) xlabelloc = _notNone(xlabelloc, xticklabelloc) @@ -2034,7 +2036,8 @@ def format(self, *, # Begin loop for (x, axis, - label, color, ticklen, + label, color, + linewidth, ticklen, margin, bounds, tickloc, spineloc, ticklabelloc, labelloc, @@ -2049,7 +2052,8 @@ def format(self, *, formatter_kw ) in zip( ('x','y'), (self.xaxis, self.yaxis), - (xlabel, ylabel), (xcolor, ycolor), (xticklen, yticklen), + (xlabel, ylabel), (xcolor, ycolor), + (xlinewidth, ylinewidth), (xticklen, yticklen), (xmargin, ymargin), (xbounds, ybounds), (xtickloc, ytickloc), (xspineloc, yspineloc), (xticklabelloc, yticklabelloc), (xlabelloc, ylabelloc), @@ -2097,6 +2101,8 @@ def format(self, *, }, True) if color is not None: kw['color'] = color + if linewidth is not None: + kw['linewidth'] = linewidth sides = ('bottom','top') if x == 'x' else ('left','right') spines = [self.spines[s] for s in sides] for spine,side in zip(spines,sides): @@ -2311,7 +2317,8 @@ def format(self, *, self.set_aspect(aspect) super().format(**kwargs) - def altx(self, *args, **kwargs): + def altx(self, **kwargs): + """Docstring applied below.""" # Cannot wrap twiny() because we want to use CartesianAxes, not # matplotlib Axes. Instead use hidden method _make_twin_axes. # See https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py @@ -2328,9 +2335,11 @@ def altx(self, *args, **kwargs): self._altx_overrides() ax._altx_overrides() self.add_child_axes(ax) + ax.format(**_parse_alt('x', kwargs)) return ax - def alty(self): + def alty(self, **kwargs): + """Docstring applied below.""" if self._alty_child: raise RuntimeError('No more than *two* twin axes!') if self._alty_parent: @@ -2343,9 +2352,11 @@ def alty(self): self._alty_overrides() ax._alty_overrides() self.add_child_axes(ax) + ax.format(**_parse_alt('y', kwargs)) return ax def dualx(self, transform, transform_kw=None, **kwargs): + """Docstring applied below.""" # The axis scale is used to transform units on the left axis, linearly # spaced, to units on the right axis... so the right scale must scale # its data with the *inverse* of this transform. We do this below. @@ -2354,22 +2365,17 @@ def dualx(self, transform, transform_kw=None, **kwargs): # an axis scale (meaning user just has to supply the forward # transformation, not the backwards one), and does not invent a new # class with a bunch of complicated setters. - ax = self.altx() - funcscale_funcs, funcscale_kw, kwargs = _parse_dualxy_args('x', - transform, transform_kw, kwargs) + funcscale_funcs, funcscale_kw = _parse_transform(transform, transform_kw) self._dualx_data = (funcscale_funcs, funcscale_kw) self._dualx_overrides() - ax.format(**kwargs) - return ax + return self.altx(**kwargs) def dualy(self, transform, transform_kw=None, **kwargs): - ax = self.alty() - funcscale_funcs, funcscale_kw, kwargs = _parse_dualxy_args('y', - transform, transform_kw, kwargs) + """Docstring applied below.""" + funcscale_funcs, funcscale_kw = _parse_transform(transform, transform_kw) self._dualy_data = (funcscale_funcs, funcscale_kw) self._dualy_overrides() - ax.format(**kwargs) - return ax + return self.alty(**kwargs) def draw(self, renderer=None, *args, **kwargs): """Adds post-processing steps before axes is drawn.""" @@ -2399,33 +2405,35 @@ def get_tightbbox(self, renderer, *args, **kwargs): return super().get_tightbbox(renderer, *args, **kwargs) def twinx(self): + """Docstring applied below.""" return self.alty() def twiny(self): + """Docstring applied below.""" return self.altx() # Add documentation - altx.__doc__ = altxy_descrip % { + altx.__doc__ = _alt_doc % { 'x':'x', 'x1':'bottom', 'x2':'top', 'y':'y', 'y1':'left', 'y2':'right', } - alty.__doc__ = altxy_descrip % { + alty.__doc__ = _alt_doc % { 'x':'y', 'x1':'left', 'x2':'right', 'y':'x', 'y1':'bottom', 'y2':'top', } - twinx.__doc__ = twinxy_descrip % { + twinx.__doc__ = _twin_doc % { 'x':'y', 'x1':'left', 'x2':'right', 'y':'x', 'y1':'bottom', 'y2':'top', } - twiny.__doc__ = twinxy_descrip % { + twiny.__doc__ = _twin_doc % { 'x':'x', 'x1':'bottom', 'x2':'top', 'y':'y', 'y1':'left', 'y2':'right', } - dualx.__doc__ = dualxy_descrip % { - 'x':'x', 'args':', '.join(dualxy_kwargs) + dualx.__doc__ = _dual_doc % { + 'x':'x', 'args':', '.join(_twin_kwargs) } - dualy.__doc__ = dualxy_descrip % { - 'x':'y', 'args':', '.join(dualxy_kwargs) + dualy.__doc__ = _dual_doc % { + 'x':'y', 'args':', '.join(_twin_kwargs) } class PolarAxes(Axes, mproj.PolarAxes): @@ -2515,7 +2523,7 @@ def format(self, *args, axes `~proplot.rctools.rc` settings. For example, ``axestitlesize=15`` modifies the :rcraw:`axes.titlesize` setting. """ - rc_kw, rc_mode, kwargs = _parse_kwargs(**kwargs) + rc_kw, rc_mode, kwargs = _parse_format(**kwargs) with rc.context(rc_kw, mode=rc_mode): # Not mutable default args thetalocator_kw = thetalocator_kw or {} @@ -2748,7 +2756,7 @@ def format(self, *, axes `~proplot.rctools.rc` settings. For example, ``axestitlesize=15`` modifies the :rcraw:`axes.titlesize` setting. """ - rc_kw, rc_mode, kwargs = _parse_kwargs(**kwargs) + rc_kw, rc_mode, kwargs = _parse_format(**kwargs) with rc.context(rc_kw, mode=rc_mode): # Parse alternative keyword args # TODO: Why isn't default latmax 80 respected sometimes? @@ -3149,7 +3157,7 @@ def get_tightbbox(self, renderer, *args, **kwargs): self._gridliners = [] return super().get_tightbbox(renderer, *args, **kwargs) - # Document projection property + # Projection property @property def projection(self): """The `~cartopy.crs.Projection` instance associated with this axes.""" @@ -3157,16 +3165,17 @@ def projection(self): @projection.setter def projection(self, map_projection): + import cartopy.crs as ccrs + if not isinstance(map_projection, ccrs.CRS): + raise ValueError(f'Projection must be a cartopy.crs.CRS instance.') self._map_projection = map_projection # Wrapped methods - # TODO: Remove this duplication of Axes! Can do this when we implement - # all wrappers as decorators. + # TODO: Remove this duplication! if GeoAxes is not object: text = _text_wrapper( GeoAxes.text ) - # Wrapped by standardize method plot = _default_transform(_plot_wrapper(_standardize_1d(_add_errorbars(_cycle_changer( GeoAxes.plot ))))) @@ -3200,8 +3209,6 @@ def projection(self, map_projection): barbs = _default_transform(_standardize_2d(_cmap_changer( GeoAxes.barbs ))) - - # Wrapped only by cmap wrapper tripcolor = _default_transform(_cmap_changer( GeoAxes.tripcolor )) @@ -3211,12 +3218,18 @@ def projection(self, map_projection): tricontourf = _default_transform(_cmap_changer( GeoAxes.tricontourf )) - - # Special GeoAxes commands - get_extent = _default_crs(GeoAxes.get_extent) - set_extent = _default_crs(GeoAxes.set_extent) - set_xticks = _default_crs(GeoAxes.set_xticks) - set_yticks = _default_crs(GeoAxes.set_yticks) + get_extent = _default_crs( + GeoAxes.get_extent + ) + set_extent = _default_crs( + GeoAxes.set_extent + ) + set_xticks = _default_crs( + GeoAxes.set_xticks + ) + set_yticks = _default_crs( + GeoAxes.set_yticks + ) class BasemapAxes(ProjectionAxes): """Axes subclass for plotting `~mpl_toolkits.basemap` projections. The @@ -3392,7 +3405,7 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, feat.update(kw) setattr(self, '_' + name, feat) - # Document projection property + # Projection property @property def projection(self): """The `~mpl_toolkits.basemap.Basemap` instance associated with @@ -3401,6 +3414,9 @@ def projection(self): @projection.setter def projection(self, map_projection): + import mpl_toolkits.basemap as mbasemap + if not isinstance(map_projection, mbasemap.Basemap): + raise ValueError(f'Projection must be a cartopy.crs.CRS instance.') self._map_projection = map_projection # Wrapped methods diff --git a/proplot/rctools.py b/proplot/rctools.py index b3476dc49..285ccbb54 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -667,7 +667,7 @@ def _set_item(rcdict, key, value): rcdict[key] = cache[key] = value for key,value in kwargs.items(): rc_short, rc_long, rc = _get_synced_params(key, value) - if ikey,ivalue in rc_short.items(): + for ikey,ivalue in rc_short.items(): _set_item(rcParamsShort, key, value) for ikey, ivalue in rc_long.items(): _set_item(rcParamsLong, ikey, ivalue) diff --git a/proplot/subplots.py b/proplot/subplots.py index cd63dfcfb..d079d0e56 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -17,7 +17,6 @@ import matplotlib.transforms as mtransforms import matplotlib.gridspec as mgridspec from numbers import Integral -from matplotlib import docstring try: import matplotlib.backends.backend_macosx as mbackend except ImportError: @@ -240,91 +239,103 @@ def _iterator(*args, **kwargs): class SubplotSpec(mgridspec.SubplotSpec): """ - Adds two helper methods to `~matplotlib.gridspec.SubplotSpec` that return - the geometry *excluding* rows and columns allocated for spaces. + Matplotlib `~matplotlib.gridspec.SubplotSpec` subclass that adds + a helpful `__repr__` method. Otherwise is identical. """ - 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 + 1)//2, (ncols + 1)//2, row1//2, row2//2, col1//2, col2//2 + def __repr__(self): + nrows, ncols, row1, row2, col1, col2 = self.get_rows_columns() + return f'SubplotSpec({nrows}, {ncols}; {row1}:{row2}, {col1}:{col2})' class GridSpec(mgridspec.GridSpec): """ - `~matplotlib.gridspec.GridSpec` generalization 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. + Matplotlib `~matplotlib.gridspec.GridSpec` subclass that allows for grids + with variable spacing between successive rows and columns of axes. """ - def __init__(self, nrows=1, ncols=1, **kwargs): + def __init__(self, nrows=1, ncols=1, + left=None, right=None, bottom=None, top=None, + **kwargs): """ Parameters ---------- nrows, ncols : int, optional The number of rows and columns on the subplot grid. This is applied automatically when the gridspec is passed. - hspace, wspace : float or str or list thereof, optional - The vertical and horizontal spacing between rows and columns of - subplots, respectively. These are specified in physical units. - Units are interpreted by `~proplot.utils.units`. - - If float or string, the spacing is identical between all rows and - columns. If a list, this sets arbitrary spacing between different - rows and columns. The length of the list must equal ``nrows-1`` - and ``ncols-1``, respectively. - height_ratios, width_ratios : list of float, optional - 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, optional + 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. - **kwargs - Passed to `~matplotlib.gridspec.GridSpec`. + hspace, wspace : float or str or list thereof, optional + The vertical and horizontal spacing between rows and columns of + 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. """ + # Basics self._figures = set() # figure tracker + self._nrows, self._ncols = nrows, ncols + + # Ratios and spaces + 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) + + # 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 + + + + + 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) + left = _default_space('left', left) + right = _default_space('right', right) + top = _default_space('top', top) + bottom = _default_space('bottom', bottom) + wspace = _default_space('wspace', wspace, sharey) + hspace = _default_space('hspace', hspace, sharex) super().__init__(self._nrows, self._ncols, hspace=0, wspace=0, # we implement these as inactive rows/columns width_ratios=wratios, height_ratios=hratios, **kwargs, - ) + def __getitem__(self, key): """Magic obfuscation that renders `~matplotlib.gridspec.GridSpec` rows and columns designated as 'spaces' inaccessible.""" nrows, ncols = self.get_geometry() - nrows_active, ncols_active = self.get_active_geometry() + nrows_active, ncols_active = self.get_geometry() if not isinstance(key, tuple): # usage gridspec[1,2] num1, num2 = self._normalize(key, nrows_active * ncols_active) else: @@ -334,122 +345,154 @@ def __getitem__(self, key): 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, 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: - return 2*(size+1) - 1 # want -1 to stay -1, -2 becomes -3, etc. - 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 - 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 - hratios=None, wratios=None, - 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(hratios, height_ratios, 1, - names=('hratios', 'height_ratios'))) - wratios = np.atleast_1d(_notNone(wratios, width_ratios, 1, - names=('wratios', 'width_ratios'))) - hspace = np.atleast_1d(_notNone(hspace, np.mean(hratios)*0.10)) # this is relative to axes - wspace = np.atleast_1d(_notNone(wspace, np.mean(wratios)*0.10)) - if len(wratios) == 1: - wratios = np.repeat(wratios, (ncols,)) - if len(hratios) == 1: - hratios = np.repeat(hratios, (nrows,)) - 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,)) - - # 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)} height_ratios.') - if len(wratios) != ncols: - raise ValueError(f'Got {ncols} columns, but {len(wratios)} width_ratios.') - if len(wspace) != ncols-1: - raise ValueError(f'Require length {ncols-1} wspace vector for {ncols} columns, but got {len(wspace)}.') - if len(hspace) != nrows-1: - raise ValueError(f'Require length {nrows-1} hspace vector for {nrows} rows, but got {len(hspace)}.') - - # 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 + return mgridspec.SubplotSpec(self, num1, num2) def add_figure(self, figure): """Adds `~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, you passed {figure!r}.') self._figures.add(figure) + def get_grid_positions(self, figure, raw=False): + """Calculates grid positions using the input figure. Also scales + the width and height ratios to figure relative coordinates.""" + # Get spacing arguments + # 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. + # 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 + nrows, ncols = self.get_geometry() + if raw: + left = bottom = 0 + right = top = 1 + wspace = hspace = 0 + elif not isinstance(figure, Figure): + raise ValueError(f'get_grid_positions() accepts only ProPlot Figure instances, you passed {figure!r}.') + else: + width, height = figure.get_size_inches() + left = self.left / width + right = 1 - self.right / width + bottom = self.bottom / height + top = 1 - self.top / height + wspace = self.wspace / (width * ncols) + hspace = self.hspace / (height * nrows) + tot_width = right - left + tot_height = top - bottom + + # Calculate accumulated heights of columns + 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: + 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: + 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): + """Returns subplot parameters in inches. If `wspace` and `hspace` are + vectors, the largest values are used.""" + # First get parameters in inches + # NOTE: GridSpec.update() calls Axes.update_params() calls + # SubplotSpec.get_position() calls GridSpec.get_grid_positions() + # calls this. Not called *anywhere else in entire API*! Therefore + # we ignore and put all scaling code in get_grid_positions(). + raise NotImplementedError(f'ProPlot GridSpec does not interact with figure SubplotParams.') + def get_hspace(self): """Returns row ratios allocated for spaces.""" - return self.get_height_ratios()[1::2] + return self.hspace def get_wspace(self): """Returns column ratios allocated for spaces.""" - return self.get_width_ratios()[1::2] + return self.wspace def get_margins(self): """Returns left, bottom, right, top values. Not sure why this method doesn't already exist on `~matplotlib.gridspec.GridSpec`.""" return self.left, self.bottom, self.right, self.top - 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 remove_figure(self, figure): """Removes `~matplotlib.figure.Figure` from the list of figures that are using this gridspec.""" self._figures.discard(figure) - def tight_layout(self): + def set_height_ratios(self, ratios): + """Sets row height ratios.""" + 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(hratios)} height ratios.') + super().set_height_ratios(self) + + def set_width_ratios(self, ratios): + """Sets column width ratios.""" + 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 got {len(wratios)} width ratios.') + super().set_width_ratios(self) + + def set_hspace(self, space): + """Sets inter-column spaceing.""" + N = self._nrows + space = np.atleast_1d(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, but got {len(space)} hspaces.') + self.hspace = space + + def set_wspace(self, space): + """Sets inter-column spaceing.""" + N = self._ncols + space = np.atleast_1d(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, but got {len(space)} wspaces.') + self.wspace = space + + def tight_layout(self, *args, **kwargs): """Method is disabled because ProPlot has its own simplified tight layout algorithm.""" - raise RuntimeError(f'Native matplotlib tight layout is disabled.') + raise NotImplementedError(f'Native matplotlib tight layout is disabled.') - def update(self, figure=None, **kwargs): + def update(self, **kwargs): """ Updates the width and height ratios, gridspec margins, and spacing allocated between subplot rows and columns. @@ -460,23 +503,13 @@ def update(self, figure=None, **kwargs): manager. ProPlot insists one gridspec per figure, tracks the figures using this gridspec object, and applies updates to those tracked figures. - """ - # Convert spaces to ratios - wratios, hratios, kwargs = self._spaces_as_ratios(**kwargs) - self.set_width_ratios(wratios) - self.set_height_ratios(hratios) - - # Validate args - kwargs.pop('ncols', None) - kwargs.pop('nrows', None) - 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(s) and all axes + Parameters + ---------- + **kwargs + Valid initialization keyword arguments. See `GridSpec`. + """ + self.__init__(**kwargs) for figure in self._figures: figure.subplotpars.update(self.left, self.bottom, self.right, self.top) for ax in figure.axes: @@ -487,12 +520,32 @@ def update(self, figure=None, **kwargs): #-----------------------------------------------------------------------------# # Figure class and helper funcs #-----------------------------------------------------------------------------# +_gridspec_doc = """ +Applies the `GridSpec` to the figure or generates 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. +""" +_save_doc = """ +Saves the figure to `filename`. Also scales figure dimensions to account for +axes; aligns row, column, and axis labels; and optionally applies "tight +layout" gridspec adjustments. + +Parameters +---------- +filename : str + The file path. User directories are automatically + expanded, e.g. ``fig.save('~/plots/plot.png')``. +**kwargs + Passed to `~matplotlib.figure.Figure.savefig`. +""" + def _approx_equal(num1, num2, digits=10): """Tests 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 _panel_kwargs(side, +def _parse_panel(side, share=None, width=None, space=None, filled=False, figure=False): """Returns default properties for new axes and figure panels.""" @@ -614,20 +667,9 @@ def __init__(self, "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 : 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, padding is determined by the - "tight layout" algorithm. - 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, wspace, hspace : float or str, optional + The `GridSpec` spacing parameters. Passed to `GridSpec` when + the first subplot is drawn. 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 @@ -687,24 +729,22 @@ def __init__(self, super().__init__(**kwargs) # Axes sharing and spanning settings - sharex = int(_notNone(sharex, share, rc['share'])) - sharey = int(_notNone(sharey, share, rc['share'])) - if sharex not in range(4) or sharey not in range(4): - raise ValueError(f'Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels). Got sharex={sharex} and sharey={sharey}.') + 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']) - alignx = _notNone(alignx, align) - aligny = _notNone(aligny, align) - if (spanx and alignx) or (spany and aligny): - warnings.warn(f'The "alignx" and "aligny" args have no effect when "spanx" and "spany" are True.') - alignx = _notNone(alignx, rc['align']) - aligny = _notNone(alignx, rc['align']) - self._alignx = alignx - self._aligny = aligny - self._sharex = sharex - self._sharey = sharey - self._spanx = spanx - self._spany = spany + if spanx and (alignx or align) + warnings.warn(f'"alignx" has no effect when spanx=True.') + if spany and (aligny or align): + warnings.warn(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) # Figure and/or axes dimensions names, values = (), () @@ -740,24 +780,18 @@ def __init__(self, # Input border spacing left, right = units(left), units(right) bottom, top = units(bottom), units(top) - self._subplotpars_orig = (left, bottom, right, top, wspace, hspace) - # Default border spacing - left = _default_space('left', left) - right = _default_space('right', right) - top = _default_space('top', top) - bottom = _default_space('bottom', bottom) - wspace = _default_space('wspace', wspace, sharey) - hspace = _default_space('hspace', hspace, sharex) - self.subplotpars.update(left, bottom, right, top, wspace, hspace) + wspace, hspace = units(wspace), units(hspace) + self._gridspecpars = [None]*6 # current values + self._gridspecpars_orig = [left, bottom, right, top, wspace, hspace] - # Various constants + # Various constants and hidden settings 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._include_panels = includepanels - self._axes_main = [] + self._mainaxes = [] self._bpanels = [] self._tpanels = [] self._lpanels = [] @@ -784,12 +818,12 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): 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 = _panel_kwargs(s, + share, width, space, space_orig = _parse_panel(s, filled=filled, figure=False, **kwargs) # Get gridspec and subplotspec indices subplotspec = ax.get_subplotspec() - nrows, ncols, row1, row2, col1, col2 = subplotspec.get_active_rows_columns() + nrows, ncols, row1, row2, col1, col2 = subplotspec.get_rows_columns() pgrid = getattr(ax, '_' + s + 'panels') offset = (len(pgrid)*bool(pgrid)) + 1 if s in 'lr': @@ -811,21 +845,17 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): idx2 += 1 # Draw and setup panel - pax = self.add_subplot(gridspec[idx1,idx2], main=False, - sharex=ax._sharex_level, sharey=ax._sharey_level, - projection='cartesian') + pax = self.add_subplot(gridspec[idx1,idx2], + main=False, projection='cartesian') getattr(ax, '_' + s + 'panels').append(pax) pax._panel_side = side pax._panel_share = share pax._panel_parent = ax - - # Axis sharing and axis setup only for non-legend or colorbar axes - if not filled: + if not filled: # axis sharing and setup ax._share_setup() axis = (pax.yaxis if side in ('left','right') else pax.xaxis) getattr(axis, 'tick_' + side)() # sets tick and tick label positions intelligently axis.set_label_position(side) - return pax def _add_figure_panel(self, side, @@ -838,7 +868,7 @@ def _add_figure_panel(self, side, if s not in 'lrbt': raise ValueError(f'Invalid side {side!r}.') side = SIDE_TRANSLATE[s] - _, width, space, space_orig = _panel_kwargs(s, + _, width, space, space_orig = _parse_panel(s, filled=True, figure=True, **kwargs) if s in 'lr': for key,value in (('col',col),('cols',cols)): @@ -922,7 +952,7 @@ def _adjust_aspect(self): fixes grids with identically fixed aspect ratios, e.g. identically zoomed-in cartopy projections and imshow images.""" # Get aspect ratio - axs = self._axes_main + axs = self._mainaxes ref = self.ref if not axs or ref > len(axs): return @@ -958,21 +988,14 @@ def _adjust_tight_layout(self, renderer): pad = self._pad axpad = self._axpad panelpad = self._panelpad - nrows, ncols = gridspec.get_active_geometry() + nrows, ncols = gridspec.get_geometry() # Get subplot params - subplotpars = self.subplotpars - (left_orig, right_orig, bottom_orig, top_orig, - wspace_orig, hspace_orig) = self._subplotpars_orig - (left, right, bottom, top, wspace, hspace) = ( - subplotpars.left, subplotpars.right, - subplotpars.bottom, subplotpars.top, - subplotpars.wspace, subplotpars.hspace, - ) + gridspecpars = self._gridspecpars + left, right, bottom, top, wspace, hspace = self._gridspecpars + left_orig, right_orig, bottom_orig, top_orig, wspace_orig, hspace_orig = self._gridspecpars_orig - # Tight box *around* figure - # Apply new bounds, permitting user overrides - # TODO: Account for bounding box NaNs? + # Tight box *around* figure permitting user overrides subplotpars.left = _notNone(left_orig, left - bbox.xmin + pad) subplotpars.right = _notNone(right_orig, @@ -1056,35 +1079,41 @@ def _adjust_tight_layout(self, renderer): def _align_axislabels(self, b=True): """Aligns 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: 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.CartesianAxes): - continue - for x,axis in zip('xy', (ax.xaxis, ax.yaxis)): + # 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): + warnings.warn(f'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.CartesianAxes) or axis in tracker: + continue s = axis.get_label_position()[0] # top or bottom, left or right - span = getattr(ax, '_span' + x + '_on') - align = getattr(ax, '_align' + x + '_on') - 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:]: - grp.join(axs[0], ax) # copied from source code, add to grouper - elif align: - warnings.warn(f'Aligning *x* and *y* axis labels required matplotlib >=3.1.0') + for ax in axs[1:]: + grp.join(axs[0], ax) # copied from source code, add to grouper 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 @@ -1222,14 +1251,14 @@ 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)[idx] == edge] + axs = [ax for ax in self._mainaxes if ax._range_gridspec(x)[idx] == edge] ord = [ax._range_gridspec(y)[0] for ax in axs] return [ax for _,ax in sorted(zip(ord, axs)) if ax.get_visible()] @@ -1303,7 +1332,7 @@ def _insert_row_column(self, side, idx, igridspec = subplotspec.get_gridspec() topmost = subplotspec.get_topmost_subplotspec() # Apply new subplotspec! - nrows, ncols, *coords = topmost.get_active_rows_columns() + nrows, ncols, *coords = topmost.get_rows_columns() for i in range(4): if inserts[i] is not None and coords[i] >= inserts[i]: coords[i] += 1 @@ -1325,7 +1354,7 @@ 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, + for ax in (*self._mainaxes, *self._lpanels, *self._rpanels, *self._bpanels, *self._tpanels): if not ax or not ax.get_visible(): continue @@ -1358,7 +1387,7 @@ def _update_axislabels(self, axis=None, **kwargs): # Apply to spanning axes and their panels axs = [ax] - if getattr(ax, '_span' + x + '_on'): + if getattr(self, '_span' + x): s = axis.get_label_position()[0] if s in 'lb': axs = ax._get_side_axes(s) @@ -1573,6 +1602,10 @@ def _update_suptitle(self, title, **kwargs): if kwargs: self._suptitle.update(kwargs) + def add_gridspec(self, *args, **kwargs): + """Docstring applied 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, @@ -1640,7 +1673,7 @@ def add_subplot(self, *args, if isinstance(args[0], SubplotSpec): subplotspec = args[0] elif isinstance(args[0], mgridspec.SubplotSpec): - raise ValueError(f'Invalid subplotspec {args[0]!r}. Figure.add_subplot() only accepts SubplotSpec objects generated by the ProPlot GridSpec class.') + 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])) @@ -1667,12 +1700,12 @@ def add_subplot(self, *args, self._initialize_geometry(nrows, ncols) self._update_geometry(nrows=nrows, ncols=ncols) gridspec = self._gridspec - elif (nrows, ncols) != gridspec.get_active_geometry(): + elif (nrows, ncols) != gridspec.get_geometry(): raise ValueError(f'Input arguments {args!r} conflict with existing gridspec geometry of {nrows} rows, {ncols} columns.') subplotspec = gridspec[(num[0] - 1):num[1]] else: if gridspec is None: - nrows, ncols, *_ = subplotspec.get_active_geometry() + nrows, ncols, *_ = subplotspec.get_geometry() gridspec = subplotspec.get_gridspec() self._initialize_geometry(nrows, ncols) self._update_geometry(nrows=nrows, ncols=ncols) @@ -1698,8 +1731,8 @@ def add_subplot(self, *args, projection=proj, number=number, **kwargs) if main: - ax.number = _notNone(number, len(self._axes_main) + 1) - self._axes_main.append(ax) + ax.number = _notNone(number, len(self._mainaxes) + 1) + self._mainaxes.append(ax) return ax def colorbar(self, *args, @@ -1759,6 +1792,35 @@ def colorbar(self, *args, row=row, col=col, rows=rows, cols=cols) return ax.colorbar(*args, loc='_fill', **kwargs) + def get_alignx(self): + """Returns the *x* axis label alignment mode.""" + return self._alignx + + def get_aligny(self): + """Returns the *y* axis label alignment mode.""" + return self._aligny + + def get_gridspec(self): + """Returns the single `GridSpec` instance associated with this figure. + If the `GridSpec` has not yet been initialized, returns ``None``.""" + return self._gridspec + + def get_sharex(self): + """Returns the *x* axis sharing level.""" + return self._sharex + + def get_sharey(self): + """Returns the *y* axis sharing level.""" + return self._sharey + + def get_spanx(self): + """Returns the *x* axis label spanning mode.""" + return self._spanx + + def get_spany(self): + """Returns the *y* axis label spanning mode.""" + return self._spany + def legend(self, *args, loc='r', width=None, space=None, row=None, col=None, rows=None, cols=None, span=None, @@ -1813,8 +1875,9 @@ def legend(self, *args, @_counter def draw(self, renderer): - """Before drawing the figure, applies "tight layout" and aspect - ratio-conserving adjustments, and aligns row and column labels.""" + """Draws the figure. Also scales figure dimensions to account for axes; + aligns row, column, and axis labels; and optionally applies "tight + layout" gridspec adjustments.""" # Renderer fixes # WARNING: *Critical* that draw() is invoked with the same renderer # FigureCanvasAgg.print_png() uses to render the image. But print_png() @@ -1823,11 +1886,11 @@ def draw(self, renderer): # 1. Could use 'get_renderer' to update 'canvas.renderer' with the new # figure width and height, then use that renderer for rest of draw # This repair *breaks* just the *macosx* popup backend and not the - # qt backend! So for now just employ simple exception if this is - # macosx backend. + # qt backend! For now just issue warning if this is macosx backend. # 2. Could set '_lastKey' on canvas and 'width' and 'height' on renderer, # but then '_renderer' was initialized with wrong width and height, # which causes bugs. And _renderer was generated with cython code + # so hard to see how it can be modified. # WARNING: Vector graphic renderers are another ballgame, *impossible* # to consistently apply successive figure size changes. SVGRenderer # and PDFRenderer both query the size in inches before calling draw, @@ -1849,19 +1912,8 @@ def draw(self, renderer): canvas.renderer = renderer return super().draw(renderer) - def savefig(self, filename, **kwargs): - """ - Before saving the figure, applies "tight layout" and aspect - ratio-conserving adjustments, and aligns row and column labels. - - Parameters - ---------- - filename : str - The file path. User directories are automatically - expanded, e.g. ``fig.save('~/plots/plot.png')``. - **kwargs - Passed to `~matplotlib.figure.Figure.savefig`. - """ + def save(self, filename, **kwargs): + """Docstring applied below.""" filename = os.path.expanduser(filename) canvas = getattr(self, 'canvas', None) if hasattr(canvas, 'get_renderer'): @@ -1876,12 +1928,69 @@ def savefig(self, filename, **kwargs): self._adjust_tight_layout(renderer) self._align_axislabels(True) else: - warnings.warn('Renderer unknown, could not adjust layout before saving.') + warnings.warn('Renderer is unknown, cannot adjust layout before saving.') super().savefig(filename, **kwargs) - save = savefig - """Alias for `~Figure.savefig`, because calling ``fig.savefig()`` - is sort of redundant.""" + def savefig(self, filename, **kwargs): + """Docstring applied below.""" + return self.save(filename, **kwargs) + + def set_alignx(self, value): + """Sets the *x* axis label alignment mode.""" + self.stale = True + self._alignx = bool(value) + + def set_aligny(self, value): + """Sets the *y* axis label alignment mode.""" + self.stale = True + self._aligny = bool(value) + + def set_gridspec(self, *args, **kwargs): + """Docstring applied below.""" + if self._gridspec is not None: + raise RuntimeError(f'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): + gridspec = args[0] + elif len(args) == 1 and isinstance(args[0], mgridspec.GridSpec): + raise ValueError(f'The gridspec must be a ProPlot GridSpec. Matplotlib gridspecs are not allowed.') + else: + gridspec = GridSpec(*args, **kwargs) + gridspec.add_figure(self) + self.stale = True + return gridspec + + def set_sharex(self, value): + """Sets the *x* axis sharing level.""" + value = int(value) + if value not in range(4): + raise ValueError(f'Invalid sharing level sharex={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') + self.stale = True + self._gridspecpars = [None]*6 # reset the defaults + self._sharex = value + + def set_sharey(self, value): + """Sets the *y* axis sharing level.""" + value = int(value) + if value not in range(4): + raise ValueError(f'Invalid sharing level sharey={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') + self.stale = True + self._gridspecpars = [None]*6 + self._sharey = value + + def set_spanx(self, value): + """Sets the *x* axis label spanning mode.""" + self.stale = True + self._spanx = bool(value) + + def set_spany(self, value): + """Sets the *y* axis label spanning mode.""" + self.stale = True + self._spany = bool(value) + + @property + def gridspec(self): + """The single `GridSpec` instance used for all subplots in the figure.""" + return self._gridspec @property def ref(self): @@ -1897,6 +2006,12 @@ def ref(self, ref): self.stale = True self._ref = ref + # Add documentation + save.__doc__ = _save_doc + savefig.__doc__ = _save_doc + add_gridspec.__doc__ = _gridspec_doc + set_gridspec.__doc__ = _gridspec_doc + #-----------------------------------------------------------------------------# # Main user interface and helper funcs #-----------------------------------------------------------------------------# diff --git a/proplot/utils.py b/proplot/utils.py index 9b2d7053f..ef032e915 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -169,7 +169,7 @@ def edges(array, axis=-1): array = np.swapaxes(array, axis, -1) return array -def units(value, output='in'): +def units(value, output='in', axes=None, figure=None, width=True): """ Converts values and lists of values between arbitrary physical units. This function is used internally all over ProPlot, permitting flexible units @@ -183,25 +183,35 @@ def units(value, output='in'): like ``'123.456unit'``, where the number is the magnitude and ``'unit'`` is one of the following. - ====== ========================================================================================= - Key Description - ====== ========================================================================================= - ``m`` Meters - ``cm`` Centimeters - ``mm`` Millimeters - ``ft`` Feet - ``in`` Inches - ``pt`` Points (1/72 inches) - ``px`` Pixels on screen, uses dpi of :rcraw:`figure.dpi` - ``pp`` Pixels once printed, uses dpi of :rcraw:`savefig.dpi` - ``em`` `Em square `__ for :rcraw:`font.size` - ``en`` `En square `__ for :rcraw:`font.size` - ``Em`` `Em square `__ for :rcraw:`axes.titlesize` - ``En`` `En square `__ for :rcraw:`axes.titlesize` - ====== ========================================================================================= + ======= ========================================================================================= + Key Description + ======= ========================================================================================= + ``m`` Meters + ``cm`` Centimeters + ``mm`` Millimeters + ``ft`` Feet + ``in`` Inches + ``pt`` Points (1/72 inches) + ``px`` Pixels on screen, uses dpi of :rcraw:`figure.dpi` + ``pp`` Pixels once printed, uses dpi of :rcraw:`savefig.dpi` + ``em`` `Em square `__ for :rcraw:`font.size` + ``en`` `En square `__ for :rcraw:`font.size` + ``Em`` `Em square `__ for :rcraw:`axes.titlesize` + ``En`` `En square `__ for :rcraw:`axes.titlesize` + ``ax`` Axes relative units. Not always available. + ``fig`` Figure relative units. Not always available. + ======= ========================================================================================= output : str, optional The output units. Default is inches, i.e. ``'in'``. + axes : `~matplotlib.axes.Axes`, optional + The axes to use for scaling units that look like ``0.1ax``. + figure : `~matplotlib.figure.Figure`, optional + The figure to use for scaling units that look like ``0.1fig``. If + ``None`` we try to get the figure from ``axes.figure``. + width : bool, optional + Whether to use the width or height for the axes and figure relative + coordinates. """ # Font unit scales # NOTE: Delay font_manager import, because want to avoid rebuilding font @@ -233,6 +243,13 @@ def units(value, output='in'): unit_dict['px'] = 1/rcParams['figure.dpi'] # once generated by backend if not isinstance(rcParams['savefig.dpi'], str): unit_dict['pp'] = 1/rcParams['savefig.dpi'] # once 'printed' i.e. saved + # Scales relative to axes and figure objects + if axes is not None and hasattr(axes, 'get_size_inches'): # proplot axes + unit_dict['ax'] = axes.get_size_inches()[1-int(width)] + if figure is None: + figure = getattr(axes, 'figure', None) + if figure is not None and hasattr(figure, 'get_size_inches'): # proplot axes + unit_dict['fig'] = fig.get_size_inches()[1-int(width)] # Scale for converting inches to arbitrary other unit try: scale = unit_dict[output] From 3dd52226e39b82c8e51dcc6cb60e4bdb2c22f9ed Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Wed, 6 Nov 2019 04:06:01 -0700 Subject: [PATCH 21/37] New geometry_configurator class, incomplete --- proplot/axes.py | 28 +- proplot/subplots.py | 1918 ++++++++++++++++++++++--------------------- proplot/utils.py | 61 +- 3 files changed, 1029 insertions(+), 978 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index 0ba44172c..0a7c7e0dd 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -43,7 +43,8 @@ colorbar_wrapper, legend_wrapper, ) try: - from cartopy.mpl.geoaxes import GeoAxes, CRS + from cartopy.mpl.geoaxes import GeoAxes + from cartopy.crs import CRS except ModuleNotFoundError: GeoAxes = object @@ -342,12 +343,12 @@ def inset_locator(ax, renderer): def _range_gridspec(self, x): """Gets the column or row range for the axes.""" - subplotspec = self.get_subplotspec() + ss = self.get_subplotspec() if x == 'x': - _, _, _, _, col1, col2 = subplotspec.get_rows_columns() + _, _, _, _, col1, col2 = ss.get_rows_columns() return col1, col2 else: - _, _, row1, row2, _, _ = subplotspec.get_rows_columns() + _, _, row1, row2, _, _ = ss.get_rows_columns() return row1, row2 def _range_tightbbox(self, x): @@ -933,25 +934,22 @@ def colorbar(self, *args, loc=None, pad=None, # 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, 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] - ax = self.figure.add_subplot(subplotspec, - main=False, projection=None) + ax = self.figure.add_subplot(gs[1], main=False, projection=None) self.add_child_axes(ax) # Location @@ -2416,18 +2414,22 @@ def twiny(self): altx.__doc__ = _alt_doc % { 'x':'x', 'x1':'bottom', 'x2':'top', 'y':'y', 'y1':'left', 'y2':'right', + 'args':', '.join(_twin_kwargs), } alty.__doc__ = _alt_doc % { 'x':'y', 'x1':'left', 'x2':'right', 'y':'x', 'y1':'bottom', 'y2':'top', + 'args':', '.join(_twin_kwargs), } twinx.__doc__ = _twin_doc % { 'x':'y', 'x1':'left', 'x2':'right', 'y':'x', 'y1':'bottom', 'y2':'top', + 'args':', '.join(_twin_kwargs), } twiny.__doc__ = _twin_doc % { 'x':'x', 'x1':'bottom', 'x2':'top', 'y':'y', 'y1':'left', 'y2':'right', + 'args':', '.join(_twin_kwargs), } dualx.__doc__ = _dual_doc % { 'x':'x', 'args':', '.join(_twin_kwargs) diff --git a/proplot/subplots.py b/proplot/subplots.py index d079d0e56..91de34cd9 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -12,6 +12,7 @@ import numpy as np import functools import warnings +from matplotlib import docstring import matplotlib.pyplot as plt import matplotlib.figure as mfigure import matplotlib.transforms as mtransforms @@ -56,6 +57,115 @@ 'aaas2': '12cm', # AAAS 2 column } +# 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 + 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. +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`. +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. +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`. +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. +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. +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 + =========== ==================== ============================================================================================================================== + ``'pnas1'`` 1-column `PNAS `__ + ``'pnas2'`` 2-column ” + ``'pnas3'`` landscape page ” + ``'ams1'`` 1-column `AMS `__ + ``'ams2'`` small 2-column ” + ``'ams3'`` medium 2-column ” + ``'ams4'`` full 2-column ” + ``'agu1'`` 1-column `AGU `__ + ``'agu2'`` 2-column ” + ``'agu3'`` full height 1-column ” + ``'agu4'`` full height 2-column ” + =========== ==================== ============================================================================================================================== + +**kwargs + Passed to `matplotlib.figure.Figure`. +""" +docstring.interpd.update(figure_doc=_figure_doc) + #-----------------------------------------------------------------------------# # Helper classes #-----------------------------------------------------------------------------# @@ -251,9 +361,14 @@ class GridSpec(mgridspec.GridSpec): Matplotlib `~matplotlib.gridspec.GridSpec` subclass that allows for grids with variable spacing between successive rows and columns of axes. """ + def __repr__(self): # do not show width and height ratios because we always fill them + nrows, ncols = self.get_geometry() + return f'GridSpec({nrows}, {ncols})' + def __init__(self, nrows=1, ncols=1, left=None, right=None, bottom=None, top=None, - **kwargs): + wspace=None, hspace=None, wratios=None, hratios=None, + width_ratios=None, height_ratios=None): """ Parameters ---------- @@ -283,85 +398,66 @@ def __init__(self, nrows=1, ncols=1, ``width_ratios=(1,2)`` scales a 2-column gridspec so that the second column is twice as wide as the first column. """ - # Basics + # 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) - # Ratios and spaces + # 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)) + 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) - # 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 - - - - - - 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) - left = _default_space('left', left) - right = _default_space('right', right) - top = _default_space('top', top) - bottom = _default_space('bottom', bottom) - wspace = _default_space('wspace', wspace, sharey) - hspace = _default_space('hspace', hspace, sharex) - super().__init__(self._nrows, self._ncols, - hspace=0, wspace=0, # we implement these as inactive rows/columns - width_ratios=wratios, - height_ratios=hratios, - **kwargs, - + def _sanitize_hspace(self, space): + """Sanitizes input wspace vector. This needs to be set apart from + set_wspace 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, but got {len(space)} hspaces.') + return space - def __getitem__(self, key): - """Magic obfuscation that renders `~matplotlib.gridspec.GridSpec` - rows and columns designated as 'spaces' inaccessible.""" - nrows, ncols = self.get_geometry() - nrows_active, ncols_active = self.get_geometry() - if not isinstance(key, tuple): # usage gridspec[1,2] - num1, num2 = self._normalize(key, nrows_active * ncols_active) - 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 mgridspec.SubplotSpec(self, num1, num2) + def _sanitize_wspace(self, space): + """Sanitizes input 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, but got {len(space)} wspaces.') + return space + filter = (space != None) + self.wspace[filter] = space[filter] def add_figure(self, figure): """Adds `~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, you passed {figure!r}.') + raise ValueError(f'add_figure() accepts only ProPlot Figure instances, you passed {type(figure)}.') self._figures.add(figure) def get_grid_positions(self, figure, raw=False): """Calculates grid positions using the input figure. Also scales the width and height ratios to figure relative coordinates.""" - # Get spacing arguments + # 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 @@ -372,29 +468,27 @@ def get_grid_positions(self, figure, raw=False): # 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. - # 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 nrows, ncols = self.get_geometry() if raw: left = bottom = 0 right = top = 1 - wspace = hspace = 0 - elif not isinstance(figure, Figure): - raise ValueError(f'get_grid_positions() accepts only ProPlot Figure instances, you passed {figure!r}.') + wspace = self._sanitize_wspace(0) + hspace = self._sanitize_hspace(0) else: + if not isinstance(figure, Figure): + raise ValueError(f'Invalid figure {figure!r}. Must be a ProPlot Figure instance.') width, height = figure.get_size_inches() - left = self.left / width - right = 1 - self.right / width - bottom = self.bottom / height - top = 1 - self.top / height - wspace = self.wspace / (width * ncols) - hspace = self.hspace / (height * nrows) - tot_width = right - left - tot_height = top - bottom + 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: @@ -420,88 +514,95 @@ def get_grid_positions(self, figure, raw=False): return fig_bottoms, fig_tops, fig_lefts, fig_rights def get_subplot_params(self, figure=None): - """Returns subplot parameters in inches. If `wspace` and `hspace` are - vectors, the largest values are used.""" - # First get parameters in inches - # NOTE: GridSpec.update() calls Axes.update_params() calls - # SubplotSpec.get_position() calls GridSpec.get_grid_positions() - # calls this. Not called *anywhere else in entire API*! Therefore - # we ignore and put all scaling code in get_grid_positions(). + """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.') def get_hspace(self): - """Returns row ratios allocated for spaces.""" + """Returns vector of row spaces.""" return self.hspace - def get_wspace(self): - """Returns column ratios allocated for spaces.""" - return self.wspace - def get_margins(self): - """Returns left, bottom, right, top values. Not sure why this method - doesn't already exist on `~matplotlib.gridspec.GridSpec`.""" + """Returns left, bottom, right, top margin spaces.""" return self.left, self.bottom, self.right, self.top + def get_wspace(self): + """Returns vector of column spaces.""" + return self.wspace + def remove_figure(self, figure): """Removes `~matplotlib.figure.Figure` from the list of figures that are using this gridspec.""" self._figures.discard(figure) def set_height_ratios(self, ratios): - """Sets row height ratios.""" + """Sets 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(hratios)} height ratios.') + raise ValueError(f'GridSpec has {N} rows, but got {len(ratios)} height ratios.') super().set_height_ratios(self) + def set_hspace(self, space): + """Sets 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 != None) + self.hspace[filter] = space[filter] + + def set_margins(self, left, right, bottom, top): + """Sets 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): - """Sets column width ratios.""" + """Sets 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 got {len(wratios)} width ratios.') + raise ValueError(f'GridSpec has {N} columns, but got {len(ratios)} width ratios.') super().set_width_ratios(self) - def set_hspace(self, space): - """Sets inter-column spaceing.""" - N = self._nrows - space = np.atleast_1d(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, but got {len(space)} hspaces.') - self.hspace = space - def set_wspace(self, space): - """Sets inter-column spaceing.""" - N = self._ncols - space = np.atleast_1d(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, but got {len(space)} wspaces.') - self.wspace = space + """Sets 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 != None) + self.wspace[filter] = space[filter] def tight_layout(self, *args, **kwargs): - """Method is disabled because ProPlot has its own simplified - tight layout algorithm.""" + """Method is disabled because ProPlot has its own tight layout + algorithm.""" raise NotImplementedError(f'Native matplotlib tight layout is disabled.') - def update(self, **kwargs): + 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): """ - Updates the width and height ratios, gridspec margins, and spacing - allocated between subplot rows and columns. + Optionally updates the gridspec with arbitrary initialization keyword + arguments then *applies* those updates for every figure using this + gridspec object. 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, tracks - the figures using this gridspec object, and applies updates to those + manager. ProPlot insists one gridspec per figure, tracks the figures + that are using this gridspec object, and applies updates to those tracked figures. Parameters @@ -509,16 +610,25 @@ def update(self, **kwargs): **kwargs Valid initialization keyword arguments. See `GridSpec`. """ - self.__init__(**kwargs) + # 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.subplotpars.update(self.left, self.bottom, self.right, self.top) + figure._geometryconfig._init() # in case gridspec values changed! for ax in figure.axes: ax.update_params() ax.set_position(ax.figbox) figure.stale = True #-----------------------------------------------------------------------------# -# Figure class and helper funcs +# Figure class and geometry class #-----------------------------------------------------------------------------# _gridspec_doc = """ Applies the `GridSpec` to the figure or generates a new `GridSpec` @@ -545,30 +655,31 @@ def _approx_equal(num1, num2, digits=10): hi, lo = 10**digits, 10**-digits return round(num1*hi)*lo == round(num2*hi)*lo -def _parse_panel(side, +def _get_panelargs(side, share=None, width=None, space=None, filled=False, figure=False): """Returns default properties for new axes and figure panels.""" s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid panel spec {side!r}.') - space_orig = units(space) - if filled: - default = rc['colorbar.width'] # default 'legend' width same as colorbar - else: - default = rc['subplots.panelwidth'] - share = _notNone(share, not filled) - width = units(_notNone(width, default)) - key = ('wspace' if s in 'lr' else 'hspace') - pad = (rc['axpad'] if figure else rc['panelpad']) - space = _default_space(key, space, share, pad=pad) - return share, width, space, space_orig - -def _default_space(key, space, share=0, pad=None): + space = space_user = units(space) + if share is None: + share = (not filled) + if width is None: + if filled: + width = rc['colorbar.width'] + else: + width = rc['subplots.panelwidth'] + width = units(width) + if space is None: + key = ('wspace' if s in 'lr' else 'hspace') + pad = (rc['axpad'] if figure else rc['panelpad']) + space = _get_space(key, share, pad=pad) + return share, width, space, space_user + +def _get_space(key, share=0, pad=None): """Returns suitable default spacing given a shared axes setting.""" - if space is not None: - space = units(space) - elif key == 'left': + if key == 'left': space = units(_notNone(pad, rc['subplots.pad'])) + ( rc['ytick.major.size'] + rc['ytick.labelsize'] + rc['ytick.major.pad'] + rc['axes.labelsize'])/72 @@ -600,363 +711,244 @@ def _default_space(key, space, share=0, pad=None): raise KeyError(f'Invalid space key {key!r}.') return space -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, - 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, - tight_layout=None, constrained_layout=None, - **kwargs): +class geometry_configurator(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 ---------- - 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 - =========== ==================== ========================================================================================================================================================== - ``'pnas1'`` 1-column `Proceedings of the National Academy of Sciences `__ - ``'pnas2'`` 2-column ” - ``'pnas3'`` landscape page ” - ``'ams1'`` 1-column `American Meteorological Society `__ - ``'ams2'`` small 2-column ” - ``'ams3'`` medium 2-column ” - ``'ams4'`` full 2-column ” - ``'agu1'`` 1-column `American Geophysical Union `__ - ``'agu2'`` 2-column ” - ``'agu3'`` full height 1-column ” - ``'agu4'`` full height 2-column ” - =========== ==================== ========================================================================================================================================================== - - 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. - tight : bool, optional - Toggles automatic tight layout adjustments. 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 `GridSpec` spacing parameters. Passed to `GridSpec` when - the first subplot is drawn. - 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. - 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. - **kwargs - Passed to `matplotlib.figure.Figure`. - - Other parameters - ---------------- - tight_layout, constrained_layout - Ignored, because ProPlot uses its own tight layout algorithm. - A warning will be issued if these are set to ``True``. + figure : `Figure` + The figure instance associated with this geometry configuration. """ - # Initialize first - if tight_layout or constrained_layout: - warnings.warn(f'Ignoring tight_layout={tight_layout} and 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) - warnings.warn(f'"alignx" has no effect when spanx=True.') - if spany and (aligny or align): - warnings.warn(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) + if not isinstance(figure, Figure): + raise ValueError(f'geometry_configurator() accepts only ProPlot Figure instances, you passed {type(figure)}.') + self._figure = figure + self._isinit = False + + def _init(self): + """Fills 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 geometry_configurator.') + + # 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 + + # """Resizes the figure based on current spacing values.""" + # def _update_gridspec(self, nrows=None, ncols=None, array=None, **kwargs): + def resize(self): + """Determines 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} and hratio={rhratio!r} for reference axes.') + aspect = self.aspect - # Figure and/or axes dimensions - names, values = (), () - if journal: - figsize = _journal_figsize(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}') + # 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 + 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: - spec.append(f'height={height!r}') - spec = ', '.join(spec) - names = ('axwidth', 'axheight') - values = (axwidth, axheight) - # Save dimensions - for name,value in zip(names,values): - if value is not None: - warnings.warn(f'You specified both {spec} and {name}={value!r}. Ignoring {name!r}.') - width, height = units(width), units(height) - axwidth, axheight = units(axwidth), units(axheight) - self._width, self._height = width, height - self._ref_width, self._ref_height = axwidth, axheight - - # Input border spacing - left, right = units(left), units(right) - bottom, top = units(bottom), units(top) - wspace, hspace = units(wspace), units(hspace) - self._gridspecpars = [None]*6 # current values - self._gridspecpars_orig = [left, bottom, right, top, wspace, hspace] + 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 - # Various constants and hidden settings - 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._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 + # 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.") - @_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 = _parse_panel(s, - filled=filled, figure=False, **kwargs) + # 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 - # Get gridspec and subplotspec indices - subplotspec = ax.get_subplotspec() - nrows, ncols, row1, row2, col1, col2 = subplotspec.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 + # Update figure size and gridspec + self.set_size_inches((width, height)) + 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, + ) else: - iratio = (row1 - offset if s == 't' else row2 + offset) - idx1 = iratio - idx2 = slice(col1, col2 + 1) - gridspec_prev = self._gridspec - 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 + 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, + ) + return self._gridspec - # Draw and setup panel - pax = self.add_subplot(gridspec[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) - getattr(axis, 'tick_' + side)() # sets tick and tick label positions intelligently - axis.set_label_position(side) - return pax - def _add_figure_panel(self, side, - span=None, row=None, col=None, rows=None, cols=None, - **kwargs): - """Adds figure panels. 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 = _parse_panel(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 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 on side {side!r}.') - span = _notNone(span, col, cols, None, names=('span', 'col', 'cols')) - - # Get props - if s in 'lr': - panels, nacross = self._wpanels, self._ncols - else: - panels, nacross = self._hpanels, self._nrows - array = getattr(self, '_' + s + 'array') - npanels, nalong = array.shape + def update(self, renderer=None): + """Updates the default values in case the spacing has changed.""" + fig = self.figure + gs = fig.gridspec + if gs is None: + raise ValueError(f'GridSpec has not been initialized yet. Cannot update geometry_configurator.') - # 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}. 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, - ) + # 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 - 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 + # Get renderer if not passed + canvas = getattr(fig, 'canvas', None) + if not hasattr(canvas, 'get_renderer'): + warnings.warn(f'Figure canvas has no get_renderer() method, cannot calculate positions.') + renderer = canvas.get_renderer() + canvas.renderer = renderer def _adjust_aspect(self): """Adjust 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 - axs = self._mainaxes - ref = self.ref - if not axs or ref > len(axs): + figure = self.figure + ax = figure.get_ref_axes() + if not ax: return - ax = axs[ref - 1] mode = ax.get_aspect() aspect = None if mode == 'equal': @@ -969,9 +961,9 @@ def _adjust_aspect(self): pass # matplotlib issues warning, forces aspect == 'auto' # Apply aspect # Account for floating point errors by rounding to 10 digits - if aspect is not None and not _approx_equal(aspect, self._ref_aspect): - self._ref_aspect = aspect - self._update_geometry() + if aspect is not None and not _approx_equal(aspect, self.aspect): + self.aspect = aspect + self._update_gridspec() def _adjust_tight_layout(self, renderer): """Applies tight layout scaling that permits flexible figure @@ -990,33 +982,23 @@ def _adjust_tight_layout(self, renderer): panelpad = self._panelpad nrows, ncols = gridspec.get_geometry() - # Get subplot params - gridspecpars = self._gridspecpars - left, right, bottom, top, wspace, hspace = self._gridspecpars - left_orig, right_orig, bottom_orig, top_orig, wspace_orig, hspace_orig = self._gridspecpars_orig - # Tight box *around* figure permitting user overrides - subplotpars.left = _notNone(left_orig, - left - bbox.xmin + pad) - subplotpars.right = _notNone(right_orig, - right - bbox.ymin + pad) - subplotpars.bottom = _notNone(bottom_orig, - bottom - (bbox_orig.xmax - bbox.xmax) + pad) - subplotpars.top = _notNone(top_orig, - top - (bbox_orig.ymax - bbox.ymax) + pad) + 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 = 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 @@ -1066,15 +1048,13 @@ def _adjust_tight_layout(self, renderer): jspaces.append((x2 - x1)/self.dpi) if jspaces: space = max(0, space - min(jspaces) + pad) # TODO: why max 0? - space = _notNone(space_orig, space) # only if user did not provide original space!!! jspace[i] = space spaces.append(jspace) - # Update subplotpars - subplotpars.wspace = spaces[0] - subplotpars.hspace = spaces[1] - # Update geometry - self._update_geometry() + # 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): """Aligns spanning *x* and *y* axis labels, accounting for figure @@ -1105,116 +1085,376 @@ def _align_axislabels(self, b=True): for _ in range(2): axs = [getattr(ax, '_share' + x) or ax for ax in axs] - # Adjust axis label offsets - axises = [getattr(ax, x + 'axis') for ax in axs] - tracker.update(axises) - for ax in axs[1:]: - grp.join(axs[0], ax) # copied from source code, add to grouper - if not span: - continue + # Adjust axis label offsets + axises = [getattr(ax, x + 'axis') for ax in axs] + tracker.update(axises) + for ax in axs[1:]: + grp.join(axs[0], ax) # copied from source code, add to grouper + if not span: + continue + + # Adjust axis label centering + c, spanax = self._get_align_coord(s, axs) + spanaxis = getattr(spanax, x + 'axis') + spanlabel = spanaxis.label + if not hasattr(spanlabel, '_orig_transform'): + spanlabel._orig_transform = spanlabel.get_transform() + spanlabel._orig_position = spanlabel.get_position() + if not b: # toggle off, done before tight layout + spanlabel.set_transform(spanlabel._orig_transform) + spanlabel.set_position(spanlabel._orig_position) + for axis in axises: + axis.label.set_visible(True) + else: # toggle on, done after tight layout + if x == 'x': + position = (c, 1) + transform = mtransforms.blended_transform_factory( + self.transFigure, mtransforms.IdentityTransform()) + else: + position = (1, c) + transform = mtransforms.blended_transform_factory( + mtransforms.IdentityTransform(), self.transFigure) + for axis in axises: + axis.label.set_visible((axis is spanaxis)) + spanlabel.update({'position':position, 'transform':transform}) + + def _align_suplabels(self, renderer): + """Adjusts position of row and column labels, and aligns figure + super title accounting for figure marins and axes and figure panels.""" + # Offset using tight bounding boxes + # TODO: Super labels fail with popup backend!! Fix this + # NOTE: Must use get_tightbbox so (1) this will work if tight layout + # mode if off and (2) actually need *two* tight bounding boxes when + # labels are present: 1 not including the labels, used to position + # them, and 1 including the labels, used to determine figure borders + suptitle = self._suptitle + suptitle_on = suptitle.get_text().strip() + width, height = self.get_size_inches() + for s in 'lrbt': + # Get axes and offset the label to relevant panel + x = ('x' if s in 'lr' else 'y') + axs = self._get_align_axes(s) + axs = [ax._reassign_suplabel(s) for ax in axs] + labels = [getattr(ax, '_' + s + 'label') for ax in axs] + coords = [None]*len(axs) + if s == 't' and suptitle_on: + supaxs = axs + with _hide_labels(*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) + 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! + if suptitle_on and supaxs: + ys = [] + for ax in supaxs: + bbox = ax.get_tightbbox(renderer) + _, y = self.transFigure.inverted().transform((0, bbox.ymax)) + ys.append(y) + x, _ = self._get_align_coord('t', supaxs) + y = max(ys) + (0.3*suptitle.get_fontsize()/72)/height + kw = {'x':x, 'y':y, 'ha':'center', 'va':'bottom', + 'transform':self.transFigure} + suptitle.update(kw) + + @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, + tight_layout=None, constrained_layout=None, + **kwargs): + """ + Parameters + ---------- + %(figure_doc)s + + Other parameters + ---------------- + tight_layout, constrained_layout + Ignored, because ProPlot uses its own tight layout algorithm. + A warning will be issued if these are set to ``True``. + """ + # Initialize first + if tight_layout or constrained_layout: + warnings.warn(f'Ignoring tight_layout={tight_layout} and 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): + warnings.warn(f'"alignx" has no effect when spanx=True.') + if spany and (aligny or align): + warnings.warn(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: + warnings.warn(f'You specified both {spec} and {name}={value!r}. 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 = geometry_configurator(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(f'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._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 + + @_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}.') + 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) + getattr(axis, 'tick_' + side)() # sets tick and tick label positions intelligently + axis.set_label_position(side) + return pax + + def _add_figure_panel(self, side, + span=None, row=None, col=None, rows=None, cols=None, + **kwargs): + """Adds figure panels. 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}.') + 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} 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} 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}. Coordinates must satisfy 1 <= c <= {nalong}.') + start, stop = span[0] - 1, span[1] # zero-indexed - # Adjust axis label centering - c, spanax = self._get_align_coord(s, axs) - spanaxis = getattr(spanax, x + 'axis') - spanlabel = spanaxis.label - if not hasattr(spanlabel, '_orig_transform'): - spanlabel._orig_transform = spanlabel.get_transform() - spanlabel._orig_position = spanlabel.get_position() - if not b: # toggle off, done before tight layout - spanlabel.set_transform(spanlabel._orig_transform) - spanlabel.set_position(spanlabel._orig_position) - for axis in axises: - axis.label.set_visible(True) - else: # toggle on, done after tight layout - if x == 'x': - position = (c, 1) - transform = mtransforms.blended_transform_factory( - self.transFigure, mtransforms.IdentityTransform()) - else: - position = (1, c) - transform = mtransforms.blended_transform_factory( - mtransforms.IdentityTransform(), self.transFigure) - for axis in axises: - axis.label.set_visible((axis is spanaxis)) - spanlabel.update({'position':position, 'transform':transform}) + # 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) - def _align_suplabels(self, renderer): - """Adjusts position of row and column labels, and aligns figure - super title accounting for figure marins and axes and figure panels.""" - # Offset using tight bounding boxes - # TODO: Super labels fail with popup backend!! Fix this - # NOTE: Must use get_tightbbox so (1) this will work if tight layout - # mode if off and (2) actually need *two* tight bounding boxes when - # labels are present: 1 not including the labels, used to position - # them, and 1 including the labels, used to determine figure borders - suptitle = self._suptitle - suptitle_on = suptitle.get_text().strip() - width, height = self.get_size_inches() - for s in 'lrbt': - # Get axes and offset the label to relevant panel - x = ('x' if s in 'lr' else 'y') - axs = self._get_align_axes(s) - axs = [ax._reassign_suplabel(s) for ax in axs] - labels = [getattr(ax, '_' + s + 'label') for ax in axs] - coords = [None]*len(axs) - if s == 't' and suptitle_on: - supaxs = axs - with _hide_labels(*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) - else: - c = max(coords) - for label in labels: - label.update({x: c}) + # 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, + ) - # Update super title position - # If no axes on the top row are visible, do not try to align! - if suptitle_on and supaxs: - ys = [] - for ax in supaxs: - bbox = ax.get_tightbbox(renderer) - _, y = self.transFigure.inverted().transform((0, bbox.ymax)) - ys.append(y) - x, _ = self._get_align_coord('t', supaxs) - y = max(ys) + (0.3*suptitle.get_fontsize()/72)/height - kw = {'x':x, 'y':y, 'ha':'center', 'va':'bottom', - 'transform':self.transFigure} - suptitle.update(kw) + # 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): """Returns figure coordinate for spanning labels and super title. The @@ -1269,86 +1509,84 @@ def _insert_row_column(self, side, idx, 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 - if not self._gridspec: - raise RuntimeError(f'Figure gridspec has not been initialized yet.') - panels = getattr(self, '_' + w + 'panels') - ratios = getattr(self, '_' + w + 'ratios') - spaces = getattr(self, '_' + w + 'space') - spaces_orig = getattr(self, '_' + w + 'space_orig') - - # Test if panel slot already exists - slot_name = ('f' if figure else s) - slot_exists = (idx not in (-1, len(panels)) and panels[idx] == slot_name) - if slot_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) - else: - 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) - - # Update geometry - if not slot_exists: - self._gridspec = None # reset - self._gridspec.remove_figure(self) - gridspec = self._update_geometry() # 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 not slot_exists: - 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! - nrows, ncols, *coords = topmost.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 - 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 + # # 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): """Iterates over all axes and panels in the figure belonging to the @@ -1397,183 +1635,9 @@ def _update_axislabels(self, axis=None, **kwargs): if pax is not None: # apply to panel? getattr(pax, x + 'axis').label.update(kwargs) - def _update_geometry(self, **kwargs): - """Saves 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 - kw = self._geometry_kw - kw.update(kwargs) - nrows, ncols = kw['nrows'], kw['ncols'] - aspect, xref, yref = kw['aspect'], kw['xref'], kw['yref'] - width, height = kw['width'], kw['height'] - axwidth, axheight = kw['axwidth'], kw['axheight'] - # Gridspec settings - wspace, hspace = kw['wspace'], kw['hspace'] - wratios, hratios = kw['wratios'], kw['hratios'] - left, bottom = kw['left'], kw['bottom'] - right, top = kw['right'], kw['top'] - - # Initialize some settings if this is a new gridspec - # Panel string toggles, lists containing empty strings '' or one of 'l', - # 'r', 'b', 't' (indicating axes panels) or 'f' (indicating figure panels) - if not self._gridspec: - 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 - wpanels = self._wpanels - hpanels = self._hpanels - - # Unfurl vectors - wratios = np.atleast_1d(wratios).tolist() - if len(wratios) == 1: - wratios = wratios * ncols - hratios = np.atleast_1d(hratios).tolist() - if len(hratios) == 1: - hratios = hratios * nrows - wspace = np.atleast_1d(wspace).tolist() - if len(wspace) == 1: - wspace = wspace * (ncols - 1) - hspace = np.atleast_1d(hspace).tolist() - if len(hspace) == 1: - hspace = hspace * (nrows - 1) - - # Checks, important now that we modify gridspec geometry - if len(hratios) != nrows: - raise ValueError(f'Expected {nrows} width ratios for {nrows} rows, got {len(hratios)}.') - if len(wratios) != ncols: - raise ValueError(f'Expected {ncols} width ratios for {ncols} columns, got {len(wratios)}.') - if len(hspace) != nrows - 1: - raise ValueError(f'Expected {nrows - 1} hspaces for {nrows} rows, got {len(hspace)}.') - if len(wspace) != ncols - 1: - raise ValueError(f'Expected {ncols - 1} wspaces for {ncols} columns, got {len(wspace)}.') - if len(hpanels) != nrows: - raise ValueError(f'Expected {nrows} hpanel toggles for {nrows} rows, got {len(hpanels)}.') - if len(wpanels) != ncols: - raise ValueError(f'Expected {ncols} wpanel toggles for {ncols} columns, got {len(wpanels)}.') - - # 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 is 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} 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 - 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) - 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 - - # Update figure size and gridspec - self.set_size_inches((width, height)) - 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, - ) - else: - 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, - ) - return self._gridspec - def _update_suplabels(self, ax, side, labels, **kwargs): - """Assigns side labels, updates label settings.""" + """Assigns side labels and updates label settings. The labels are + aligned down the line by geometry_configurators.""" s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid label side {side!r}.') @@ -1667,11 +1731,11 @@ def add_subplot(self, *args, # Copied from SubplotBase __init__ # Interpret positional args - gridspec = self._gridspec - subplotspec = None + gs = self._gridspec + ss = None if len(args) == 1: if isinstance(args[0], SubplotSpec): - subplotspec = args[0] + 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: @@ -1687,7 +1751,7 @@ def add_subplot(self, *args, # Initialize gridspec and subplotspec # Also enforce constant geometry - if subplotspec is None: + 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] @@ -1696,22 +1760,20 @@ def add_subplot(self, *args, raise ValueError(f'num must be 1 <= num <= {nrows*ncols}, not {num}') if not isinstance(num, tuple): num = (num, num) - if gridspec is None: - self._initialize_geometry(nrows, ncols) - self._update_geometry(nrows=nrows, ncols=ncols) - gridspec = self._gridspec - elif (nrows, ncols) != gridspec.get_geometry(): + 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.') - subplotspec = gridspec[(num[0] - 1):num[1]] + ss = gs[(num[0] - 1):num[1]] else: - if gridspec is None: - nrows, ncols, *_ = subplotspec.get_geometry() - gridspec = subplotspec.get_gridspec() - self._initialize_geometry(nrows, ncols) - self._update_geometry(nrows=nrows, ncols=ncols) - elif subplotspec.get_gridspec() is not gridspec: # also covers geometry discrepancies + 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: # also covers geometry discrepancies raise ValueError(f'Invalid subplotspec {args[0]!r}. Figure.add_subplot() only accepts SubplotSpec objects whose parent is the main gridspec.') - gridspec.add_figure(self) + gs.add_figure(self) # Impose projection # TODO: Have Proj return all unused keyword args, with a @@ -1727,9 +1789,7 @@ def add_subplot(self, *args, proj = 'basemap' if basemap else 'cartopy' # Return subplot - ax = super().add_subplot(subplotspec, - projection=proj, number=number, - **kwargs) + ax = super().add_subplot(ss, projection=proj, number=number, **kwargs) if main: ax.number = _notNone(number, len(self._mainaxes) + 1) self._mainaxes.append(ax) @@ -1805,6 +1865,14 @@ def get_gridspec(self): If the `GridSpec` has not yet been initialized, returns ``None``.""" return self._gridspec + def get_ref_axes(self): + """Returns the reference axes associated with the reference axes + number `Figure.ref`.""" + for ax in self._mainaxes: + if ax.number == self.ref: + return ax + return None # no error + def get_sharex(self): """Returns the *x* axis sharing level.""" return self._sharex @@ -1899,6 +1967,7 @@ def draw(self, renderer): # canvas has no renderer, so cannot apply tight layout yet! for ax in self._iter_axes(): ax._draw_auto_legends_colorbars() + self._fill_gridspecpars() self._adjust_aspect() self._align_axislabels(False) self._align_suplabels(renderer) @@ -1921,6 +1990,7 @@ def save(self, filename, **kwargs): canvas.renderer = renderer for ax in self._iter_axes(): ax._draw_auto_legends_colorbars() + self._fill_gridspecpars() self._adjust_aspect() self._align_axislabels(False) self._align_suplabels(renderer) @@ -1947,17 +2017,21 @@ def set_aligny(self, value): def set_gridspec(self, *args, **kwargs): """Docstring applied below.""" + # Create and apply the gridspec if self._gridspec is not None: raise RuntimeError(f'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): - gridspec = args[0] + gs = args[0] elif len(args) == 1 and isinstance(args[0], mgridspec.GridSpec): raise ValueError(f'The gridspec must be a ProPlot GridSpec. Matplotlib gridspecs are not allowed.') else: - gridspec = GridSpec(*args, **kwargs) - gridspec.add_figure(self) + gs = GridSpec(*args, **kwargs) + gs.add_figure(self) + ncols, nrows = gs.get_geometry() + self._gridspec = gs + self._geometryconfig._init() self.stale = True - return gridspec + return gs def set_sharex(self, value): """Sets the *x* axis sharing level.""" @@ -1965,7 +2039,6 @@ def set_sharex(self, value): if value not in range(4): raise ValueError(f'Invalid sharing level sharex={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') self.stale = True - self._gridspecpars = [None]*6 # reset the defaults self._sharex = value def set_sharey(self, value): @@ -1974,7 +2047,6 @@ def set_sharey(self, value): if value not in range(4): raise ValueError(f'Invalid sharing level sharey={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') self.stale = True - self._gridspecpars = [None]*6 self._sharey = value def set_spanx(self, value): @@ -2084,6 +2156,7 @@ def show(): plt.show() # TODO: Figure out how to save subplots keyword args! +@docstring.dedent_interpd def figure(**kwargs): """ Analogous to `matplotlib.pyplot.figure`, creates an empty figure meant @@ -2091,14 +2164,14 @@ def figure(**kwargs): Parameters ---------- + %(figure_doc)s **kwargs - Passed to `~matplotlib.figure.Figure`. + Passed to `Figure`. """ return plt.figure(FigureClass=Figure, **kwargs) -def subplots(array=None, ncols=1, nrows=1, - ref=1, order='C', - hspace=None, wspace=None, space=None, +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 @@ -2132,13 +2205,6 @@ def subplots(array=None, ncols=1, nrows=1, (``'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`). - hratios, wratios - Aliases for `height_ratios`, `width_ratios`. - width_ratios, height_ratios : float or list thereof, optional - Passed to `GridSpec`. 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. proj, projection : str or dict-like, optional The map projection name(s), passed to `~proplot.projs.Proj`. The argument is interpreted as follows. @@ -2170,8 +2236,10 @@ def subplots(array=None, ncols=1, nrows=1, 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 ------- @@ -2180,6 +2248,8 @@ def subplots(array=None, ncols=1, nrows=1, axs : `axes_grid` A special list of axes instances. See `axes_grid`. """ + # NOTE: White lie that spacing params are passed to figure, but since + # we initialize the gridspec here, just apply them to the gridspec. # Build array if order not in ('C','F'): # better error message raise ValueError(f'Invalid order {order!r}. Choose from "C" (row-major, default) and "F" (column-major).') @@ -2204,14 +2274,10 @@ def subplots(array=None, ncols=1, nrows=1, if ref not in nums: raise ValueError(f'Invalid reference number {ref!r}. For array {array!r}, must be 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! + # Get axes ranges from array axids = [np.where(array == i) for i in np.sort(np.unique(array)) if i > 0] # 0 stands for empty xrange = np.array([[x.min(), x.max()] for _,x in axids]) yrange = np.array([[y.min(), y.max()] for y,_ in axids]) # range accounting for panels - 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')) @@ -2220,26 +2286,6 @@ def subplots(array=None, ncols=1, nrows=1, proj_kw = _axes_dict(naxs, proj_kw, kw=True) basemap = _axes_dict(naxs, basemap, kw=False, default=False) - # 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)}.') - wspace, hspace = wspace.tolist(), hspace.tolist() - wspace_orig, hspace_orig = wspace, hspace - - # Default spaces between axes - wspace, hspace = np.array(wspace), np.array(hspace) # also copies! - wspace[wspace==None] = _default_space('wspace', None, sharey) - hspace[hspace==None] = _default_space('hspace', None, sharex) - wspace, hspace = wspace.tolist(), hspace.tolist() - # Standardized user input ratios wratios = np.atleast_1d(_notNone(width_ratios, wratios, 1, names=('width_ratios', 'wratios'))) @@ -2255,17 +2301,13 @@ def subplots(array=None, ncols=1, nrows=1, raise ValueError(f'Got {nrows} rows, but {len(hratios)} hratios.') wratios, hratios = wratios.tolist(), hratios.tolist() # also makes copy - # Parse arguments, fix dimensions in light of desired aspect ratio - # TODO: Fix xref and yref so update geometry gets them automatically - # from the reference axes! Then user can also change the reference axes - # which is pretty neat! + # 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) - fig._wspace_orig = wspace_orig - fig._hspace_orig = hspace_orig - gridspec = fig._update_geometry(nrows=nrows, ncols=ncols, - xref=xref, yref=yref, - wratios=wratios, hratios=hratios, wspace=wspace, hspace=hspace, - ) + gs = fig._update_gridspec(nrows=nrows, ncols=ncols, + left=left, right=right, bottom=bottom, top=top, + wratios=wratios, hratios=hratios) # Draw main subplots axs = naxs*[None] # list of axes @@ -2275,8 +2317,8 @@ def subplots(array=None, ncols=1, nrows=1, 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] - axs[idx] = fig.add_subplot(subplotspec, number=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]) diff --git a/proplot/utils.py b/proplot/utils.py index ef032e915..d4e421459 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -18,6 +18,7 @@ # Change this to turn on benchmarking BENCHMARK = False +NUMBER = re.compile('^([-+]?[0-9._]+([eE][-+]?[0-9_]+)?)(.*)$') # Benchmarking tools for developers class _benchmark(object): @@ -169,7 +170,7 @@ def edges(array, axis=-1): array = np.swapaxes(array, axis, -1) return array -def units(value, output='in', axes=None, figure=None, width=True): +def units(value, units='in', axes=None, figure=None, width=True): """ Converts values and lists of values between arbitrary physical units. This function is used internally all over ProPlot, permitting flexible units @@ -179,31 +180,33 @@ def units(value, output='in', axes=None, figure=None, width=True): ---------- value : float or str or list thereof A size specifier or *list thereof*. If numeric, nothing is done. - If string, it is converted to `output` units. The string should look + If string, it is converted to `units` units. The string should look like ``'123.456unit'``, where the number is the magnitude and ``'unit'`` is one of the following. - ======= ========================================================================================= - Key Description - ======= ========================================================================================= - ``m`` Meters - ``cm`` Centimeters - ``mm`` Millimeters - ``ft`` Feet - ``in`` Inches - ``pt`` Points (1/72 inches) - ``px`` Pixels on screen, uses dpi of :rcraw:`figure.dpi` - ``pp`` Pixels once printed, uses dpi of :rcraw:`savefig.dpi` - ``em`` `Em square `__ for :rcraw:`font.size` - ``en`` `En square `__ for :rcraw:`font.size` - ``Em`` `Em square `__ for :rcraw:`axes.titlesize` - ``En`` `En square `__ for :rcraw:`axes.titlesize` - ``ax`` Axes relative units. Not always available. - ``fig`` Figure relative units. Not always available. - ======= ========================================================================================= + ========= ========================================================================================= + Key Description + ========= ========================================================================================= + ``'m'`` Meters + ``'cm'`` Centimeters + ``'mm'`` Millimeters + ``'ft'`` Feet + ``'in'`` Inches + ``'pt'`` `Points `__ (1/72 inches) + ``'pc'`` `Pica `__ (1/6 inches) + ``'px'`` Pixels on screen, uses dpi of :rcraw:`figure.dpi` + ``'pp'`` Pixels once printed, uses dpi of :rcraw:`savefig.dpi` + ``'em'`` `Em square `__ for :rcraw:`font.size` + ``'en'`` `En square `__ for :rcraw:`font.size` + ``'Em'`` `Em square `__ for :rcraw:`axes.titlesize` + ``'En'`` `En square `__ for :rcraw:`axes.titlesize` + ``'ax'`` Axes relative units. Not always available. + ``'fig'`` Figure relative units. Not always available. + ``'ly'`` Light years ;) + ========= ========================================================================================= - output : str, optional - The output units. Default is inches, i.e. ``'in'``. + units : str, optional + The destination units. Default is inches, i.e. ``'in'``. axes : `~matplotlib.axes.Axes`, optional The axes to use for scaling units that look like ``0.1ax``. figure : `~matplotlib.figure.Figure`, optional @@ -232,10 +235,12 @@ def units(value, output='in', axes=None, figure=None, width=True): 'cm': 0.3937, 'mm': 0.03937, 'pt': 1/72.0, + 'pc': 1/6.0, 'em': small/72.0, 'en': 0.5*small/72.0, 'Em': large/72.0, 'En': 0.5*large/72.0, + 'ly': 3.725e+17, } # Scales for converting display units to inches # WARNING: In ipython shell these take the value 'figure' @@ -252,9 +257,9 @@ def units(value, output='in', axes=None, figure=None, width=True): unit_dict['fig'] = fig.get_size_inches()[1-int(width)] # Scale for converting inches to arbitrary other unit try: - scale = unit_dict[output] + scale = unit_dict[units] except KeyError: - raise ValueError(f'Invalid numeric unit {output!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') + raise ValueError(f'Invalid destination units {units!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') # Convert units for each value in list result = [] @@ -265,10 +270,12 @@ def units(value, output='in', axes=None, figure=None, width=True): continue elif not isinstance(val, str): raise ValueError(f'Size spec must be string or number or list thereof. Got {value!r}.') - regex = re.match('^([-+]?[0-9.]*)(.*)$', val) - num, unit = regex.groups() + regex = NUMBER.match(val) + if not regex: + raise ValueError(f'Invalid size spec {val!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') + number, _, units = regex.groups() # second group is exponential try: - result.append(float(num) * (unit_dict[unit]/scale if unit else 1)) + result.append(float(number) * (unit_dict[units]/scale if units else 1)) except (KeyError, ValueError): raise ValueError(f'Invalid size spec {val!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') if singleton: From 4ef57fa1ce86053a2d44ee676b2272826f72d178 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Wed, 6 Nov 2019 04:29:01 -0700 Subject: [PATCH 22/37] Make rc.get/fill/category 'context' keyword only --- proplot/axes.py | 88 +++++++++++++++++++++++----------------------- proplot/rctools.py | 6 ++-- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index 0a7c7e0dd..8658f5278 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -237,14 +237,14 @@ def _get_title_props(self, abc=False, loc=None): setting keyword arguments for the relevant title or a-b-c label at location `loc`.""" # Location string and position coordinates - cache = True + context = True prefix = 'abc' if abc else 'title' - loc = _notNone(loc, rc.get(f'{prefix}.loc', True)) + loc = _notNone(loc, rc.get(f'{prefix}.loc', context=True)) loc_prev = getattr(self, '_' + ('abc' if abc else 'title') + '_loc') # old if loc is None: loc = loc_prev elif loc_prev is not None and loc != loc_prev: - cache = False + context = False try: loc = self._loc_translate(loc) except KeyError: @@ -263,7 +263,7 @@ def _get_title_props(self, abc=False, loc=None): obj = self._titles_dict[loc] # New object else: - cache = False + context = False width, height = self.get_size_inches() if loc in ('upper center', 'lower center'): x, ha = 0.5, 'center' @@ -297,7 +297,7 @@ def _get_title_props(self, abc=False, loc=None): 'border': f'{prefix}.border', 'linewidth': f'{prefix}.linewidth', 'fontfamily': 'font.family', - }, cache) + }, context=True) if loc in ('left', 'right', 'center'): kw.pop('border', None) kw.pop('linewidth', None) @@ -625,11 +625,11 @@ def format(self, *, title=None, top=None, """ # Figure patch (for some reason needs to be re-asserted even if # declared before figure is drawn) - kw = rc.fill({'facecolor':'figure.facecolor'}, True) + kw = rc.fill({'facecolor':'figure.facecolor'}, context=True) self.figure.patch.update(kw) if top is not None: self._title_above_panel = top - pad = rc.get('axes.titlepad', True) + pad = rc.get('axes.titlepad', context=True) if pad is not None: self._set_title_offset_trans(pad) self._title_pad = pad @@ -646,7 +646,7 @@ def format(self, *, title=None, top=None, 'weight': 'suptitle.weight', 'color': 'suptitle.color', 'fontfamily': 'font.family' - }, True) + }, context=True) if suptitle or kw: fig._update_suptitle(suptitle, **kw) # Labels @@ -663,7 +663,7 @@ def format(self, *, title=None, top=None, 'weight': side + 'label.weight', 'color': side + 'label.color', 'fontfamily': 'font.family' - }) + }, context=True) if labels or kw: fig._update_suplabels(self, side, labels, **kw) @@ -671,7 +671,7 @@ def format(self, *, title=None, top=None, titles_dict = self._titles_dict if not self._panel_side: # Location and text - abcstyle = rc.get('abc.style', True) # changed or running format first time? + abcstyle = rc.get('abc.style', context=True) # changed or running format first time? if 'abcformat' in kwargs: # super sophisticated deprecation system abcstyle = kwargs.pop('abcformat') warnings.warn(f'The "abcformat" setting is deprecated. Please use "abcstyle".') @@ -699,7 +699,7 @@ def format(self, *, title=None, top=None, # Toggle visibility # NOTE: If abc is a matplotlib 'title' attribute, making it # invisible messes stuff up. Just set text to empty. - abc = rc.get('abc', True) + abc = rc.get('abc', context=True) if abc is not None: obj.set_text(self._abc_text if bool(abc) else '') @@ -1556,8 +1556,8 @@ def _parse_rcloc(x, string): # figures out string location """Converts *boolean* "left", "right", "top", and "bottom" rc settings to location *string*. Will return ``None`` if settings are unchanged.""" if x == 'x': - top = rc.get(f'{string}.top', True) - bottom = rc.get(f'{string}.bottom', True) + top = rc.get(f'{string}.top', context=True) + bottom = rc.get(f'{string}.bottom', context=True) if top is None and bottom is None: return None elif top and bottom: @@ -1569,8 +1569,8 @@ def _parse_rcloc(x, string): # figures out string location else: return 'neither' else: - left = rc.get(f'{string}.left', True) - right = rc.get(f'{string}.right', True) + left = rc.get(f'{string}.left', context=True) + right = rc.get(f'{string}.right', context=True) if left is None and right is None: return None elif left and right: @@ -1965,7 +1965,7 @@ def format(self, *, kw_face = rc.fill({ 'facecolor': 'axes.facecolor', 'alpha': 'axes.alpha' - }, True) + }, context=True) patch_kw = patch_kw or {} kw_face.update(patch_kw) self.patch.update(kw_face) @@ -1982,12 +1982,12 @@ def format(self, *, xminorlocator_kw = xminorlocator_kw or {} yminorlocator_kw = yminorlocator_kw or {} # Flexible keyword args, declare defaults - xmargin = _notNone(xmargin, rc.get('axes.xmargin', True)) - ymargin = _notNone(ymargin, rc.get('axes.ymargin', True)) - xtickdir = _notNone(xtickdir, rc.get('xtick.direction', True)) - ytickdir = _notNone(ytickdir, rc.get('ytick.direction', True)) - xtickminor = _notNone(xtickminor, rc.get('xtick.minor.visible', True)) - ytickminor = _notNone(ytickminor, rc.get('ytick.minor.visible', True)) + xmargin = _notNone(xmargin, rc.get('axes.xmargin', context=True)) + ymargin = _notNone(ymargin, rc.get('axes.ymargin', context=True)) + xtickdir = _notNone(xtickdir, rc.get('xtick.direction', context=True)) + ytickdir = _notNone(ytickdir, rc.get('ytick.direction', context=True)) + xtickminor = _notNone(xtickminor, rc.get('xtick.minor.visible', context=True)) + ytickminor = _notNone(ytickminor, rc.get('ytick.minor.visible', context=True)) xformatter = _notNone(xticklabels, xformatter, None, names=('xticklabels', 'xformatter')) yformatter = _notNone(yticklabels, yformatter, None, names=('yticklabels', 'yformatter')) xlocator = _notNone(xticks, xlocator, None, names=('xticks', 'xlocator')) @@ -1995,8 +1995,8 @@ def format(self, *, xminorlocator = _notNone(xminorticks, xminorlocator, None, names=('xminorticks', 'xminorlocator')) yminorlocator = _notNone(yminorticks, yminorlocator, None, names=('yminorticks', 'yminorlocator')) # Grid defaults are more complicated - grid = rc.get('axes.grid', True) - which = rc.get('axes.grid.which', True) + grid = rc.get('axes.grid', context=True) + which = rc.get('axes.grid.which', context=True) if which is not None or grid is not None: # if *one* was changed axis = rc['axes.grid.axis'] # always need this property if grid is None: @@ -2096,7 +2096,7 @@ def format(self, *, kw = rc.fill({ 'linewidth': 'axes.linewidth', 'color': 'axes.edgecolor', - }, True) + }, context=True) if color is not None: kw['color'] = color if linewidth is not None: @@ -2145,7 +2145,7 @@ def format(self, *, } for which,igrid in zip(('major', 'minor'), (grid,gridminor)): # Tick properties - kw_ticks = rc.category(x + 'tick.' + which, True) + kw_ticks = rc.category(x + 'tick.' + which, context=True) if kw_ticks is None: kw_ticks = {} else: @@ -2159,10 +2159,10 @@ def format(self, *, if igrid is not None: axis.grid(igrid, which=which) # toggle with special global props if which == 'major': - kw_grid = rc.fill(_grid_dict('grid'), True) + kw_grid = rc.fill(_grid_dict('grid'), context=True) else: kw_major = kw_grid - kw_grid = rc.fill(_grid_dict('gridminor'), True) + kw_grid = rc.fill(_grid_dict('gridminor'), context=True) kw_grid.update({key:value for key,value in kw_major.items() if key not in kw_grid}) # Changed rc settings axis.set_tick_params(which=which, **kw_grid, **kw_ticks) @@ -2209,7 +2209,7 @@ def format(self, *, 'labelcolor': 'tick.labelcolor', # new props 'labelsize': 'tick.labelsize', 'color': x + 'tick.color', - }, True) + }, context=True) if color: kw['color'] = color kw['labelcolor'] = color @@ -2229,7 +2229,7 @@ def format(self, *, kw = rc.fill({ 'fontfamily': 'font.family', 'weight': 'tick.labelweight' - }, True) + }, context=True) if rotation is not None: kw = {'rotation':rotation} if x == 'x': @@ -2250,7 +2250,7 @@ def format(self, *, 'fontsize': 'axes.labelsize', 'weight': 'axes.labelweight', 'fontfamily': 'font.family', - }, True) + }, context=True) if label is not None: kw['text'] = label if color: @@ -2587,7 +2587,7 @@ def format(self, *args, kw = rc.fill({ 'linewidth': 'axes.linewidth', 'color': 'axes.edgecolor', - }, True) + }, context=True) sides = ('inner','polar') if r == 'r' else ('start','end') spines = [self.spines[s] for s in sides] for spine,side in zip(spines,sides): @@ -2603,13 +2603,13 @@ def format(self, *args, 'grid_alpha': 'grid.alpha', 'grid_linewidth': 'grid.linewidth', 'grid_linestyle': 'grid.linestyle', - }, True) + }, context=True) axis.set_tick_params(which='both', **kw) # Label settings that can't be controlled with set_tick_params kw = rc.fill({ 'fontfamily': 'font.family', 'weight': 'tick.labelweight' - }, True) + }, context=True) for t in axis.get_ticklabels(): t.update(kw) @@ -2762,11 +2762,11 @@ def format(self, *, with rc.context(rc_kw, mode=rc_mode): # Parse alternative keyword args # TODO: Why isn't default latmax 80 respected sometimes? - lonlines = _notNone(lonlines, lonlocator, rc.get('geogrid.lonstep', True), names=('lonlines', 'lonlocator')) - latlines = _notNone(latlines, latlocator, rc.get('geogrid.latstep', True), names=('latlines', 'latlocator')) - latmax = _notNone(latmax, rc.get('geogrid.latmax', True)) - labels = _notNone(labels, rc.get('geogrid.labels', True)) - grid = _notNone(grid, rc.get('geogrid', True)) + lonlines = _notNone(lonlines, lonlocator, rc.get('geogrid.lonstep', context=True), names=('lonlines', 'lonlocator')) + latlines = _notNone(latlines, latlocator, rc.get('geogrid.latstep', context=True), names=('latlines', 'latlocator')) + latmax = _notNone(latmax, rc.get('geogrid.latmax', context=True)) + labels = _notNone(labels, rc.get('geogrid.labels', context=True)) + grid = _notNone(grid, rc.get('geogrid', context=True)) if labels: lonlabels = _notNone(lonlabels, 1) latlabels = _notNone(latlabels, 1) @@ -3035,7 +3035,7 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, 'color': 'geogrid.color', 'linewidth': 'geogrid.linewidth', 'linestyle': 'geogrid.linestyle', - }, True) + }, context=True) gl.collection_kwargs.update(kw) # Grid locations # TODO: Check eps @@ -3125,13 +3125,13 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # Update patch kw_face = rc.fill({ 'facecolor': 'geoaxes.facecolor' - }, True) + }, context=True) kw_face.update(patch_kw) self.background_patch.update(kw_face) kw_edge = rc.fill({ 'edgecolor': 'geoaxes.edgecolor', 'linewidth': 'geoaxes.linewidth' - }, True) + }, context=True) self.outline_patch.update(kw_edge) def _hide_labels(self): @@ -3301,10 +3301,10 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, kw_edge = rc.fill({ 'linewidth': 'geoaxes.linewidth', 'edgecolor': 'geoaxes.edgecolor' - }, True) + }, context=True) kw_face = rc.fill({ 'facecolor': 'geoaxes.facecolor' - }, True) + }, context=True) patch_kw = patch_kw or {} kw_face.update(patch_kw) self.axesPatch = self.patch # bugfix or something diff --git a/proplot/rctools.py b/proplot/rctools.py index 285ccbb54..079477c82 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -766,7 +766,7 @@ def _get_item(self, key, mode=None): else: return None - def category(self, cat, context=False): + def category(self, cat, *, context=False): """ Returns a dictionary of settings belonging to the indicated category, i.e. settings beginning with the substring ``cat + '.'``. @@ -872,7 +872,7 @@ def dict(self): output[key] = self[key] return output - def get(self, key, context=False): + def get(self, key, *, context=False): """ Returns a setting. @@ -887,7 +887,7 @@ def get(self, key, context=False): mode = 0 if not context else None return self._get_item(key, mode) - def fill(self, props, context=False): + def fill(self, props, *, context=False): """ Returns a dictionary filled with `rc` settings, used internally to build dictionaries for updating `~matplotlib.artist.Artist` instances. From 3205443f3f4e3b926f2536c87a7905e8cbcf5f7c Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sun, 10 Nov 2019 14:53:42 -0500 Subject: [PATCH 23/37] Misc cleanup, add EdgeStack class Tmp --- proplot/__init__.py | 2 +- proplot/axes.py | 33 ++++++++++--------- proplot/rctools.py | 79 +++++++++++++++++++++++---------------------- proplot/subplots.py | 74 ++++++++++++++++++++++++++---------------- proplot/utils.py | 10 +++--- 5 files changed, 108 insertions(+), 90 deletions(-) diff --git a/proplot/__init__.py b/proplot/__init__.py index 313e4b66b..29dadb2a7 100644 --- a/proplot/__init__.py +++ b/proplot/__init__.py @@ -35,9 +35,9 @@ def _warning_proplot(message, category, filename, lineno, line=None): # WARNING: Import order is meaningful! Loads modules that are dependencies # of other modules last, and loads styletools early so we can try to update # TTFPATH before the fontManager is loaded by other matplotlib modules +from .utils import * from .utils import _benchmark with _benchmark('total time'): - from .utils import * with _benchmark('styletools'): # colors and fonts from .styletools import * with _benchmark('rctools'): # custom configuration implementation diff --git a/proplot/axes.py b/proplot/axes.py index 8658f5278..988efc338 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -44,7 +44,6 @@ ) try: from cartopy.mpl.geoaxes import GeoAxes - from cartopy.crs import CRS except ModuleNotFoundError: GeoAxes = object @@ -297,7 +296,7 @@ def _get_title_props(self, abc=False, loc=None): 'border': f'{prefix}.border', 'linewidth': f'{prefix}.linewidth', 'fontfamily': 'font.family', - }, context=True) + }, context=context) if loc in ('left', 'right', 'center'): kw.pop('border', None) kw.pop('linewidth', None) @@ -356,7 +355,7 @@ def _range_tightbbox(self, x): which are not considered real children and so aren't ordinarily included in the tight bounding box calc. `~proplot.axes.Axes.get_tightbbox` caches tight bounding boxes when `~Figure.get_tightbbox` is called.""" - # TODO: Better resting for axes visibility + # TODO: Better testing for axes visibility if x == 'x': return self._tight_bbox.xmin, self._tight_bbox.xmax else: @@ -1964,7 +1963,7 @@ def format(self, *, self.patch.set_zorder(-1) kw_face = rc.fill({ 'facecolor': 'axes.facecolor', - 'alpha': 'axes.alpha' + 'alpha': 'axes.facealpha' }, context=True) patch_kw = patch_kw or {} kw_face.update(patch_kw) @@ -3124,14 +3123,15 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # Update patch kw_face = rc.fill({ - 'facecolor': 'geoaxes.facecolor' + 'facecolor': 'geoaxes.facecolor', + 'alpha': 'geoaxes.facealpha', }, context=True) - kw_face.update(patch_kw) - self.background_patch.update(kw_face) kw_edge = rc.fill({ 'edgecolor': 'geoaxes.edgecolor', - 'linewidth': 'geoaxes.linewidth' + 'linewidth': 'geoaxes.linewidth', }, context=True) + kw_face.update(patch_kw or {}) + self.background_patch.update(kw_face) self.outline_patch.update(kw_edge) def _hide_labels(self): @@ -3298,15 +3298,15 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # edges/fill color disappear # * For now will enforce that map plots *always* have background # whereas axes plots can have transparent background + kw_face = rc.fill({ + 'facecolor': 'geoaxes.facecolor', + 'alpha': 'geoaxes.facealpha', + }, context=True) kw_edge = rc.fill({ 'linewidth': 'geoaxes.linewidth', - 'edgecolor': 'geoaxes.edgecolor' - }, context=True) - kw_face = rc.fill({ - 'facecolor': 'geoaxes.facecolor' + 'edgecolor': 'geoaxes.edgecolor', }, context=True) - patch_kw = patch_kw or {} - kw_face.update(patch_kw) + kw_face.update(patch_kw or {}) self.axesPatch = self.patch # bugfix or something if self.projection.projection in self._proj_non_rectangular: self.patch.set_alpha(0) # make patch invisible @@ -3314,12 +3314,13 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, p = self.projection.drawmapboundary(ax=self) # set fill_color to 'none' to make transparent else: p = self.projection._mapboundarydrawn - p.update({**kw_face, **kw_edge}) + p.update(kw_face) + p.update(kw_edge) p.set_rasterized(False) 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/rctools.py b/proplot/rctools.py index 079477c82..b13e8501e 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -41,11 +41,13 @@ ================ ==================================================================================================================================================================================================================================== Key Description ================ ==================================================================================================================================================================================================================================== -``nbsetup`` Whether to run `nb_setup` on import. Can only be changed from the ``~/.proplotrc`` file. -``format`` The inline backend figure format, one of ``retina``, ``png``, ``jpeg``, ``pdf``, or ``svg``. Can only be changed from the ``~/.proplotrc`` file. +``abc`` Boolean, indicates whether to draw a-b-c labels by default. ``autosave`` If not empty or ``0`` and :rcraw:`nbsetup` is ``True``, passed to `%autosave `__. Can only be changed from the ``~/.proplotrc`` file. ``autoreload`` If not empty or ``0`` and :rcraw:`nbsetup` is ``True``, passed to `%autoreload `__. Can only be changed from the ``~/.proplotrc`` file. -``abc`` Boolean, indicates whether to draw a-b-c labels by default. +``alpha`` The opacity of the background axes patch. +``facecolor`` The color of the background axes patch. +``format`` The inline backend figure format, one of ``retina``, ``png``, ``jpeg``, ``pdf``, or ``svg``. Can only be changed from the ``~/.proplotrc`` file. +``nbsetup`` Whether to run `notebook_setup` on import. Can only be changed from the ``~/.proplotrc`` file. ``tight`` Boolean, indicates whether to auto-adjust figure bounds and subplot spacings. ``share`` The axis sharing level, one of ``0``, ``1``, ``2``, or ``3``. See `~proplot.subplots.subplots` for details. ``align`` Whether to align axis labels during draw. See `aligning labels `__. @@ -56,8 +58,6 @@ ``cycle`` The default color cycle name, used e.g. for lines. ``rgbcycle`` If ``True``, and ``colorblind`` is the current cycle, this registers the ``colorblind`` colors as ``'r'``, ``'b'``, ``'g'``, etc., like in `seaborn `__. ``color`` The color of axis spines, tick marks, tick labels, and labels. -``alpha`` The opacity of the background axes patch. -``facecolor`` The color of the background axes patch. ``small`` Font size for legend text, tick labels, axis labels, and text generated with `~matplotlib.axes.Axes.text`. ``large`` Font size for titles, "super" titles, and a-b-c subplot labels. ``linewidth`` Thickness of axes spines and major tick lines. @@ -95,14 +95,15 @@ `~proplot.axes.Axes.colorbar` properties. The new ``gridminor`` category controls minor gridline settings, -and the new ``geogrid`` category controls meridian and parallel line settings -for `~proplot.axes.ProjectionAxes`. For both ``gridminor`` and ``geogrid``, if -a property is empty, the corresponding property from ``grid`` is used. +and the new ``geogrid`` category controls `~proplot.axes.ProjectionAxes` +meridian and parallel line settings. For both ``gridminor`` and ``geogrid``, +if a property is empty, the corresponding property from ``grid`` is used. Finally, the ``geoaxes``, ``land``, ``ocean``, ``rivers``, ``lakes``, -``borders``, and ``innerborders`` categories control various -`~proplot.axes.ProjectionAxes` settings. These are used when the boolean -toggles for the corresponding :ref:`rcParamsShort` settings are turned on. +``borders``, and ``innerborders`` categories control properties for +`~proplot.axes.ProjectionAxes` geographic features. These are used when the +boolean toggles for the corresponding :ref:`rcParamsShort` settings are turned +on. =================================================================== ========================================================================================================================================================================================================================================================= Key(s) Description @@ -112,6 +113,7 @@ ``abc.border`` Boolean, indicates whether to draw a white border around a-b-c labels inside an axes. ``abc.linewidth`` Width of the white border around a-b-c labels. ``abc.color``, ``abc.size``, ``abc.weight`` Font color, size, and weight for a-b-c labels. +``axes.facealpha`` Transparency of the axes background. ``axes.formatter.zerotrim`` Boolean, indicates whether trailing decimal zeros are trimmed on tick labels. ``axes.formatter.timerotation`` Float, indicates the default *x* axis tick label rotation for datetime tick labels. ``borders.color``, ``borders.linewidth`` Line color and linewidth for country border lines. @@ -176,10 +178,10 @@ :literal: """ -# TODO: Add 'style' setting that overrides .proplotrc +# TODO: Add 'style' option that overrides .proplotrc # Adapted from seaborn; see: https://github.com/mwaskom/seaborn/blob/master/seaborn/rcmod.py from . import utils -from .utils import _counter, _timer, _benchmark +from .utils import _counter, _timer import re import os import yaml @@ -187,17 +189,15 @@ import warnings import matplotlib.colors as mcolors import matplotlib.cm as mcm -with _benchmark('pyplot'): - import matplotlib.pyplot as plt try: import IPython get_ipython = IPython.get_ipython except ModuleNotFoundError: get_ipython = lambda: None -__all__ = ['rc', 'rc_configurator', 'nb_setup'] +__all__ = ['rc', 'rc_configurator', 'notebook_setup'] # Initialize -from matplotlib import rcParams +from matplotlib import style, rcParams defaultParamsShort = { 'nbsetup': True, 'format': 'retina', @@ -239,9 +239,9 @@ 'innerborders': False, } defaultParamsLong = { - 'abc.loc': 'l', # left side above the axes 'title.loc': 'c', # centered above the axes 'title.pad': 3.0, # copy + 'abc.loc': 'l', # left side above the axes 'abc.style': 'a', 'abc.size': None, # = large 'abc.color': 'k', @@ -273,7 +273,7 @@ 'bottomlabel.color': 'k', 'image.edgefix': True, 'image.levels': 11, - 'axes.alpha': None, # if empty, depends on 'savefig.transparent' setting + 'axes.facealpha': None, # if empty, depends on 'savefig.transparent' setting 'axes.formatter.zerotrim': True, 'axes.formatter.timerotation': 90, 'axes.gridminor': True, @@ -293,6 +293,7 @@ 'geogrid.linestyle': ': ', 'geoaxes.linewidth': None, # = linewidth 'geoaxes.facecolor': None, # = facecolor + 'geoaxes.facealpha': None, # = alpha 'geoaxes.edgecolor': None, # = color 'land.color': 'k', 'ocean.color': 'w', @@ -323,32 +324,23 @@ 'subplots.panelpad': '0.5em', } defaultParams = { - 'figure.dpi': 90, - 'figure.facecolor': '#f2f2f2', - 'figure.autolayout': False, - 'figure.titleweight': 'bold', - 'figure.max_open_warning': 0, - 'savefig.directory': '', - 'savefig.dpi': 300, - 'savefig.facecolor': 'white', - 'savefig.transparent': True, - 'savefig.format': 'pdf', - 'savefig.bbox': 'standard', - 'savefig.pad_inches': 0.0, 'axes.titleweight': 'normal', 'axes.xmargin': 0.0, 'axes.ymargin': 0.0, 'axes.grid': True, 'axes.labelpad': 3.0, 'axes.titlepad': 3.0, + 'figure.dpi': 90, + 'figure.facecolor': '#f2f2f2', + 'figure.autolayout': False, + 'figure.titleweight': 'bold', + 'figure.max_open_warning': 0, 'grid.color': 'k', 'grid.alpha': 0.1, 'grid.linewidth': 0.6, 'grid.linestyle': '-', 'hatch.color': 'k', 'hatch.linewidth': 0.6, - 'lines.linewidth': 1.3, - 'lines.markersize': 3.0, 'legend.frameon': True, 'legend.framealpha': 0.8, 'legend.fancybox': False, @@ -358,11 +350,20 @@ 'legend.columnspacing': 1.0, 'legend.borderpad': 0.5, 'legend.borderaxespad': 0, - 'xtick.minor.visible': True, - 'ytick.minor.visible': True, + 'lines.linewidth': 1.3, + 'lines.markersize': 3.0, 'mathtext.bf': 'sans:bold', 'mathtext.it': 'sans:it', 'mathtext.default': 'regular', + 'savefig.directory': '', + 'savefig.dpi': 300, + 'savefig.facecolor': 'white', + 'savefig.transparent': True, + 'savefig.format': 'pdf', + 'savefig.bbox': 'standard', + 'savefig.pad_inches': 0.0, + 'xtick.minor.visible': True, + 'ytick.minor.visible': True, } rcParamsShort = {} rcParamsLong = {} @@ -401,7 +402,7 @@ def _tabulate(rcdict): 'fontname': ('font.family',), 'cmap': ('image.cmap',), 'lut': ('image.lut',), - 'alpha': ('axes.alpha',), # this is a custom setting + 'alpha': ('axes.facealpha', 'geoaxes.facealpha'), # this is a custom setting 'facecolor': ('axes.facecolor', 'geoaxes.facecolor'), 'color': ('axes.edgecolor', 'geoaxes.edgecolor', 'axes.labelcolor', 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color'), # change the 'color' of an axes 'small': ('font.size', 'tick.labelsize', 'xtick.labelsize', 'ytick.labelsize', 'axes.labelsize', 'legend.fontsize', 'geogrid.labelsize'), # the 'small' fonts @@ -628,7 +629,7 @@ def __init__(self, local=True): """ # Attributes and style object.__setattr__(self, '_context', []) - plt.style.use('default') + style.use('default') # Update from defaults rcParamsLong.clear() @@ -992,7 +993,7 @@ def values(self): # Ipython notebook behavior @_timer -def nb_setup(): +def notebook_setup(): """ Sets up your iPython workspace, called on import if :rcraw:`nbsetup` is ``True``. For all iPython sessions, passes :rcraw:`autoreload` to the useful @@ -1043,7 +1044,7 @@ def nb_setup(): # Setup notebook and issue warning # TODO: Add to list of incompatible backends? if rcParamsShort['nbsetup']: - nb_setup() + notebook_setup() if rcParams['backend'][:2] == 'nb' or rcParams['backend'] in ('MacOSX',): warnings.warn(f'Due to automatic figure resizing, using ProPlot with the {rcParams["backend"]!r} backend may result in unexpected behavior. Try using %matplotlib inline or %matplotlib qt, or just import ProPlot before specifying the backend and ProPlot will automatically load it.') diff --git a/proplot/subplots.py b/proplot/subplots.py index 91de34cd9..f3524985a 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -13,7 +13,6 @@ import functools import warnings from matplotlib import docstring -import matplotlib.pyplot as plt import matplotlib.figure as mfigure import matplotlib.transforms as mtransforms import matplotlib.gridspec as mgridspec @@ -22,12 +21,18 @@ import matplotlib.backends.backend_macosx as mbackend except ImportError: mbackend = None -from .rctools import rc -from .utils import _notNone, _counter, units from . import projs, axes +from .rctools import rc +from .utils import _notNone, _counter, _benchmark, units +with _benchmark('pyplot'): + import matplotlib.pyplot as plt __all__ = [ - 'axes_grid', 'close', 'figure', - 'Figure', 'GridSpec', + 'axes_grid', 'close', + 'EdgeStack', + 'figure', + 'Figure', + 'GeometrySolver', + 'GridSpec', 'show', 'subplots', 'SubplotSpec', ] @@ -711,7 +716,16 @@ def _get_space(key, share=0, pad=None): raise KeyError(f'Invalid space key {key!r}.') return space -class geometry_configurator(object): +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), args) + +class GeometrySolver(object): """ ProPlot's answer to the matplotlib `~matplotlib.figure.SubplotParams` class. When `tight` is ``False``, this object is filled with sensible @@ -727,7 +741,7 @@ def __init__(self, figure): The figure instance associated with this geometry configuration. """ if not isinstance(figure, Figure): - raise ValueError(f'geometry_configurator() accepts only ProPlot Figure instances, you passed {type(figure)}.') + raise ValueError(f'GeometrySolver() accepts only ProPlot Figure instances, you passed {type(figure)}.') self._figure = figure self._isinit = False @@ -751,7 +765,7 @@ def _init(self): fig = self.figure gs = self._gridspec if gs is None: - raise RuntimeError(f'GridSpec is not present. Cannot initialize geometry_configurator.') + 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 @@ -913,7 +927,7 @@ def update(self, renderer=None): fig = self.figure gs = fig.gridspec if gs is None: - raise ValueError(f'GridSpec has not been initialized yet. Cannot update geometry_configurator.') + raise ValueError(f'GridSpec has not been initialized yet. Cannot update GeometrySolver.') # Adjust aspect ratio ax = fig.get_ref_axes() @@ -1286,7 +1300,7 @@ def __init__(self, # self._dimensions = (units(width), units(height), units(axwidth), units(axheight), aspect) if np.iterable(aspect): aspect = aspect[0]/aspect[1] - self._geometryconfig = geometry_configurator(width, height, axwidth, axheight, aspect) + 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(f'Invalid spacing parameter. Must be scalar when passed to Figure().') @@ -1841,16 +1855,19 @@ def colorbar(self, *args, *args, **kwargs Passed to `~proplot.axes.Axes.colorbar`. """ - if 'cax' in kwargs: - return super().colorbar(*args, **kwargs) - elif 'ax' in kwargs: - return kwargs.pop('ax').colorbar(*args, - space=space, width=width, **kwargs) - else: - ax = self._add_figure_panel(loc, - space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols) - return ax.colorbar(*args, loc='_fill', **kwargs) + ax = kwargs.pop('ax', None) + cax = kwargs.pop('cax', None) + # Fill this axes + if cax is not None: + return super().colorbar(*args, cax=cax, **kwargs) + # Generate axes panel + elif ax is not None: + return ax.colorbar(*args, space=space, width=width, **kwargs) + # Generate figure panel + ax = self._add_figure_panel(loc, + space=space, width=width, span=span, + row=row, col=col, rows=rows, cols=cols) + return ax.colorbar(*args, loc='_fill', **kwargs) def get_alignx(self): """Returns the *x* axis label alignment mode.""" @@ -1932,14 +1949,15 @@ def legend(self, *args, *args, **kwargs Passed to `~proplot.axes.Axes.legend`. """ - if 'ax' in kwargs: - return kwargs.pop('ax').legend(*args, - space=space, width=width, **kwargs) - else: - ax = self._add_figure_panel(loc, - space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols) - return ax.legend(*args, loc='_fill', **kwargs) + ax = kwargs.pop('ax', None) + # Generate axes panel + if ax is not None: + return ax.legend(*args, space=space, width=width, **kwargs) + # Generate figure panel + ax = self._add_figure_panel(loc, + space=space, width=width, span=span, + row=row, col=col, rows=rows, cols=cols) + return ax.legend(*args, loc='_fill', **kwargs) @_counter def draw(self, renderer): diff --git a/proplot/utils.py b/proplot/utils.py index d4e421459..266956eb0 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -16,17 +16,16 @@ ic = lambda *a: None if not a else (a[0] if len(a) == 1 else a) # noqa __all__ = ['arange', 'edges', 'units'] -# Change this to turn on benchmarking -BENCHMARK = False NUMBER = re.compile('^([-+]?[0-9._]+([eE][-+]?[0-9_]+)?)(.*)$') +BENCHMARK = False -# Benchmarking tools for developers class _benchmark(object): - """Timer object that can be used to time things.""" + """Context object that can be used to time import statements.""" def __init__(self, message): self.message = message def __enter__(self): - self.time = time.clock() + if BENCHMARK: + self.time = time.clock() def __exit__(self, *args): if BENCHMARK: print(f'{self.message}: {time.clock() - self.time}s') @@ -72,7 +71,6 @@ def decorator(*args, **kwargs): decorator.count = 0 # initialize return decorator -# Important private helper func def _notNone(*args, names=None): """Returns the first non-``None`` value, used with keyword arg aliases and for setting default values. Ugly name but clear purpose. Pass the `names` From 7cc81a0f21b4c258f4e38d5cea9ffe03b92e5750 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 11 Nov 2019 16:23:13 -0700 Subject: [PATCH 24/37] Imperative mood for all docstrings --- proplot/axes.py | 231 ++++++++++++++++++++-------------------- proplot/axistools.py | 109 ++++++++++--------- proplot/rctools.py | 131 ++++++++++------------- proplot/styletools.py | 237 +++++++++++++++++++++++------------------- proplot/subplots.py | 183 ++++++++++++++++---------------- proplot/utils.py | 50 ++++----- 6 files changed, 467 insertions(+), 474 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index 988efc338..0405a8e87 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -101,10 +101,11 @@ def _abc(i): # Wrapper generator def _disable_decorator(msg): - """Generates decorators that disable methods. Also sets __doc__ to None so - that ProPlot fork of automodapi doesn't add these methods to the website - documentation. Users can still call help(ax.method) because python looks - for superclass method docstrings if a docstring is empty.""" + """Return a decorator that disables methods with message `msg`. The + docstring is set to ``None`` so the ProPlot fork of automodapi doesn't add + these methods to the website documentation. Users can still call + help(ax.method) because python looks for superclass method docstrings if a + docstring is empty.""" def decorator(func): @functools.wraps(func) def _wrapper(self, *args, **kwargs): @@ -117,7 +118,7 @@ def _wrapper(self, *args, **kwargs): # Generalized custom axes class #-----------------------------------------------------------------------------# def _parse_format(mode=2, rc_kw=None, **kwargs): - """Separates `~proplot.rctools.rc` setting name value pairs from + """Separate `~proplot.rctools.rc` setting name value pairs from `~Axes.format` keyword arguments.""" kw = {} rc_kw = rc_kw or {} @@ -195,7 +196,7 @@ def _draw_auto_legends_colorbars(self): self._auto_colorbar = {} def _get_side_axes(self, side): - """Returns axes whose left, right, top, or bottom side abutts + """Return the axes whose left, right, top, or bottom sides abutt against the same row or column as this axes.""" s = side[0] if s not in 'lrbt': @@ -213,10 +214,9 @@ def _get_side_axes(self, side): return axs def _get_extent_axes(self, x): - """Returns axes whose horizontal or vertical extent in the main + """Return the axes whose horizontal or vertical extent in the main gridspec matches the horizontal or vertical extend of this axes. - Also sorts the list so the leftmost or bottommost axes is at the - start of the list.""" + The lefmost or bottommost axes are at the start of the list.""" if not hasattr(self, 'get_subplotspec'): return [self] y = ('y' if x == 'x' else 'x') @@ -232,9 +232,9 @@ def _get_extent_axes(self, x): return [pax, *axs] def _get_title_props(self, abc=False, loc=None): - """Returns standardized location name, position keyword arguments, and - setting keyword arguments for the relevant title or a-b-c label at location - `loc`.""" + """Return the standardized location name, position keyword arguments, + and setting keyword arguments for the relevant title or a-b-c label at + location `loc`.""" # Location string and position coordinates context = True prefix = 'abc' if abc else 'title' @@ -303,7 +303,7 @@ def _get_title_props(self, abc=False, loc=None): return loc, obj, kw def _iter_panels(self, sides='lrbt'): - """Iterates over axes and child panel axes.""" + """Return a list of axes and child panel axes.""" axs = [self] if self.get_visible() else [] if not ({*sides} <= {*'lrbt'}): raise ValueError(f'Invalid sides {sides!r}.') @@ -316,7 +316,8 @@ def _iter_panels(self, sides='lrbt'): @staticmethod def _loc_translate(loc, default=None): - """Translates location string `loc` into a standardized form.""" + """Return the location string `loc` translated into a standardized + form.""" if loc in (None, True): loc = default elif isinstance(loc, (str, Integral)): @@ -331,7 +332,7 @@ def _loc_translate(loc, default=None): return loc def _make_inset_locator(self, bounds, trans): - """Helper function, copied from private matplotlib version.""" + """Return a locator that determines inset axes bounds.""" def inset_locator(ax, renderer): bbox = mtransforms.Bbox.from_bounds(*bounds) bb = mtransforms.TransformedBbox(bbox, trans) @@ -341,7 +342,9 @@ def inset_locator(ax, renderer): return inset_locator def _range_gridspec(self, x): - """Gets the column or row range for the axes.""" + """Return the column or row gridspec range for the axes.""" + if not hasattr(self, 'get_subplotspec'): + raise RuntimeError(f'Axes is not a subplot.') ss = self.get_subplotspec() if x == 'x': _, _, _, _, col1, col2 = ss.get_rows_columns() @@ -351,10 +354,11 @@ def _range_gridspec(self, x): return row1, row2 def _range_tightbbox(self, x): - """Gets span of tight bounding box, including twin axes and panels - which are not considered real children and so aren't ordinarily included in - the tight bounding box calc. `~proplot.axes.Axes.get_tightbbox` caches - tight bounding boxes when `~Figure.get_tightbbox` is called.""" + """Return the tight bounding box span, including twin axes and panels + which are not considered real children and so aren't ordinarily + included in the tight bounding box calculation. + `~proplot.axes.Axes.get_tightbbox` caches tight bounding boxes when + `~Figure.get_tightbbox` is called.""" # TODO: Better testing for axes visibility if x == 'x': return self._tight_bbox.xmin, self._tight_bbox.xmax @@ -362,11 +366,13 @@ def _range_tightbbox(self, x): return self._tight_bbox.ymin, self._tight_bbox.ymax def _reassign_suplabel(self, side): - """Re-assigns the column and row labels to panel axes, if they exist. - This is called by `~proplot.subplots.Figure._align_suplabel`.""" + """Re-assign the column and row labels to the relevant panel if + present. This is called by `~proplot.subplots.Figure._align_suplabel`. + """ # 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: @@ -391,13 +397,15 @@ def _reassign_suplabel(self, side): return pax def _reassign_title(self): - """Re-assigns title to the first upper panel if present. We cannot - simply add upper panel as child axes, because then title will be offset - but still belong to main axes, which messes up tight bounding box.""" + """Re-assign the title to the first upper panel if present. We cannot + simply add the upper panel as a child axes, because then the title will + be offset but still belong to main axes, which messes up the tight + bounding box.""" # Reassign title from main axes to top panel -- works when this is # 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: @@ -433,7 +441,8 @@ def _reassign_title(self): tax._set_title_offset_trans(self._title_pad + pad) def _sharex_setup(self, sharex, level=None): - """Sets up panel axis sharing.""" + """Configure x-axis sharing for panels. Main axis sharing is done in + `~CartesianAxes._sharex_setup`.""" if level is None: level = self.figure._sharex if level not in range(4): @@ -444,7 +453,8 @@ def _sharex_setup(self, sharex, level=None): self._share_long_axis(sharex, 't', level) def _sharey_setup(self, sharey, level): - """Sets up panel axis sharing.""" + """Configure y-axis sharing for panels. Main axis sharing is done in + `~CartesianAxes._sharey_setup`.""" if level is None: level = self.figure._sharey if level not in range(4): @@ -455,8 +465,8 @@ def _sharey_setup(self, sharey, level): self._share_long_axis(sharey, 'r', level) def _share_setup(self): - """Applies axis sharing for axes that share the same horizontal or - vertical extent, and for their panels.""" + """Automatically configure axis sharing based on the horizontal and + vertical extent of subplots in the figure gridspec.""" # Panel axes sharing, between main subplot and its panels shared = lambda paxs: [pax for pax in paxs if not pax._panel_filled and pax._panel_share] if not self._panel_side: # this is a main axes @@ -521,7 +531,8 @@ def _share_long_axis(self, share, side, level): getattr(pax, '_share' + axis + '_setup')(share, level) def _update_title(self, obj, **kwargs): - """Redraws title if updating with input keyword args failed.""" + """Redraw the title if updating with the input keyword arguments + failed.""" # Try to just return updated object, redraw may be necessary # WARNING: Making text instances invisible seems to mess up tight # bounding box calculations and cause other issues. Just reset text. @@ -550,9 +561,9 @@ def format(self, *, title=None, top=None, **kwargs, ): """ - Called by `CartesianAxes.format`, `ProjectionAxes.format`, and - `PolarAxes.format`. Formats the axes title(s), the a-b-c label, row - and column labels, and the figure title. + Modify the axes title(s), the a-b-c label, row and column labels, and + the figure title. Called by `CartesianAxes.format`, + `ProjectionAxes.format`, and `PolarAxes.format`. Parameters ---------- @@ -760,14 +771,11 @@ def cmapline(self, *args, values=None, cmap=None, norm=None, interp=0, **kwargs): """ - Invoked when you pass the `cmap` keyword argument to - `~matplotlib.axes.Axes.plot`. Draws a "colormap line", - i.e. a line whose color changes as a function of the parametric - coordinate ``values``. using the input colormap ``cmap``. - - This is actually a collection of lines, added as a - `~matplotlib.collections.LineCollection` instance. See `this matplotlib example - `__. + Draw a "colormap line" whose color changes as a function of the + parametric coordinate ``values`` using the input colormap ``cmap``. + Invoked when you pass ``cmap`` to `~matplotlib.axes.Axes.plot`. + Returns a `~matplotlib.collections.LineCollection` instance. See + `this matplotlib example `__. Parameters ---------- @@ -851,8 +859,8 @@ def colorbar(self, *args, loc=None, pad=None, alpha=None, linewidth=None, edgecolor=None, facecolor=None, **kwargs): """ - Adds colorbar as an *inset* or along the outside edge of the axes. - See `~proplot.wrappers.colorbar_wrapper` for details. + Add an *inset* colorbar or *outer* colorbar along the outside edge of + the axes. See `~proplot.wrappers.colorbar_wrapper` for details. Parameters ---------- @@ -1051,7 +1059,7 @@ def colorbar(self, *args, loc=None, pad=None, def legend(self, *args, loc=None, width=None, space=None, **kwargs): """ - Adds an *inset* legend or *outer* legend along the edge of the axes. + Add an *inset* legend or *outer* legend along the edge of the axes. See `~proplot.wrappers.legend_wrapper` for details. Parameters @@ -1134,29 +1142,31 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): return legend_wrapper(self, *args, loc=loc, **kwargs) def draw(self, renderer=None, *args, **kwargs): - """Adds post-processing steps before axes is drawn.""" + """Perform post-processing steps then draw the axes.""" self._reassign_title() super().draw(renderer, *args, **kwargs) def get_size_inches(self): - """Returns the width and the height of the axes in inches.""" + """Return the width and the height of the axes in inches. Similar + to `~matplotlib.Figure.get_size_inches`.""" width, height = self.figure.get_size_inches() width = width*abs(self.get_position().width) height = height*abs(self.get_position().height) return width, height def get_tightbbox(self, renderer, *args, **kwargs): - """Adds post-processing steps before tight bounding box is - calculated, and stores the bounding box as an attribute.""" + """Perform post-processing steps, return the tight bounding box + surrounding axes artists, and cache the bounding box as an attribute. + """ self._reassign_title() bbox = super().get_tightbbox(renderer, *args, **kwargs) self._tight_bbox = bbox return bbox def heatmap(self, *args, **kwargs): - """Calls `~matplotlib.axes.Axes.pcolormesh` and applies default formatting - that is suitable for heatmaps: no gridlines, no minor ticks, and major - ticks at the center of each grid box.""" + """Pass all arguments to `~matplotlib.axes.Axes.pcolormesh` then apply + settings that are suitable for heatmaps: no gridlines, no minor ticks, + and major ticks at the center of each grid box.""" obj = self.pcolormesh(*args, **kwargs) xlocator, ylocator = None, None if hasattr(obj, '_coordinates'): # be careful in case private API changes! but this is only way to infer coordinates @@ -1173,8 +1183,8 @@ def heatmap(self, *args, **kwargs): def inset_axes(self, bounds, *, transform=None, zorder=5, zoom=True, zoom_kw=None, **kwargs): """ - Like the builtin `~matplotlib.axes.Axes.inset_axes` method, but - draws an inset `CartesianAxes` axes and adds some options. + Return an inset `CartesianAxes`. This is similar to the builtin + `~matplotlib.axes.Axes.inset_axes` but includes some extra options. Parameters ---------- @@ -1228,11 +1238,11 @@ def indicate_inset_zoom(self, alpha=None, lw=None, linewidth=None, color=None, edgecolor=None, **kwargs): """ - Called automatically when using `~Axes.inset` with ``zoom=True``. - Like `~matplotlib.axes.Axes.indicate_inset_zoom`, but *refreshes* the - lines at draw-time. - - This method is called from the *inset* axes, not the parent axes. + Draw lines indicating the zoom range of the inset axes. This is similar + to the builtin `~matplotlib.axes.Axes.indicate_inset_zoom` except + lines are *refreshed* at draw-time. This is also called automatically + when ``zoom=True`` is passed to `~Axes.inset_axes`. Note this method + must be called from the *inset* axes and not the parent axes. Parameters ---------- @@ -1279,7 +1289,7 @@ def indicate_inset_zoom(self, alpha=None, def panel_axes(self, side, **kwargs): """ - Returns a panel drawn along the edge of an axes. + Return a panel axes drawn along the edge of this axes. Parameters ---------- @@ -1315,8 +1325,8 @@ def violins(self, *args, **kwargs): @property def number(self): - """The axes number, controls a-b-c label order and order of - appearence in the `~proplot.subplots.axes_grid` returned by + """The axes number. This controls the order of a-b-c labels and the + order of appearence in the `~proplot.subplots.axes_grid` returned by `~proplot.subplots.subplots`.""" return self._number @@ -1431,7 +1441,7 @@ def number(self, num): ) _dual_doc = """ -Makes a secondary *%(x)s* axis for denoting equivalent *%(x)s* +Return a secondary *%(x)s* axis for denoting equivalent *%(x)s* coordinates in *alternate units*. Parameters @@ -1464,9 +1474,10 @@ def number(self, num): """ _alt_doc = """ -Alias and more intuitive name for `~CartesianAxes.twin%(y)s`. -The matplotlib `~matplotlib.axes.Axes.twiny` function -generates two *x* axes with a shared ("twin") *y* axis. +Return an axes in the same location as this one but whose %(x)s axis is on +the %(x2)s. This is an alias and more intuitive name for +`~CartesianAxes.twin%(y)s`, which confusingly generates two *%(x)s* axes with +a shared ("twin") *%(y)s* axes. Parameters ---------- @@ -1489,7 +1500,7 @@ def number(self, num): """ _twin_doc = """ -Mimics matplotlib's `~matplotlib.axes.Axes.twin%(y)s`. +Mimics the builtin `~matplotlib.axes.Axes.twin%(y)s` method. Parameters ---------- @@ -1511,7 +1522,7 @@ def number(self, num): """ def _parse_alt(x, kwargs): - """Interprets keyword args passed to all "twin axis" methods so they + """Interpret keyword args passed to all "twin axis" methods so they can be passed to Axes.format.""" kw_bad, kw_out = {}, {} for key,value in kwargs.items(): @@ -1527,7 +1538,7 @@ def _parse_alt(x, kwargs): return kwargs def _parse_transform(transform, transform_kw): - """Interprets the dualx and dualy transform. Returns the forward and + """Interpret the dualx and dualy transform and return the forward and inverse transform functions and keyword args passed to the FuncScale.""" # NOTE: Do not support arbitrary transforms, because transforms are a huge # group that include ND and non-invertable transformations, but transforms @@ -1552,8 +1563,8 @@ def _parse_transform(transform, transform_kw): return funcscale_funcs, funcscale_kw def _parse_rcloc(x, string): # figures out string location - """Converts *boolean* "left", "right", "top", and "bottom" rc settings to - location *string*. Will return ``None`` if settings are unchanged.""" + """Convert the *boolean* "left", "right", "top", and "bottom" rc settings + to a location string. Returns ``None`` if settings are unchanged.""" if x == 'x': top = rc.get(f'{string}.top', context=True) bottom = rc.get(f'{string}.bottom', context=True) @@ -1607,7 +1618,7 @@ def __init__(self, *args, **kwargs): self._dualx_data = None def _altx_overrides(self): - """Applies alternate *x* axis overrides.""" + """Apply alternate *x* axis overrides.""" # Unlike matplotlib API, we strong arm user into certain twin axes # settings... doesn't really make sense to have twin axes without this if self._altx_child is not None: # altx was called on this axes @@ -1627,7 +1638,7 @@ def _altx_overrides(self): self.patch.set_visible(False) def _alty_overrides(self): - """Applies alternate *y* axis overrides.""" + """Apply alternate *y* axis overrides.""" if self._alty_child is not None: self._shared_x_axes.join(self, self._alty_child) self.spines['right'].set_visible(False) @@ -1645,7 +1656,7 @@ def _alty_overrides(self): self.patch.set_visible(False) def _datex_rotate(self): - """Applies default rotation to datetime axis coordinates.""" + """Apply default rotation to datetime axis coordinates.""" # NOTE: Rotation is done *before* horizontal/vertical alignment, # cannot change alignment with set_tick_params. Must apply to text # objects. fig.autofmt_date calls subplots_adjust, so cannot use it. @@ -1661,7 +1672,7 @@ def _datex_rotate(self): self._datex_rotated = True # do not need to apply more than once def _dualx_overrides(self): - """Locks child "dual" *x* axis limits to the parent.""" + """Lock the child "dual" *x* axis limits to the parent.""" # Why did I copy and paste the dualx/dualy code you ask? Copy # pasting is bad, but so are a bunch of ugly getattr(attr)() calls data = self._dualx_data @@ -1697,7 +1708,7 @@ def _dualx_overrides(self): child.set_xlim(nlim) def _dualy_overrides(self): - """Locks child "dual" *y* axis limits to the parent.""" + """Lock the child "dual" *y* axis limits to the parent.""" data = self._dualy_data if data is None: return @@ -1723,9 +1734,9 @@ def _dualy_overrides(self): child.set_ylim(nlim) def _hide_labels(self): - """Function called at drawtime that enforces "shared" axis and - tick labels. If this is not called at drawtime, "shared" labels can - be inadvertantly turned off e.g. when the axis scale is changed.""" + """Enforce the "shared" axis labels and axis tick labels. If this is + not called at drawtime, "shared" labels can be inadvertantly turned + off e.g. when the axis scale is changed.""" for x in 'xy': # "Shared" axis and tick labels axis = getattr(self, x + 'axis') @@ -1741,8 +1752,8 @@ def _hide_labels(self): axis.set_minor_formatter(mticker.NullFormatter()) def _make_twin_axes(self, *args, **kwargs): - """Makes a twin axes of self. This is used for twinx and twiny. Copied - from matplotlib in case the API changes.""" + """Return a twin of this axes. This is used for twinx and twiny and was + copied from matplotlib in case the private API changes.""" # Typically, SubplotBase._make_twin_axes is called instead of this. # There is also an override in axes_grid1/axes_divider.py. if 'sharex' in kwargs and 'sharey' in kwargs: @@ -1754,8 +1765,8 @@ def _make_twin_axes(self, *args, **kwargs): return ax2 def _sharex_setup(self, sharex, level): - """Sets up shared axes. The input is the 'parent' axes, from which - this one will draw its properties.""" + """Configure shared axes accounting for panels. The input is the + 'parent' axes, from which this one will draw its properties.""" # Call Axes method super()._sharex_setup(sharex, level) # sets up panels if sharex in (None,self) or not isinstance(sharex, CartesianAxes): @@ -1767,8 +1778,8 @@ def _sharex_setup(self, sharex, level): self._shared_x_axes.join(self, sharex) def _sharey_setup(self, sharey, level): - """Sets up shared axes. The input is the 'parent' axes, from which - this one will draw its properties.""" + """Configure shared axes accounting for panels. The input is the + 'parent' axes, from which this one will draw its properties.""" # Call Axes method super()._sharey_setup(sharey, level) if sharey in (None,self) or not isinstance(sharey, CartesianAxes): @@ -1813,9 +1824,9 @@ def format(self, *, patch_kw=None, **kwargs): """ - Calls `Axes.format` and `Axes.context`, formats the - *x* and *y* axis labels, tick locations, tick labels, - axis scales, spine settings, and more. + Modify the *x* and *y* axis labels, tick locations, tick labels, + axis scales, spine settings, and more. Unknown keyword arguments + are passed to `Axes.format` and `Axes.context`. Parameters ---------- @@ -2315,7 +2326,7 @@ def format(self, *, super().format(**kwargs) def altx(self, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" # Cannot wrap twiny() because we want to use CartesianAxes, not # matplotlib Axes. Instead use hidden method _make_twin_axes. # See https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py @@ -2336,7 +2347,7 @@ def altx(self, **kwargs): return ax def alty(self, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" if self._alty_child: raise RuntimeError('No more than *two* twin axes!') if self._alty_parent: @@ -2353,7 +2364,7 @@ def alty(self, **kwargs): return ax def dualx(self, transform, transform_kw=None, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" # The axis scale is used to transform units on the left axis, linearly # spaced, to units on the right axis... so the right scale must scale # its data with the *inverse* of this transform. We do this below. @@ -2368,14 +2379,14 @@ def dualx(self, transform, transform_kw=None, **kwargs): return self.altx(**kwargs) def dualy(self, transform, transform_kw=None, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" funcscale_funcs, funcscale_kw = _parse_transform(transform, transform_kw) self._dualy_data = (funcscale_funcs, funcscale_kw) self._dualy_overrides() return self.alty(**kwargs) def draw(self, renderer=None, *args, **kwargs): - """Adds post-processing steps before axes is drawn.""" + """Perform post-processing steps then draw the axes.""" # NOTE: This mimics matplotlib API, which calls identical # post-processing steps in both draw() and get_tightbbox() self._hide_labels() @@ -2389,8 +2400,7 @@ def draw(self, renderer=None, *args, **kwargs): super().draw(renderer, *args, **kwargs) def get_tightbbox(self, renderer, *args, **kwargs): - """Adds post-processing steps before tight bounding box is - calculated.""" + """Perform post-processing steps then return the tight bounding box.""" self._hide_labels() self._altx_overrides() self._alty_overrides() @@ -2402,11 +2412,11 @@ def get_tightbbox(self, renderer, *args, **kwargs): return super().get_tightbbox(renderer, *args, **kwargs) def twinx(self): - """Docstring applied below.""" + """This docstring is replaced below.""" return self.alty() def twiny(self): - """Docstring applied below.""" + """This docstring is replaced below.""" return self.altx() # Add documentation @@ -2468,10 +2478,10 @@ def format(self, *args, thetaformatter_kw=None, rformatter_kw=None, **kwargs): """ - Calls `Axes.format` and `Axes.context`, formats radial gridline - locations, gridline labels, limits, and more. All ``theta`` arguments - are specified in *degrees*, not radians. The below parameters are - specific to `PolarAxes`. + Modify radial gridline locations, gridline labels, limits, and more. + Unknown keyword arguments are passed to `Axes.format` and + `Axes.context`. All ``theta`` arguments are specified in *degrees*, not + radians. The below parameters are specific to `PolarAxes`. Parameters ---------- @@ -2698,9 +2708,9 @@ def format(self, *, patch_kw=None, **kwargs, ): """ - Calls `Axes.format` and `Axes.context`, formats the meridian - and parallel labels, longitude and latitude map limits, geographic - features, and more. + Modify the meridian and parallel labels, longitude and latitude map + limits, geographic features, and more. Unknown keyword arguments are + passed to `Axes.format` and `Axes.context`. Parameters ---------- @@ -2948,7 +2958,7 @@ def __init__(self, *args, map_projection=None, **kwargs): def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, lonlines, latlines, latmax, lonarray, latarray): - """Applies formatting to cartopy axes.""" + """Apply changes to the cartopy axes.""" # Imports import cartopy.feature as cfeature import cartopy.crs as ccrs @@ -3135,13 +3145,12 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, self.outline_patch.update(kw_edge) def _hide_labels(self): - """No-op for now. In future will hide meridian and parallel labels - for rectangular projections.""" + """No-op for now. In future this will hide meridian and parallel + labels for rectangular projections.""" pass def get_tightbbox(self, renderer, *args, **kwargs): - """Draw gridliner objects so tight bounding box algorithm will - incorporate gridliner labels.""" + """Draw the gridliner objects then return the tight bounding box.""" self._hide_labels() if self.get_autoscale_on() and self.ignore_existing_data_limits: self.autoscale_view() @@ -3236,8 +3245,8 @@ def projection(self, map_projection): class BasemapAxes(ProjectionAxes): """Axes subclass for plotting `~mpl_toolkits.basemap` projections. The `~mpl_toolkits.basemap.Basemap` projection instance is added as - the `map_projection` attribute, but this is all abstracted away -- you can use - `~matplotlib.axes.Axes` methods like `~matplotlib.axes.Axes.plot` and + the `map_projection` attribute, but this is all abstracted away -- you can + use `~matplotlib.axes.Axes` methods like `~matplotlib.axes.Axes.plot` and `~matplotlib.axes.Axes.contour` with your raw longitude-latitude data.""" name = 'basemap' """The registered projection name.""" @@ -3284,7 +3293,7 @@ def __init__(self, *args, map_projection=None, **kwargs): def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, lonlines, latlines, latmax, lonarray, latarray): - """Applies formatting to basemap axes.""" + """Apply changes to the basemap axes.""" # Checks if (lonlim is not None or latlim is not None or boundinglat is not None): diff --git a/proplot/axistools.py b/proplot/axistools.py index ca07361ca..2e35aa6d2 100644 --- a/proplot/axistools.py +++ b/proplot/axistools.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -Defines various axis scales, locators, and formatters. Also "registers" -the locator and formatter names, so that they can be called selected with -the `~proplot.axes.CartesianAxes.format` method. +This page documents new axis formatters, new axis scales, and the constructor +functions `Scale`, `Formatter`, and `Locator` that are used to +"register" classes for use with `~proplot.axes.Axes.format`. """ import re from .utils import _notNone @@ -49,8 +49,8 @@ #-----------------------------------------------------------------------------# def Locator(locator, *args, **kwargs): """ - Returns a `~matplotlib.ticker.Locator` instance, used to interpret the - `xlocator`, `xlocator_kw`, `ylocator`, `ylocator_kw`, `xminorlocator`, + Return a `~matplotlib.ticker.Locator` instance. This function is used to + interpret the `xlocator`, `xlocator_kw`, `ylocator`, `ylocator_kw`, `xminorlocator`, `xminorlocator_kw`, `yminorlocator`, and `yminorlocator_kw` arguments when passed to `~proplot.axes.CartesianAxes.format`, and the `locator`, `locator_kw` `minorlocator`, and `minorlocator_kw` arguments when passed to colorbar @@ -130,8 +130,8 @@ def Locator(locator, *args, **kwargs): def Formatter(formatter, *args, date=False, **kwargs): r""" - Returns a `~matplotlib.ticker.Formatter` instance, used to interpret the - `xformatter`, `xformatter_kw`, `yformatter`, and `yformatter_kw` arguments + Return a `~matplotlib.ticker.Formatter` instance. This function is used to + interpret the `xformatter`, `xformatter_kw`, `yformatter`, and `yformatter_kw` arguments when passed to `~proplot.axes.CartesianAxes.format`, and the `formatter` and `formatter_kw` arguments when passed to colorbar methods wrapped by `~proplot.wrappers.colorbar_wrapper`. @@ -249,9 +249,9 @@ def Formatter(formatter, *args, date=False, **kwargs): def Scale(scale, *args, **kwargs): """ - Returns a `~matplotlib.scale.ScaleBase` instance, used to interpret the - `xscale`, `xscale_kw`, `yscale`, and `yscale_kw` arguments when passed to - `~proplot.axes.CartesianAxes.format`. + Return a `~matplotlib.scale.ScaleBase` instance. This function is used to + interpret the `xscale`, `xscale_kw`, `yscale`, and `yscale_kw` arguments + when passed to `~proplot.axes.CartesianAxes.format`. Parameters ---------- @@ -395,10 +395,11 @@ def SimpleFormatter(*args, precision=6, prefix=None, suffix=None, negpos=None, **kwargs): """ - Replicates features of `AutoFormatter`, but as a simpler - `~matplotlib.ticker.FuncFormatter` instance. This is more suitable for - arbitrary number formatting not necessarily associated with any - `~matplotlib.axis.Axis` instance, e.g. labelling contours. + Return a formatter function that replicates the features of + `AutoFormatter` with a `~matplotlib.ticker.FuncFormatter` instance. This + is suitable for arbitrary number formatting that is not necessarily + associated with any `~matplotlib.axis.Axis` instance, e.g. labelling + contours. Parameters ---------- @@ -434,12 +435,10 @@ def f(x, pos): def FracFormatter(symbol='', number=1): r""" - Returns a `~matplotlib.ticker.FuncFormatter` that formats numbers as - fractions or multiples of some value, e.g. a physical constant. - - This is powered by the python builtin `~fractions.Fraction` class. - We account for floating point errors using the - `~fractions.Fraction.limit_denominator` method. + Return a `~matplotlib.ticker.FuncFormatter` that formats numbers as + fractions or multiples of some arbitrary value. + This is powered by the builtin `~fractions.Fraction` class + and the `~fractions.Fraction.limit_denominator` method. Parameters ---------- @@ -473,9 +472,8 @@ def f(x, pos): # must accept location argument # Native scale overrides #-----------------------------------------------------------------------------# def _scale_factory(scale, axis, *args, **kwargs): - """If `scale` is a `~matplotlib.scale.ScaleBase` instance, nothing is - done. If it is a registered scale name, that scale is looked up and - instantiated.""" + """If `scale` is a `~matplotlib.scale.ScaleBase` instance, do nothing. If + it is a registered scale name, look up and instantiate that scale.""" if isinstance(scale, mscale.ScaleBase): if args or kwargs: warnings.warn(f'Ignoring args {args} and keyword args {kwargs}.') @@ -487,7 +485,7 @@ def _scale_factory(scale, axis, *args, **kwargs): return scales[scale](axis, *args, **kwargs) def _parse_logscale_args(kwargs, *keys): - """Parses args for `LogScale` and `SymmetricalLogScale` that + """Parse arguments for `LogScale` and `SymmetricalLogScale` that inexplicably require ``x`` and ``y`` suffixes by default.""" for key in keys: value = _notNone( # issues warning when multiple args passed! @@ -501,7 +499,7 @@ def _parse_logscale_args(kwargs, *keys): return kwargs class _dummy_axis(object): - """Dummy axis used to initialize scales.""" + """Empty dummy class used to initialize scales.""" # See notes in source code for `~matplotlib.scale.ScaleBase`. All scales # accept 'axis' for backwards-compatibility reasons, but it is *virtually # unused* except to check for the `axis_name` attribute in log scales to @@ -514,7 +512,7 @@ class _ScaleBase(object): def set_default_locators_and_formatters(self, axis, only_if_default=False): """ Apply all locators and formatters defined as attributes on - initialization, and define defaults for all scales. + initialization and define defaults for all scales. Parameters ---------- @@ -563,25 +561,25 @@ def get_transform(self): class LinearScale(_ScaleBase, mscale.LinearScale): """ - As with `~matplotlib.scale.LinearScale`, but applies new default - major formatter. + As with `~matplotlib.scale.LinearScale`. `AutoFormatter` is the new + default major formatter. """ name = 'linear' """The registered scale name.""" class LogitScale(_ScaleBase, mscale.LogitScale): """ - As with `~matplotlib.scale.LogitScale`, but applies new default - major formatter. + As with `~matplotlib.scale.LogitScale`. `AutoFormatter` is the new + default major formatter. """ name = 'logit' """The registered scale name.""" class LogScale(_ScaleBase, mscale.LogScale): """ - As with `~matplotlib.scale.LogScale`, but applies new default major - formatter and fixes the inexplicable choice to have separate "``x``" and - "``y``" versions of each keyword argument. + As with `~matplotlib.scale.LogScale`. `AutoFormatter` is the new + default major formatter. Separate "``x``" and "``y``" versions of each + keyword argument are no longer required. """ name = 'log' """The registered scale name.""" @@ -611,9 +609,9 @@ def __init__(self, axis, **kwargs): class SymmetricalLogScale(_ScaleBase, mscale.SymmetricalLogScale): """ - As with `~matplotlib.scale.SymmetricLogScale`, but applies new default - major formatter and fixes the inexplicable choice to have separate "``x``" - and "``y``" versions of each keyword argument. + As with `~matplotlib.scale.SymmetricLogScale`. `AutoFormatter` is the new + default major formatter. Separate "``x``" and "``y``" versions of each + keyword argument are no longer required. """ name = 'symlog' """The registered scale name.""" @@ -654,9 +652,9 @@ def __init__(self, axis, **kwargs): class FuncScale(_ScaleBase, mscale.ScaleBase): """ Arbitrary scale with user-supplied forward and inverse functions and - arbitrary additional transform applied thereafter. Input is a tuple - of functions and, optionally, a `~matplotlib.transforms.Transform` or - `~matplotlib.scale.ScaleBase` instance. + an arbitrary additional transform applied thereafter. The input should + be a tuple of functions and, optionally, a + `~matplotlib.transforms.Transform` instance. """ name = 'function' """The registered scale name.""" @@ -726,7 +724,7 @@ def inverted(self): #-----------------------------------------------------------------------------# class PowerScale(_ScaleBase, mscale.ScaleBase): r""" - Returns a "power scale" that performs the transformation + "Power scale" that performs the transformation .. math:: @@ -797,16 +795,16 @@ def inverted(self): class ExpScale(_ScaleBase, mscale.ScaleBase): """ - An "exponential scale". When `inverse` is ``False`` (the default), this - performs the transformation + "Exponential scale" that performs either of two transformations. When + `inverse` is ``False`` (the default), performs the transformation .. math:: Ca^{bx} where the constants :math:`a`, :math:`b`, and :math:`C` are set by the - input (see below). When `inverse` is ``False``, this performs the inverse - transformation + input arguments (see below). When `inverse` is ``True``, performs the + inverse transformation .. math:: @@ -841,7 +839,7 @@ def __init__(self, axis, else: self._transform = InvertedExpTransform(a, b, c, minpos) def limit_range_for_scale(self, vmin, vmax, minpos): - """Returns the range *vmin* and *vmax* limited to positive numbers.""" + """Return *vmin* and *vmax* limited to positive numbers.""" return max(vmin, minpos), max(vmax, minpos) class ExpTransform(mtransforms.Transform): @@ -885,8 +883,8 @@ def inverted(self): return ExpTransform(self._a, self._b, self._c, self.minpos) class CutoffScale(_ScaleBase, mscale.ScaleBase): - """Axis scale with arbitrary cutoffs that "accelerate" parts of the - axis, "decelerate" parts of the axes, or discretely jumps between + """Axis scale with arbitrary cutoffs that "accelerates" parts of the + axis, "decelerates" parts of the axes, or discretely jumps between numbers. If `upper` is not provided, you have the following two possibilities. @@ -1018,7 +1016,7 @@ def inverted(self): class MercatorLatitudeScale(_ScaleBase, mscale.ScaleBase): r""" - Scales axis as with latitude in the `Mercator projection + Axis scale that transforms values as with latitude in the `Mercator projection `__. Adapted from `this example `__. @@ -1055,7 +1053,7 @@ def __init__(self, axis, *, thresh=85.0): self._major_formatter = Formatter('deg') self._smart_bounds = True def limit_range_for_scale(self, vmin, vmax, minpos): - """Returns the range *vmin* and *vmax* limited to some range within + """Return *vmin* and *vmax* limited to some range within +/-90 degrees (exclusive).""" return max(vmin, -self._thresh), min(vmax, self._thresh) @@ -1097,8 +1095,9 @@ def inverted(self): class SineLatitudeScale(_ScaleBase, mscale.ScaleBase): r""" - Scales axis to be linear in the *sine* of *x* in degrees. - The scale function is as follows. + Axis scale that is linear in the *sine* of *x*. The axis limits are + constrained to fall between ``-90`` and ``+90`` degrees. The scale + function is as follows. .. math:: @@ -1124,7 +1123,7 @@ def __init__(self, axis): self._major_formatter = Formatter('deg') self._smart_bounds = True def limit_range_for_scale(self, vmin, vmax, minpos): - """Returns the range *vmin* and *vmax* limited to some range within + """Return *vmin* and *vmax* limited to some range within +/-90 degrees (inclusive).""" return max(vmin, -90), min(vmax, 90) @@ -1168,8 +1167,8 @@ def inverted(self): class InverseScale(_ScaleBase, mscale.ScaleBase): r""" - Scales axis to be linear in the *inverse* of *x*. The scale - function and inverse scale function are as follows. + Axis scale that is linear in the *inverse* of *x*. The forward and inverse + scale functions are as follows. .. math:: @@ -1197,7 +1196,7 @@ def __init__(self, axis, **kwargs): self._smart_bounds = True # self._minor_formatter = Fromatter('log') def limit_range_for_scale(self, vmin, vmax, minpos): - """Returns the range *vmin* and *vmax* limited to positive numbers.""" + """Return *vmin* and *vmax* limited to positive numbers.""" return max(vmin, minpos), max(vmax, minpos) class InverseTransform(mtransforms.Transform): diff --git a/proplot/rctools.py b/proplot/rctools.py index b13e8501e..9903d6c51 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -431,8 +431,7 @@ def _tabulate(rcdict): # Helper funcs def _to_points(key, value): - """Converts certain keys to the units "points". If "key" is passed, tests - that key against possible keys that accept physical units.""" + """Convert certain rc keys to the units "points".""" # See: https://matplotlib.org/users/customizing.html, all props matching # the below strings use the units 'points', and custom categories are in if (isinstance(value,str) and key.split('.')[0] not in ('colorbar','subplots') @@ -441,7 +440,7 @@ def _to_points(key, value): return value def _get_config_paths(): - """Returns configuration file paths.""" + """Return a list of configuration file paths.""" # Local configuration idir = os.getcwd() paths = [] @@ -461,8 +460,8 @@ def _get_config_paths(): return paths def _get_synced_params(key, value): - """Returns dictionaries for updating "child" properties in - `rcParams` and `rcParamsLong` with global property.""" + """Return dictionaries for updating the `rcParamsShort`, `rcParamsLong`, + and `rcParams` properties associted with this key.""" kw = {} # builtin properties that global setting applies to kw_long = {} # custom properties that global setting applies to kw_short = {} # short name properties @@ -576,6 +575,8 @@ def _get_synced_params(key, value): kw_long[key] = value elif key in rcParams: kw[key] = value + else: + raise KeyError(f'Invalid key {key!r}.') for name in RC_CHILDREN.get(key, ()): if name in rcParamsLong: kw_long[name] = value @@ -587,7 +588,7 @@ def _get_synced_params(key, value): # Main class #-----------------------------------------------------------------------------# def _sanitize_key(key): - """Converts the key to a palatable value.""" + """Convert the key to a palatable value.""" if not isinstance(key, str): raise KeyError(f'Invalid key {key!r}. Must be string.') if '.' not in key and key not in rcParamsShort: @@ -661,42 +662,41 @@ def __init__(self, local=True): raise RuntimeError(f'{file!r} has invalid key {key!r}.') def __enter__(self): - """Applies settings from the most recent context object.""" + """Apply settings from the most recent context block.""" *_, kwargs, cache, restore = self._context[-1] # missing arg is previous mode - def _set_item(rcdict, key, value): - restore[key] = rcdict[key] - rcdict[key] = cache[key] = value + def _update(rcdict, newdict): + for key,value in newdict.items(): + restore[key] = rcdict[key] + rcdict[key] = cache[key] = value for key,value in kwargs.items(): rc_short, rc_long, rc = _get_synced_params(key, value) - for ikey,ivalue in rc_short.items(): - _set_item(rcParamsShort, key, value) - for ikey, ivalue in rc_long.items(): - _set_item(rcParamsLong, ikey, ivalue) - for ikey, ivalue in rc.items(): - _set_item(rcParams, ikey, ivalue) + _update(rcParamsShort, rc_short) + _update(rcParamsLong, rc_long) + _update(rcParams, rc) def __exit__(self, *args): - """Restores configurator cache to initial state.""" + """Restore settings from the most recent context block.""" *_, restore = self._context[-1] for key,value in restore.items(): self[key] = value del self._context[-1] def __delitem__(self, *args): - """Pseudo-immutability.""" + """Raise an error. This enforces pseudo-immutability.""" raise RuntimeError('rc settings cannot be deleted.') def __delattr__(self, *args): - """Pseudo-immutability.""" + """Raise an error. This enforces pseudo-immutability.""" raise RuntimeError('rc settings cannot be deleted.') def __getattr__(self, attr): - """Invokes `~rc_configurator.__getitem__`.""" + """Pass the attribute to `~rc_configurator.__getitem__` and return + the result.""" return self[attr] def __getitem__(self, key): - """Returns `rcParams `__, - :ref:`rcParamsLong`, and :ref:`rcParamsShort` settings.""" + """Return the relevant `rcParams `__, + :ref:`rcParamsLong`, and :ref:`rcParamsShort` setting.""" key = _sanitize_key(key) for kw in (rcParamsShort, rcParamsLong, rcParams): try: @@ -706,44 +706,21 @@ def __getitem__(self, key): raise KeyError(f'Invalid property name {key!r}.') def __setattr__(self, attr, value): - """Invokes `~rc_configurator.__setitem__`.""" + """Pass the attribute and value to `~rc_configurator.__setitem__`.""" self[attr] = value def __setitem__(self, key, value): - """Sets `rcParams `__, - :ref:`rcParamsLong`, and :ref:`rcParamsShort` settings.""" + """Modify the relevant `rcParams `__, + :ref:`rcParamsLong`, and :ref:`rcParamsShort` setting(s).""" rc_short, rc_long, rc = _get_synced_params(key, value) - for ikey, ivalue in rc_short.items(): - rcParamsShort[ikey] = ivalue - for ikey, ivalue in rc_long.items(): - rcParamsLong[ikey] = ivalue - for ikey, ivalue in rc.items(): - rcParams[ikey] = ivalue - - - - if '.' not in key and key not in rcParamsShort: - key = RC_NODOTS.get(key, key) - if key == 'title.pad': - key = 'axes.titlepad' - if key in rcParamsShort: - rc, rc_long = _get_synced_params(key, value) - rcParamsShort[key] = value - for ikey, ivalue in rc.items(): - rcParams[ikey] = ivalue - for ikey, ivalue in rc_long.items(): - rcParamsLong[ikey] = ivalue - elif key in rcParamsLong: - rcParamsLong[key] = _to_points(key, value) - elif key in rcParams: - rcParams[key] = _to_points(key, value) - else: - raise KeyError(f'Invalid key {key!r}.') + rcParamsShort.update(rc_short) + rcParamsLong.update(rc_long) + rcParams.update(rc) def _get_item(self, key, mode=None): - """Ax with `~rc_configurator.__getitem__`, but limits the search - based on the context mode, and returns ``None`` if the key is not - found in the searched dictionaries.""" + """As with `~rc_configurator.__getitem__` but the search is limited + based on the context mode and ``None`` is returned if the key is not + found in the dictionaries.""" if mode is None: mode = min((context[0] for context in self._context), default=0) caches = (context[2] for context in self._context) @@ -769,8 +746,8 @@ def _get_item(self, key, mode=None): def category(self, cat, *, context=False): """ - Returns a dictionary of settings belonging to the indicated category, - i.e. settings beginning with the substring ``cat + '.'``. + Return a dictionary of settings beginning with the substring + ``cat + '.'``. Parameters ---------- @@ -797,16 +774,16 @@ def category(self, cat, *, context=False): def context(self, *args, mode=0, **kwargs): """ - Temporarily modifies settings in a "with as" block, - used by ProPlot internally but may also be useful for power users. + Temporarily modify the rc settings in a "with as" block. - This function was invented to prevent successive calls to + This is used by ProPlot internally but may also be useful for power + users. It was invented to prevent successive calls to `~proplot.axes.Axes.format` from constantly looking up and re-applying unchanged settings. Testing showed that these gratuitous `rcParams `__ lookups and artist updates increased runtime by seconds, even for relatively simple plots. It also resulted in overwriting previous - rc changes with the default values on successive calls to + rc changes with the default values upon subsequent calls to `~proplot.axes.Axes.format`. Parameters @@ -866,7 +843,7 @@ def context(self, *args, mode=0, **kwargs): def dict(self): """ - Returns a dictionary of all settings. + Return a raw dictionary of all settings. """ output = {} for key in sorted((*rcParamsShort, *rcParamsLong, *rcParams)): @@ -875,7 +852,7 @@ def dict(self): def get(self, key, *, context=False): """ - Returns a setting. + Return a single setting. Parameters ---------- @@ -890,13 +867,13 @@ def get(self, key, *, context=False): def fill(self, props, *, context=False): """ - Returns a dictionary filled with `rc` settings, used internally to build - dictionaries for updating `~matplotlib.artist.Artist` instances. + Return a dictionary filled with settings whose names match the + string values in the input dictionary. Parameters ---------- props : dict-like - Dictionary whose values are names of `rc` settings. The values + Dictionary whose values are names of settings. The values are replaced with the corresponding property only if `~rc_configurator.__getitem__` does not return ``None``. Otherwise, that key, value pair is omitted from the output dictionary. @@ -915,21 +892,23 @@ def fill(self, props, *, context=False): def items(self): """ - Iterates over all setting names and values. Same as `dict.items`. + Return an iterator that loops over all setting names and values. + Same as `dict.items`. """ for key in self: yield key, self[key] def keys(self): """ - Iterates over all setting names. Same as `dict.keys`. + Return an iterator that loops over all setting names. + Same as `dict.items`. """ for key in self: yield key def update(self, *args, **kwargs): """ - Bulk updates settings. + Update multiple settings at once. Parameters ---------- @@ -968,7 +947,7 @@ def update(self, *args, **kwargs): def reset(self, **kwargs): """ - Resets the configurator to its initial state. + Reset the configurator to its initial state. Parameters ---------- @@ -979,7 +958,8 @@ def reset(self, **kwargs): def values(self): """ - Iterates over all setting values. Same as `dict.values`. + Return an iterator that loops over all setting values. + Same as `dict.values`. """ for key in self: yield self[key] @@ -995,14 +975,13 @@ def values(self): @_timer def notebook_setup(): """ - Sets up your iPython workspace, called on import if :rcraw:`nbsetup` is - ``True``. For all iPython sessions, passes :rcraw:`autoreload` to the useful + Set up the iPython workspace. This is called on import if :rcraw:`nbsetup` + is ``True``. For all iPython sessions, this passes :rcraw:`autoreload` to the useful `autoreload `__ - extension. For iPython *notebook* sessions, results in higher-quality inline figures - and passes :rcraw:`autosave` to the `autosave `__ + extension. For *notebook* iPython sessions, this also configures the inline + backend for higher quality figures and passes :rcraw:`autosave` to the + `autosave `__ extension. - - See the `~proplot.rctools` documentation for details. """ # Make sure we are in session ipython = get_ipython() diff --git a/proplot/styletools.py b/proplot/styletools.py index 067333df6..8ff37d557 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 """ -Registers colormaps, color cycles, and color string names with `register_cmaps`, -`register_cycles`, and `register_colors`. Defines the `Colormap` and `Cycle` -tools for creating new colormaps and color cycles. Defines helpful new -`~matplotlib.colors.Normalize` and `~matplotlib.colors.Colormap` classes. -Adds tools for visualizing colorspaces, colormaps, color names, and color -cycles. - -See the :ref:`Color usage` section for details. +This page includes new colormap and dictionary classes, +functions for loading colors, fonts, colormaps, and color cycles, +and the constructor functions `Colormap`, `Cycle`, and +`Norm` used for generating arbitrary colormaps, color cycles, and colornmap +normalizers. It also includes various ``show_`` functions for visualizing +the available colors, fonts, colormaps, color cycles, colorspaces, and +colormap channel values. See the :ref:`Color usage` section for details. """ # Potential bottleneck, loading all this stuff? *No*. Try using @timer on # register functions, turns out worst is colormap one at 0.1 seconds. Just happens @@ -200,7 +199,7 @@ # Color manipulation functions #-----------------------------------------------------------------------------# def _get_space(space): - """Verify requested colorspace is valid.""" + """Return a sanitized version of the colorspace name.""" space = space.lower() if space in ('hpluv', 'hsluv'): space = space[:3] @@ -209,7 +208,7 @@ def _get_space(space): return space def _get_channel(color, channel, space='hsl'): - """Gets hue, saturation, or luminance channel value from registered + """Return the hue, saturation, or luminance channel value from registered string color name. The color name `color` can optionally be a string with the format ``'color+x'`` or ``'color-x'``, where `x` specifies the offset from the channel value.""" @@ -234,7 +233,8 @@ def _get_channel(color, channel, space='hsl'): return offset + to_xyz(to_rgb(color), space)[channel] def shade(color, scale=0.5): - """Changes the "shade" of a color by scaling its luminance channel by `scale`.""" + """"Shade" the input color. Its luminance channel is multiplied by + `scale`.""" color = to_rgb(color) # ensure is valid color color = [*colormath.rgb_to_hsl(*color)] color[2] = max(0, min(color[2]*scale, 100)) # multiply luminance by this value @@ -242,7 +242,8 @@ def shade(color, scale=0.5): return tuple(color) def saturate(color, scale=0.5): - """Changes the saturation of a color by scaling its saturation channel by `scale`.""" + """"Saturate" the input color. Its saturation channel is multiplied by + `scale`.""" color = to_rgb(color) # ensure is valid color color = [*colormath.rgb_to_hsl(*color)] color[1] = max(0, min(color[1]*scale, 100)) # multiply luminance by this value @@ -250,11 +251,21 @@ def saturate(color, scale=0.5): return tuple(color) def to_rgb(color, space='rgb', cycle=None): - """Generalization of matplotlib's `~matplotlib.colors.to_rgb`. Translates - colors from *any* colorspace to RGB, converts color strings to RGB - tuples, and transforms color cycle strings (e.g. ``'C0'``, ``'C1'``, ``'C2'``) - into their corresponding RGB colors using the input `cycle`, which defaults - to the current color cycler. Inverse of `to_xyz`.""" + """ + Return the RGB tuple matching the input color. Inverse of `to_xyz`. + This is a generalization of matplotlib's `~matplotlib.colors.to_rgb`. + + Parameters + ---------- + color : str or length-3 list + The color specification or container of channel values. + space : {'rgb', 'hsv', 'hpl', 'hsl', 'hcl'}, optional + The colorspace for the input channel values. Ignored unless `color` is + an container of numbers. + cycle : str or list, optional + The registered color cycle name or a list of colors. Ignored unless + `color` is a color cycle string, e.g. ``'C0'``, ``'C1'``, ... + """ # Convert color cycle strings if isinstance(color, str) and re.match('^C[0-9]$', color): if isinstance(cycle, str): @@ -300,8 +311,16 @@ def to_rgb(color, space='rgb', cycle=None): return color def to_xyz(color, space): - """Translates from the RGB colorspace to colorspace `space`. Inverse - of `to_rgb`.""" + """Return the channel values matching the input RGB color. This is the + inverse of `to_rgb`. + + Parameters + ---------- + color : color-spec + The RGB color. Interpreted by `to_rgb`. + space : {'rgb', 'hsv', 'hpl', 'hsl', 'hcl'}, optional + The colorspace for the output channel values. + """ # Run tuple conversions # NOTE: Don't pass color tuple, because we may want to permit out-of-bounds RGB values to invert conversion color = to_rgb(color) @@ -324,9 +343,9 @@ def to_xyz(color, space): #-----------------------------------------------------------------------------# def _clip_colors(colors, clip=True, gray=0.2): """ - Clips impossible colors rendered in an HSl-to-RGB colorspace conversion. + Clip impossible colors rendered in an HSL-to-RGB colorspace conversion. Used by `PerceptuallyUniformColormap`. If `mask` is ``True``, impossible - colors are masked out + colors are masked out. Parameters ---------- @@ -390,9 +409,9 @@ def _make_segmentdata_array(values, ratios=None): def make_mapping_array(N, data, gamma=1.0, inverse=False): r""" - Mostly a copy of `~matplotlib.colors.makeMappingArray`, but allows + Similar to `~matplotlib.colors.makeMappingArray` but permits *circular* hue gradations along 0-360, disables clipping of - out-of-bounds channel values, and with fancier "gamma" scaling. + out-of-bounds channel values, and uses fancier "gamma" scaling. Parameters ---------- @@ -498,7 +517,7 @@ class _Colormap(): """Mixin class used to add some helper methods.""" def _get_data(self, ext): """ - Returns a string containing the colormap colors for saving. + Return a string containing the colormap colors for saving. Parameters ---------- @@ -524,7 +543,7 @@ def _get_data(self, ext): def _parse_path(self, path, dirname='.', ext=''): """ - Parses user input path. + Parse the user input path. Parameters ---------- @@ -577,7 +596,7 @@ def _resample(self, N): def concatenate(self, *args, ratios=1, name=None, **kwargs): """ - Appends arbitrary colormaps onto this one. + Append arbitrary colormaps onto this one. Parameters ---------- @@ -675,7 +694,7 @@ def data(ix, funcs=funcs): def updated(self, name=None, segmentdata=None, N=None, gamma=None, cyclic=None): """ - Returns a new colormap, with relevant properties copied from this one + Return a new colormap, with relevant properties copied from this one if they were not provided as keyword arguments. Parameters @@ -700,7 +719,7 @@ def updated(self, name=None, segmentdata=None, N=None, def reversed(self, name=None, **kwargs): """ - Returns a reversed copy of the colormap, as in + Return a reversed copy of the colormap, as in `~matplotlib.colors.LinearSegmentedColormap`. Parameters @@ -731,7 +750,7 @@ def func_r(x): def save(self, path=None): """ - Saves the colormap data to a file. + Save the colormap data to a file. Parameters ---------- @@ -772,14 +791,14 @@ def save(self, path=None): def set_cyclic(self, b): """ - Accepts boolean value that sets whether this colormap is - "cyclic". See `LinearSegmentedColormap` for details. + Set whether this colormap is "cyclic". See `LinearSegmentedColormap` + for details. """ self._cyclic = bool(b) def shifted(self, shift=None, name=None, **kwargs): """ - Returns a cyclically shifted copy of the colormap. + Return a cyclically shifted copy of the colormap. Parameters ---------- @@ -815,7 +834,7 @@ def shifted(self, shift=None, name=None, **kwargs): def sliced(self, left=None, right=None, cut=None, name=None, **kwargs): """ - Returns a sliced copy of the colormap. + Return a sliced copy of the colormap. Parameters ---------- @@ -893,7 +912,7 @@ def sliced(self, left=None, right=None, cut=None, name=None, **kwargs): @staticmethod def from_list(name, colors, *args, **kwargs): """ - Makes a linear segmented colormap from a list of colors. See + Make a linear segmented colormap from a list of colors. See `~matplotlib.colors.LinearSegmentedColormap`. Parameters @@ -939,7 +958,7 @@ def __repr__(self): def concatenate(self, *args, name=None, N=None, **kwargs): """ - Appends arbitrary colormaps onto this colormap. + Append arbitrary colormaps onto this colormap. Parameters ---------- @@ -964,7 +983,8 @@ def concatenate(self, *args, name=None, N=None, **kwargs): def updated(self, colors=None, name=None, N=None): """ - Creates copy of the colormap. + Return a new colormap, with relevant properties copied from this one + if they were not provided as keyword arguments. Parameters ---------- @@ -984,7 +1004,7 @@ def updated(self, colors=None, name=None, N=None): def save(self, path=None): """ - Saves the colormap data to a file. + Save the colormap data to a file. Parameters ---------- @@ -1013,7 +1033,7 @@ def save(self, path=None): def shifted(self, shift=None, name=None): """ - Returns a copy of the colormap with cyclically shifted colors. + Return a copy of the colormap with cyclically shifted colors. Parameters ---------- @@ -1034,7 +1054,7 @@ def shifted(self, shift=None, name=None): def sliced(self, left=None, right=None, name=None): """ - Returns a copy of the colormap containing a subselection of the + Return a copy of the colormap containing a subselection of the original colors. Parameters @@ -1059,8 +1079,9 @@ def sliced(self, left=None, right=None, name=None): class PerceptuallyUniformColormap(LinearSegmentedColormap, _Colormap): """Similar to `~matplotlib.colors.LinearSegmentedColormap`, but instead - of varying the RGB channels, we vary hue, saturation, and luminance in - either the HCL colorspace or the HSLuv or HPLuv scalings of HCL.""" + of varying the RGB channels, the hue, saturation, and luminance channels + are varied across the HCL colorspace or the HSLuv or HPLuv scalings of + HCL.""" def __init__(self, name, segmentdata, N=None, space=None, clip=True, gamma=None, gamma1=None, gamma2=None, cyclic=False, @@ -1149,8 +1170,8 @@ def __init__(self, super().__init__(name, segmentdata, N, gamma=1.0, cyclic=cyclic) def _init(self): - """As with `~matplotlib.colors.LinearSegmentedColormap`, but converts - each value in the lookup table from 'input' to RGB.""" + """As with `~matplotlib.colors.LinearSegmentedColormap`, but convert + each value in the lookup table from ``self._space`` to RGB.""" # First generate the lookup table channels = ('hue','saturation','luminance') inverses = (False, False, True) # gamma weights *low chroma* and *high luminance* @@ -1172,13 +1193,13 @@ def _init(self): self._lut[:,:3] = _clip_colors(self._lut[:,:3], self._clip) def _resample(self, N): - """Returns a new colormap with *N* entries.""" + """Return a new colormap with *N* entries.""" return self.updated(N=N) def updated(self, name=None, segmentdata=None, N=None, space=None, clip=None, gamma=None, gamma1=None, gamma2=None, cyclic=None): """ - Returns a new colormap, with relevant properties copied from this one + Return a new colormap, with relevant properties copied from this one if they were not provided as keyword arguments. Parameters @@ -1214,7 +1235,7 @@ def updated(self, name=None, segmentdata=None, N=None, space=None, @staticmethod def from_color(name, color, fade=None, space='hsl', **kwargs): """ - Returns a monochromatic "sequential" colormap that blends from white + Return a monochromatic "sequential" colormap that blends from white or near-white to the input color. Parameters @@ -1254,7 +1275,7 @@ def from_hsl(name, hue=0, saturation=100, luminance=(100, 20), alpha=None, ratios=None, **kwargs): """ - Makes a `~PerceptuallyUniformColormap` by specifying the hue, + Make a `~PerceptuallyUniformColormap` by specifying the hue, saturation, and luminance transitions individually. Parameters @@ -1300,7 +1321,7 @@ def from_hsl(name, @staticmethod def from_list(name, colors, ratios=None, **kwargs): """ - Makes a `PerceptuallyUniformColormap` from a list of RGB colors. + Make a `PerceptuallyUniformColormap` from a list of RGB colors. Parameters ---------- @@ -1337,7 +1358,7 @@ def from_list(name, colors, ratios=None, **kwargs): def set_gamma(self, gamma=None, gamma1=None, gamma2=None): """ - Sets new gamma value(s) and regenerates the colormap. + Modify the gamma value(s) and refresh the lookup table. Parameters ---------- @@ -1383,11 +1404,12 @@ def __init__(self, kwargs): self[key] = value def __getitem__(self, key): - """Retrieves case-insensitive colormap name. If the name ends in - ``'_r'``, returns the result of ``cmap.reversed()`` for the colormap - with name ``key[:-2]``. Also returns reversed diverging colormaps - when their "reversed name" is requested -- for example, ``'BuRd'`` is - equivalent to ``'RdBu_r'``.""" + """Retrieve the colormap associated with the sanitized key name. The + key name is case insensitive. If it ends in ``'_r'``, the result of + ``cmap.reversed()`` is returned for the colormap registered under + the name ``key[:-2]``. Reversed diverging colormaps can be requested + with their "reversed" name -- for example, ``'BuRd'`` is equivalent + to ``'RdBu_r'``.""" key = self._sanitize_key(key, mirror=True) reverse = (key[-2:] == '_r') if reverse: @@ -1401,7 +1423,7 @@ def __getitem__(self, key): return value def __setitem__(self, key, item): - """Stores the colormap under its lowercase name. If the colormap is + """Store the colormap under its lowercase name. If the colormap is a matplotlib `~matplotlib.colors.ListedColormap` or `~matplotlib.colors.LinearSegmentedColormap`, it is converted to the ProPlot `ListedColormap` or `LinearSegmentedColormap` subclass.""" @@ -1421,7 +1443,7 @@ def __setitem__(self, key, item): return super().__setitem__(key, item) def __contains__(self, item): - """Tests membership for sanitized key name.""" + """Test for membership using the sanitized colormap name.""" try: # by default __contains__ uses object.__getitem__ and ignores overrides self.__getitem__(item) return True @@ -1429,7 +1451,7 @@ def __contains__(self, item): return False def _sanitize_key(self, key, mirror=True): - """Sanitizes key name.""" + """Return the sanitized colormap name.""" if not isinstance(key, str): raise KeyError(f'Invalid key {key!r}. Key must be a string.') key = key.lower() @@ -1453,17 +1475,17 @@ def _sanitize_key(self, key, mirror=True): return key def get(self, key, *args): - """Retrieves sanitized key name.""" + """Retrieve the sanitized colormap name.""" key = self._sanitize_key(key, mirror=True) return super().get(key, *args) def pop(self, key, *args): - """Pops sanitized key name.""" + """Pop the sanitized colormap name.""" key = self._sanitize_key(key, mirror=True) return super().pop(key, *args) def update(self, *args, **kwargs): - """Replicates dictionary update with sanitized key names.""" + """Update the dictionary with sanitized colormap names.""" if len(args) == 1: kwargs.update(args[0]) elif len(args) > 1: @@ -1518,8 +1540,8 @@ def __getitem__(self, key): # Colormap and cycle constructor functions #-----------------------------------------------------------------------------# def colors(*args, **kwargs): - """Identical to `Cycle`, but returns a list of colors instead of - a `~cycler.Cycler` object.""" + """Pass all arguments to `Cycle` and return the list of colors from + the cycler object.""" cycle = Cycle(*args, **kwargs) return [dict_['color'] for dict_ in cycle] @@ -1529,10 +1551,10 @@ def Colormap(*args, name=None, listmode='perceptual', save=False, save_kw=None, **kwargs): """ - Generates or retrieves colormaps and optionally merges and manipulates - them in a variety of ways; used to interpret the `cmap` and `cmap_kw` - arguments when passed to any plotting method wrapped by - `~proplot.wrappers.cmap_wrapper`. + Generate a new colormap, retrieve a registered colormap, or merge and + manipulate colormap(s) in a variety of ways. This is used to interpret + the `cmap` and `cmap_kw` arguments when passed to any plotting method + wrapped by `~proplot.wrappers.cmap_changer`. Parameters ---------- @@ -1880,9 +1902,9 @@ def Cycle(*args, N=None, name=None, #-----------------------------------------------------------------------------# def Norm(norm, levels=None, **kwargs): """ - Returns an arbitrary `~matplotlib.colors.Normalize` instance, used to - interpret the `norm` and `norm_kw` arguments when passed to any plotting - method wrapped by `~proplot.wrappers.cmap_wrapper`. + Return an arbitrary `~matplotlib.colors.Normalize` instance. This is + used to interpret the `norm` and `norm_kw` arguments when passed to any + plotting method wrapped by `~proplot.wrappers.cmap_changer`. Parameters ---------- @@ -2058,7 +2080,7 @@ def __init__(self, levels, norm=None, clip=False, step=1.0, extend='neither'): self.N = levels.size def __call__(self, xq, clip=None): - """Normalizes data values to the range 0-1.""" + """Normalize data values to 0-1.""" # Follow example of LinearSegmentedNorm, but perform no interpolation, # just use searchsorted to bin the data. norm_clip = self._norm_clip @@ -2070,7 +2092,7 @@ def __call__(self, xq, clip=None): return ma.array(yq, mask=mask) def inverse(self, yq): - """Raises RuntimeError. Inversion after discretization is impossible.""" + """Raise an error. Inversion after discretization is impossible.""" raise RuntimeError('BinNorm is not invertible.') #-----------------------------------------------------------------------------# @@ -2080,12 +2102,13 @@ class LinearSegmentedNorm(mcolors.Normalize): """ This is the default normalizer paired with `BinNorm` whenever `levels` are non-linearly spaced. The normalized value is linear with respect to - its **average index** in the `levels` vector, allowing uniform color transitions - across **arbitrarily spaced** monotonically increasing values. + its average index in the `levels` vector, allowing uniform color + transitions across arbitrarily spaced monotonically increasing values. - It accomplishes this following the example of the `~matplotlib.colors.LinearSegmentedColormap` - source code, by performing efficient, vectorized linear interpolation - between the provided boundary levels. + It accomplishes this following the example of the + `~matplotlib.colors.LinearSegmentedColormap` source code, by performing + efficient, vectorized linear interpolation between the provided boundary + levels. Can be used by passing ``norm='segmented'`` or ``norm='segments'`` to any command accepting ``cmap``. The default midpoint is zero. @@ -2114,7 +2137,7 @@ def __init__(self, levels, vmin=None, vmax=None, **kwargs): self._y = np.linspace(0, 1, len(levels)) def __call__(self, xq, clip=None): - """Normalizes data values to the range 0-1. Inverse operation + """Normalize the data values to 0-1. Inverse of `~LinearSegmentedNorm.inverse`.""" # Follow example of make_mapping_array for efficient, vectorized # linear interpolation across multiple segments. @@ -2173,8 +2196,7 @@ def __init__(self, midpoint=0, vmin=None, vmax=None, clip=None): self._midpoint = midpoint def __call__(self, xq, clip=None): - """Normalizes data values to the range 0-1. Inverse operation of - `~MidpointNorm.inverse`.""" + """Normalize data values to 0-1. Inverse of `~MidpointNorm.inverse`.""" # Get middle point in 0-1 coords, and value # Notes: # * Look up these three values in case vmin/vmax changed; this is @@ -2217,7 +2239,7 @@ def inverse(self, yq, clip=None): # Functions for loading and visualizing stuff #-----------------------------------------------------------------------------# def _get_data_paths(dirname): - """Returns configuration file paths.""" + """Return the data directory paths.""" # Home configuration paths = [] ipath = os.path.join(os.path.expanduser('~'), '.proplot', dirname) @@ -2230,9 +2252,7 @@ def _get_data_paths(dirname): return paths def _load_cmap(filename, listed=False): - """ - Helper function that reads generalized colormap and color cycle files. - """ + """Read generalized colormap and color cycle files.""" filename = os.path.expanduser(filename) if os.path.isdir(filename): # no warning return @@ -2344,7 +2364,7 @@ def _load_cmap(filename, listed=False): @_timer def register_cmaps(): """ - Adds colormaps packaged with ProPlot or saved to the ``~/.proplot/cmaps`` + Register colormaps packaged with ProPlot or saved to the ``~/.proplot/cmaps`` folder. This is called on import. Maps are registered according to their filenames -- for example, ``name.xyz`` will be registered as ``'name'``. Use `show_cmaps` to generate a table of the registered colormaps @@ -2385,7 +2405,7 @@ def register_cmaps(): @_timer def register_cycles(): """ - Adds color cycles packaged with ProPlot or saved to the ``~/.proplot/cycles`` + Register color cycles packaged with ProPlot or saved to the ``~/.proplot/cycles`` folder. This is called on import. Cycles are registered according to their filenames -- for example, ``name.hex`` will be registered under the name ``'name'`` as a `~matplotlib.colors.ListedColormap` map (see `Cycle` for @@ -2417,10 +2437,11 @@ def register_cycles(): @_timer def register_colors(nmax=np.inf): """ - Reads full database of crowd-sourced XKCD color names and official - Crayola color names, then filters them to be sufficiently "perceptually - distinct" in the HCL colorspace. This is called on import. Use `show_colors` - to generate a table of the resulting filtered colors. + Register colors from the crowd-sourced XKCD color names database and + the official Crayola color names. Too-similar colors are omitted by + ensuring they are sufficiently distinct in the perceptually uniform HCL + colorspace. This is called on import. Use `show_colors` to generate a + table of the resulting filtered colors. """ # Reset native colors dictionary and add some default groups # Add in CSS4 so no surprises for user, but we will not encourage this @@ -2488,10 +2509,9 @@ def register_colors(nmax=np.inf): @_timer def register_fonts(): - """Adds fonts packaged with ProPlot or saved to the ``~/.proplot/fonts`` - folder. Also deletes the font cache, which may cause delays. - Detects ``.ttf`` and ``.otf`` files -- see `this link - `__ + """Add fonts packaged with ProPlot or saved to the ``~/.proplot/fonts`` + folder, if they are not already added. Detects ``.ttf`` and ``.otf`` files + -- see `this link `__ for a guide on converting various other font file types to ``.ttf`` and ``.otf`` for use with matplotlib.""" # Add proplot path to TTFLIST and rebuild cache @@ -2551,10 +2571,9 @@ def register_fonts(): def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, width=100, aspect=1, axwidth=1.7): """ - Shows how arbitrary colormap(s) vary with respect to the hue, chroma, - luminance, HSL saturation, and HPL saturation channels, and optionally - the red, blue and green channels. Adapted from - `this example `__. + Visualize how the input colormap(s) vary with respect to the hue, chroma, + and luminance channels. Adapted from `this example + `__. Parameters ---------- @@ -2669,7 +2688,7 @@ def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, width=100, def show_colorspaces(luminance=None, saturation=None, hue=None): """ - Generates hue-saturation, hue-luminance, and luminance-saturation + Generate hue-saturation, hue-luminance, and luminance-saturation cross-sections for the HCL, HSLuv, and HPLuv colorspaces. Parameters @@ -2747,7 +2766,7 @@ def show_colorspaces(luminance=None, saturation=None, hue=None): def show_colors(nhues=17, minsat=0.2): """ - Visualizes the registered color names in two figures. Adapted from + Generate two tables of the registered color names. Adapted from `this example `_. Parameters @@ -2857,15 +2876,14 @@ def show_colors(nhues=17, minsat=0.2): def show_cmaps(*args, N=256, length=4.0, width=0.2, unknown='User'): """ - Visualizes all registered colormaps, or the list of colormap names if - positional arguments are passed. Adapted from `this example + Generate a table of the registered colormaps or the input colormaps. + Adapted from `this example `__. Parameters ---------- *args : colormap-spec, optional - Positional arguments are colormap names or objects. Default is - all of the registered colormaps. + Colormap names or objects. N : int, optional The number of levels in each colorbar. length : float or str, optional @@ -2944,14 +2962,13 @@ def show_cmaps(*args, N=256, length=4.0, width=0.2, unknown='User'): def show_cycles(*args, axwidth=1.5): """ - Visualizes all registered color cycles, or the list of cycle names if - positional arguments are passed. + Generate a table of registered color cycle names or the input color + cycles. Parameters ---------- *args : colormap-spec, optional - Positional arguments are cycle names or objects. Default is - all of the registered colormaps. + Cycle names or objects. axwidth : str or float, optional Average width of each subplot. Units are interpreted by `~proplot.utils.units`. @@ -2993,8 +3010,10 @@ def show_cycles(*args, axwidth=1.5): return fig def show_fonts(fonts=None, size=12): - """Displays table of the fonts installed by ProPlot or in the user-supplied - `fonts` list. Use `size` to change the fontsize for fonts shown in the figure.""" + """ + Generate a table of the fonts installed by ProPlot or by the user. + Use `size` to change the fontsize for fonts shown in the figure. + """ from . import subplots fonts = ('DejaVu Sans', *fonts_proplot) math = r'(0) + {1} - [2] * <3> / 4,0 $\geq\gg$ 5.0 $\leq\ll$ ~6 $\times$ 7 $\equiv$ 8 $\approx$ 9 $\propto$' diff --git a/proplot/subplots.py b/proplot/subplots.py index f3524985a..ca8aee039 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -175,7 +175,7 @@ # Helper classes #-----------------------------------------------------------------------------# class _hide_labels(object): - """Hides objects temporarily so they are ignored by the tight bounding + """Hide objects temporarily so they are ignored by the tight bounding box algorithm.""" def __init__(self, *args): self._labels = args @@ -222,7 +222,7 @@ def __setitem__(self, key, value): raise LookupError('axes_grid is immutable.') def __getitem__(self, key): - """If an integer is passed, the item is returned, and if a slice is passed, + """If an integer is passed, the item is returned. If a slice is passed, an `axes_grid` of the items is returned. You can also use 2D indexing, and the corresponding axes in the axes grid will be chosen. @@ -297,13 +297,15 @@ def __getitem__(self, key): def __getattr__(self, attr): """ - If the attribute is *callable*, returns a dummy function that loops + If the attribute is *callable*, return a dummy function that loops through each identically named method, calls them in succession, and returns a tuple of the results. This lets you call arbitrary methods - on multiple axes at once! If the `axes_grid` has length ``1``, - just returns the single result. If the attribute is *not callable*, - returns a tuple of identically named attributes for every object in - the list. + on multiple axes at once! If the `axes_grid` has length ``1``, the + single result is returned. + + If the attribute is *not callable*, return a tuple of identically + named attributes for every object in the list. If the `axes_grid` has + length ``1``, the single value is returned. Example ------- @@ -422,8 +424,8 @@ def __init__(self, nrows=1, ncols=1, self.set_margins(left, right, bottom, top) def _sanitize_hspace(self, space): - """Sanitizes input wspace vector. This needs to be set apart from - set_wspace because gridspec params adopted from the figure are often + """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)) @@ -434,7 +436,7 @@ def _sanitize_hspace(self, space): return space def _sanitize_wspace(self, space): - """Sanitizes input wspace vector.""" + """Sanitize the wspace vector.""" N = self._ncols space = np.atleast_1d(units(space)) if len(space) == 1: @@ -446,7 +448,7 @@ def _sanitize_wspace(self, space): self.wspace[filter] = space[filter] def add_figure(self, figure): - """Adds `~matplotlib.figure.Figure` to the list of figures that are + """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): @@ -454,7 +456,7 @@ def add_figure(self, figure): self._figures.add(figure) def get_grid_positions(self, figure, raw=False): - """Calculates grid positions using the input figure. Also scales + """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 @@ -519,29 +521,29 @@ def get_grid_positions(self, figure, raw=False): return fig_bottoms, fig_tops, fig_lefts, fig_rights def get_subplot_params(self, figure=None): - """Method is disabled because ProPlot does not and cannot use the - SubplotParams stored on figures.""" + """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.') def get_hspace(self): - """Returns vector of row spaces.""" + """Return the vector of row spaces.""" return self.hspace def get_margins(self): - """Returns left, bottom, right, top margin spaces.""" + """Return the left, bottom, right, top margin spaces.""" return self.left, self.bottom, self.right, self.top def get_wspace(self): - """Returns vector of column spaces.""" + """Return the vector of column spaces.""" return self.wspace def remove_figure(self, figure): - """Removes `~matplotlib.figure.Figure` from the list of figures that + """Remove `~matplotlib.figure.Figure` from the list of figures that are using this gridspec.""" self._figures.discard(figure) def set_height_ratios(self, ratios): - """Sets row height ratios. Value must be a vector of length + """Set the row height ratios. Value must be a vector of length ``nrows``.""" N = self._nrows ratios = np.atleast_1d(ratios) @@ -552,7 +554,7 @@ def set_height_ratios(self, ratios): super().set_height_ratios(self) def set_hspace(self, space): - """Sets inter-row spacing in physical units. Units are interpreted + """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) @@ -560,7 +562,7 @@ def set_hspace(self, space): self.hspace[filter] = space[filter] def set_margins(self, left, right, bottom, top): - """Sets margin values in physical units. Units are interpreted by + """Set the margin values in physical units. Units are interpreted by `~proplot.utils.units`.""" if left is not None: self.left = units(left) @@ -572,7 +574,7 @@ def set_margins(self, left, right, bottom, top): self.top = units(top) def set_width_ratios(self, ratios): - """Sets column width ratios. Value must be a vector of length + """Set the column width ratios. Value must be a vector of length ``ncols``.""" N = self._ncols ratios = np.atleast_1d(ratios) @@ -583,7 +585,7 @@ def set_width_ratios(self, ratios): super().set_width_ratios(self) def set_wspace(self, space): - """Sets inter-column spacing in physical units. Units are interpreted + """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) @@ -599,9 +601,8 @@ 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): """ - Optionally updates the gridspec with arbitrary initialization keyword - arguments then *applies* those updates for every figure using this - gridspec object. + Update the gridspec with arbitrary initialization keyword arguments + then *apply* those updates for 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 @@ -636,15 +637,14 @@ def update(self, left=None, right=None, bottom=None, top=None, # Figure class and geometry class #-----------------------------------------------------------------------------# _gridspec_doc = """ -Applies the `GridSpec` to the figure or generates a new `GridSpec` +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. """ _save_doc = """ -Saves the figure to `filename`. Also scales figure dimensions to account for -axes; aligns row, column, and axis labels; and optionally applies "tight -layout" gridspec adjustments. +Save the figure to `filename` after performing the post-processing steps +described in `~Figure.draw`. Parameters ---------- @@ -656,14 +656,14 @@ def update(self, left=None, right=None, bottom=None, top=None, """ def _approx_equal(num1, num2, digits=10): - """Tests equality of two floating point numbers out to `N` digits.""" + """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 _get_panelargs(side, share=None, width=None, space=None, filled=False, figure=False): - """Returns default properties for new axes and figure panels.""" + """Return default properties for new axes and figure panels.""" s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid panel spec {side!r}.') @@ -683,7 +683,7 @@ def _get_panelargs(side, return share, width, space, space_user def _get_space(key, share=0, pad=None): - """Returns suitable default spacing given a shared axes setting.""" + """Return suitable default spacing given a shared axes setting.""" if key == 'left': space = units(_notNone(pad, rc['subplots.pad'])) + ( rc['ytick.major.size'] + rc['ytick.labelsize'] @@ -746,7 +746,7 @@ def __init__(self, figure): self._isinit = False def _init(self): - """Fills the spacing parameters with defaults.""" + """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 @@ -797,7 +797,7 @@ def _init(self): # """Resizes the figure based on current spacing values.""" # def _update_gridspec(self, nrows=None, ncols=None, array=None, **kwargs): def resize(self): - """Determines the figure size necessary to preserve physical + """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 @@ -923,7 +923,7 @@ def resize(self): def update(self, renderer=None): - """Updates the default values in case the spacing has changed.""" + """Update the default values in case the spacing has changed.""" fig = self.figure gs = fig.gridspec if gs is None: @@ -980,7 +980,7 @@ def _adjust_aspect(self): self._update_gridspec() def _adjust_tight_layout(self, renderer): - """Applies tight layout scaling that permits flexible figure + """Apply tight layout scaling that permits flexible figure dimensions and preserves panel widths and subplot aspect ratios. The `renderer` should be a `~matplotlib.backend_bases.RendererBase` instance.""" @@ -1071,7 +1071,7 @@ def _adjust_tight_layout(self, renderer): self._update_gridspec() def _align_axislabels(self, b=True): - """Aligns spanning *x* and *y* axis labels, accounting for figure + """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 @@ -1133,8 +1133,8 @@ def _align_axislabels(self, b=True): spanlabel.update({'position':position, 'transform':transform}) def _align_suplabels(self, renderer): - """Adjusts position of row and column labels, and aligns figure - super title accounting for figure marins and axes and figure panels.""" + """Adjust the position of row and column labels, and align figure + super title accounting for figure margins and axes and figure panels.""" # Offset using tight bounding boxes # TODO: Super labels fail with popup backend!! Fix this # NOTE: Must use get_tightbbox so (1) this will work if tight layout @@ -1327,7 +1327,7 @@ def __init__(self, @_counter def _add_axes_panel(self, ax, side, filled=False, **kwargs): - """Hidden method that powers `~proplot.axes.panel_axes`.""" + """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 @@ -1382,8 +1382,8 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): def _add_figure_panel(self, side, span=None, row=None, col=None, rows=None, cols=None, **kwargs): - """Adds figure panels. Also modifies the panel attribute stored - on the figure to include these panels.""" + """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': @@ -1471,8 +1471,8 @@ def _add_figure_panel(self, side, return pax def _get_align_coord(self, side, axs): - """Returns figure coordinate for spanning labels and super title. The - `x` can be ``'x'`` or ``'y'``.""" + """Return the figure coordinate for spanning labels and super titles. + The `x` can be ``'x'`` or ``'y'``.""" # Get position in figure relative coordinates s = side[0] x = ('y' if s in 'lr' else 'x') @@ -1495,7 +1495,7 @@ def _get_align_coord(self, side, axs): return pos, spanax def _get_align_axes(self, side): - """Returns main axes along the left, right, bottom, or top sides + """Return the main axes along the left, right, bottom, or top sides of the figure.""" # Initial stuff s = side[0] @@ -1519,10 +1519,9 @@ def _get_align_axes(self, side): def _insert_row_column(self, side, idx, ratio, space, space_orig, figure=False, ): - """Helper function that "overwrites" 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.""" + """"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 @@ -1603,8 +1602,8 @@ def _insert_row_column(self, side, idx, # return gs, slot_new 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.""" + """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): @@ -1620,8 +1619,8 @@ def _iter_axes(self): return axs def _update_axislabels(self, axis=None, **kwargs): - """Applies axis labels to the relevant shared axis. If spanning - labels are toggled, keeps the labels synced for all subplots in the + """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 @@ -1650,7 +1649,7 @@ def _update_axislabels(self, axis=None, **kwargs): getattr(pax, x + 'axis').label.update(kwargs) def _update_suplabels(self, ax, side, labels, **kwargs): - """Assigns side labels and updates label settings. The labels are + """Assign side labels and update label settings. The labels are aligned down the line by geometry_configurators.""" s = side[0] if s not in 'lrbt': @@ -1674,14 +1673,14 @@ def _update_suplabels(self, ax, side, labels, **kwargs): obj.update(kwargs) def _update_suptitle(self, title, **kwargs): - """Assign figure "super title".""" + """Assign the figure "super title".""" if title is not None and self._suptitle.get_text() != title: self._suptitle.set_text(title) if kwargs: self._suptitle.update(kwargs) def add_gridspec(self, *args, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" return self.set_gridspec(*args, **kwargs) def add_subplot(self, *args, @@ -1690,7 +1689,7 @@ def add_subplot(self, *args, sharex=None, sharey=None, **kwargs): """ - Adds subplot using the existing figure gridspec. + Add a subplot to the figure. Parameters ---------- @@ -1814,7 +1813,7 @@ def colorbar(self, *args, row=None, col=None, rows=None, cols=None, span=None, **kwargs): """ - Draws a colorbar along the left, right, bottom, or top side + Draw a colorbar along the left, right, bottom, or top side of the figure, centered between the leftmost and rightmost (or topmost and bottommost) main axes. @@ -1870,20 +1869,20 @@ def colorbar(self, *args, return ax.colorbar(*args, loc='_fill', **kwargs) def get_alignx(self): - """Returns the *x* axis label alignment mode.""" + """Return the *x* axis label alignment mode.""" return self._alignx def get_aligny(self): - """Returns the *y* axis label alignment mode.""" + """Return the *y* axis label alignment mode.""" return self._aligny def get_gridspec(self): - """Returns the single `GridSpec` instance associated with this figure. + """Return the single `GridSpec` instance associated with this figure. If the `GridSpec` has not yet been initialized, returns ``None``.""" return self._gridspec def get_ref_axes(self): - """Returns the reference axes associated with the reference axes + """Return the reference axes associated with the reference axes number `Figure.ref`.""" for ax in self._mainaxes: if ax.number == self.ref: @@ -1891,19 +1890,19 @@ def get_ref_axes(self): return None # no error def get_sharex(self): - """Returns the *x* axis sharing level.""" + """Return the *x* axis sharing level.""" return self._sharex def get_sharey(self): - """Returns the *y* axis sharing level.""" + """Return the *y* axis sharing level.""" return self._sharey def get_spanx(self): - """Returns the *x* axis label spanning mode.""" + """Return the *x* axis label spanning mode.""" return self._spanx def get_spany(self): - """Returns the *y* axis label spanning mode.""" + """Return the *y* axis label spanning mode.""" return self._spany def legend(self, *args, @@ -1911,7 +1910,7 @@ def legend(self, *args, row=None, col=None, rows=None, cols=None, span=None, **kwargs): """ - Draws a legend along the left, right, bottom, or top side of the + Draw a legend along the left, right, bottom, or top side of the figure, centered between the leftmost and rightmost (or topmost and bottommost) main axes. @@ -1961,9 +1960,9 @@ def legend(self, *args, @_counter def draw(self, renderer): - """Draws the figure. Also scales figure dimensions to account for axes; - aligns row, column, and axis labels; and optionally applies "tight - layout" gridspec adjustments.""" + """Draw the figure and apply various post-processing steps: Scale the + figure dimensions to account for axes; align row, column, and axis + labels; and optionally apply "tight layout" gridspec adjustments.""" # Renderer fixes # WARNING: *Critical* that draw() is invoked with the same renderer # FigureCanvasAgg.print_png() uses to render the image. But print_png() @@ -2000,7 +1999,7 @@ def draw(self, renderer): return super().draw(renderer) def save(self, filename, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" filename = os.path.expanduser(filename) canvas = getattr(self, 'canvas', None) if hasattr(canvas, 'get_renderer'): @@ -2020,21 +2019,21 @@ def save(self, filename, **kwargs): super().savefig(filename, **kwargs) def savefig(self, filename, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" return self.save(filename, **kwargs) def set_alignx(self, value): - """Sets the *x* axis label alignment mode.""" + """Set the *x* axis label alignment mode.""" self.stale = True self._alignx = bool(value) def set_aligny(self, value): - """Sets the *y* axis label alignment mode.""" + """Set the *y* axis label alignment mode.""" self.stale = True self._aligny = bool(value) def set_gridspec(self, *args, **kwargs): - """Docstring applied below.""" + """This docstring is replaced below.""" # Create and apply the gridspec if self._gridspec is not None: raise RuntimeError(f'The gridspec has already been declared and multiple GridSpecs are not allowed. Call Figure.get_gridspec() to retrieve it.') @@ -2052,7 +2051,7 @@ def set_gridspec(self, *args, **kwargs): return gs def set_sharex(self, value): - """Sets the *x* axis sharing level.""" + """Set the *x* axis sharing level.""" value = int(value) if value not in range(4): raise ValueError(f'Invalid sharing level sharex={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') @@ -2060,7 +2059,7 @@ def set_sharex(self, value): self._sharex = value def set_sharey(self, value): - """Sets the *y* axis sharing level.""" + """Set the *y* axis sharing level.""" value = int(value) if value not in range(4): raise ValueError(f'Invalid sharing level sharey={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') @@ -2068,12 +2067,12 @@ def set_sharey(self, value): self._sharey = value def set_spanx(self, value): - """Sets the *x* axis label spanning mode.""" + """Set the *x* axis label spanning mode.""" self.stale = True self._spanx = bool(value) def set_spany(self, value): - """Sets the *y* axis label spanning mode.""" + """Set the *y* axis label spanning mode.""" self.stale = True self._spany = bool(value) @@ -2146,7 +2145,7 @@ def _axes_dict(naxs, value, kw=False, default=None): return kwargs def _journal_figsize(journal): - """Journal sizes for figures.""" + """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: @@ -2161,23 +2160,23 @@ def _journal_figsize(journal): return width, height def close(): - """Alias for ``matplotlib.pyplot.close('all')``, included so you don't have - to import `~matplotlib.pyplot`. Closes all figures stored + """Alias for ``matplotlib.pyplot.close('all')``. This is included so you + don't have to import `~matplotlib.pyplot`. Closes all figures stored in memory.""" plt.close('all') def show(): - """Alias for ``matplotlib.pyplot.show()``, included so you don't have - to import `~matplotlib.pyplot`. Note this command should be + """Alias for ``matplotlib.pyplot.show()``. This is included so you don't + have to import `~matplotlib.pyplot`. Note this command should be unnecessary if you are doing inline iPython notebook plotting and ran the - `~proplot.notebook.nbsetup` command.""" + `~proplot.notebook.notebook_setup` command.""" plt.show() # TODO: Figure out how to save subplots keyword args! @docstring.dedent_interpd def figure(**kwargs): """ - Analogous to `matplotlib.pyplot.figure`, creates an empty figure meant + Analogous to `matplotlib.pyplot.figure`, create an empty figure meant to be filled with axes using `Figure.add_subplot`. Parameters @@ -2195,13 +2194,9 @@ def subplots(array=None, ncols=1, nrows=1, ref=1, order='C', basemap=False, **kwargs ): """ - Analogous to `matplotlib.pyplot.subplots`, creates a figure with a single - axes or arbitrary grids of axes, any of which can be map projections, - and optional "panels" along axes or figure edges. - - The parameters are sorted into the following rough sections: subplot grid - specifications, figure and subplot sizes, axis sharing, - figure panels, axes panels, and map projections. + Analogous to `matplotlib.pyplot.subplots`, create a figure with a single + axes or arbitrary grid of axes. The axes can use arbitrary map + projections. Parameters ---------- diff --git a/proplot/utils.py b/proplot/utils.py index 266956eb0..8e3a2e7d4 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -17,7 +17,7 @@ __all__ = ['arange', 'edges', 'units'] NUMBER = re.compile('^([-+]?[0-9._]+([eE][-+]?[0-9_]+)?)(.*)$') -BENCHMARK = False +BENCHMARK = True class _benchmark(object): """Context object that can be used to time import statements.""" @@ -30,19 +30,8 @@ def __exit__(self, *args): if BENCHMARK: print(f'{self.message}: {time.clock() - self.time}s') -def _logger(func): - """A decorator that logs the activity of the script (it actually just prints it, - but it could be logging!). See: https://stackoverflow.com/a/1594484/4970632""" - @functools.wraps(func) - def decorator(*args, **kwargs): - res = func(*args, **kwargs) - if BENCHMARK: - print(f'{func.__name__} called with: {args} {kwargs}') - return res - return decorator - def _timer(func): - """A decorator that prints the time a function takes to execute. + """Decorator that prints the time a function takes to execute. See: https://stackoverflow.com/a/1594484/4970632""" @functools.wraps(func) def decorator(*args, **kwargs): @@ -55,7 +44,7 @@ def decorator(*args, **kwargs): return decorator def _counter(func): - """A decorator that counts and prints the cumulative time a function + """Decorator that counts and prints the cumulative time a function has benn running. See: https://stackoverflow.com/a/1594484/4970632""" @functools.wraps(func) def decorator(*args, **kwargs): @@ -72,10 +61,10 @@ def decorator(*args, **kwargs): return decorator def _notNone(*args, names=None): - """Returns the first non-``None`` value, used with keyword arg aliases and - for setting default values. Ugly name but clear purpose. Pass the `names` - keyword arg to issue warning if multiple args were passed. Must be list - of non-empty strings.""" + """Return the first non-``None`` value. This is used with keyword arg + aliases and for setting default values. Ugly name but clear purpose. Pass + the `names` keyword arg to issue warning if multiple args were passed. Must + be list of non-empty strings.""" if names is None: for arg in args: if arg is not None: @@ -93,15 +82,16 @@ def _notNone(*args, names=None): first = arg if name: kwargs[name] = arg - if len(kwargs)>1: - warnings.warn(f'Got conflicting or duplicate keyword args, using the first one: {kwargs}') + if len(kwargs) > 1: + warnings.warn(f'Got conflicting or duplicate keyword args: {kwargs}. Using the first one.') return first # Accessible for user def arange(min_, *args): - """Identical to `numpy.arange`, but with inclusive endpoints. For + """Identical to `numpy.arange` but with inclusive endpoints. For example, ``plot.arange(2,4)`` returns ``np.array([2,3,4])`` instead - of ``np.array([2,3])``.""" + of ``np.array([2,3])``. This command is useful for generating lists of + tick locations or colorbar level boundaries.""" # Optional arguments just like np.arange if len(args) == 0: max_ = min_ @@ -129,10 +119,12 @@ def arange(min_, *args): def edges(array, axis=-1): """ - Calculates approximate "edge" values given "center" values. This is used - internally to calculate graitule edges when you supply centers to - `~matplotlib.axes.Axes.pcolor` or `~matplotlib.axes.Axes.pcolormesh`, and - in a few other places. + Calculate the approximate "edge" values along an arbitrary axis, given + "center" values. This is used internally to calculate graticule edges when + you supply centers to `~matplotlib.axes.Axes.pcolor` or + `~matplotlib.axes.Axes.pcolormesh`, and to calculate colorbar level edges + when you supply centers to any method wrapped by + `~proplot.wrappers.cmap_changer`. Parameters ---------- @@ -170,9 +162,9 @@ def edges(array, axis=-1): def units(value, units='in', axes=None, figure=None, width=True): """ - Converts values and lists of values between arbitrary physical units. This - function is used internally all over ProPlot, permitting flexible units - for various keyword arguments. + Convert values and lists of values between arbitrary physical units. This + is used internally all over ProPlot, permitting flexible units for various + keyword arguments. Parameters ---------- From fd386f0423c8e2040fdbccf3cee3f7a7cc63ade2 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Tue, 12 Nov 2019 21:43:13 -0700 Subject: [PATCH 25/37] New show_cycles, cleanup and benchmarking changes --- proplot/__init__.py | 9 +-- proplot/rctools.py | 13 +-- proplot/styletools.py | 179 +++++++++++++++++++----------------------- proplot/subplots.py | 5 +- proplot/utils.py | 13 ++- 5 files changed, 100 insertions(+), 119 deletions(-) diff --git a/proplot/__init__.py b/proplot/__init__.py index 29dadb2a7..6e889b122 100644 --- a/proplot/__init__.py +++ b/proplot/__init__.py @@ -1,15 +1,12 @@ #!/usr/bin/env python3 -#------------------------------------------------------------------------------# # Import everything into the top-level module namespace -# Have sepearate files for various categories, so we don't end up with a -# single enormous 12,000-line file -#------------------------------------------------------------------------------# -# Constants +# Package is broken up so we don't end up with a single enormous 12k line file name = 'ProPlot' __version__ = '1.0' # 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 +# 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 diff --git a/proplot/rctools.py b/proplot/rctools.py index 9903d6c51..5eaded17a 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -181,7 +181,7 @@ # TODO: Add 'style' option that overrides .proplotrc # Adapted from seaborn; see: https://github.com/mwaskom/seaborn/blob/master/seaborn/rcmod.py from . import utils -from .utils import _counter, _timer +from .utils import _counter, _timer, _benchmark import re import os import yaml @@ -189,15 +189,15 @@ import warnings import matplotlib.colors as mcolors import matplotlib.cm as mcm +from matplotlib import style, rcParams try: - import IPython - get_ipython = IPython.get_ipython + from IPython import get_ipython + from IPython.utils.io import capture_output except ModuleNotFoundError: get_ipython = lambda: None __all__ = ['rc', 'rc_configurator', 'notebook_setup'] # Initialize -from matplotlib import style, rcParams defaultParamsShort = { 'nbsetup': True, 'format': 'retina', @@ -630,7 +630,8 @@ def __init__(self, local=True): """ # Attributes and style object.__setattr__(self, '_context', []) - style.use('default') + with _benchmark(' use'): + style.use('default') # Update from defaults rcParamsLong.clear() @@ -1013,7 +1014,7 @@ def notebook_setup(): # Choose svg vector or retina hi-res bitmap backends autosave = rcParamsShort['autosave'] if autosave: # capture annoying message + line breaks - with IPython.utils.io.capture_output(): + with capture_output(): ipython.magic("autosave " + str(autosave)) ipython.magic("config InlineBackend.figure_formats = ['" + rcParamsShort['format'] + "']") ipython.magic("config InlineBackend.rc = {}") # no notebook-specific overrides diff --git a/proplot/styletools.py b/proplot/styletools.py index 8ff37d557..6f180c294 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -71,7 +71,7 @@ 'NegPos', 'Div', 'DryWet', 'Moisture', ), # Nice diverging maps - 'Miscellaneous Diverging': ( + 'Other Diverging': ( 'ColdHot', 'CoolWarm', 'BR', ), # cmOcean @@ -123,7 +123,7 @@ 'gist_earth', 'gist_gray', 'gist_heat', 'gist_ncar', 'gist_rainbow', 'gist_stern', 'gist_yarg', ), - 'Miscellaneous': ( + 'Other': ( 'binary', 'bwr', 'brg', # appear to be custom matplotlib, very simple construction 'cubehelix', 'wistia', 'CMRmap', # individually released 'seismic', 'terrain', 'nipy_spectral', # origin ambiguous @@ -1650,12 +1650,16 @@ def Colormap(*args, name=None, listmode='perceptual', cmaps = [] tmp = '_no_name' # name required, but we only care about name of final merged map for i,cmap in enumerate(args): - # First load data + # Properties specific to each map + ireverse = False if not np.iterable(reverse) else reverse[i] + ileft = None if not np.iterable(left) else left[i] + iright = None if not np.iterable(right) else right[i] + # Load registered colormaps and maps on file # TODO: Document how 'listmode' also affects loaded files if isinstance(cmap, str): if '.' in cmap: if os.path.isfile(os.path.expanduser(cmap)): - tmp, cmap = _load_cmap(cmap, cmap=(listmode != 'listed')) + cmap = _load_cmap(cmap, listed=(listmode == 'listed')) else: raise FileNotFoundError(f'Colormap or cycle file {cmap!r} not found.') else: @@ -1663,10 +1667,6 @@ def Colormap(*args, name=None, listmode='perceptual', cmap = mcm.cmap_d[cmap] except KeyError: pass - # Properties specific to each map - ireverse = False if not np.iterable(reverse) else reverse[i] - ileft = None if not np.iterable(left) else left[i] - iright = None if not np.iterable(right) else right[i] # Convert matplotlib colormaps to subclasses if isinstance(cmap, (ListedColormap, LinearSegmentedColormap)): pass @@ -2568,6 +2568,45 @@ def register_fonts(): }) fonts[:] = sorted((*fonts_system, *fonts_proplot)) +def _draw_bars(cmapdict, length=4.0, width=0.2, nrows=None): + """ + Draw colorbars for "colormaps" and "color cycles". This is called by + `show_cycles` and `show_cmaps`. + """ + # Figure + from . import subplots + naxs = len(cmapdict) + sum(map(len, cmapdict.values())) + fig, axs = subplots( + nrows=naxs, axwidth=length, axheight=width, + share=0, hspace=0.03, + ) + iax = -1 + nheads = nbars = 0 # for deciding which axes to plot in + a = np.linspace(0, 1, 257).reshape(1,-1) + a = np.vstack((a,a)) + for cat,names in cmapdict.items(): + if not names: + continue + nheads += 1 + for imap,name in enumerate(names): + iax += 1 + if imap + nheads + nbars > naxs: + break + ax = axs[iax] + if imap == 0: # allocate this axes for title + iax += 1 + ax.set_visible(False) + ax = axs[iax] + cmap = mcm.cmap_d[name] + ax.imshow(a, cmap=name, origin='lower', aspect='auto', + levels=cmap.N) + ax.format(ylabel=name, + ylabel_kw={'rotation':0, 'ha':'right', 'va':'center'}, + xticks='none', yticks='none', # no ticks + xloc='neither', yloc='neither', # no spines + title=(cat if imap == 0 else None)) + nbars += len(names) + def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, width=100, aspect=1, axwidth=1.7): """ @@ -2874,7 +2913,7 @@ def show_colors(nhues=17, minsat=0.2): figs.append(fig) return figs -def show_cmaps(*args, N=256, length=4.0, width=0.2, unknown='User'): +def show_cmaps(*args, N=None, unknown='User', **kwargs): """ Generate a table of the registered colormaps or the input colormaps. Adapted from `this example @@ -2885,16 +2924,17 @@ def show_cmaps(*args, N=256, length=4.0, width=0.2, unknown='User'): *args : colormap-spec, optional Colormap names or objects. N : int, optional - The number of levels in each colorbar. + The number of levels in each colorbar. Default is + :rc:`image.lut`. + unknown : str, optional + Category name for colormaps that are unknown to ProPlot. The + default is ``'User'``. length : float or str, optional - The length of each colorbar. Units are interpreted by + The length of the colorbars. Units are interpreted by `~proplot.utils.units`. width : float or str, optional - The width of each colorbar. Units are interpreted by + The width of the colorbars. Units are interpreted by `~proplot.utils.units`. - unknown : str, optional - Category name for colormaps that are unknown to ProPlot. The - default is ``'User'``. Returns ------- @@ -2902,65 +2942,26 @@ def show_cmaps(*args, N=256, length=4.0, width=0.2, unknown='User'): The figure. """ # Have colormaps separated into categories + N = _notNone(N, rcParams['image.lut']) if args: - inames = [Colormap(cmap, N=N).name for cmap in args] + names = [Colormap(cmap, N=N).name for cmap in args] else: - inames = [ - name for name in mcm.cmap_d.keys() if name not in ('vega', 'greys', 'no_name') - and isinstance(mcm.cmap_d[name], LinearSegmentedColormap) + names = [name for name in mcm.cmap_d.keys() if + isinstance(mcm.cmap_d[name], LinearSegmentedColormap) ] # Get dictionary of registered colormaps and their categories - inames = list(map(str.lower, inames)) - cats = {cat:names for cat,names in CMAPS_TABLE.items()} - cats_plot = {cat:[name for name in names if name.lower() in inames] for cat,names in cats.items()} - # Distinguish known from unknown (i.e. user) maps, add as a new category - imaps_known = [name.lower() for cat,names in cats.items() for name in names if name.lower() in inames] - imaps_unknown = [name for name in inames if name not in imaps_known] - # Remove categories with no known maps and put user at start - cats_plot = {unknown:imaps_unknown, **cats_plot} - cats_plot = {cat:maps for cat,maps in cats_plot.items() if maps} + cmapdict = {} + names_all = list(map(str.lower, names)) + names_known = sum(CMAPS_TABLE.values(), []) + cmapdict[unknown] = [name for name in names if name not in names_known] + for cat,names in CMAPS_TABLE.items(): + cmapdict[cat] = [name for name in names if name.lower() in names_all] - # Figure - from . import subplots - naxs = len(imaps_known) + len(imaps_unknown) + len(cats_plot) - fig, axs = subplots( - nrows=naxs, axwidth=length, axheight=width, - share=0, hspace=0.03, - ) - iax = -1 - ntitles = nplots = 0 # for deciding which axes to plot in - a = np.linspace(0, 1, 257).reshape(1,-1) - a = np.vstack((a,a)) - for cat,names in cats_plot.items(): - # Space for title - if not names: - continue - ntitles += 1 - for imap,name in enumerate(names): - # Draw colorbar - iax += 1 - if imap + ntitles + nplots > naxs: - break - ax = axs[iax] - if imap == 0: # allocate this axes for title - iax += 1 - ax.set_visible(False) - ax = axs[iax] - if name not in mcm.cmap_d or name.lower() not in inames: # i.e. the expected builtin colormap is missing - ax.set_visible(False) # empty space - continue - ax.imshow(a, cmap=name, origin='lower', aspect='auto', levels=N) - ax.format(ylabel=name, - ylabel_kw={'rotation':0, 'ha':'right', 'va':'center'}, - xticks='none', yticks='none', # no ticks - xloc='neither', yloc='neither', # no spines - title=(cat if imap == 0 else None)) - # Space for plots - nplots += len(names) - return fig + # Return figure of colorbars + return _draw_bars(cmapdict, **kwargs) -def show_cycles(*args, axwidth=1.5): +def show_cycles(*args, **kwargs): """ Generate a table of registered color cycle names or the input color cycles. @@ -2969,8 +2970,12 @@ def show_cycles(*args, axwidth=1.5): ---------- *args : colormap-spec, optional Cycle names or objects. - axwidth : str or float, optional - Average width of each subplot. Units are interpreted by `~proplot.utils.units`. + length : float or str, optional + The length of the colorbars. Units are interpreted by + `~proplot.utils.units`. + width : float or str, optional + The width of the colorbars. Units are interpreted by + `~proplot.utils.units`. Returns ------- @@ -2979,35 +2984,15 @@ def show_cycles(*args, axwidth=1.5): """ # Get the list of cycles if args: - icycles = [colors(cycle) for cycle in args] + names = [Colormap(cmap, listmode='listed').name for cmap in args] else: - icycles = {key:mcm.cmap_d[key].colors for key in cycles} # use global cycles variable - nrows = len(icycles)//3 + len(icycles)%3 + names = [name for name in mcm.cmap_d.keys() if + isinstance(mcm.cmap_d[name], ListedColormap) + ] - # Create plot - from . import subplots - state = np.random.RandomState(12345) - fig, axs = subplots( - ncols=3, nrows=nrows, aspect=1, axwidth=axwidth, - sharey=False, sharex=False, axpad=0.05 - ) - for i,(ax,(key,cycle)) in enumerate(zip(axs, icycles.items())): - key = key.lower() - array = state.rand(20,len(cycle)) - 0.5 - array = array[:,:1] + array.cumsum(axis=0) + np.arange(0,len(cycle)) - for j,color in enumerate(cycle): - l, = ax.plot(array[:,j], lw=5, ls='-', color=color) - l.set_zorder(10+len(cycle)-j) # make first lines have big zorder - title = f'{key}: {len(cycle)} colors' - ax.set_title(title) - ax.grid(True) - for axis in 'xy': - ax.tick_params(axis=axis, - which='both', labelbottom=False, labelleft=False, - bottom=False, top=False, left=False, right=False) - if axs[i+1:]: - axs[i+1:].set_visible(False) - return fig + # Return figure of colorbars + cmapdict = {'Color cycles': names} + return _draw_bars(cmapdict, **kwargs) def show_fonts(fonts=None, size=12): """ @@ -3040,7 +3025,7 @@ def show_fonts(fonts=None, size=12): _cmap = mcm.cmap_d.get(_name, None) if _cmap and isinstance(_cmap, mcolors.ListedColormap): mcm.cmap_d[_name] = LinearSegmentedColormap.from_list(_name, _cmap.colors, cyclic=('twilight' in _name)) -for _cat in ('MATLAB', 'GNUplot', 'GIST', 'Miscellaneous'): +for _cat in ('MATLAB', 'GNUplot', 'GIST', 'Other'): for _name in CMAPS_TABLE[_cat]: mcm.cmap_d.pop(_name, None) diff --git a/proplot/subplots.py b/proplot/subplots.py index ca8aee039..cfaf2b25d 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -16,6 +16,7 @@ import matplotlib.figure as mfigure import matplotlib.transforms as mtransforms import matplotlib.gridspec as mgridspec +import matplotlib.pyplot as plt from numbers import Integral try: import matplotlib.backends.backend_macosx as mbackend @@ -23,9 +24,7 @@ mbackend = None from . import projs, axes from .rctools import rc -from .utils import _notNone, _counter, _benchmark, units -with _benchmark('pyplot'): - import matplotlib.pyplot as plt +from .utils import _notNone, _counter, units __all__ = [ 'axes_grid', 'close', 'EdgeStack', diff --git a/proplot/utils.py b/proplot/utils.py index 8e3a2e7d4..56d1e5386 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -4,12 +4,11 @@ """ import re import time -import numpy as np import functools import warnings -import matplotlib as mpl +import numpy as np +from matplotlib import rcParams from numbers import Number, Integral -rcParams = mpl.rcParams try: from icecream import ic except ImportError: # graceful fallback if IceCream isn't installed @@ -17,7 +16,7 @@ __all__ = ['arange', 'edges', 'units'] NUMBER = re.compile('^([-+]?[0-9._]+([eE][-+]?[0-9_]+)?)(.*)$') -BENCHMARK = True +BENCHMARK = False class _benchmark(object): """Context object that can be used to time import statements.""" @@ -39,13 +38,13 @@ def decorator(*args, **kwargs): t = time.clock() res = func(*args, **kwargs) if BENCHMARK: - print(f'{func.__name__}() time: {time.clock()-t}s') + print(f' {func.__name__}() time: {time.clock()-t}s') return res return decorator def _counter(func): """Decorator that counts and prints the cumulative time a function - has benn running. See: https://stackoverflow.com/a/1594484/4970632""" + has been running. See: https://stackoverflow.com/a/1594484/4970632""" @functools.wraps(func) def decorator(*args, **kwargs): if BENCHMARK: @@ -54,7 +53,7 @@ def decorator(*args, **kwargs): if BENCHMARK: decorator.time += (time.clock() - t) decorator.count += 1 - print(f'{func.__name__}() cumulative time: {decorator.time}s ({decorator.count} calls)') + print(f' {func.__name__}() cumulative time: {decorator.time}s ({decorator.count} calls)') return res decorator.time = 0 decorator.count = 0 # initialize From 1e488a78d187d0d674465ae4a43be6c34ce49b95 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Fri, 15 Nov 2019 13:07:03 -0700 Subject: [PATCH 26/37] Add tickminor global setting --- proplot/rctools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/proplot/rctools.py b/proplot/rctools.py index 5eaded17a..7b89bf537 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -65,6 +65,7 @@ ``ticklen`` Length of major ticks in points. ``tickdir`` Major and minor tick direction. Must be one of ``out``, ``in``, or ``inout``. ``tickpad`` Padding between ticks and tick labels in points. +``tickminor`` Boolean, toggles minor ticks on and off. ``grid`` Boolean, toggles major grid lines on and off. ``gridminor`` Boolean, toggles minor grid lines on and off. ``tickratio`` Ratio of minor tickline width to major tickline width. @@ -227,6 +228,7 @@ 'tickpad': 2.0, 'tickratio': 0.8, 'ticklenratio': 0.5, + 'tickminor': True, 'gridratio': 0.5, 'reso': 'lo', 'geogrid': True, @@ -413,7 +415,8 @@ def _tabulate(rcdict): 'gridminor': ('axes.gridminor',), 'geogrid': ('axes.geogrid',), 'ticklen': ('xtick.major.size', 'ytick.major.size'), - 'tickdir': ('xtick.direction', 'ytick.direction'), + 'tickdir': ('xtick.direction', 'ytick.direction'), + 'tickminor': ('xtick.minor.visible', 'ytick.minor.visible'), 'tickpad': ('xtick.major.pad', 'xtick.minor.pad', 'ytick.major.pad', 'ytick.minor.pad'), 'title.pad': ('axes.titlepad',), } From cd23f3f674a6a3e944d5ebfe5b94f226ac6e0c61 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sat, 16 Nov 2019 16:57:12 -0700 Subject: [PATCH 27/37] Use standard library for xml parsing Stash pop Stash --- docs/requirements.txt | 1 - proplot/styletools.py | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index bd05139a9..d4cf3b72b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,6 @@ # 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 -lxml>=4.0.0 pyyaml>=5.0.0 numpy>=1.14 ipython>=7.0.0 diff --git a/proplot/styletools.py b/proplot/styletools.py index 6f180c294..e3c364937 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -17,7 +17,7 @@ import glob import cycler from collections.abc import Sized -from lxml import etree +from xml.etree import ElementTree from numbers import Number, Integral from matplotlib import rcParams import numpy as np @@ -2302,12 +2302,12 @@ def _load_cmap(filename, listed=False): # Adapted from script found here: https://sciviscolor.org/matlab-matplotlib-pv44/ elif ext == 'xml': try: - xmldoc = etree.parse(filename) + doc = ElementTree.parse(filename) except IOError: warnings.warn(f'Failed to load {filename!r}.') return x, data = [], [] - for s in xmldoc.getroot().findall('.//Point'): + for s in doc.getroot().findall('.//Point'): # Verify keys if any(key not in s.attrib for key in 'xrgb'): warnings.warn(f'Failed to load {filename!r}. Missing an x, r, g, or b specification inside one or more tags.') @@ -2331,7 +2331,7 @@ def _load_cmap(filename, listed=False): string = open(filename).read() # into single string data = re.findall('#[0-9a-fA-F]{6}', string) # list of strings if len(data) < 2: - warnings.warn(f'Failed to load "{filename}".') + warnings.warn(f'Failed to load {filename!r}. No hex strings found.') return # Convert to array x = np.linspace(0, 1, len(data)) From d34750737c49e4208c4009d87f4e99e1bc935268 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sat, 16 Nov 2019 17:07:23 -0700 Subject: [PATCH 28/37] Stashed changes --- proplot/subplots.py | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index cfaf2b25d..3d9403c30 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -173,18 +173,6 @@ #-----------------------------------------------------------------------------# # Helper classes #-----------------------------------------------------------------------------# -class _hide_labels(object): - """Hide objects temporarily so they are ignored by the tight bounding - box algorithm.""" - 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 axes_grid(list): """List subclass and pseudo-2D array that is used as a container for the list of axes returned by `subplots`, lists of figure panels, and lists of @@ -353,6 +341,22 @@ def _iterator(*args, **kwargs): # Mixed raise AttributeError(f'Found mixed types for attribute {attr!r}.') + # TODO: Implement these + # def colorbar(self, loc=None): + # """Draws a colorbar that spans axes in the selected range.""" + # for ax in self: + # pass + # + # def legend(self, loc=None): + # """Draws a legend that spans axes in the selected range.""" + # for ax in self: + # pass + # + # def text(self, loc=None): + # """Draws text that spans axes in the selected range.""" + # for ax in self: + # pass + class SubplotSpec(mgridspec.SubplotSpec): """ Matplotlib `~matplotlib.gridspec.SubplotSpec` subclass that adds @@ -715,6 +719,19 @@ def _get_space(key, share=0, pad=None): raise KeyError(f'Invalid space key {key!r}.') return space +class _hide_labels(object): + """Hides objects temporarily so they are ignored by the tight bounding + box algorithm.""" + # TODO: Remove this by overriding the tight bounding box calculation + 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 EdgeStack(object): """ Container for groups of `~matplotlib.artist.Artist` objects stacked From d5c1199c6902c9fea7359358444ec73ac0ece306 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Tue, 19 Nov 2019 14:49:34 -0700 Subject: [PATCH 29/37] Add from_file staticmethods --- proplot/styletools.py | 87 +++++++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/proplot/styletools.py b/proplot/styletools.py index e3c364937..ace5bc81d 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -581,9 +581,9 @@ def __init__(self, *args, cyclic=False, **kwargs): Parameters ---------- cyclic : bool, optional - Whether this colormap is cyclic. This affects how colors at either - end of the colorbar are scaled, and whether `extend` settings other - than ``'neither'`` are allowed. + Whether the colormap is cyclic. If ``True``, this changes how the + leftmost and rightmost color levels are selected, and `extend` can only + be ``'neither'`` (a warning will be issued otherwise). *args, **kwargs Passed to `~matplotlib.colors.LinearSegmentedColormap`. """ @@ -909,6 +909,29 @@ def sliced(self, left=None, right=None, cut=None, name=None, **kwargs): kwargs[ikey] = gamma return self.updated(name, segmentdata, **kwargs) + @staticmethod + def from_file(path): + """ + Load colormap from a file. + Valid file extensions are described in the below table. + + ===================== ============================================================================================================================================================================================================= + Extension Description + ===================== ============================================================================================================================================================================================================= + ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': + ``.xml`` XML files with ```` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values. + ``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values. + ``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate. + ``.rgba``, ``.xrgba`` As with ``.rgb``, ``.xrgb``, but with a trailing opacity (or "alpha") column. + ===================== ============================================================================================================================================================================================================= + + Parameters + ---------- + path : str + The file path. + """ + return _from_file(path, listed=False) + @staticmethod def from_list(name, colors, *args, **kwargs): """ @@ -1077,6 +1100,29 @@ def sliced(self, left=None, right=None, name=None): colors = self.colors[left:right] return self.updated(colors, name, len(colors)) + @staticmethod + def from_file(path): + """ + Load color cycle from a file. + Valid file extensions are described in the below table. + + ===================== ============================================================================================================================================================================================================= + Extension Description + ===================== ============================================================================================================================================================================================================= + ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': + ``.xml`` XML files with ```` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values. + ``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values. + ``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate. + ``.rgba``, ``.xrgba`` As with ``.rgb``, ``.xrgb``, but with a trailing opacity (or "alpha") column. + ===================== ============================================================================================================================================================================================================= + + Parameters + ---------- + path : str + The file path. + """ + return _from_file(path, listed=True) + class PerceptuallyUniformColormap(LinearSegmentedColormap, _Colormap): """Similar to `~matplotlib.colors.LinearSegmentedColormap`, but instead of varying the RGB channels, the hue, saturation, and luminance channels @@ -1115,8 +1161,9 @@ def __init__(self, Whether to "clip" impossible colors, i.e. truncate HCL colors with RGB channels with values >1, or mask them out as gray. cyclic : bool, optional - Whether this colormap is cyclic. See `LinearSegmentedColormap` - for details. + Whether the colormap is cyclic. If ``True``, this changes how the + leftmost and rightmost color levels are selected, and `extend` can only + be ``'neither'`` (a warning will be issued otherwise). gamma : float, optional Sets `gamma1` and `gamma2` to this identical value. gamma1 : float, optional @@ -1659,7 +1706,10 @@ def Colormap(*args, name=None, listmode='perceptual', if isinstance(cmap, str): if '.' in cmap: if os.path.isfile(os.path.expanduser(cmap)): - cmap = _load_cmap(cmap, listed=(listmode == 'listed')) + if listmode == 'listed': + cmap = ListedColormap.from_file(cmap) + else: + cmap = LinearSegmentedColormap.from_file(cmap) else: raise FileNotFoundError(f'Colormap or cycle file {cmap!r} not found.') else: @@ -2251,7 +2301,7 @@ def _get_data_paths(dirname): paths.insert(0, ipath) return paths -def _load_cmap(filename, listed=False): +def _from_file(filename, listed=False): """Read generalized colormap and color cycle files.""" filename = os.path.expanduser(filename) if os.path.isdir(filename): # no warning @@ -2367,19 +2417,9 @@ def register_cmaps(): Register colormaps packaged with ProPlot or saved to the ``~/.proplot/cmaps`` folder. This is called on import. Maps are registered according to their filenames -- for example, ``name.xyz`` will be registered as ``'name'``. - Use `show_cmaps` to generate a table of the registered colormaps - - Valid extensions are described in the below table. - - ===================== ============================================================================================================================================================================================================= - Extension Description - ===================== ============================================================================================================================================================================================================= - ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': - ``.xml`` XML files with ```` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values. - ``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values. - ``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate. - ``.rgba``, ``.xrgba`` As with ``.rgb``, ``.xrgb``, but with a trailing opacity (or "alpha") column. - ===================== ============================================================================================================================================================================================================= + + For a table of valid extensions, see `LinearSegmentedColormap.from_file`. + To visualize the registerd colormaps, use `show_cmaps`. """ # Fill initial user-accessible cmap list with the colormaps we will keep cmaps.clear() @@ -2391,7 +2431,7 @@ def register_cmaps(): # Add colormaps from ProPlot and user directories for i,path in enumerate(_get_data_paths('cmaps')): for filename in sorted(glob.glob(os.path.join(path, '*'))): - cmap = _load_cmap(filename, listed=False) + cmap = LinearSegmentedColormap.from_file(filename) if not cmap: continue if i == 0 and cmap.name.lower() in ('phase', 'graycycle'): @@ -2411,7 +2451,8 @@ def register_cycles(): ``'name'`` as a `~matplotlib.colors.ListedColormap` map (see `Cycle` for details). Use `show_cycles` to generate a table of the registered cycles. - For valid file formats, see `register_cmaps`. + For a table of valid extensions, see `ListedColormap.from_file`. + To visualize the registerd colormaps, use `show_cmaps`. """ # Empty out user-accessible cycle list cycles.clear() @@ -2423,7 +2464,7 @@ def register_cycles(): # Read cycles from directories for path in _get_data_paths('cycles'): for filename in sorted(glob.glob(os.path.join(path, '*'))): - cmap = _load_cmap(filename, listed=True) + cmap = ListedColormap.from_file(filename) if not cmap: continue if isinstance(cmap, LinearSegmentedColormap): From 88af38439436969cba920fc4f4107aa471f21748 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 28 Nov 2019 12:41:23 -0700 Subject: [PATCH 30/37] Organize projs --- proplot/projs.py | 96 ++++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/proplot/projs.py b/proplot/projs.py index 101b9a1ee..fdf99b841 100644 --- a/proplot/projs.py +++ b/proplot/projs.py @@ -166,30 +166,65 @@ def Proj(name, basemap=False, **kwargs): proj = crs(**kwargs) return proj -# Various pseudo-rectangular projections -# Inspired by source code for Mollweide implementation -class Hammer(_WarpedRectangularProjection): - """The `Hammer `__ +# New cartopy projections +class NorthPolarAzimuthalEquidistant(AzimuthalEquidistant): + """Analogous to `~cartopy.crs.NorthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): + super().__init__(central_latitude=90, + central_longitude=central_longitude, globe=globe) + +class SouthPolarAzimuthalEquidistant(AzimuthalEquidistant): + """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): + super().__init__(central_latitude=-90, + central_longitude=central_longitude, globe=globe) + +class NorthPolarLambertAzimuthalEqualArea(LambertAzimuthalEqualArea): + """Analogous to `~cartopy.crs.NorthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): + super().__init__(central_latitude=90, + central_longitude=central_longitude, globe=globe) + +class SouthPolarLambertAzimuthalEqualArea(LambertAzimuthalEqualArea): + """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): + super().__init__(central_latitude=-90, + central_longitude=central_longitude, globe=globe) + +class NorthPolarGnomonic(Gnomonic): + """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): + super().__init__(central_latitude=90, + central_longitude=central_longitude, globe=globe) + +class SouthPolarGnomonic(Gnomonic): + """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): + super().__init__(central_latitude=-90, + central_longitude=central_longitude, globe=globe) + +class Aitoff(_WarpedRectangularProjection): + """The `Aitoff `__ projection.""" - __name__ = 'hammer' - name = 'hammer' + __name__ = 'aitoff' + name = 'aitoff' """Registered projection name.""" def __init__(self, central_longitude=0, globe=None): #, threshold=1e2): - proj4_params = {'proj':'hammer', 'lon_0':central_longitude} + proj4_params = {'proj':'aitoff', 'lon_0':central_longitude} super().__init__(proj4_params, central_longitude, globe=globe) @property def threshold(self): # how finely to interpolate line data, etc. """Projection resolution.""" return 1e4 -class Aitoff(_WarpedRectangularProjection): - """The `Aitoff `__ +class Hammer(_WarpedRectangularProjection): + """The `Hammer `__ projection.""" - __name__ = 'aitoff' - name = 'aitoff' + __name__ = 'hammer' + name = 'hammer' """Registered projection name.""" def __init__(self, central_longitude=0, globe=None): #, threshold=1e2): - proj4_params = {'proj':'aitoff', 'lon_0':central_longitude} + proj4_params = {'proj':'hammer', 'lon_0':central_longitude} super().__init__(proj4_params, central_longitude, globe=globe) @property def threshold(self): # how finely to interpolate line data, etc. @@ -224,43 +259,6 @@ def threshold(self): """Projection resolution.""" return 1e4 -# Extra polar projections matching basemap's options -class NorthPolarAzimuthalEquidistant(AzimuthalEquidistant): - """Analogous to `~cartopy.crs.NorthPolarStereo`.""" - def __init__(self, central_longitude=0.0, globe=None): - super().__init__(central_latitude=90, - central_longitude=central_longitude, globe=globe) - -class SouthPolarAzimuthalEquidistant(AzimuthalEquidistant): - """Analogous to `~cartopy.crs.SouthPolarStereo`.""" - def __init__(self, central_longitude=0.0, globe=None): - super().__init__(central_latitude=-90, - central_longitude=central_longitude, globe=globe) - -class NorthPolarLambertAzimuthalEqualArea(LambertAzimuthalEqualArea): - """Analogous to `~cartopy.crs.NorthPolarStereo`.""" - def __init__(self, central_longitude=0.0, globe=None): - super().__init__(central_latitude=90, - central_longitude=central_longitude, globe=globe) - -class SouthPolarLambertAzimuthalEqualArea(LambertAzimuthalEqualArea): - """Analogous to `~cartopy.crs.SouthPolarStereo`.""" - def __init__(self, central_longitude=0.0, globe=None): - super().__init__(central_latitude=-90, - central_longitude=central_longitude, globe=globe) - -class NorthPolarGnomonic(Gnomonic): - """Analogous to `~cartopy.crs.SouthPolarStereo`.""" - def __init__(self, central_longitude=0.0, globe=None): - super().__init__(central_latitude=90, - central_longitude=central_longitude, globe=globe) - -class SouthPolarGnomonic(Gnomonic): - """Analogous to `~cartopy.crs.SouthPolarStereo`.""" - def __init__(self, central_longitude=0.0, globe=None): - super().__init__(central_latitude=-90, - central_longitude=central_longitude, globe=globe) - # Hidden constants BASEMAP_TRANSLATE = { 'eqc': 'cyl', From b4df87428da00b709d8f9ca4b626a164e97cc5a9 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Fri, 29 Nov 2019 17:10:12 -0700 Subject: [PATCH 31/37] Pep8 compliance --- proplot/__init__.py | 30 +- proplot/axes.py | 611 ++++++++++++++++++++++++----------------- proplot/axistools.py | 9 +- proplot/colormath.py | 96 ++++--- proplot/projs.py | 67 +++-- proplot/rctools.py | 499 ++++++++++++++++++--------------- proplot/styletools.py | 485 ++++++++++++++++++-------------- proplot/subplots.py | 625 ++++++++++++++++++++++++++---------------- proplot/utils.py | 98 ++++--- proplot/wrappers.py | 20 +- 10 files changed, 1477 insertions(+), 1063 deletions(-) diff --git a/proplot/__init__.py b/proplot/__init__.py index fba9c3669..f4d885940 100644 --- a/proplot/__init__.py +++ b/proplot/__init__.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 # Import everything into the top-level module namespace -#------------------------------------------------------------------------------# # 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 +# 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 warnings as _warnings import pkg_resources as _pkg from .utils import _benchmark @@ -68,31 +70,9 @@ def _warning_proplot(message, category, filename, lineno, line=None): if not _os.path.isdir(_rc_sub): _os.mkdir(_rc_sub) -# Import stuff in reverse dependency order -# Make sure to load styletools early so we can try to update TTFPATH before -# the fontManager is loaded by other modules (requiring a rebuild) -from .utils import _benchmark -with _benchmark('total time'): - from .utils import * - with _benchmark('styletools'): - from .styletools import * - with _benchmark('rctools'): - from .rctools import * - with _benchmark('axistools'): - from .axistools import * - with _benchmark('wrappers'): - from .wrappers import * - with _benchmark('projs'): - from .projs import * - with _benchmark('axes'): - from .axes import * - with _benchmark('subplots'): - from .subplots import * - # SCM versioning name = 'proplot' try: version = __version__ = _pkg.get_distribution(__name__).version except _pkg.DistributionNotFound: version = __version__ = 'unknown' - diff --git a/proplot/axes.py b/proplot/axes.py index 54bd71e46..64e809e6c 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -78,34 +78,35 @@ 'r': 'right', 'b': 'bottom', 't': 'top', - } +} LOC_TRANSLATE = { 'inset': 'best', - 'i': 'best', - 0: 'best', - 1: 'upper right', - 2: 'upper left', - 3: 'lower left', - 4: 'lower right', - 5: 'center left', - 6: 'center right', - 7: 'lower center', - 8: 'upper center', - 9: 'center', - 'l': 'left', - 'r': 'right', - 'b': 'bottom', - 't': 'top', - 'c': 'center', - 'ur': 'upper right', - 'ul': 'upper left', - 'll': 'lower left', - 'lr': 'lower right', - 'cr': 'center right', - 'cl': 'center left', - 'uc': 'upper center', - 'lc': 'lower center', - } + 'i': 'best', + 0: 'best', + 1: 'upper right', + 2: 'upper left', + 3: 'lower left', + 4: 'lower right', + 5: 'center left', + 6: 'center right', + 7: 'lower center', + 8: 'upper center', + 9: 'center', + 'l': 'left', + 'r': 'right', + 'b': 'bottom', + 't': 'top', + 'c': 'center', + 'ur': 'upper right', + 'ul': 'upper left', + 'll': 'lower left', + 'lr': 'lower right', + 'cr': 'center right', + 'cl': 'center left', + 'uc': 'upper center', + 'lc': 'lower center', +} + def _abc(i): """Function for a-b-c labeling, returns a...z...aa...zz...aaa...zzz.""" @@ -129,15 +130,13 @@ def _wrapper(self, *args, **kwargs): return _wrapper return decorator -#-----------------------------------------------------------------------------# -# Generalized custom axes class -#-----------------------------------------------------------------------------# + def _parse_format(mode=2, rc_kw=None, **kwargs): """Separate `~proplot.rctools.rc` setting name value pairs from `~Axes.format` keyword arguments.""" kw = {} rc_kw = rc_kw or {} - for key,value in kwargs.items(): + for key, value in kwargs.items(): key_fixed = RC_NODOTS.get(key, None) if key_fixed is None: kw[key] = value @@ -145,9 +144,11 @@ def _parse_format(mode=2, rc_kw=None, **kwargs): rc_kw[key_fixed] = value return rc_kw, mode, kw + 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, **kwargs): """ Parameters @@ -170,10 +171,10 @@ def __init__(self, *args, number=None, **kwargs): # Properties self._abc_loc = None self._abc_text = None - self._titles_dict = {} # dictionary of title text objects and their locations - self._title_loc = None # location of main title - self._title_pad = rc['axes.titlepad'] # can be overwritten by format() - self._title_above_panel = True # TODO: add rc prop? + self._titles_dict = {} # dictionary of titles and locs + self._title_loc = None # location of main title + self._title_pad = rc['axes.titlepad'] # format() can overwrite + self._title_above_panel = True # TODO: add rc prop? self._bpanels = [] self._tpanels = [] self._lpanels = [] @@ -192,15 +193,41 @@ def __init__(self, *args, number=None, **kwargs): self._altx_parent = None self._auto_colorbar = {} # stores handles and kwargs for auto colorbar self._auto_legend = {} - coltransform = mtransforms.blended_transform_factory(self.transAxes, self.figure.transFigure) - rowtransform = mtransforms.blended_transform_factory(self.figure.transFigure, self.transAxes) - self._llabel = self.text(0.05, 0.5, '', va='center', ha='right', transform=rowtransform) - self._rlabel = self.text(0.95, 0.5, '', va='center', ha='left', transform=rowtransform) - self._blabel = self.text(0.5, 0.05, '', va='top', ha='center', transform=coltransform) - self._tlabel = self.text(0.5, 0.95, '', va='bottom', ha='center', transform=coltransform) # reasonable starting point + coltransform = mtransforms.blended_transform_factory( + self.transAxes, self.figure.transFigure) + rowtransform = mtransforms.blended_transform_factory( + self.figure.transFigure, self.transAxes) + self._llabel = self.text( + 0.05, + 0.5, + '', + va='center', + ha='right', + transform=rowtransform) + self._rlabel = self.text( + 0.95, + 0.5, + '', + va='center', + ha='left', + transform=rowtransform) + self._blabel = self.text( + 0.5, + 0.05, + '', + va='top', + ha='center', + transform=coltransform) + self._tlabel = self.text( + 0.5, + 0.95, + '', + va='bottom', + ha='center', + transform=coltransform) # reasonable starting point self._share_setup() - self.number = number # for abc numbering - self.format(mode=1) # mode == 1 applies the rcExtraParams + self.number = number # for abc numbering + self.format(mode=1) # mode == 1 applies the rcExtraParams def _draw_auto_legends_colorbars(self): """Generate automatic legends and colorbars. Wrapper funcs @@ -257,7 +284,9 @@ def _get_title_props(self, abc=False, loc=None): context = True prefix = 'abc' if abc else 'title' loc = _notNone(loc, rc.get(f'{prefix}.loc', context=True)) - loc_prev = getattr(self, '_' + ('abc' if abc else 'title') + '_loc') # old + loc_prev = getattr( + self, '_' + ('abc' if abc else 'title') + + '_loc') # old if loc is None: loc = loc_prev elif loc_prev is not None and loc != loc_prev: @@ -267,11 +296,11 @@ def _get_title_props(self, abc=False, loc=None): except KeyError: raise ValueError(f'Invalid title or abc loc {loc!r}.') else: - if loc in ('top','bottom','best') or not isinstance(loc, str): + if loc in ('top', 'bottom', 'best') or not isinstance(loc, str): raise ValueError(f'Invalid title or abc loc {loc!r}.') # Existing object - if loc in ('left','right','center'): + if loc in ('left', 'right', 'center'): if loc == 'center': obj = self.title else: @@ -285,21 +314,21 @@ def _get_title_props(self, abc=False, loc=None): if loc in ('upper center', 'lower center'): x, ha = 0.5, 'center' elif loc in ('upper left', 'lower left'): - xpad = rc['axes.titlepad']/(72*width) - x, ha = 1.5*xpad, 'left' + xpad = rc['axes.titlepad'] / (72 * width) + x, ha = 1.5 * xpad, 'left' elif loc in ('upper right', 'lower right'): - xpad = rc['axes.titlepad']/(72*width) - x, ha = 1 - 1.5*xpad, 'right' + xpad = rc['axes.titlepad'] / (72 * width) + x, ha = 1 - 1.5 * xpad, 'right' else: - raise RuntimeError # should be impossible + raise RuntimeError # should be impossible if loc in ('upper left', 'upper right', 'upper center'): - ypad = rc['axes.titlepad']/(72*height) - y, va = 1 - 1.5*ypad, 'top' + ypad = rc['axes.titlepad'] / (72 * height) + y, va = 1 - 1.5 * ypad, 'top' elif loc in ('lower left', 'lower right', 'lower center'): - ypad = rc['axes.titlepad']/(72*height) - y, va = 1.5*ypad, 'bottom' + ypad = rc['axes.titlepad'] / (72 * height) + y, va = 1.5 * ypad, 'bottom' else: - raise RuntimeError # should be impossible + raise RuntimeError # should be impossible obj = self.text(x, y, '', ha=ha, va=va, transform=self.transAxes) obj.set_transform(self.transAxes) @@ -308,13 +337,13 @@ def _get_title_props(self, abc=False, loc=None): # just changed ones. This is important if e.g. user calls in two # lines ax.format(titleweight='bold') then ax.format(title='text') kw = rc.fill({ - 'fontsize': f'{prefix}.size', - 'weight': f'{prefix}.weight', - 'color': f'{prefix}.color', - 'border': f'{prefix}.border', - 'linewidth': f'{prefix}.linewidth', + 'fontsize': f'{prefix}.size', + 'weight': f'{prefix}.weight', + 'color': f'{prefix}.color', + 'border': f'{prefix}.border', + 'linewidth': f'{prefix}.linewidth', 'fontfamily': 'font.family', - }, context=context) + }, context=context) if loc in ('left', 'right', 'center'): kw.pop('border', None) kw.pop('linewidth', None) @@ -343,7 +372,8 @@ def _loc_translate(loc, default=None): loc = LOC_TRANSLATE[loc] except KeyError: raise KeyError(f'Invalid location {loc!r}.') - elif np.iterable(loc) and len(loc) == 2 and all(isinstance(l, Number) for l in loc): + elif np.iterable(loc) and len(loc) == 2 and all( + isinstance(l, Number) for l in loc): loc = np.array(loc) else: raise KeyError(f'Invalid location {loc!r}.') @@ -465,7 +495,11 @@ def _sharex_setup(self, sharex, level=None): if level is None: level = self.figure._sharex if level not in range(4): - raise ValueError('Level can be 1 (do not share limits, just hide axis labels), 2 (share limits, but do not hide tick labels), or 3 (share limits and hide tick labels).') + raise ValueError( + 'Axis sharing level can be 0 (no sharing), ' + '1 (sharing, but keep all labels), ' + '2 (sharing, only keep one set of tick labels), ' + 'or 3 (sharing, only keep one axis label).') self._share_short_axis(sharex, 'l', level) self._share_short_axis(sharex, 'r', level) self._share_long_axis(sharex, 'b', level) @@ -477,7 +511,11 @@ def _sharey_setup(self, sharey, level): if level is None: level = self.figure._sharey if level not in range(4): - raise ValueError('Level can be 1 (do not share limits, just hide axis labels), 2 (share limits, but do not hide tick labels), or 3 (share limits and hide tick labels).') + raise ValueError( + 'Axis sharing level can be 0 (no sharing), ' + '1 (sharing, but keep all labels), ' + '2 (sharing, only keep one set of tick labels), ' + 'or 3 (sharing, only keep one axis label).') self._share_short_axis(sharey, 'b', level) self._share_short_axis(sharey, 't', level) self._share_long_axis(sharey, 'l', level) @@ -487,8 +525,11 @@ def _share_setup(self): """Automatically configure axis sharing based on the horizontal and vertical extent of subplots in the figure gridspec.""" # Panel axes sharing, between main subplot and its panels - shared = lambda paxs: [pax for pax in paxs if not pax._panel_filled and pax._panel_share] - if not self._panel_side: # this is a main axes + def shared(paxs): + return [pax for pax in paxs + if not pax._panel_filled and pax._panel_share] + + if not self._panel_side: # this is a main axes # Top and bottom bottom = self paxs = shared(self._bpanels) @@ -604,12 +645,13 @@ def _update_title(self, obj, **kwargs): y = _notNone(kwargs.pop('y', y), pos[1]) return self.text(x, y, text, **kwextra) - def format(self, *, title=None, top=None, - figtitle=None, suptitle=None, rowlabels=None, collabels=None, - leftlabels=None, rightlabels=None, toplabels=None, bottomlabels=None, - llabels=None, rlabels=None, tlabels=None, blabels=None, - **kwargs, - ): + def format( + self, *, title=None, top=None, + figtitle=None, suptitle=None, rowlabels=None, collabels=None, + leftlabels=None, rightlabels=None, + toplabels=None, bottomlabels=None, + llabels=None, rlabels=None, tlabels=None, blabels=None, + **kwargs): """ Modify the axes title(s), the a-b-c label, row and column labels, and the figure title. Called by `CartesianAxes.format`, @@ -653,8 +695,7 @@ def format(self, *, title=None, top=None, 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 @@ -666,8 +707,7 @@ def format(self, *, title=None, top=None, 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. @@ -692,10 +732,10 @@ def format(self, *, title=None, top=None, :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) + kw = rc.fill({'facecolor': 'figure.facecolor'}, context=True) self.figure.patch.update(kw) if top is not None: self._title_above_panel = top @@ -710,25 +750,29 @@ def format(self, *, title=None, top=None, # whatnot. May result in redundant assignments if formatting more than # one axes, but operations are fast so some redundancy is nbd. fig = self.figure - suptitle = _notNone(figtitle, suptitle, None, - names=('figtitle', 'suptitle')) + suptitle = _notNone( + figtitle, suptitle, None, names=('figtitle', 'suptitle')) kw = rc.fill({ 'fontsize': 'suptitle.size', 'weight': 'suptitle.weight', 'color': 'suptitle.color', 'fontfamily': 'font.family' - }, context=True) + }, context=True) if suptitle or kw: fig._update_suptitle(suptitle, **kw) # Labels - llabels = _notNone(rowlabels, leftlabels, llabels, - None, names=('rowlabels', 'leftlabels', 'llabels')) - tlabels = _notNone(collabels, toplabels, tlabels, - None, names=('collabels', 'toplabels', 'tlabels')) - rlabels = _notNone(rightlabels, rlabels, None, - names=('rightlabels', 'rlabels')) - blabels = _notNone(bottomlabels, blabels, None, - names=('bottomlabels', 'blabels')) + llabels = _notNone( + rowlabels, leftlabels, llabels, None, + names=('rowlabels', 'leftlabels', 'llabels')) + tlabels = _notNone( + collabels, toplabels, tlabels, None, + names=('collabels', 'toplabels', 'tlabels')) + rlabels = _notNone( + rightlabels, rlabels, None, + names=('rightlabels', 'rlabels')) + blabels = _notNone( + bottomlabels, blabels, None, + names=('bottomlabels', 'blabels')) for side, labels in zip( ('left', 'right', 'top', 'bottom'), (llabels, rlabels, tlabels, blabels)): @@ -737,7 +781,7 @@ def format(self, *, title=None, top=None, 'weight': side + 'label.weight', 'color': side + 'label.color', 'fontfamily': 'font.family' - }, context=True) + }, context=True) if labels or kw: fig._update_suplabels(self, side, labels, **kw) @@ -745,10 +789,13 @@ def format(self, *, title=None, top=None, titles_dict = self._titles_dict if not self._panel_side: # Location and text - abcstyle = rc.get('abc.style', context=True) # changed or running format first time? - if 'abcformat' in kwargs: # super sophisticated deprecation system + # changed or running format first time? + abcstyle = rc.get('abc.style', context=True) + if 'abcformat' in kwargs: # super sophisticated deprecation system abcstyle = kwargs.pop('abcformat') - warnings.warn(f'The "abcformat" setting is deprecated. Please use "abcstyle".') + warnings.warn( + 'The "abcformat" setting is deprecated. ' + 'Please use "abcstyle".') if abcstyle and self.number is not None: if not isinstance(abcstyle, str) or ( abcstyle.count('a') != 1 and abcstyle.count('A') != 1): @@ -870,9 +917,10 @@ def colorbar(self, *args, loc=None, pad=None, colorbars, units are interpreted by `~proplot.utils.units`. Default is :rc:`colorbar.insetlength`. width : float or str, optional - The colorbar width. Units are interpreted by `~proplot.utils.units`. - For outer colorbars, default is :rc:`colorbar.width`. For inset - colorbars, default is :rc:`colorbar.insetwidth`. + The colorbar width. Units are interpreted by + `~proplot.utils.units`. For outer colorbars, default is + :rc:`colorbar.width`. For inset colorbars, default is + :rc:`colorbar.insetwidth`. space : float or str, optional The space between the colorbar and the main axes. For outer colorbars only. Units are interpreted by `~proplot.utils.units`. @@ -891,11 +939,11 @@ def colorbar(self, *args, loc=None, pad=None, Passed to `~proplot.wrappers.colorbar_wrapper`. """ # TODO: add option to pad inset away from axes edge! - kwargs.update({'edgecolor':edgecolor, 'linewidth':linewidth}) + kwargs.update({'edgecolor': edgecolor, 'linewidth': linewidth}) loc = self._loc_translate(loc, rc['colorbar.loc']) - if not isinstance(loc, str): # e.g. 2-tuple or ndarray + if not isinstance(loc, str): # e.g. 2-tuple or ndarray raise ValueError(f'Invalid colorbar location {loc!r}.') - if loc == 'best': # white lie + if loc == 'best': # white lie loc = 'lower right' # Generate panel @@ -921,19 +969,21 @@ def colorbar(self, *args, loc=None, pad=None, length = _notNone(length, rc['colorbar.length']) ss = self.get_subplotspec() if length <= 0 or length > 1: - raise ValueError(f'Panel colorbar length must satisfy 0 < length <= 1, got length={length!r}.') - if side in ('bottom','top'): + raise ValueError( + 'Panel colorbar length must satisfy 0 < length <= 1, ' + f'got length={length!r}.') + if side in ('bottom', 'top'): gs = mgridspec.GridSpecFromSubplotSpec( - nrows=1, ncols=3, wspace=0, - subplot_spec=ss, - width_ratios=((1-length)/2, length, (1-length)/2), - ) + nrows=1, ncols=3, wspace=0, + subplot_spec=ss, + width_ratios=((1 - length) / 2, length, (1 - length) / 2), + ) else: gs = mgridspec.GridSpecFromSubplotSpec( - nrows=3, ncols=1, hspace=0, - subplot_spec=ss, - height_ratios=((1-length)/2, length, (1-length)/2), - ) + nrows=3, ncols=1, hspace=0, + subplot_spec=ss, + height_ratios=((1 - length) / 2, length, (1 - length) / 2), + ) ax = self.figure.add_subplot(gs[1], main=False, projection=None) self.add_child_axes(ax) @@ -965,11 +1015,22 @@ def colorbar(self, *args, loc=None, pad=None, # Default props cbwidth, cblength = width, length width, height = self.get_size_inches() - extend = units(_notNone(kwargs.get('extendsize', None), rc['colorbar.insetextend'])) - cbwidth = units(_notNone(cbwidth, rc['colorbar.insetwidth']))/height - cblength = units(_notNone(cblength, rc['colorbar.insetlength']))/width + extend = units( + _notNone( + kwargs.get( + 'extendsize', + None), + rc['colorbar.insetextend'])) + cbwidth = units( + _notNone( + cbwidth, + rc['colorbar.insetwidth'])) / height + cblength = units( + _notNone( + cblength, + rc['colorbar.insetlength'])) / width pad = units(_notNone(pad, rc['colorbar.insetpad'])) - xpad, ypad = pad/width, pad/height + xpad, ypad = pad / width, pad / height # Get location in axes-relative coordinates # Bounds are x0, y0, width, height in axes-relative coordinates @@ -1006,17 +1067,18 @@ def colorbar(self, *args, loc=None, pad=None, names=('frame', 'frameon')) if frameon: xmin, ymin, width, height = fbounds - patch = mpatches.Rectangle((xmin,ymin), width, height, - snap=True, zorder=4.5, transform=self.transAxes) + patch = mpatches.Rectangle( + (xmin, ymin), width, height, + snap=True, zorder=4.5, transform=self.transAxes) alpha = _notNone(alpha, rc['colorbar.framealpha']) linewidth = _notNone(linewidth, rc['axes.linewidth']) edgecolor = _notNone(edgecolor, rc['axes.edgecolor']) facecolor = _notNone(facecolor, rc['axes.facecolor']) patch.update({ - 'alpha':alpha, - 'linewidth':linewidth, - 'edgecolor':edgecolor, - 'facecolor':facecolor}) + 'alpha': alpha, + 'linewidth': linewidth, + 'edgecolor': edgecolor, + 'facecolor': facecolor}) self.add_artist(patch) # Make axes @@ -1179,8 +1241,7 @@ def inset_axes(self, bounds, *, transform=None, zorder=5, ---------- 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`, @@ -1202,7 +1263,7 @@ def inset_axes(self, bounds, *, transform=None, zorder=5, ---------------- **kwargs Passed to `XYAxes`. - """ + """ # noqa # Carbon copy with my custom axes if not transform: transform = self.transAxes @@ -1322,7 +1383,8 @@ def parametric(self, *args, values=None, parametric coordinate ``values`` using the input colormap ``cmap``. Invoked when you pass ``cmap`` to `~matplotlib.axes.Axes.plot`. Returns a `~matplotlib.collections.LineCollection` instance. See - `this matplotlib example `__. + `this matplotlib example + `__. Parameters ---------- @@ -1340,7 +1402,7 @@ def parametric(self, *args, values=None, between the `values` coordinates. The number corresponds to the number of additional color levels between the line joints and the halfway points between line joints. - """ + """ # noqa # First error check # WARNING: So far this only works for 1D *x* and *y* coordinates. # Cannot draw multiple colormap lines at once @@ -1532,15 +1594,12 @@ def number(self, num): ) -#-----------------------------------------------------------------------------# -# Axes subclasses -#-----------------------------------------------------------------------------# _twin_kwargs = ( 'label', 'locator', 'formatter', 'ticks', 'ticklabels', 'minorlocator', 'minorticks', 'tickminor', 'ticklen', 'tickrange', 'tickdir', 'ticklabeldir', 'tickrotation', 'bounds', 'margin', 'color', 'linewidth', 'grid', 'gridminor', 'gridcolor', - ) +) _dual_doc = """ Return a secondary *%(x)s* axis for denoting equivalent *%(x)s* @@ -1624,15 +1683,18 @@ def number(self, num): %(y)s axis labels invisible. """ + def _parse_alt(x, kwargs): """Interpret keyword args passed to all "twin axis" methods so they can be passed to Axes.format.""" kw_bad, kw_out = {}, {} - for key,value in kwargs.items(): + for key, value in kwargs.items(): if key in _twin_kwargs: kw_out[x + key] = value elif key[0] == x and key[1:] in _twin_kwargs: - warnings.warn(f'Twin axis keyword arg {key!r} is deprecated. Use {key[1:]!r} instead.') + warnings.warn( + f'Twin axis keyword arg {key!r} is deprecated. ' + f'Use {key[1:]!r} instead.') kw_out[key] = value else: kw_bad[key] = value @@ -1640,6 +1702,7 @@ def _parse_alt(x, kwargs): raise TypeError(f'Unexpected keyword argument(s): {kw_bad!r}') return kwargs + def _parse_transform(transform, transform_kw): """Interpret the dualx and dualy transform and return the forward and inverse transform functions and keyword args passed to the FuncScale.""" @@ -1664,10 +1727,13 @@ def _parse_transform(transform, transform_kw): elif callable(transform): funcscale_funcs = (transform, lambda x: x) else: - raise ValueError(f'Invalid transform {transform!r}. Must be function, tuple of two functions, or scale name.') + raise ValueError( + f'Invalid transform {transform!r}. ' + 'Must be function, tuple of two functions, or scale name.') return funcscale_funcs, funcscale_kw -def _parse_rcloc(x, string): # figures out string location + +def _parse_rcloc(x, string): # figures out string location """Convert the *boolean* "left", "right", "top", and "bottom" rc settings to a location string. Returns ``None`` if settings are unchanged.""" if x == 'x': @@ -1771,7 +1837,7 @@ def _datex_rotate(self): or self._datex_rotated): return rotation = rc['axes.formatter.timerotation'] - kw = {'rotation':rotation} + kw = {'rotation': rotation} if rotation not in (0, 90, -90): kw['ha'] = ('right' if rotation > 0 else 'left') for label in self.xaxis.get_ticklabels(): @@ -2087,7 +2153,7 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. See also -------- :py:obj:`Axes.format`, :py:obj:`Axes.context` - """ + """ # noqa rc_kw, rc_mode, kwargs = _parse_format(**kwargs) with rc.context(rc_kw, mode=rc_mode): # Background basics @@ -2095,8 +2161,8 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. self.patch.set_zorder(-1) kw_face = rc.fill({ 'facecolor': 'axes.facecolor', - 'alpha': 'axes.facealpha' - }, context=True) + 'alpha': 'axes.facealpha' + }, context=True) patch_kw = patch_kw or {} kw_face.update(patch_kw) self.patch.update(kw_face) @@ -2113,31 +2179,49 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. xminorlocator_kw = xminorlocator_kw or {} yminorlocator_kw = yminorlocator_kw or {} # Flexible keyword args, declare defaults - xmargin = _notNone(xmargin, rc.get('axes.xmargin', context=True)) - ymargin = _notNone(ymargin, rc.get('axes.ymargin', context=True)) - xtickdir = _notNone(xtickdir, rc.get('xtick.direction', context=True)) - ytickdir = _notNone(ytickdir, rc.get('ytick.direction', context=True)) - xtickminor = _notNone(xtickminor, rc.get('xtick.minor.visible', context=True)) - ytickminor = _notNone(ytickminor, rc.get('ytick.minor.visible', context=True)) - xformatter = _notNone(xticklabels, xformatter, None, names=('xticklabels', 'xformatter')) - yformatter = _notNone(yticklabels, yformatter, None, names=('yticklabels', 'yformatter')) - xlocator = _notNone(xticks, xlocator, None, names=('xticks', 'xlocator')) - ylocator = _notNone(yticks, ylocator, None, names=('yticks', 'ylocator')) - xminorlocator = _notNone(xminorticks, xminorlocator, None, names=('xminorticks', 'xminorlocator')) - yminorlocator = _notNone(yminorticks, yminorlocator, None, names=('yminorticks', 'yminorlocator')) + xmargin = _notNone(xmargin, rc.get('axes.xmargin', context=True)) + ymargin = _notNone(ymargin, rc.get('axes.ymargin', context=True)) + xtickdir = _notNone( + xtickdir, rc.get('xtick.direction', context=True)) + ytickdir = _notNone( + ytickdir, rc.get('ytick.direction', context=True)) + xtickminor = _notNone( + xtickminor, rc.get('xtick.minor.visible', context=True)) + ytickminor = _notNone( + ytickminor, rc.get('ytick.minor.visible', context=True)) + xformatter = _notNone( + xticklabels, xformatter, None, + names=('xticklabels', 'xformatter')) + yformatter = _notNone( + yticklabels, yformatter, None, + names=('yticklabels', 'yformatter')) + xlocator = _notNone( + xticks, xlocator, None, + names=('xticks', 'xlocator')) + ylocator = _notNone( + yticks, ylocator, None, + names=('yticks', 'ylocator')) + xminorlocator = _notNone( + xminorticks, xminorlocator, None, + names=('xminorticks', 'xminorlocator')) + yminorlocator = _notNone( + yminorticks, yminorlocator, None, + names=('yminorticks', 'yminorlocator')) # Grid defaults are more complicated grid = rc.get('axes.grid', context=True) which = rc.get('axes.grid.which', context=True) - if which is not None or grid is not None: # if *one* was changed - axis = rc['axes.grid.axis'] # always need this property + if which is not None or grid is not None: # if *one* was changed + axis = rc['axes.grid.axis'] # always need this property if grid is None: grid = rc['axes.grid'] elif which is None: which = rc['axes.grid.which'] - xgrid = _notNone(xgrid, grid - and axis in ('x','both') and which in ('major','both')) - ygrid = _notNone(ygrid, grid - and axis in ('y','both') and which in ('major','both')) + xgrid = _notNone( + xgrid, grid and axis in ('x', 'both') + and which in ('major', 'both')) + ygrid = _notNone( + ygrid, grid and axis in ('y', 'both') + and which in ('major', 'both')) xgridminor = _notNone(xgridminor, grid and axis in ('x', 'both') and which in ('minor', 'both')) @@ -2148,12 +2232,20 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. # Sensible defaults for spine, tick, tick label, and label locs # NOTE: Allow tick labels to be present without ticks! User may # want this sometimes! Same goes for spines! - xspineloc = _notNone(xloc, xspineloc, None, names=('xloc', 'xspineloc')) - yspineloc = _notNone(yloc, yspineloc, None, names=('yloc', 'yspineloc')) - xtickloc = _notNone(xtickloc, xspineloc, _parse_rcloc('x', 'xtick')) - ytickloc = _notNone(ytickloc, yspineloc, _parse_rcloc('y', 'ytick')) - xspineloc = _notNone(xspineloc, _parse_rcloc('x', 'axes.spines')) - yspineloc = _notNone(yspineloc, _parse_rcloc('y', 'axes.spines')) + xspineloc = _notNone( + xloc, xspineloc, None, + names=('xloc', 'xspineloc')) + yspineloc = _notNone( + yloc, yspineloc, None, + names=('yloc', 'yspineloc')) + xtickloc = _notNone( + xtickloc, xspineloc, _parse_rcloc( + 'x', 'xtick')) + ytickloc = _notNone( + ytickloc, yspineloc, _parse_rcloc( + 'y', 'ytick')) + xspineloc = _notNone(xspineloc, _parse_rcloc('x', 'axes.spines')) + yspineloc = _notNone(yspineloc, _parse_rcloc('y', 'axes.spines')) if xtickloc != 'both': xticklabelloc = _notNone(xticklabelloc, xtickloc) xlabelloc = _notNone(xlabelloc, xticklabelloc) @@ -2166,7 +2258,8 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. ylabelloc = 'left' # Begin loop - for (x, axis, + for ( + x, axis, label, color, linewidth, ticklen, margin, bounds, @@ -2181,8 +2274,8 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. label_kw, scale_kw, locator_kw, minorlocator_kw, formatter_kw - ) in zip( - ('x','y'), (self.xaxis, self.yaxis), + ) in zip( + ('x', 'y'), (self.xaxis, self.yaxis), (xlabel, ylabel), (xcolor, ycolor), (xlinewidth, ylinewidth), (xticklen, yticklen), (xmargin, ymargin), (xbounds, ybounds), @@ -2229,13 +2322,13 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. # Fix spines kw = rc.fill({ 'linewidth': 'axes.linewidth', - 'color': 'axes.edgecolor', - }, context=True) + 'color': 'axes.edgecolor', + }, context=True) if color is not None: kw['color'] = color if linewidth is not None: kw['linewidth'] = linewidth - sides = ('bottom','top') if x == 'x' else ('left','right') + sides = ('bottom', 'top') if x == 'x' else ('left', 'right') spines = [self.spines[s] for s in sides] for spine, side in zip(spines, sides): # Line properties @@ -2277,14 +2370,17 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. spines = [side for side, spine in zip( sides, spines) if spine.get_visible()] + # Helper func + def _grid_dict(grid): + return { + 'grid_color': grid + '.color', + 'grid_alpha': grid + '.alpha', + 'grid_linewidth': grid + '.linewidth', + 'grid_linestyle': grid + '.linestyle', + } + # Tick and grid settings for major and minor ticks separately # Override is just a "new default", but user can override this - _grid_dict = lambda grid: { - 'grid_color': grid + '.color', - 'grid_alpha': grid + '.alpha', - 'grid_linewidth': grid + '.linewidth', - 'grid_linestyle': grid + '.linestyle', - } for which, igrid in zip(('major', 'minor'), (grid, gridminor)): # Tick properties kw_ticks = rc.category(x + 'tick.' + which, context=True) @@ -2296,7 +2392,8 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. if which == 'major': kw_ticks['size'] = utils.units(ticklen, 'pt') else: - kw_ticks['size'] = utils.units(ticklen, 'pt') * rc['ticklenratio'] + kw_ticks['size'] = utils.units( + ticklen, 'pt') * rc['ticklenratio'] # Grid style and toggling if igrid is not None: # toggle with special global props @@ -2305,8 +2402,11 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. kw_grid = rc.fill(_grid_dict('grid'), context=True) else: kw_major = kw_grid - kw_grid = rc.fill(_grid_dict('gridminor'), context=True) - kw_grid.update({key:value for key,value in kw_major.items() if key not in kw_grid}) + kw_grid = rc.fill( + _grid_dict('gridminor'), context=True) + kw_grid.update({ + key: value for key, value in kw_major.items() + if key not in kw_grid}) # Changed rc settings axis.set_tick_params(which=which, **kw_grid, **kw_ticks) @@ -2367,7 +2467,7 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. 'labelcolor': 'tick.labelcolor', # new props 'labelsize': 'tick.labelsize', 'color': x + 'tick.color', - }, context=True) + }, context=True) if color: kw['color'] = color kw['labelcolor'] = color @@ -2376,8 +2476,9 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. kw['pad'] = 1 # ticklabels should be much closer if ticklabeldir == 'in': # put tick labels inside the plot tickdir = 'in' - kw['pad'] = -1*sum(rc[f'{x}tick.{key}'] - for key in ('major.size', 'major.pad', 'labelsize')) + kw['pad'] = -1 * sum( + rc[f'{x}tick.{key}'] for key in ( + 'major.size', 'major.pad', 'labelsize')) if tickdir is not None: kw['direction'] = tickdir axis.set_tick_params(which='both', **kw) @@ -2386,8 +2487,8 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. # Also set rotation and alignment here kw = rc.fill({ 'fontfamily': 'font.family', - 'weight': 'tick.labelweight' - }, context=True) + 'weight': 'tick.labelweight' + }, context=True) if rotation is not None: kw = {'rotation': rotation} if x == 'x': @@ -2408,7 +2509,7 @@ class generated by `plot.CutoffScaleFactory('cutoff', 0.5, 2)`. 'weight': 'axes.labelweight', 'fontsize': 'axes.labelsize', 'fontfamily': 'font.family', - }, context=True) + }, context=True) if label is not None: kw['text'] = label if color: @@ -2489,14 +2590,13 @@ def altx(self, **kwargs): """This docstring is replaced below.""" # Cannot wrap twiny() because we want to use XYAxes, not # matplotlib Axes. Instead use hidden method _make_twin_axes. - # See https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py - # NOTE: _make_twin_axes uses self.add_axes + # See: https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py # noqa if self._altx_child: raise RuntimeError('No more than *two* twin axes!') if self._altx_parent: raise RuntimeError('This *is* a twin axes!') ax = self._make_twin_axes(sharey=self, projection='xy') - ax.set_autoscaley_on(self.get_autoscaley_on()) # shared axes must have matching autoscale + ax.set_autoscaley_on(self.get_autoscaley_on()) ax.grid(False) self._altx_child = ax ax._altx_parent = self @@ -2513,7 +2613,7 @@ def alty(self, **kwargs): if self._alty_parent: raise RuntimeError('This *is* a twin axes!') ax = self._make_twin_axes(sharex=self, projection='xy') - ax.set_autoscalex_on(self.get_autoscalex_on()) # shared axes must have matching autoscale + ax.set_autoscalex_on(self.get_autoscalex_on()) ax.grid(False) self._alty_child = ax ax._alty_parent = self @@ -2533,14 +2633,16 @@ def dualx(self, transform, transform_kw=None, **kwargs): # an axis scale (meaning user just has to supply the forward # transformation, not the backwards one), and does not invent a new # class with a bunch of complicated setters. - funcscale_funcs, funcscale_kw = _parse_transform(transform, transform_kw) + funcscale_funcs, funcscale_kw = _parse_transform( + transform, transform_kw) self._dualx_data = (funcscale_funcs, funcscale_kw) self._dualx_overrides() return self.altx(**kwargs) def dualy(self, transform, transform_kw=None, **kwargs): """This docstring is replaced below.""" - funcscale_funcs, funcscale_kw = _parse_transform(transform, transform_kw) + funcscale_funcs, funcscale_kw = _parse_transform( + transform, transform_kw) self._dualy_data = (funcscale_funcs, funcscale_kw) self._dualy_overrides() return self.alty(**kwargs) @@ -2581,31 +2683,32 @@ def twiny(self): # Add documentation altx.__doc__ = _alt_doc % { - 'x':'x', 'x1':'bottom', 'x2':'top', - 'y':'y', 'y1':'left', 'y2':'right', - 'args':', '.join(_twin_kwargs), - } + 'x': 'x', 'x1': 'bottom', 'x2': 'top', + 'y': 'y', 'y1': 'left', 'y2': 'right', + 'args': ', '.join(_twin_kwargs), + } alty.__doc__ = _alt_doc % { - 'x':'y', 'x1':'left', 'x2':'right', - 'y':'x', 'y1':'bottom', 'y2':'top', - 'args':', '.join(_twin_kwargs), - } + 'x': 'y', 'x1': 'left', 'x2': 'right', + 'y': 'x', 'y1': 'bottom', 'y2': 'top', + 'args': ', '.join(_twin_kwargs), + } twinx.__doc__ = _twin_doc % { - 'x':'y', 'x1':'left', 'x2':'right', - 'y':'x', 'y1':'bottom', 'y2':'top', - 'args':', '.join(_twin_kwargs), - } + 'x': 'y', 'x1': 'left', 'x2': 'right', + 'y': 'x', 'y1': 'bottom', 'y2': 'top', + 'args': ', '.join(_twin_kwargs), + } twiny.__doc__ = _twin_doc % { - 'x':'x', 'x1':'bottom', 'x2':'top', - 'y':'y', 'y1':'left', 'y2':'right', - 'args':', '.join(_twin_kwargs), - } + 'x': 'x', 'x1': 'bottom', 'x2': 'top', + 'y': 'y', 'y1': 'left', 'y2': 'right', + 'args': ', '.join(_twin_kwargs), + } dualx.__doc__ = _dual_doc % { - 'x':'x', 'args':', '.join(_twin_kwargs) - } + 'x': 'x', 'args': ', '.join(_twin_kwargs) + } dualy.__doc__ = _dual_doc % { - 'x':'y', 'args':', '.join(_twin_kwargs) - } + 'x': 'y', 'args': ', '.join(_twin_kwargs) + } + class PolarAxes(Axes, mproj.PolarAxes): """Intermediate class, mixes `ProjAxes` with @@ -2699,7 +2802,7 @@ def format(self, *args, See also -------- :py:obj:`Axes.format`, :py:obj:`Axes.context` - """ + """ # noqa rc_kw, rc_mode, kwargs = _parse_format(**kwargs) with rc.context(rc_kw, mode=rc_mode): # Not mutable default args @@ -2775,8 +2878,8 @@ def format(self, *args, kw = rc.fill({ 'linewidth': 'axes.linewidth', 'color': 'axes.edgecolor', - }, context=True) - sides = ('inner','polar') if r == 'r' else ('start','end') + }, context=True) + sides = ('inner', 'polar') if r == 'r' else ('start', 'end') spines = [self.spines[s] for s in sides] for spine, side in zip(spines, sides): spine.update(kw) @@ -2791,13 +2894,13 @@ def format(self, *args, 'grid_alpha': 'grid.alpha', 'grid_linewidth': 'grid.linewidth', 'grid_linestyle': 'grid.linestyle', - }, context=True) + }, context=True) axis.set_tick_params(which='both', **kw) # Label settings that can't be controlled with set_tick_params kw = rc.fill({ 'fontfamily': 'font.family', - 'weight': 'tick.labelweight' - }, context=True) + 'weight': 'tick.labelweight' + }, context=True) for t in axis.get_ticklabels(): t.update(kw) @@ -2943,8 +3046,7 @@ def format(self, *, `~mpl_toolkits.basemap.Basemap.drawmeridians` and `~mpl_toolkits.basemap.Basemap.drawparallels` methods. - land, ocean, coast, rivers, lakes, borders, innerborders : bool, \ -optional + land, ocean, coast, rivers, lakes, borders, innerborders : bool, optional Toggles various geographic features. These are actually the :rcraw:`land`, :rcraw:`ocean`, :rcraw:`coast`, :rcraw:`rivers`, :rcraw:`lakes`, :rcraw:`borders`, and :rcraw:`innerborders` @@ -2967,13 +3069,19 @@ def format(self, *, See also -------- :py:obj:`Axes.format`, :py:obj:`Axes.context` - """ + """ # noqa rc_kw, rc_mode, kwargs = _parse_format(**kwargs) with rc.context(rc_kw, mode=rc_mode): # Parse alternative keyword args # TODO: Why isn't default latmax 80 respected sometimes? - lonlines = _notNone(lonlines, lonlocator, rc.get('geogrid.lonstep', context=True), names=('lonlines', 'lonlocator')) - latlines = _notNone(latlines, latlocator, rc.get('geogrid.latstep', context=True), names=('latlines', 'latlocator')) + lonlines = _notNone( + lonlines, lonlocator, rc.get( + 'geogrid.lonstep', context=True), names=( + 'lonlines', 'lonlocator')) + latlines = _notNone( + latlines, latlocator, rc.get( + 'geogrid.latstep', context=True), names=( + 'latlines', 'latlocator')) latmax = _notNone(latmax, rc.get('geogrid.latmax', context=True)) labels = _notNone(labels, rc.get('geogrid.labels', context=True)) grid = _notNone(grid, rc.get('geogrid', context=True)) @@ -3010,7 +3118,8 @@ def format(self, *, if latlines is not None or latmax is not None: # Fill defaults if latlines is None: - latlines = _notNone(self._latlines_values, rc['geogrid.latstep']) + latlines = _notNone( + self._latlines_values, rc['geogrid.latstep']) ilatmax = _notNone(latmax, self._latmax, rc['geogrid.latmax']) # Get tick locations if not np.iterable(latlines): @@ -3169,7 +3278,7 @@ def __init__(self, *args, map_projection=None, **kwargs): self.set_global() def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, - lonlines, latlines, latmax, lonarray, latarray): + lonlines, latlines, latmax, lonarray, latarray): """Apply changes to the cartopy axes.""" # Imports import cartopy.feature as cfeature @@ -3261,7 +3370,7 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, 'color': 'geogrid.color', 'linewidth': 'geogrid.linewidth', 'linestyle': 'geogrid.linestyle', - }, context=True) + }, context=True) gl.collection_kwargs.update(kw) # Grid locations # TODO: Check eps @@ -3313,10 +3422,10 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # NOTE: The natural_earth_shp method is deprecated, use add_feature. # See: https://cartopy-pelson.readthedocs.io/en/readthedocs/whats_new.html # noqa # NOTE: The e.g. cfeature.COASTLINE features are just for convenience, - # hi res versions. Use cfeature.COASTLINE.name to see how it can be looked - # up with NaturalEarthFeature. + # hi res versions. Use cfeature.COASTLINE.name to see how it can be + # looked up with NaturalEarthFeature. reso = rc['reso'] - if reso not in ('lo','med','hi'): + if reso not in ('lo', 'med', 'hi'): raise ValueError(f'Invalid resolution {reso!r}.') reso = { 'lo': '110m', @@ -3334,14 +3443,14 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, } for name, args in features.items(): # Get feature - if not rc[name]: # toggled + if not rc[name]: # toggled continue if getattr(self, '_' + name, None): # already drawn continue feat = cfeature.NaturalEarthFeature(*args, reso) # For 'lines', need to specify edgecolor and facecolor # See: https://github.com/SciTools/cartopy/issues/803 - kw = rc.category(name) # do not omit uncached props + kw = rc.category(name) # do not omit uncached props if name in ('coast', 'rivers', 'borders', 'innerborders'): kw['edgecolor'] = kw.pop('color') kw['facecolor'] = 'none' @@ -3355,12 +3464,12 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # Update patch kw_face = rc.fill({ 'facecolor': 'geoaxes.facecolor', - 'alpha': 'geoaxes.facealpha', - }, context=True) + 'alpha': 'geoaxes.facealpha', + }, context=True) kw_edge = rc.fill({ 'edgecolor': 'geoaxes.edgecolor', 'linewidth': 'geoaxes.linewidth', - }, context=True) + }, context=True) kw_face.update(patch_kw or {}) self.background_patch.update(kw_face) self.outline_patch.update(kw_edge) @@ -3407,14 +3516,14 @@ def projection(self, map_projection): if GeoAxes is not object: text = _text_wrapper( GeoAxes.text - ) - plot = _default_transform(_plot_wrapper(_standardize_1d(_add_errorbars(_cycle_changer( - GeoAxes.plot - ))))) - scatter = _default_transform(_scatter_wrapper(_standardize_1d(_add_errorbars(_cycle_changer( - GeoAxes.scatter - ))))) - fill_between = _fill_between_wrapper(_standardize_1d(_cycle_changer( + ) + plot = _default_transform(_plot_wrapper(_standardize_1d( + _add_errorbars(_cycle_changer(GeoAxes.plot)) + ))) + scatter = _default_transform(_scatter_wrapper(_standardize_1d( + _add_errorbars(_cycle_changer(GeoAxes.scatter)) + ))) + fill_between = _fill_between_wrapper(_standardize_1d(_cycle_changer( GeoAxes.fill_between ))) fill_betweenx = _fill_betweenx_wrapper(_standardize_1d(_cycle_changer( @@ -3440,7 +3549,7 @@ def projection(self, map_projection): ))) barbs = _default_transform(_standardize_2d(_cmap_changer( GeoAxes.barbs - ))) + ))) tripcolor = _default_transform(_cmap_changer( GeoAxes.tripcolor )) @@ -3449,19 +3558,19 @@ def projection(self, map_projection): )) tricontourf = _default_transform(_cmap_changer( GeoAxes.tricontourf - )) + )) get_extent = _default_crs( GeoAxes.get_extent - ) + ) set_extent = _default_crs( GeoAxes.set_extent - ) + ) set_xticks = _default_crs( GeoAxes.set_xticks - ) + ) set_yticks = _default_crs( GeoAxes.set_yticks - ) + ) class BasemapAxes(ProjAxes): @@ -3516,7 +3625,7 @@ def __init__(self, *args, map_projection=None, **kwargs): super().__init__(*args, **kwargs) def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, - lonlines, latlines, latmax, lonarray, latarray): + lonlines, latlines, latmax, lonarray, latarray): """Apply changes to the basemap axes.""" # Checks if (lonlim is not None or latlim is not None @@ -3540,14 +3649,14 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, # whereas axes plots can have transparent background kw_face = rc.fill({ 'facecolor': 'geoaxes.facecolor', - 'alpha': 'geoaxes.facealpha', - }, context=True) + 'alpha': 'geoaxes.facealpha', + }, context=True) kw_edge = rc.fill({ 'linewidth': 'geoaxes.linewidth', 'edgecolor': 'geoaxes.edgecolor', - }, context=True) + }, context=True) kw_face.update(patch_kw or {}) - self.axesPatch = self.patch # bugfix or something + self.axesPatch = self.patch # bugfix or something if self.projection.projection in self._proj_non_rectangular: self.patch.set_alpha(0) # make patch invisible if not self.projection._mapboundarydrawn: @@ -3574,11 +3683,11 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, 'color': 'geogrid.color', 'linewidth': 'geogrid.linewidth', 'linestyle': 'geogrid.linestyle', - }) # always apply + }) # always apply tkw = rc.fill({ 'color': 'geogrid.color', 'fontsize': 'geogrid.labelsize', - }) + }) # Change from left/right/bottom/top to left/right/top/bottom if lonarray is not None: lonarray[2:] = lonarray[2:][::-1] @@ -3637,7 +3746,7 @@ def _format_apply(self, patch_kw, lonlim, latlim, boundinglat, 'innerborders': 'drawstates', } for name, method in features.items(): - if not rc[name]: # toggled + if not rc[name]: # toggled continue if getattr(self, f'_{name}', None): # already drawn continue diff --git a/proplot/axistools.py b/proplot/axistools.py index e418e30a3..6725a666d 100644 --- a/proplot/axistools.py +++ b/proplot/axistools.py @@ -860,7 +860,7 @@ def transform_non_affine(self, a): class ExpScale(_ScaleBase, mscale.ScaleBase): - """ + r""" "Exponential scale" that performs either of two transformations. When `inverse` is ``False`` (the default), performs the transformation @@ -1114,9 +1114,10 @@ def transform_non_affine(self, a): class MercatorLatitudeScale(_ScaleBase, mscale.ScaleBase): r""" - Axis scale that transforms values as with latitude in the `Mercator projection - `__. Adapted from `this - example `__. + Axis scale that transforms coordinates as with latitude in the `Mercator + projection `__. Adapted + from `this matplotlib example + `__. The scale function is as follows. diff --git a/proplot/colormath.py b/proplot/colormath.py index 678932852..30b9ee866 100644 --- a/proplot/colormath.py +++ b/proplot/colormath.py @@ -25,7 +25,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 @@ -36,82 +36,89 @@ [3.2406, -1.5372, -0.4986], [-0.9689, 1.8758, 0.0415], [0.0557, -0.2040, 1.0570] - ] +] m_inv = [ [0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505] - ] +] # Hard-coded D65 illuminant (has to do with expected light intensity and # white balance that falls upon the generated color) # See: https://en.wikipedia.org/wiki/Illuminant_D65 # Also: https://github.com/hsluv/hsluv-python/issues/3 -refX = 0.95047 -refY = 1.00000 -refZ = 1.08883 -refU = 0.19784 -refV = 0.46834 +refX = 0.95047 +refY = 1.00000 +refZ = 1.08883 +refU = 0.19784 +refV = 0.46834 lab_e = 0.008856 lab_k = 903.3 -#------------------------------------------------------------------------------# -# Public API -#------------------------------------------------------------------------------# -# Basic conversion + def hsluv_to_rgb(h, s, l): return lchuv_to_rgb(*hsluv_to_lchuv([h, s, l])) + def hsluv_to_hex(h, s, l): return rgb_to_hex(hsluv_to_rgb(h, s, l)) + def rgb_to_hsluv(r, g, b): return lchuv_to_hsluv(rgb_to_lchuv(r, g, b)) + def hex_to_hsluv(color): return rgb_to_hsluv(*hex_to_rgb(color)) + def hpluv_to_rgb(h, s, l): return lchuv_to_rgb(*hpluv_to_lchuv([h, s, l])) + def hpluv_to_hex(h, s, l): return rgb_to_hex(hpluv_to_rgb(h, s, l)) + def rgb_to_hpluv(r, g, b): return lchuv_to_hpluv(rgb_to_lchuv(r, g, b)) + def hex_to_hpluv(color): return rgb_to_hpluv(*hex_to_rgb(color)) + def lchuv_to_rgb(l, c, h): return CIExyz_to_rgb(CIEluv_to_CIExyz(lchuv_to_CIEluv([l, c, h]))) + def rgb_to_lchuv(r, g, b): return CIEluv_to_lchuv(CIExyz_to_CIEluv(rgb_to_CIExyz([r, g, b]))) -# Make ordering of channels consistent with above functions + def hsl_to_rgb(h, s, l): h /= 360.0 s /= 100.0 - l /= 100.0 - return hls_to_rgb(h, l, s) + l /= 100.0 # noqa + return hls_to_rgb(h, l, s) + def rgb_to_hsl(r, g, b): h, l, s = rgb_to_hls(r, g, b) h *= 360.0 s *= 100.0 - l *= 100.0 + l *= 100.0 # noqa return h, s, l + def hcl_to_rgb(h, c, l): return CIExyz_to_rgb(CIEluv_to_CIExyz(lchuv_to_CIEluv([l, c, h]))) + def rgb_to_hcl(r, g, b): l, c, h = CIEluv_to_lchuv(CIExyz_to_CIEluv(rgb_to_CIExyz([r, g, b]))) return h, c, l -#------------------------------------------------------------------------------# -# RGB to HEX conversions -#------------------------------------------------------------------------------# + def rgb_prepare(triple): ret = [] for ch in triple: @@ -122,13 +129,16 @@ def rgb_prepare(triple): ch = 0 if ch > 1: ch = 1 - ret.append(int(round(ch * 255 + 0.001, 0))) # the +0.001 fixes rounding error + # the +0.001 fixes rounding error + ret.append(int(round(ch * 255 + 0.001, 0))) return ret + def rgb_to_hex(triple): [r, g, b] = triple return '#%02x%02x%02x' % tuple(rgb_prepare([r, g, b])) + def hex_to_rgb(color): if color.startswith('#'): color = color[1:] @@ -137,9 +147,7 @@ def hex_to_rgb(color): b = int(color[4:6], 16) / 255.0 return [r, g, b] -#------------------------------------------------------------------------------# -# Helper functions for fancier conversions -#------------------------------------------------------------------------------# + def max_chroma(L, H): hrad = math.radians(H) sinH = (math.sin(hrad)) @@ -159,19 +167,21 @@ def max_chroma(L, H): C = (L * (top - 1.05122 * t) / (bottom + 0.17266 * sinH * t)) if C > 0.0 and C < result: result = C - # print('maxima', result) return result + def hrad_extremum(L): - lhs = (math.pow(L, 3.0) + 48.0 * math.pow(L, 2.0) + 768.0 * L + 4096.0) / 1560896.0 + lhs = (math.pow(L, 3.0) + 48.0 * math.pow(L, 2.0) + + 768.0 * L + 4096.0) / 1560896.0 rhs = 1107.0 / 125000.0 sub = lhs if lhs > rhs else 10.0 * L / 9033.0 - chroma = float("inf") + chroma = float('inf') result = None for row in m: for limit in (0.0, 1.0): [m1, m2, m3] = row - top = -3015466475.0 * m3 * sub + 603093295.0 * m2 * sub - 603093295.0 * limit + top = -3015466475.0 * m3 * sub + 603093295.0 * m2 * sub \ + - 603093295.0 * limit bottom = 1356959916.0 * m1 * sub - 452319972.0 * m3 * sub hrad = math.atan2(top, bottom) if limit == 0.0: @@ -182,13 +192,12 @@ def hrad_extremum(L): result = hrad return result + def max_chroma_pastel(L): H = math.degrees(hrad_extremum(L)) return max_chroma(L, H) -#------------------------------------------------------------------------------# -# Converting between the fancy colorspaces -#------------------------------------------------------------------------------# + def hsluv_to_lchuv(triple): H, S, L = triple if L > 99.9999999: @@ -201,6 +210,7 @@ def hsluv_to_lchuv(triple): # raise ValueError(f'HSL color {triple} is outside LCH colorspace.') return [L, C, H] + def lchuv_to_hsluv(triple): L, C, H = triple if L > 99.9999999: @@ -211,6 +221,7 @@ def lchuv_to_hsluv(triple): S = 100.0 * C / mx return [H, S, L] + def hpluv_to_lchuv(triple): H, S, L = triple if L > 99.9999999: @@ -223,6 +234,7 @@ def hpluv_to_lchuv(triple): # raise ValueError(f'HPL color {triple} is outside LCH colorspace.') return [L, C, H] + def lchuv_to_hpluv(triple): L, C, H = triple if L > 99.9999999: @@ -233,19 +245,19 @@ def lchuv_to_hpluv(triple): S = 100.0 * C / mx return [H, S, L] -#------------------------------------------------------------------------------# -# Converting to CIE official colorspace coordinates -#------------------------------------------------------------------------------# + def dot_product(a, b): - return sum(i*j for i,j in zip(a,b)) + return sum(i * j for i, j in zip(a, b)) # return sum(map(operator.mul, a, b)) + def from_linear(c): if c <= 0.0031308: return 12.92 * c else: return (1.055 * math.pow(c, 1.0 / 2.4) - 0.055) + def to_linear(c): a = 0.055 if c > 0.04045: @@ -253,14 +265,17 @@ def to_linear(c): else: return (c / 12.92) + def CIExyz_to_rgb(triple): CIExyz = map(lambda row: dot_product(row, triple), m) return list(map(from_linear, CIExyz)) + def rgb_to_CIExyz(triple): rgbl = list(map(to_linear, triple)) return list(map(lambda row: dot_product(row, rgbl), m_inv)) + def CIEluv_to_lchuv(triple): L, U, V = triple C = (math.pow(math.pow(U, 2) + math.pow(V, 2), (1.0 / 2.0))) @@ -270,6 +285,7 @@ def CIEluv_to_lchuv(triple): H = 360.0 + H return [L, C, H] + def lchuv_to_CIEluv(triple): L, C, H = triple Hrad = math.radians(H) @@ -277,26 +293,26 @@ def lchuv_to_CIEluv(triple): V = (math.sin(Hrad) * C) return [L, U, V] -#------------------------------------------------------------------------------# -# Converting between different CIE standards -#------------------------------------------------------------------------------# + # Try setting gamma from: https://en.wikipedia.org/wiki/HCL_color_space # The 3.0 used below should be the same; don't mess with it -gamma = 3.0 # tunable? nah, get weird stuff +gamma = 3.0 # tunable? nah, get weird stuff + -# Functiona def CIEfunc(t): if t > lab_e: return (math.pow(t, 1.0 / gamma)) else: return (7.787 * t + 16.0 / 116.0) + def CIEfunc_inverse(t): if math.pow(t, 3.0) > lab_e: return (math.pow(t, gamma)) else: return (116.0 * t - 16.0) / lab_k + def CIExyz_to_CIEluv(triple): X, Y, Z = triple if X == Y == Z == 0.0: @@ -311,6 +327,7 @@ def CIExyz_to_CIEluv(triple): V = 13.0 * L * (varV - refV) return [L, U, V] + def CIEluv_to_CIExyz(triple): L, U, V = triple if L == 0: @@ -322,4 +339,3 @@ def CIEluv_to_CIExyz(triple): X = 0.0 - (9.0 * Y * varU) / ((varU - 4.0) * varV - varU * varV) Z = (9.0 * Y - (15.0 * varV * Y) - (varV * X)) / (3.0 * varV) return [X, Y, Z] - diff --git a/proplot/projs.py b/proplot/projs.py index 7d44f85f7..e84e5affd 100644 --- a/proplot/projs.py +++ b/proplot/projs.py @@ -10,7 +10,6 @@ # if version.parse(cartopy.__version__) < version.parse("0.13"): # raise RuntimeError('Require cartopy version >=0.13.') # adds # set_boundary method -import numpy as np import warnings __all__ = [ 'Proj', @@ -22,11 +21,13 @@ ] try: from mpl_toolkits.basemap import Basemap -except: +except BaseException: Basemap = object try: - from cartopy.crs import (CRS, _WarpedRectangularProjection, - LambertAzimuthalEqualArea, AzimuthalEquidistant, Gnomonic) + from cartopy.crs import ( + CRS, _WarpedRectangularProjection, + LambertAzimuthalEqualArea, AzimuthalEquidistant, Gnomonic + ) except ModuleNotFoundError: CRS = object _WarpedRectangularProjection = object @@ -131,13 +132,16 @@ def Proj(name, basemap=False, **kwargs): See also -------- `~proplot.axes.GeoAxes`, `~proplot.axes.BasemapAxes` - """ + """ # noqa # Class instances if ((CRS is not object and isinstance(name, CRS)) - or (Basemap is not object and isinstance(name, Basemap))): + or (Basemap is not object and isinstance(name, Basemap))): proj = name elif not isinstance(proj, str): - raise ValueError(f'Unexpected Proj() argument {proj!r}. Must be name, mpl_toolkits.basemap.Basemap instance, or cartopy.crs.CRS instance.') + raise ValueError( + f'Unexpected Proj() argument {proj!r}. ' + 'Must be name, mpl_toolkits.basemap.Basemap instance, ' + 'or cartopy.crs.CRS instance.') # Basemap if basemap: import mpl_toolkits.basemap as mbasemap @@ -156,11 +160,11 @@ def Proj(name, basemap=False, **kwargs): proj = mbasemap.Basemap(projection=name, resolution=reso, **kwproj) # Cartopy else: - import cartopy.crs as _ # noqa + import cartopy.crs as _ # noqa kwargs = { CARTOPY_CRS_TRANSLATE.get(key, key): value - for key,value in kwargs.items() - } + for key, value in kwargs.items() + } crs = cartopy_projs.get(name, None) if name == 'geos': # fix common mistake kwargs.pop('central_latitude', None) @@ -169,46 +173,62 @@ def Proj(name, basemap=False, **kwargs): f'"boundinglat" must be passed to the ax.format() command ' 'for cartopy axes.') if crs is None: - raise ValueError(f'Unknown projection {name!r}. Options are: {", ".join(map(repr, cartopy_projs.keys()))}.') + raise ValueError( + f'Unknown projection {name!r}. Options are: ' + ', '.join(map(repr, cartopy_projs.keys())) + '.') proj = crs(**kwargs) return proj # New cartopy projections + + class NorthPolarAzimuthalEquidistant(AzimuthalEquidistant): """Analogous to `~cartopy.crs.NorthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): super().__init__(central_latitude=90, - central_longitude=central_longitude, globe=globe) + central_longitude=central_longitude, globe=globe) + class SouthPolarAzimuthalEquidistant(AzimuthalEquidistant): """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): super().__init__(central_latitude=-90, - central_longitude=central_longitude, globe=globe) + central_longitude=central_longitude, globe=globe) + class NorthPolarLambertAzimuthalEqualArea(LambertAzimuthalEqualArea): """Analogous to `~cartopy.crs.NorthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): super().__init__(central_latitude=90, - central_longitude=central_longitude, globe=globe) + central_longitude=central_longitude, globe=globe) + class SouthPolarLambertAzimuthalEqualArea(LambertAzimuthalEqualArea): """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): super().__init__(central_latitude=-90, - central_longitude=central_longitude, globe=globe) + central_longitude=central_longitude, globe=globe) + class NorthPolarGnomonic(Gnomonic): """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): super().__init__(central_latitude=90, - central_longitude=central_longitude, globe=globe) + central_longitude=central_longitude, globe=globe) + class SouthPolarGnomonic(Gnomonic): """Analogous to `~cartopy.crs.SouthPolarStereo`.""" + def __init__(self, central_longitude=0.0, globe=None): super().__init__(central_latitude=-90, - central_longitude=central_longitude, globe=globe) + central_longitude=central_longitude, globe=globe) + class Aitoff(_WarpedRectangularProjection): """The `Aitoff `__ @@ -216,8 +236,9 @@ class Aitoff(_WarpedRectangularProjection): __name__ = 'aitoff' name = 'aitoff' """Registered projection name.""" - def __init__(self, central_longitude=0, globe=None): #, threshold=1e2): - proj4_params = {'proj':'aitoff', 'lon_0':central_longitude} + + def __init__(self, central_longitude=0, globe=None): # , threshold=1e2): + proj4_params = {'proj': 'aitoff', 'lon_0': central_longitude} super().__init__(proj4_params, central_longitude, globe=globe) @property @@ -225,14 +246,16 @@ def threshold(self): # how finely to interpolate line data, etc. """Projection resolution.""" return 1e4 + class Hammer(_WarpedRectangularProjection): """The `Hammer `__ projection.""" __name__ = 'hammer' name = 'hammer' """Registered projection name.""" - def __init__(self, central_longitude=0, globe=None): #, threshold=1e2): - proj4_params = {'proj':'hammer', 'lon_0':central_longitude} + + def __init__(self, central_longitude=0, globe=None): # , threshold=1e2): + proj4_params = {'proj': 'hammer', 'lon_0': central_longitude} super().__init__(proj4_params, central_longitude, globe=globe) @property @@ -275,6 +298,7 @@ def threshold(self): """Projection resolution.""" return 1e4 + # Hidden constants BASEMAP_TRANSLATE = { 'eqc': 'cyl', @@ -323,7 +347,6 @@ def threshold(self): #: Mapping of "projection names" to cartopy `~cartopy.crs.Projection` classes. cartopy_projs = {} -"""Mapping of "projection names" to cartopy `~cartopy.crs.Projection` classes.""" if CRS is not object: # Custom ones, these are always present import cartopy.crs as ccrs # verify package is available diff --git a/proplot/rctools.py b/proplot/rctools.py index 417eb5168..ec2f44d74 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -4,7 +4,8 @@ See :ref:`Configuring proplot` for details. """ # TODO: Add 'style' option that overrides .proplotrc -# Adapted from seaborn; see: https://github.com/mwaskom/seaborn/blob/master/seaborn/rcmod.py +# Adapted from seaborn; see: +# https://github.com/mwaskom/seaborn/blob/master/seaborn/rcmod.py from . import utils from .utils import _counter, _timer, _benchmark import re @@ -28,173 +29,173 @@ def get_ipython(): # Initialize defaultParamsShort = { - 'nbsetup': True, - 'format': 'retina', - 'autosave': 30, - 'autoreload': 2, - 'abc': False, - 'share': 3, - 'align': False, - 'span': True, - 'tight': True, - 'fontname': 'Helvetica Neue', - 'cmap': 'fire', - 'lut': 256, - 'cycle': 'colorblind', - 'rgbcycle': False, - 'color': 'k', - 'alpha': 1, - 'facecolor': 'w', - 'small': 8, - 'large': 9, - 'linewidth': 0.6, - 'margin': 0.0, - 'grid': True, - 'gridminor': False, - 'ticklen': 4.0, - 'tickdir': 'out', - 'tickpad': 2.0, - 'tickratio': 0.8, + 'nbsetup': True, + 'format': 'retina', + 'autosave': 30, + 'autoreload': 2, + 'abc': False, + 'share': 3, + 'align': False, + 'span': True, + 'tight': True, + 'fontname': 'Helvetica Neue', + 'cmap': 'fire', + 'lut': 256, + 'cycle': 'colorblind', + 'rgbcycle': False, + 'color': 'k', + 'alpha': 1, + 'facecolor': 'w', + 'small': 8, + 'large': 9, + 'linewidth': 0.6, + 'margin': 0.0, + 'grid': True, + 'gridminor': False, + 'ticklen': 4.0, + 'tickdir': 'out', + 'tickpad': 2.0, + 'tickratio': 0.8, 'ticklenratio': 0.5, - 'tickminor': True, - 'gridratio': 0.5, - 'reso': 'lo', - 'geogrid': True, - 'land': False, - 'ocean': False, - 'coast': False, - 'rivers': False, - 'lakes': False, - 'borders': False, + 'tickminor': True, + 'gridratio': 0.5, + 'reso': 'lo', + 'geogrid': True, + 'land': False, + 'ocean': False, + 'coast': False, + 'rivers': False, + 'lakes': False, + 'borders': False, 'innerborders': False, - } +} defaultParamsLong = { - 'title.loc': 'c', # centered above the axes - 'title.pad': 3.0, # copy - 'abc.loc': 'l', # left side above the axes - 'abc.style': 'a', - 'abc.size': None, # = large - 'abc.color': 'k', - 'abc.weight': 'bold', - 'abc.border': True, - 'abc.linewidth': 1.5, - 'tick.labelsize': None, # = small - 'tick.labelcolor': None, # = color - 'tick.labelweight': 'normal', - 'title.size': None, # = large - 'title.color': 'k', - 'title.weight': 'normal', - 'title.border': True, - 'title.linewidth': 1.5, - 'suptitle.size': None, # = large - 'suptitle.color': 'k', - 'suptitle.weight': 'bold', - 'leftlabel.size': None, # = large - 'leftlabel.weight': 'bold', - 'leftlabel.color': 'k', - 'toplabel.size': None, # = large - 'toplabel.weight': 'bold', - 'toplabel.color': 'k', - 'rightlabel.size': None, # = large - 'rightlabel.weight': 'bold', - 'rightlabel.color': 'k', - 'bottomlabel.size': None, # = large - 'bottomlabel.weight': 'bold', - 'bottomlabel.color': 'k', - 'image.edgefix': True, - 'image.levels': 11, - 'axes.facealpha': None, # if empty, depends on 'savefig.transparent' setting - 'axes.formatter.zerotrim': True, + 'title.loc': 'c', # centered above the axes + 'title.pad': 3.0, # copy + 'abc.loc': 'l', # left side above the axes + 'abc.style': 'a', + 'abc.size': None, # = large + 'abc.color': 'k', + 'abc.weight': 'bold', + 'abc.border': True, + 'abc.linewidth': 1.5, + 'tick.labelsize': None, # = small + 'tick.labelcolor': None, # = color + 'tick.labelweight': 'normal', + 'title.size': None, # = large + 'title.color': 'k', + 'title.weight': 'normal', + 'title.border': True, + 'title.linewidth': 1.5, + 'suptitle.size': None, # = large + 'suptitle.color': 'k', + 'suptitle.weight': 'bold', + 'leftlabel.size': None, # = large + 'leftlabel.weight': 'bold', + 'leftlabel.color': 'k', + 'toplabel.size': None, # = large + 'toplabel.weight': 'bold', + 'toplabel.color': 'k', + 'rightlabel.size': None, # = large + 'rightlabel.weight': 'bold', + 'rightlabel.color': 'k', + 'bottomlabel.size': None, # = large + 'bottomlabel.weight': 'bold', + 'bottomlabel.color': 'k', + 'image.edgefix': True, + 'image.levels': 11, + 'axes.facealpha': None, # if empty, depends on 'savefig.transparent' + 'axes.formatter.zerotrim': True, 'axes.formatter.timerotation': 90, - 'axes.gridminor': True, - 'axes.geogrid': True, - 'gridminor.alpha': None, # = grid.alpha - 'gridminor.color': None, # = grid.color - 'gridminor.linestyle': None, # = grid.linewidth - 'gridminor.linewidth': None, # = grid.linewidth x gridratio - 'geogrid.labels': False, - 'geogrid.labelsize': None, # = small - 'geogrid.latmax': 90, - 'geogrid.lonstep': 30, - 'geogrid.latstep': 20, - 'geogrid.alpha': 0.5, - 'geogrid.color': 'k', - 'geogrid.linewidth': 1.0, - 'geogrid.linestyle': ': ', - 'geoaxes.linewidth': None, # = linewidth - 'geoaxes.facecolor': None, # = facecolor - 'geoaxes.facealpha': None, # = alpha - 'geoaxes.edgecolor': None, # = color - 'land.color': 'k', - 'ocean.color': 'w', - 'lakes.color': 'w', - 'coast.color': 'k', - 'coast.linewidth': 0.6, - 'borders.color': 'k', - 'borders.linewidth': 0.6, - 'innerborders.color': 'k', - 'innerborders.linewidth': 0.6, - 'rivers.color': 'k', - 'rivers.linewidth': 0.6, - 'colorbar.loc': 'right', - 'colorbar.grid': False, - 'colorbar.frameon': True, - 'colorbar.framealpha': 0.8, - 'colorbar.insetpad': '0.5em', - 'colorbar.extend': '1.3em', - 'colorbar.insetextend': '1em', - 'colorbar.length': 1, - 'colorbar.insetlength': '8em', - 'colorbar.width': '1.5em', - 'colorbar.insetwidth': '1.2em', - 'subplots.axwidth': '18em', - 'subplots.panelwidth': '4em', - 'subplots.pad': '0.5em', - 'subplots.axpad': '1em', - 'subplots.panelpad': '0.5em', - } + 'axes.gridminor': True, + 'axes.geogrid': True, + 'gridminor.alpha': None, # = grid.alpha + 'gridminor.color': None, # = grid.color + 'gridminor.linestyle': None, # = grid.linewidth + 'gridminor.linewidth': None, # = grid.linewidth x gridratio + 'geogrid.labels': False, + 'geogrid.labelsize': None, # = small + 'geogrid.latmax': 90, + 'geogrid.lonstep': 30, + 'geogrid.latstep': 20, + 'geogrid.alpha': 0.5, + 'geogrid.color': 'k', + 'geogrid.linewidth': 1.0, + 'geogrid.linestyle': ':', + 'geoaxes.linewidth': None, # = linewidth + 'geoaxes.facecolor': None, # = facecolor + 'geoaxes.facealpha': None, # = alpha + 'geoaxes.edgecolor': None, # = color + 'land.color': 'k', + 'ocean.color': 'w', + 'lakes.color': 'w', + 'coast.color': 'k', + 'coast.linewidth': 0.6, + 'borders.color': 'k', + 'borders.linewidth': 0.6, + 'innerborders.color': 'k', + 'innerborders.linewidth': 0.6, + 'rivers.color': 'k', + 'rivers.linewidth': 0.6, + 'colorbar.loc': 'right', + 'colorbar.grid': False, + 'colorbar.frameon': True, + 'colorbar.framealpha': 0.8, + 'colorbar.insetpad': '0.5em', + 'colorbar.extend': '1.3em', + 'colorbar.insetextend': '1em', + 'colorbar.length': 1, + 'colorbar.insetlength': '8em', + 'colorbar.width': '1.5em', + 'colorbar.insetwidth': '1.2em', + 'subplots.axwidth': '18em', + 'subplots.panelwidth': '4em', + 'subplots.pad': '0.5em', + 'subplots.axpad': '1em', + 'subplots.panelpad': '0.5em', +} defaultParams = { - 'axes.titleweight': 'normal', - 'axes.xmargin': 0.0, - 'axes.ymargin': 0.0, - 'axes.grid': True, - 'axes.labelpad': 3.0, - 'axes.titlepad': 3.0, - 'figure.dpi': 90, - 'figure.facecolor': '#f2f2f2', - 'figure.autolayout': False, - 'figure.titleweight': 'bold', + 'axes.titleweight': 'normal', + 'axes.xmargin': 0.0, + 'axes.ymargin': 0.0, + 'axes.grid': True, + 'axes.labelpad': 3.0, + 'axes.titlepad': 3.0, + 'figure.dpi': 90, + 'figure.facecolor': '#f2f2f2', + 'figure.autolayout': False, + 'figure.titleweight': 'bold', 'figure.max_open_warning': 0, - 'grid.color': 'k', - 'grid.alpha': 0.1, - 'grid.linewidth': 0.6, - 'grid.linestyle': '-', - 'hatch.color': 'k', - 'hatch.linewidth': 0.6, - 'legend.frameon': True, - 'legend.framealpha': 0.8, - 'legend.fancybox': False, - 'legend.labelspacing': 0.5, - 'legend.handletextpad': 0.5, - 'legend.handlelength': 1.5, - 'legend.columnspacing': 1.0, - 'legend.borderpad': 0.5, - 'legend.borderaxespad': 0, - 'lines.linewidth': 1.3, - 'lines.markersize': 3.0, - 'mathtext.bf': 'sans:bold', - 'mathtext.it': 'sans:it', - 'mathtext.default': 'regular', - 'savefig.directory': '', - 'savefig.dpi': 300, - 'savefig.facecolor': 'white', - 'savefig.transparent': True, - 'savefig.format': 'pdf', - 'savefig.bbox': 'standard', - 'savefig.pad_inches': 0.0, - 'xtick.minor.visible': True, - 'ytick.minor.visible': True, - } + 'grid.color': 'k', + 'grid.alpha': 0.1, + 'grid.linewidth': 0.6, + 'grid.linestyle': '-', + 'hatch.color': 'k', + 'hatch.linewidth': 0.6, + 'legend.frameon': True, + 'legend.framealpha': 0.8, + 'legend.fancybox': False, + 'legend.labelspacing': 0.5, + 'legend.handletextpad': 0.5, + 'legend.handlelength': 1.5, + 'legend.columnspacing': 1.0, + 'legend.borderpad': 0.5, + 'legend.borderaxespad': 0, + 'lines.linewidth': 1.3, + 'lines.markersize': 3.0, + 'mathtext.bf': 'sans:bold', + 'mathtext.it': 'sans:it', + 'mathtext.default': 'regular', + 'savefig.directory': '', + 'savefig.dpi': 300, + 'savefig.facecolor': 'white', + 'savefig.transparent': True, + 'savefig.format': 'pdf', + 'savefig.bbox': 'standard', + 'savefig.pad_inches': 0.0, + 'xtick.minor.visible': True, + 'ytick.minor.visible': True, +} rcParamsShort = {} rcParamsLong = {} @@ -204,7 +205,7 @@ def get_ipython(): def _tabulate(rcdict): string = '' maxlen = max(map(len, rcdict)) - for key,value in rcdict.items(): + for key, value in rcdict.items(): value = '' if value is None else repr(value) space = ' ' * (maxlen - len(key) + 1) * int(bool(value)) string += f'# {key}:{space}{value}\n' @@ -229,47 +230,60 @@ def _tabulate(rcdict): # "Global" settings and the lower-level settings they change # NOTE: This whole section, declaring dictionaries and sets, takes 1ms RC_CHILDREN = { - 'fontname': ('font.family',), - 'cmap': ('image.cmap',), - 'lut': ('image.lut',), - 'alpha': ('axes.facealpha', 'geoaxes.facealpha'), # this is a custom setting + 'fontname': ('font.family',), + 'cmap': ('image.cmap',), + 'lut': ('image.lut',), + 'alpha': ('axes.facealpha', 'geoaxes.facealpha'), 'facecolor': ('axes.facecolor', 'geoaxes.facecolor'), - 'color': ('axes.edgecolor', 'geoaxes.edgecolor', 'axes.labelcolor', 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color'), # change the 'color' of an axes - 'small': ('font.size', 'tick.labelsize', 'xtick.labelsize', 'ytick.labelsize', 'axes.labelsize', 'legend.fontsize', 'geogrid.labelsize'), # the 'small' fonts - 'large': ('abc.size', 'figure.titlesize', 'axes.titlesize', 'suptitle.size', 'title.size', 'leftlabel.size', 'toplabel.size', 'rightlabel.size', 'bottomlabel.size'), # the 'large' fonts - 'linewidth': ('axes.linewidth', 'geoaxes.linewidth', 'hatch.linewidth', 'xtick.major.width', 'ytick.major.width'), - 'margin': ('axes.xmargin', 'axes.ymargin'), - 'grid': ('axes.grid',), + 'color': ('axes.edgecolor', 'geoaxes.edgecolor', 'axes.labelcolor', + 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color'), + 'small': ('font.size', 'tick.labelsize', + 'xtick.labelsize', 'ytick.labelsize', 'axes.labelsize', + 'legend.fontsize', 'geogrid.labelsize'), + 'large': ('abc.size', 'figure.titlesize', 'axes.titlesize', + 'suptitle.size', 'title.size', 'leftlabel.size', 'toplabel.size', + 'rightlabel.size', 'bottomlabel.size'), # the 'large' fonts + 'linewidth': ('axes.linewidth', 'geoaxes.linewidth', 'hatch.linewidth', + 'xtick.major.width', 'ytick.major.width'), + 'margin': ('axes.xmargin', 'axes.ymargin'), + 'grid': ('axes.grid',), 'gridminor': ('axes.gridminor',), - 'geogrid': ('axes.geogrid',), - 'ticklen': ('xtick.major.size', 'ytick.major.size'), - 'tickdir': ('xtick.direction', 'ytick.direction'), + 'geogrid': ('axes.geogrid',), + 'ticklen': ('xtick.major.size', 'ytick.major.size'), + 'tickdir': ('xtick.direction', 'ytick.direction'), 'tickminor': ('xtick.minor.visible', 'ytick.minor.visible'), - 'tickpad': ('xtick.major.pad', 'xtick.minor.pad', 'ytick.major.pad', 'ytick.minor.pad'), + 'tickpad': ('xtick.major.pad', 'xtick.minor.pad', + 'ytick.major.pad', 'ytick.minor.pad'), 'title.pad': ('axes.titlepad',), - } +} # Used by Axes.format, allows user to pass rc settings as keyword args, # way less verbose. For example, landcolor='b' vs. rc_kw={'land.color':'b'}. -RC_NODOTS = { # useful for passing these as kwargs +RC_NODOTS = { # useful for passing these as kwargs name.replace('.', ''): name for names in (rcParams, rcParamsLong) for name in names - } +} # Categories for returning dict of subcategory properties RC_CATEGORIES = { - *(re.sub('\.[^.]*$', '', name) for names in (rcParams, rcParamsLong) for name in names), - *(re.sub('\..*$', '', name) for names in (rcParams, rcParamsLong) for name in names) - } + *(re.sub(r'\.[^.]*$', '', name) + for names in (rcParams, rcParamsLong) for name in names), + *(re.sub(r'\..*$', '', name) + for names in (rcParams, rcParamsLong) for name in names) +} # Helper funcs + + def _to_points(key, value): """Convert certain rc keys to the units "points".""" # See: https://matplotlib.org/users/customizing.html, all props matching # the below strings use the units 'points', and custom categories are in - if (isinstance(value,str) and key.split('.')[0] not in ('colorbar','subplots') - and re.match('^.*(width|space|size|pad|len|small|large)$', key)): + if (isinstance(value, str) + and key.split('.')[0] not in ('colorbar', 'subplots') + and re.match('^.*(width|space|size|pad|len|small|large)$', key)): value = utils.units(value, 'pt') return value + def _get_config_paths(): """Return a list of configuration file paths.""" # Local configuration @@ -290,12 +304,13 @@ def _get_config_paths(): paths.insert(0, ipath) return paths + def _get_synced_params(key, value): """Return dictionaries for updating the `rcParamsShort`, `rcParamsLong`, and `rcParams` properties associted with this key.""" - kw = {} # builtin properties that global setting applies to - kw_long = {} # custom properties that global setting applies to - kw_short = {} # short name properties + kw = {} # builtin properties that global setting applies to + kw_long = {} # custom properties that global setting applies to + kw_short = {} # short name properties if '.' not in key and key not in rcParamsShort: key = RC_NODOTS.get(key, key) # Skip full name keys @@ -310,18 +325,27 @@ def _get_synced_params(key, value): try: colors = mcm.cmap_d[cycle].colors except (KeyError, AttributeError): - cycles = sorted(name for name,cmap in mcm.cmap_d.items() if isinstance(cmap, mcolors.ListedColormap)) - raise ValueError(f'Invalid cycle name {cycle!r}. Options are: {", ".join(map(repr, cycles))}') + cycles = sorted( + name for name, + cmap in mcm.cmap_d.items() if isinstance( + cmap, + mcolors.ListedColormap)) + raise ValueError( + f'Invalid cycle name {cycle!r}. Options are: ' + ', '.join(map(repr, cycles)) + '.') if rgbcycle and cycle.lower() == 'colorblind': regcolors = colors + [(0.1, 0.1, 0.1)] - elif mcolors.to_rgb('r') != (1.0,0.0,0.0): # reset - regcolors = [(0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.75, 0.75, 0.0), (0.75, 0.75, 0.0), (0.0, 0.75, 0.75), (0.0, 0.0, 0.0)] + elif mcolors.to_rgb('r') != (1.0, 0.0, 0.0): # reset + regcolors = [ + (0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), + (0.75, 0.75, 0.0), (0.75, 0.75, 0.0), (0.0, 0.75, 0.75), + (0.0, 0.0, 0.0)] else: - regcolors = [] # no reset necessary - for code,color in zip('brgmyck', regcolors): + regcolors = [] # no reset necessary + for code, color in zip('brgmyck', regcolors): rgb = mcolors.to_rgb(color) mcolors.colorConverter.colors[code] = rgb - mcolors.colorConverter.cache[code] = rgb + mcolors.colorConverter.cache[code] = rgb kw['patch.facecolor'] = colors[0] kw['axes.prop_cycle'] = cycler.cycler('color', colors) @@ -339,8 +363,8 @@ def _get_synced_params(key, value): else: ticklen = rcParamsShort['ticklen'] ratio = value - kw['xtick.minor.size'] = ticklen*ratio - kw['ytick.minor.size'] = ticklen*ratio + kw['xtick.minor.size'] = ticklen * ratio + kw['ytick.minor.size'] = ticklen * ratio # Spine width/major-minor tick width ratio elif key in ('linewidth', 'tickratio'): @@ -350,8 +374,8 @@ def _get_synced_params(key, value): else: tickwidth = rcParamsShort['linewidth'] ratio = value - kw['xtick.minor.width'] = tickwidth*ratio - kw['ytick.minor.width'] = tickwidth*ratio + kw['xtick.minor.width'] = tickwidth * ratio + kw['ytick.minor.width'] = tickwidth * ratio # Gridline width elif key in ('grid.linewidth', 'gridratio'): @@ -361,7 +385,7 @@ def _get_synced_params(key, value): else: gridwidth = rcParams['grid.linewidth'] ratio = value - kw_long['gridminor.linewidth'] = gridwidth*ratio + kw_long['gridminor.linewidth'] = gridwidth * ratio # Gridline toggling, complicated because of the clunky way this is # implemented in matplotlib. There should be a gridminor setting! @@ -372,13 +396,15 @@ def _get_synced_params(key, value): if not value: # Gridlines are already off, or they are on for the particular # ones that we want to turn off. Instruct to turn both off. - if not ovalue or (key == 'grid' and owhich == 'major') or (key == 'gridminor' and owhich == 'minor'): - which = 'both' # disable both sides - # Gridlines are currently on for major and minor ticks, so we instruct - # to turn on gridlines for the one we *don't* want off - elif owhich == 'both': # and ovalue is True, as we already tested + if not ovalue or (key == 'grid' and owhich == 'major') or ( + key == 'gridminor' and owhich == 'minor'): + which = 'both' # disable both sides + # Gridlines are currently on for major and minor ticks, so we + # instruct to turn on gridlines for the one we *don't* want off + elif owhich == 'both': # and ovalue is True, as we already tested + # if gridminor=False, enable major, and vice versa value = True - which = 'major' if key == 'gridminor' else 'minor' # if gridminor=False, enable major, and vice versa + which = 'major' if key == 'gridminor' else 'minor' # Gridlines are on for the ones that we *didn't* instruct to turn # off, and off for the ones we do want to turn off. This just # re-asserts the ones that are already on. @@ -389,7 +415,8 @@ def _get_synced_params(key, value): else: # Gridlines are already both on, or they are off only for the ones # that we want to turn on. Turn on gridlines for both. - if owhich == 'both' or (key == 'grid' and owhich == 'minor') or (key == 'gridminor' and owhich == 'major'): + if owhich == 'both' or (key == 'grid' and owhich == 'minor') or ( + key == 'gridminor' and owhich == 'major'): which = 'both' # Gridlines are off for both, or off for the ones that we # don't want to turn on. We can just turn on these ones. @@ -415,9 +442,7 @@ def _get_synced_params(key, value): kw[name] = value return kw_short, kw_long, kw -#-----------------------------------------------------------------------------# -# Main class -#-----------------------------------------------------------------------------# + def _sanitize_key(key): """Convert the key to a palatable value.""" if not isinstance(key, str): @@ -436,22 +461,27 @@ class rc_configurator(object): ``~/.proplotrc`` file. See the `~proplot.rctools` documentation for details. """ + def __contains__(self, key): return key in rcParamsShort or key in rcParamsLong or key in rcParams + def __iter__(self): for key in sorted((*rcParamsShort, *rcParamsLong, *rcParams)): yield key + def __repr__(self): rcdict = type('rc', (dict,), {})(rcParamsShort) string = type(rcParams).__repr__(rcdict) - indent = ' ' * 4 # indent is rc({ - return string.strip('})') + f'\n{indent}... (rcParams) ...\n{indent}}})' - def __str__(self): # encapsulate params in temporary class whose name is used by rcParams.__str__ + indent = ' ' * 4 # indent is rc({ + return string.strip( + '})') + f'\n{indent}... (rcParams) ...\n{indent}}})' + + def __str__(self): # encapsulate params in temporary class rcdict = type('rc', (dict,), {})(rcParamsShort) string = type(rcParams).__str__(rcdict) return string + '\n... (rcParams) ...' - @_counter # about 0.05s + @_counter # about 0.05s def __init__(self, local=True): """ Parameters @@ -471,7 +501,7 @@ def __init__(self, local=True): rcParamsShort.clear() rcParamsShort.update(defaultParamsShort) for rcdict in (rcParamsShort, rcParamsLong): - for key,value in rcdict.items(): + for key, value in rcdict.items(): _, rc_long, rc = _get_synced_params(key, value) rcParamsLong.update(rc_long) rcParams.update(rc) @@ -479,7 +509,7 @@ def __init__(self, local=True): # Update from files if not local: return - for i,file in enumerate(_get_config_paths()): + for i, file in enumerate(_get_config_paths()): if not os.path.exists(file): continue with open(file) as f: @@ -488,7 +518,7 @@ def __init__(self, local=True): except yaml.YAMLError as err: print('{file!r} has invalid YAML syntax.') raise err - for key,value in (data or {}).items(): + for key, value in (data or {}).items(): try: self[key] = value except KeyError: @@ -496,12 +526,16 @@ def __init__(self, local=True): def __enter__(self): """Apply settings from the most recent context block.""" - *_, kwargs, cache, restore = self._context[-1] # missing arg is previous mode + if not self._context: + raise RuntimeError( + f'rc context must be initialized with rc.context().') + *_, kwargs, cache, restore = self._context[-1] + def _update(rcdict, newdict): - for key,value in newdict.items(): + for key, value in newdict.items(): restore[key] = rcdict[key] rcdict[key] = cache[key] = value - for key,value in kwargs.items(): + for key, value in kwargs.items(): rc_short, rc_long, rc = _get_synced_params(key, value) _update(rcParamsShort, rc_short) _update(rcParamsLong, rc_long) @@ -509,8 +543,11 @@ def _update(rcdict, newdict): def __exit__(self, *args): """Restore settings from the most recent context block.""" + if not self._context: + raise RuntimeError( + f'rc context must be initialized with rc.context().') *_, restore = self._context[-1] - for key,value in restore.items(): + for key, value in restore.items(): self[key] = value del self._context[-1] @@ -528,7 +565,8 @@ def __getattr__(self, attr): return self[attr] def __getitem__(self, key): - """Return the relevant `rcParams `__, + """Return the relevant `rcParams + `__, :ref:`rcParamsLong`, and :ref:`rcParamsShort` setting.""" key = _sanitize_key(key) for kw in (rcParamsShort, rcParamsLong, rcParams): @@ -543,7 +581,8 @@ def __setattr__(self, attr, value): self[attr] = value def __setitem__(self, key, value): - """Modify the relevant `rcParams `__, + """Modify the relevant `rcParams + `__, :ref:`rcParamsLong`, and :ref:`rcParamsShort` setting(s).""" rc_short, rc_long, rc = _get_synced_params(key, value) rcParamsShort.update(rc_short) @@ -560,7 +599,7 @@ def _get_item(self, key, mode=None): if mode == 0: rcdicts = (*caches, rcParamsShort, rcParamsLong, rcParams) elif mode == 1: - rcdicts = (*caches, rcParamsShort, rcParamsLong) # custom only! + rcdicts = (*caches, rcParamsShort, rcParamsLong) # custom only! elif mode == 2: rcdicts = (*caches,) else: @@ -592,7 +631,9 @@ def category(self, cat, *, context=False): See `~rc_configurator.context`. """ if cat not in RC_CATEGORIES: - raise ValueError(f'Invalid rc category {cat!r}. Valid categories are {", ".join(map(repr, RC_CATEGORIES))}.') + raise ValueError( + f'Invalid rc category {cat!r}. Valid categories are ' + ', '.join(map(repr, RC_CATEGORIES)) + '.') kw = {} mode = 0 if not context else None for rcdict in (rcParamsLong, rcParams): @@ -635,10 +676,12 @@ def context(self, *args, mode=0, **kwargs): "with as" block when called with ``context=True``. The options are as follows. - 0. All settings (`rcParams `__, + 0. All settings (`rcParams + `__, :ref:`rcParamsLong`, and :ref:`rcParamsShort`) are returned, whether or not `~rc_configurator.context` has changed them. - 1. Unchanged `rcParams `__ + 1. Unchanged `rcParams + `__ return ``None``. :ref:`rcParamsLong` and :ref:`rcParamsShort` are returned whether or not `~rc_configurator.context` has changed them. This is used in the `~proplot.axes.Axes.__init__` @@ -717,7 +760,7 @@ def fill(self, props, *, context=False): """ kw = {} mode = 0 if not context else None - for key,value in props.items(): + for key, value in props.items(): item = self._get_item(value, mode) if item is not None: kw[key] = item diff --git a/proplot/styletools.py b/proplot/styletools.py index 61114991d..346a1cd5b 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -14,7 +14,6 @@ import json import glob import cycler -from collections.abc import Sized from xml.etree import ElementTree from numbers import Number, Integral from matplotlib import rcParams @@ -126,14 +125,14 @@ 'GIST': ( 'gist_earth', 'gist_gray', 'gist_heat', 'gist_ncar', 'gist_rainbow', 'gist_stern', 'gist_yarg', - ), + ), 'Other': ( - 'binary', 'bwr', 'brg', # appear to be custom matplotlib, very simple construction - 'cubehelix', 'wistia', 'CMRmap', # individually released - 'seismic', 'terrain', 'nipy_spectral', # origin ambiguous - 'tab10', 'tab20', 'tab20b', 'tab20c', # merged colormap cycles - ) - } + 'binary', 'bwr', 'brg', # appear to be custom matplotlib + 'cubehelix', 'wistia', 'CMRmap', # individually released + 'seismic', 'terrain', 'nipy_spectral', # origin ambiguous + 'tab10', 'tab20', 'tab20b', 'tab20c', # merged colormap cycles + ) +} CMAPS_DIVERGING = tuple( (key1.lower(), key2.lower()) for key1, key2 in ( ('PiYG', 'GYPi'), @@ -152,17 +151,19 @@ )) # Named color filter props -FILTER_SPACE_NAME = 'hcl' # dist 'distinct-ness' of colors using this colorspace -FILTER_SPACE_THRESH = 0.10 # bigger number equals fewer colors +FILTER_SPACE_NAME = 'hcl' # color "distinctness" defined with this cspace +FILTER_SPACE_THRESH = 0.10 # bigger number equals fewer colors FILTER_IGNORE = re.compile('(' + '|'.join(( - 'shit', 'poop', 'poo', 'pee', 'piss', 'puke', 'vomit', 'snot', 'booger', 'bile', 'diarrhea', - )) + ')') # filter these out, let's try to be professional here... + 'shit', 'poop', 'poo', 'pee', 'piss', 'puke', + 'vomit', 'snot', 'booger', 'bile', 'diarrhea', +)) + ')') # filter these out, let's try to be professional here... FILTER_OVERRIDE = ( - 'charcoal', 'sky blue', 'eggshell', 'sea blue', 'coral', 'aqua', 'tomato red', 'brick red', 'crimson', + 'charcoal', 'sky blue', 'eggshell', 'sea blue', + 'coral', 'aqua', 'tomato red', 'brick red', 'crimson', 'red orange', 'yellow orange', 'yellow green', 'blue green', 'blue violet', 'red violet', - ) # common names that should always be included -FILTER_TRANS = tuple((re.compile(regex), sub) for regex,sub in ( +) # common names that should always be included +FILTER_TRANS = tuple((re.compile(regex), sub) for regex, sub in ( ('/', ' '), ("'s", ''), ('grey', 'gray'), ('pinky', 'pink'), @@ -180,28 +181,26 @@ ('bluegray', 'blue gray'), ('grayblue', 'gray blue'), ('lightblue', 'light blue') - )) # prevent registering similar-sounding names +)) # prevent registering similar-sounding names # Named color stuff OPEN_COLORS = ( 'red', 'pink', 'grape', 'violet', 'indigo', 'blue', 'cyan', 'teal', 'green', 'lime', 'yellow', 'orange', 'gray' - ) +) BASE_COLORS = { - 'blue': (0, 0, 1), - 'green': (0, 0.5, 0), - 'red': (1, 0, 0), - 'cyan': (0, 0.75, 0.75), + 'blue': (0, 0, 1), + 'green': (0, 0.5, 0), + 'red': (1, 0, 0), + 'cyan': (0, 0.75, 0.75), 'magenta': (0.75, 0, 0.75), 'yellow': (0.75, 0.75, 0), 'black': (0, 0, 0), 'white': (1, 1, 1), } -#-----------------------------------------------------------------------------# -# Color manipulation functions -#-----------------------------------------------------------------------------# + def _get_space(space): """Return a sanitized version of the colorspace name.""" space = space.lower() @@ -211,6 +210,7 @@ def _get_space(space): raise ValueError(f'Unknown colorspace {space!r}.') return space + def _get_channel(color, channel, space='hsl'): """ Get the hue, saturation, or luminance channel value from the input color. @@ -664,7 +664,9 @@ def _get_data(self, ext): data = '\n'.join(','.join(str(num) for num in line) for line in data) else: - raise ValueError(f'Invalid extension {ext!r}. Options are: "hex", "txt", "rgb", "rgba".') + raise ValueError( + f'Invalid extension {ext!r}. Options are: ' + "'hex', 'txt', 'rgb', 'rgba'.") return data def _parse_path(self, path, dirname='.', ext=''): @@ -693,6 +695,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})' @@ -716,8 +719,8 @@ def __init__(self, *args, cyclic=False, alpha=None, **kwargs): ---------- cyclic : bool, optional Whether the colormap is cyclic. If ``True``, this changes how the - leftmost and rightmost color levels are selected, and `extend` can only - be ``'neither'`` (a warning will be issued otherwise). + leftmost and rightmost color levels are selected, and `extend` can + only be ``'neither'`` (a warning will be issued otherwise). alpha : float, optional The opacity for the entire colormap. Overrides the input segment data. @@ -1144,7 +1147,7 @@ def from_file(path): ===================== ============================================================================================================================================================================================================= Extension Description ===================== ============================================================================================================================================================================================================= - ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': + ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': ``.xml`` XML files with ```` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values. ``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values. ``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate. @@ -1155,7 +1158,7 @@ def from_file(path): ---------- path : str The file path. - """ + """ # noqa return _from_file(path, listed=False) @staticmethod @@ -1202,14 +1205,16 @@ def from_list(name, colors, ratios=None, **kwargs): # Build segmentdata keys = ('red', 'green', 'blue', 'alpha') cdict = {} - for key,values in zip(keys, zip(*colors)): + for key, values in zip(keys, zip(*colors)): cdict[key] = _make_segmentdata_array(values, coords, ratios) return LinearSegmentedColormap(name, cdict, **kwargs) + 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})' @@ -1383,7 +1388,7 @@ def from_file(path): ===================== ============================================================================================================================================================================================================= Extension Description ===================== ============================================================================================================================================================================================================= - ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': + ``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).'ColorBlind10': ``.xml`` XML files with ```` entries specifying ``x``, ``r``, ``g``, ``b``, and optionally, ``a`` values, where ``x`` is the colormap coordinate and the rest are the RGB and opacity (or "alpha") values. ``.rgb`` 3-column table delimited by commas or consecutive spaces, each column indicating red, blue and green color values. ``.xrgb`` As with ``.rgb``, but with 4 columns. The first column indicates the colormap coordinate. @@ -1394,19 +1399,21 @@ def from_file(path): ---------- path : str The file path. - """ + """ # noqa return _from_file(path, listed=True) + class PerceptuallyUniformColormap(LinearSegmentedColormap, _Colormap): """Similar to `~matplotlib.colors.LinearSegmentedColormap`, but instead of varying the RGB channels, the hue, saturation, and luminance channels are varied across the HCL colorspace or the HSLuv or HPLuv scalings of HCL.""" + def __init__(self, - name, segmentdata, N=None, space=None, clip=True, - gamma=None, gamma1=None, gamma2=None, - **kwargs, - ): + name, segmentdata, N=None, space=None, clip=True, + gamma=None, gamma1=None, gamma2=None, + **kwargs, + ): """ Parameters ---------- @@ -1437,8 +1444,8 @@ def __init__(self, with RGB channels with values >1, or mask them out as gray. cyclic : bool, optional Whether the colormap is cyclic. If ``True``, this changes how the - leftmost and rightmost color levels are selected, and `extend` can only - be ``'neither'`` (a warning will be issued otherwise). + leftmost and rightmost color levels are selected, and `extend` can + only be ``'neither'`` (a warning will be issued otherwise). gamma : float, optional Sets `gamma1` and `gamma2` to this identical value. gamma1 : float, optional @@ -1553,9 +1560,9 @@ def set_gamma(self, gamma=None, gamma1=None, gamma2=None): self._init() def updated(self, name=None, segmentdata=None, N=None, *, - alpha=None, gamma=None, cyclic=None, - clip=None, gamma1=None, gamma2=None, space=None, - ): + alpha=None, gamma=None, cyclic=None, + clip=None, gamma1=None, gamma2=None, space=None, + ): """ Returns a new colormap, with relevant properties copied from this one if they were not provided as keyword arguments. @@ -1567,7 +1574,7 @@ def updated(self, name=None, segmentdata=None, N=None, *, segmentdata, N, alpha, clip, cyclic, gamma, gamma1, gamma2, space : optional See `PerceptuallyUniformColormap`. If not provided, these are copied from the current colormap. - """ + """ # noqa if name is None: name = self.name + '_updated' if segmentdata is None: @@ -1586,10 +1593,10 @@ def updated(self, name=None, segmentdata=None, N=None, *, cyclic = self._cyclic if N is None: N = self.N - cmap = PerceptuallyUniformColormap(name, segmentdata, N, + cmap = PerceptuallyUniformColormap( + name, segmentdata, N, alpha=alpha, clip=clip, cyclic=cyclic, - gamma1=gamma1, gamma2=gamma2, space=space, - ) + gamma1=gamma1, gamma2=gamma2, space=space) cmap._rgba_bad = self._rgba_bad cmap._rgba_under = self._rgba_under cmap._rgba_over = self._rgba_over @@ -1749,6 +1756,7 @@ def from_list(name, colors, ratios=None, **kwargs): cdict[key] = _make_segmentdata_array(values, coords, ratios) return PerceptuallyUniformColormap(name, cdict, **kwargs) + class CmapDict(dict): """ Dictionary subclass used to replace the `matplotlib.cm.cmap_d` @@ -1806,13 +1814,16 @@ def __setitem__(self, key, item): elif item is None: return else: - raise ValueError(f'Invalid colormap {item}. Must be instance of matplotlib.colors.ListedColormap or matplotlib.colors.LinearSegmentedColormap.') + raise ValueError( + f'Invalid colormap {item}. Must be instance of ' + 'matplotlib.colors.ListedColormap or ' + 'matplotlib.colors.LinearSegmentedColormap.') key = self._sanitize_key(key, mirror=False) return super().__setitem__(key, item) def __contains__(self, item): """Test for membership using the sanitized colormap name.""" - try: # by default __contains__ uses object.__getitem__ and ignores overrides + try: # by default __contains__ ignores __getitem__ overrides self.__getitem__(item) return True except KeyError: @@ -1921,9 +1932,7 @@ def __getitem__(self, key): return rgba return super().__getitem__((rgb, alpha)) -#-----------------------------------------------------------------------------# -# Colormap and cycle constructor functions -#-----------------------------------------------------------------------------# + def colors(*args, **kwargs): """Pass all arguments to `Cycle` and return the list of colors from the cycler object.""" @@ -1931,11 +1940,12 @@ def colors(*args, **kwargs): return [dict_['color'] for dict_ in cycle] -def Colormap(*args, name=None, listmode='perceptual', - fade=None, cycle=None, - shift=None, cut=None, left=None, right=None, reverse=False, - save=False, save_kw=None, - **kwargs): +def Colormap( + *args, name=None, listmode='perceptual', + fade=None, cycle=None, + shift=None, cut=None, left=None, right=None, reverse=False, + save=False, save_kw=None, + **kwargs): """ Generate a new colormap, retrieve a registered colormap, or merge and manipulate colormap(s) in a variety of ways. This is used to interpret @@ -2034,14 +2044,12 @@ def Colormap(*args, name=None, listmode='perceptual', raise ValueError( f'Colormap() requires at least one positional argument.') if listmode not in ('listed', 'linear', 'perceptual'): - raise ValueError(f'Invalid listmode={listmode!r}. Options are: "listed", "linear", "perceptual".') + raise ValueError( + f'Invalid listmode={listmode!r}. Options are: ' + "'listed', 'linear', 'perceptual'.") cmaps = [] - tmp = '_no_name' # name required, but we only care about name of final merged map - for i,cmap in enumerate(args): - # Properties specific to each map - ireverse = False if not np.iterable(reverse) else reverse[i] - ileft = None if not np.iterable(left) else left[i] - iright = None if not np.iterable(right) else right[i] + tmp = '_no_name' # name required + for i, cmap in enumerate(args): # Load registered colormaps and maps on file # TODO: Document how 'listmode' also affects loaded files if isinstance(cmap, str): @@ -2096,8 +2104,12 @@ def Colormap(*args, name=None, listmode='perceptual', except (ValueError, TypeError): msg = f'Invalid cmap, cycle, or color {cmap!r}.' if isinstance(cmap, str): - msg += (f'\nValid cmap and cycle names: {", ".join(map(repr, sorted(mcm.cmap_d)))}.' - f'\nValid color names: {", ".join(map(repr, sorted(mcolors.colorConverter.colors)))}.') + msg += ( + f'\nValid cmap and cycle names: ' + ', '.join(map(repr, sorted(mcm.cmap_d))) + '.' + f'\nValid color names: ' + ', '.join(map(repr, sorted( + mcolors.colorConverter.colors))) + '.') raise ValueError(msg) cmap = PerceptuallyUniformColormap.from_color(tmp, color, fade) if ireverse: @@ -2137,11 +2149,14 @@ def Colormap(*args, name=None, listmode='perceptual', cmap.save(**save_kw) return cmap -def Cycle(*args, N=None, name=None, - marker=None, alpha=None, dashes=None, linestyle=None, linewidth=None, - markersize=None, markeredgewidth=None, markeredgecolor=None, markerfacecolor=None, - save=False, save_kw=None, - **kwargs): + +def Cycle( + *args, N=None, name=None, + marker=None, alpha=None, dashes=None, linestyle=None, linewidth=None, + markersize=None, markeredgewidth=None, + markeredgecolor=None, markerfacecolor=None, + save=False, save_kw=None, + **kwargs): """ Function for generating and merging `~cycler.Cycler` instances in a variety of ways; used to interpret the `cycle` and `cycle_kw` arguments @@ -2256,17 +2271,19 @@ def Cycle(*args, N=None, name=None, else: # Collect samples if args and isinstance(args[-1], Number): - args, N = args[:-1], args[-1] # means we want to sample existing colormaps or cycles + # means we want to sample existing colormaps or cycles + args, N = args[:-1], args[-1] kwargs.setdefault('fade', 90) kwargs.setdefault('listmode', 'listed') cmap = Colormap(*args, **kwargs) # the cmap object itself if isinstance(cmap, ListedColormap): - colors = cmap.colors[:N] # if N is None, does nothing + colors = cmap.colors[:N] # if N is None, does nothing else: N = _notNone(N, 10) if isinstance(N, Integral): - x = np.linspace(0, 1, N) # from edge to edge - elif np.iterable(N) and all(isinstance(item,Number) for item in N): + x = np.linspace(0, 1, N) # from edge to edge + elif np.iterable(N) and all( + isinstance(item, Number) for item in N): x = np.array(N) else: raise ValueError(f'Invalid samples {N!r}.') @@ -2283,8 +2300,8 @@ def Cycle(*args, N=None, name=None, # Add to property dict nprops = max(nprops, len(colors)) - props['color'] = [tuple(color) if not isinstance(color,str) else color - for color in cmap.colors] # save the tupled version! + props['color'] = [tuple(color) if not isinstance(color, str) else color + for color in cmap.colors] # save the tupled version! # Build cycler, make sure lengths are the same for key, value in props.items(): @@ -2338,13 +2355,17 @@ def Norm(norm, levels=None, **kwargs): # Get class norm_out = normalizers.get(norm, None) if norm_out is None: - raise ValueError(f'Unknown normalizer {norm!r}. Options are: {", ".join(map(repr, normalizers.keys()))}.') + raise ValueError( + f'Unknown normalizer {norm!r}. Options are: ' + ', '.join(map(repr, normalizers.keys())) + '.') # Instantiate class if norm_out is LinearSegmentedNorm: if not np.iterable(levels): - raise ValueError(f'Need levels for normalizer {norm!r}. Received levels={levels!r}.') - kwargs.update({'levels':levels}) - norm_out = norm_out(**kwargs) # initialize + raise ValueError( + f'Need levels for normalizer {norm!r}. ' + 'Received levels={levels!r}.') + kwargs.update({'levels': levels}) + norm_out = norm_out(**kwargs) # initialize else: raise ValueError(f'Unknown norm {norm_out!r}.') return norm_out @@ -2380,10 +2401,9 @@ class BinNorm(mcolors.BoundaryNorm): 3. Out-of-bounds coordinates are added. These depend on the value of the `extend` keyword argument. For `extend` equal to ``'neither'``, the coordinates including out-of-bounds values are - ``[0, 0, 0.25, 0.5, 0.75, 1, 1]`` -- - out-of-bounds values have the same color as the nearest in-bounds values. - For `extend` equal to ``'both'``, the bins are - ``[0, 0.16, 0.33, 0.5, 0.66, 0.83, 1]`` -- + ``[0, 0, 0.25, 0.5, 0.75, 1, 1]`` -- out-of-bounds values have the same + color as the nearest in-bounds values. For `extend` equal to ``'both'``, + the bins are ``[0, 0.16, 0.33, 0.5, 0.66, 0.83, 1]`` -- out-of-bounds values are given distinct colors. This makes sure your colorbar always shows the **full range of colors** in the colormap. 4. Whenever `BinNorm.__call__` is invoked, the input value normalized by @@ -2527,6 +2547,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 @@ -2650,9 +2671,7 @@ def inverse(self, yq, clip=None): mask = ma.getmaskarray(yq) return ma.array(xq, mask=mask) -#-----------------------------------------------------------------------------# -# Functions for loading and visualizing stuff -#-----------------------------------------------------------------------------# + def _get_data_paths(dirname): """Return the data directory paths.""" # Home configuration @@ -2666,10 +2685,11 @@ def _get_data_paths(dirname): paths.insert(0, ipath) return paths + def _from_file(filename, listed=False): """Read generalized colormap and color cycle files.""" filename = os.path.expanduser(filename) - if os.path.isdir(filename): # no warning + if os.path.isdir(filename): # no warning return # Directly read segmentdata json file @@ -2702,14 +2722,18 @@ def _from_file(filename, listed=False): try: data = [[float(num) for num in line] for line in data] except ValueError: - warnings.warn(f'Failed to load {filename!r}. Expected a table of comma or space-separated values.') + warnings.warn( + f'Failed to load {filename!r}. ' + 'Expected a table of comma or space-separated values.') return # Build x-coordinates and standardize shape data = np.array(data) if data.shape[1] != len(ext): - warnings.warn(f'Failed to load {filename!r}. Got {data.shape[1]} columns, but expected {len(ext)}.') + warnings.warn( + f'Failed to load {filename!r}. ' + f'Got {data.shape[1]} columns, but expected {len(ext)}.') return - if ext[0] != 'x': # i.e. no x-coordinates specified explicitly + if ext[0] != 'x': # i.e. no x-coordinates specified explicitly x = np.linspace(0, 1, data.shape[0]) else: x, data = data[:, 0], data[:, 1:] @@ -2727,7 +2751,9 @@ def _from_file(filename, listed=False): for s in doc.getroot().findall('.//Point'): # Verify keys if any(key not in s.attrib for key in 'xrgb'): - warnings.warn(f'Failed to load {filename!r}. Missing an x, r, g, or b specification inside one or more tags.') + warnings.warn( + f'Failed to load {filename!r}. Missing an x, r, g, or b ' + 'specification inside one or more tags.') return # Get data color = [] @@ -2738,9 +2764,12 @@ def _from_file(filename, listed=False): x.append(float(s.attrib['x'])) data.append(color) # Convert to array - if not all(len(data[0]) == len(color) and len(color) in (3,4) for color in data): - warnings.warn(f'Failed to load {filename!r}. Unexpected number of channels or mixed channels across tags.') - return + if not all(len(data[0]) == len(color) and len( + color) in (3, 4) for color in data): + warnings.warn( + f'Failed to load {filename!r}. Unexpected number of channels ' + 'or mixed channels across tags.') + return # Read hex strings elif ext == 'hex': @@ -2748,13 +2777,15 @@ def _from_file(filename, listed=False): string = open(filename).read() # into single string data = re.findall('#[0-9a-fA-F]{6}', string) # list of strings if len(data) < 2: - warnings.warn(f'Failed to load {filename!r}. No hex strings found.') + warnings.warn( + f'Failed to load {filename!r}. No hex strings found.') return # Convert to array x = np.linspace(0, 1, len(data)) data = [to_rgb(color) for color in data] else: - warnings.warn(f'Colormap or cycle file {filename!r} has unknown extension.') + warnings.warn( + f'Colormap or cycle file {filename!r} has unknown extension.') return # Standardize and reverse if necessary to cmap @@ -2773,7 +2804,7 @@ def _from_file(filename, listed=False): if listed: cmap = ListedColormap(data, name, N=len(data)) else: - data = [(x,color) for x,color in zip(x,data)] + data = [(x, color) for x, color in zip(x, data)] cmap = LinearSegmentedColormap.from_list(name, data, N=N) # Return colormap or data @@ -2783,9 +2814,10 @@ def _from_file(filename, listed=False): @_timer def register_cmaps(): """ - Register colormaps packaged with ProPlot or saved to the ``~/.proplot/cmaps`` - folder. This is called on import. Maps are registered according to their - filenames -- for example, ``name.xyz`` will be registered as ``'name'``. + Register colormaps packaged with ProPlot or saved to the + ``~/.proplot/cmaps`` folder. This is called on import. Maps are registered + according to their filenames -- for example, ``name.xyz`` will be + registered as ``'name'``. For a table of valid extensions, see `LinearSegmentedColormap.from_file`. To visualize the registered colormaps, use `show_cmaps`. @@ -2798,7 +2830,7 @@ def register_cmaps(): ] # Add colormaps from ProPlot and user directories - for i,path in enumerate(_get_data_paths('cmaps')): + for i, path in enumerate(_get_data_paths('cmaps')): for filename in sorted(glob.glob(os.path.join(path, '*'))): cmap = LinearSegmentedColormap.from_file(filename) if not cmap: @@ -2809,7 +2841,7 @@ def register_cmaps(): cmaps.append(cmap.name) # Sort - cmaps[:] = sorted(cmaps, key = lambda s: s.lower()) + cmaps[:] = sorted(cmaps, key=lambda s: s.lower()) @_timer @@ -2827,9 +2859,9 @@ def register_cycles(): # Empty out user-accessible cycle list cycles.clear() cycles[:] = [ - name for name,cmap in mcm.cmap_d.items() + name for name, cmap in mcm.cmap_d.items() if isinstance(cmap, ListedColormap) - ] + ] # Read cycles from directories for path in _get_data_paths('cycles'): @@ -2843,7 +2875,7 @@ def register_cycles(): cycles.append(cmap.name) # Sort - cycles[:] = sorted(cycles, key = lambda s: s.lower()) + cycles[:] = sorted(cycles, key=lambda s: s.lower()) @_timer @@ -2866,15 +2898,15 @@ def register_colors(nmax=np.inf): scale = (360, 100, 100) colordict.clear() base.update(mcolors.BASE_COLORS) - base.update(BASE_COLORS) # full names - mcolors.colorConverter.colors.clear() # clean out! - mcolors.colorConverter.cache.clear() # clean out! - for name,dict_ in (('base',base), ('css',mcolors.CSS4_COLORS)): - colordict.update({name:dict_}) + base.update(BASE_COLORS) # full names + mcolors.colorConverter.colors.clear() # clean out! + mcolors.colorConverter.cache.clear() # clean out! + for name, dict_ in (('base', base), ('css', mcolors.CSS4_COLORS)): + colordict.update({name: dict_}) # Load colors from file and get their HCL values - seen = {*base} # never overwrite base names, e.g. 'blue' and 'b'! - hcls = np.empty((0,3)) + seen = {*base} # never overwrite base names, e.g. 'blue' and 'b'! + hcls = np.empty((0, 3)) pairs = [] for path in _get_data_paths('colors'): # prefer xkcd @@ -2900,14 +2932,14 @@ def register_colors(nmax=np.inf): for name, color in data: # is list of name, color tuples if i >= nmax: # e.g. for xkcd colors break - for regex,sub in FILTER_TRANS: + for regex, sub in FILTER_TRANS: name = regex.sub(sub, name) if name in seen or FILTER_IGNORE.search(name): continue seen.add(name) - pairs.append((cat, name)) # save the category name pair + pairs.append((cat, name)) # save the category name pair ihcls.append(to_xyz(color, space=FILTER_SPACE_NAME)) - dict_[name] = color # save the color + dict_[name] = color # save the color i += 1 _colordict_unfiltered[cat] = dict_ hcls = np.concatenate((hcls, ihcls), axis=0) @@ -2915,14 +2947,14 @@ def register_colors(nmax=np.inf): # Remove colors that are 'too similar' by rounding to the nearest n units # WARNING: Unique axis argument requires numpy version >=1.13 if hcls.size > 0: - hcls = hcls/np.array(scale) - hcls = np.round(hcls/FILTER_SPACE_THRESH).astype(np.int64) + hcls = hcls / np.array(scale) + hcls = np.round(hcls / FILTER_SPACE_THRESH).astype(np.int64) deleted = 0 _, idxs, _ = np.unique(hcls, - return_index=True, - return_counts=True, - axis=0) # get unique rows - for idx,(cat,name) in enumerate(pairs): + return_index=True, + return_counts=True, + axis=0) # get unique rows + for idx, (cat, name) in enumerate(pairs): if name not in FILTER_OVERRIDE and idx not in idxs: deleted += 1 else: @@ -2937,9 +2969,10 @@ def register_colors(nmax=np.inf): def register_fonts(): """Add fonts packaged with ProPlot or saved to the ``~/.proplot/fonts`` folder, if they are not already added. Detects ``.ttf`` and ``.otf`` files - -- see `this link `__ + -- see `this link + `__ for a guide on converting various other font file types to ``.ttf`` and - ``.otf`` for use with matplotlib.""" + ``.otf`` for use with matplotlib.""" # noqa # Add proplot path to TTFLIST and rebuild cache # NOTE: Delay font_manager import, because want to avoid rebuilding font # cache, which means import must come after TTFPATH added to environ! @@ -3003,6 +3036,7 @@ def register_fonts(): }) fonts[:] = sorted((*fonts_system, *fonts_proplot)) + def _draw_bars(cmapdict, length=4.0, width=0.2, nrows=None): """ Draw colorbars for "colormaps" and "color cycles". This is called by @@ -3014,36 +3048,38 @@ def _draw_bars(cmapdict, length=4.0, width=0.2, nrows=None): fig, axs = subplots( nrows=naxs, axwidth=length, axheight=width, share=0, hspace=0.03, - ) + ) iax = -1 - nheads = nbars = 0 # for deciding which axes to plot in - a = np.linspace(0, 1, 257).reshape(1,-1) - a = np.vstack((a,a)) - for cat,names in cmapdict.items(): + nheads = nbars = 0 # for deciding which axes to plot in + a = np.linspace(0, 1, 257).reshape(1, -1) + a = np.vstack((a, a)) + for cat, names in cmapdict.items(): if not names: continue nheads += 1 - for imap,name in enumerate(names): + for imap, name in enumerate(names): iax += 1 if imap + nheads + nbars > naxs: break ax = axs[iax] - if imap == 0: # allocate this axes for title + if imap == 0: # allocate this axes for title iax += 1 ax.set_visible(False) ax = axs[iax] cmap = mcm.cmap_d[name] ax.imshow(a, cmap=name, origin='lower', aspect='auto', - levels=cmap.N) + levels=cmap.N) ax.format(ylabel=name, - ylabel_kw={'rotation':0, 'ha':'right', 'va':'center'}, - xticks='none', yticks='none', # no ticks - xloc='neither', yloc='neither', # no spines - title=(cat if imap == 0 else None)) + ylabel_kw={'rotation': 0, 'ha': 'right', 'va': 'center'}, + xticks='none', yticks='none', # no ticks + xloc='neither', yloc='neither', # no spines + title=(cat if imap == 0 else None)) nbars += len(names) -def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, - width=100, axwidth=1.7): + +def show_channels( + *args, N=100, rgb=True, saturation=True, minhue=0, + maxsat=500, width=100, axwidth=1.7): """ Visualize how the input colormap(s) vary with respect to the hue, chroma, and luminance channels. Adapted from `this example @@ -3080,7 +3116,7 @@ def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, from . import subplots if not args: raise ValueError(f'At least one positional argument required.') - array = [[1,1,2,2,3,3]] + array = [[1, 1, 2, 2, 3, 3]] labels = ('Hue', 'Chroma', 'Luminance') if saturation: array += [[0, 4, 4, 5, 5, 0]] @@ -3091,7 +3127,7 @@ def show_channels(*args, N=100, rgb=True, scalings=True, minhue=0, fig, axs = subplots( array=array, span=False, share=1, axwidth=axwidth, axpad='1em', - ) + ) # Iterate through colormaps mc, ms, mp = 0, 0, 0 cmaps = [] @@ -3244,6 +3280,7 @@ def show_colorspaces(luminance=None, saturation=None, hue=None, axwidth=2): title=space.upper(), titleweight='bold') return fig + def show_colors(nhues=17, minsat=0.2): """ Generate two tables of the registered color names. Adapted from @@ -3278,14 +3315,15 @@ def show_colors(nhues=17, minsat=0.2): icolors.update(colordict[name]) # add category dictionary # Group opencolor names together - # names = [[name + str(i) for i in range(nrows)] for name in OPEN_COLORS] + # names = [ + # [name + str(i) for i in range(nrows)] for name in OPEN_COLORS] if open_colors: wscale = 0.5 swatch = 1.5 nrows, ncols = 10, 13 - names = np.reshape([*icolors.keys()], (ncols,nrows)) + names = np.reshape([*icolors.keys()], (ncols, nrows)) names = np.array(names, order='C') - names.resize(((ncols + 1)//2, nrows*2)) # fill in with blanks + names.resize(((ncols + 1) // 2, nrows * 2)) # fill in with blanks # Group colors together by discrete range of hue, then sort by value else: # Transform to HCL space @@ -3293,52 +3331,62 @@ def show_colors(nhues=17, minsat=0.2): wscale = 1 swatch = 1 colors_hcl = { - key: [c/s for c,s in zip(to_xyz(value, FILTER_SPACE_NAME), scale)] - for key,value in icolors.items() - } - # Separate into columns and roughly sort by brightness in these columns - names = [] # initialize - hues = np.linspace(0, 1, nhues) # group in blocks of 20 hues - sat_test = (lambda x: x < minsat) # test saturation for 'grays' + key: [ + c / s for c, + s in zip( + to_xyz( + value, + FILTER_SPACE_NAME), + scale)] + for key, value in icolors.items() + } + # Separate into columns and roughly sort by brightness in these + # columns + names = [] # initialize + hues = np.linspace(0, 1, nhues) # group in blocks of 20 hues + sat_test = (lambda x: x < minsat) # test saturation for 'grays' for i in range(nhues): # 'Grays' column if i == 0: hue_colors = [ - (name,hcl) for name,hcl in colors_hcl.items() + (name, hcl) for name, hcl in colors_hcl.items() if sat_test(hcl[1]) - ] + ] # Nth color column else: - b1, b2 = hues[i-1], hues[i] + b1, b2 = hues[i - 1], hues[i] hue_test = ( (lambda x: b1 <= x <= b2) if b2 is hues[-1] else (lambda x: b1 <= x < b2) - ) + ) hue_colors = [ - (name,hcl) for name,hcl + (name, hcl) for name, hcl in colors_hcl.items() if hue_test(hcl[0]) and not sat_test(hcl[1]) - ] # grays have separate category + ] # grays have separate category # Get indices to build sorted list, then append sorted list idx_sorted = np.argsort([pair[1][2] for pair in hue_colors]) names.append([hue_colors[i][0] for i in idx_sorted]) # Concatenate the columns rather than plot single column for # each hue, so get nice grid names = [i for sublist in names for i in sublist] - nrows = len(names)//ncols + 1 + nrows = len(names) // ncols + 1 names = np.array(names, order='C') - names.resize((ncols, nrows)) # fill in with blanks + names.resize((ncols, nrows)) # fill in with blanks # Create plot by iterating over columns fig, ax = subplots( width=8 * wscale * (ncols / 4), height=5 * (nrows / 40), left=0, right=0, top=0, bottom=0, tight=False - ) - X, Y = fig.get_dpi()*fig.get_size_inches() # size in *dots*; make these axes units - hsep, wsep = Y/(nrows+1), X/ncols # height and width of row/column in *dots* - for col,huelist in enumerate(names): - for row,name in enumerate(huelist): # list of colors in hue category - if not name: # empty slot + ) + # size in *dots*; make these axes units + X, Y = fig.get_dpi() * fig.get_size_inches() + # height and width of row/column in *dots* + hsep, wsep = Y / (nrows + 1), X / ncols + for col, huelist in enumerate(names): + for row, name in enumerate( + huelist): # list of colors in hue category + if not name: # empty slot continue y = Y - hsep * (row + 1) y_line = y + hsep * 0.1 @@ -3354,6 +3402,7 @@ def show_colors(nhues=17, minsat=0.2): figs.append(fig) return figs + def show_cmaps(*args, N=None, unknown='User', **kwargs): """ Generate a table of the registered colormaps or the input colormaps. @@ -3388,20 +3437,21 @@ def show_cmaps(*args, N=None, unknown='User', **kwargs): names = [Colormap(cmap, N=N).name for cmap in args] else: names = [name for name in mcm.cmap_d.keys() if - isinstance(mcm.cmap_d[name], LinearSegmentedColormap) - ] + isinstance(mcm.cmap_d[name], LinearSegmentedColormap) + ] # Get dictionary of registered colormaps and their categories cmapdict = {} names_all = list(map(str.lower, names)) names_known = sum(CMAPS_TABLE.values(), []) cmapdict[unknown] = [name for name in names if name not in names_known] - for cat,names in CMAPS_TABLE.items(): + for cat, names in CMAPS_TABLE.items(): cmapdict[cat] = [name for name in names if name.lower() in names_all] # Return figure of colorbars return _draw_bars(cmapdict, **kwargs) + def show_cycles(*args, **kwargs): """ Generate a table of registered color cycle names or the input color @@ -3428,8 +3478,8 @@ def show_cycles(*args, **kwargs): names = [Colormap(cmap, listmode='listed').name for cmap in args] else: names = [name for name in mcm.cmap_d.keys() if - isinstance(mcm.cmap_d[name], ListedColormap) - ] + isinstance(mcm.cmap_d[name], ListedColormap) + ] # Return figure of colorbars cmapdict = {'Color cycles': names} @@ -3452,25 +3502,37 @@ def show_fonts(fonts=None, size=12): letters = 'the quick brown fox jumps over a lazy dog\n' \ 'THE QUICK BROWN FOX JUMPS OVER A LAZY DOG' for weight in ('normal',): - fig, axs = subplots(ncols=1, nrows=len(fonts), space=0, axwidth=4.5, axheight=5.5*size/72) - axs.format(xloc='neither', yloc='neither', xlocator='null', ylocator='null', alpha=0) - axs[0].format(title='Fonts demo', titlesize=size, titleloc='l', titleweight='bold') - for i,ax in enumerate(axs): + fig, axs = subplots(ncols=1, nrows=len(fonts), space=0, + axwidth=4.5, axheight=5.5 * size / 72) + axs.format( + xloc='neither', + yloc='neither', + xlocator='null', + ylocator='null', + alpha=0) + axs[0].format( + title='Fonts demo', + titlesize=size, + titleloc='l', + titleweight='bold') + for i, ax in enumerate(axs): font = fonts[i] - ax.text(0, 0.5, f'{font}: {letters}\n{math}\n{greek}', fontfamily=font, - fontsize=size, weight=weight, ha='left', va='center') + ax.text(0, 0.5, f'{font}: {letters}\n{math}\n{greek}', + fontfamily=font, fontsize=size, weight=weight, + ha='left', va='center') return fig -#-----------------------------------------------------------------------------# -# Load stuff -#-----------------------------------------------------------------------------# + # Apply custom changes -mcm.cmap_d['Grays'] = mcm.cmap_d.pop('Greys', None) # 'Murica, and consistency with registered color names -mcm.cmap_d['Spectral'] = mcm.cmap_d['Spectral'].reversed(name='Spectral') # make spectral go from 'cold' to 'hot' -for _name in CMAPS_TABLE['Matplotlib Originals']: # initialize as empty lists +# 'Murica, and consistency with registered color names +mcm.cmap_d['Grays'] = mcm.cmap_d.pop('Greys', None) +mcm.cmap_d['Spectral'] = mcm.cmap_d['Spectral'].reversed( + name='Spectral') # make spectral go from 'cold' to 'hot' +for _name in CMAPS_TABLE['Matplotlib Originals']: # initialize as empty lists _cmap = mcm.cmap_d.get(_name, None) if _cmap and isinstance(_cmap, mcolors.ListedColormap): - mcm.cmap_d[_name] = LinearSegmentedColormap.from_list(_name, _cmap.colors, cyclic=('twilight' in _name)) + mcm.cmap_d[_name] = LinearSegmentedColormap.from_list( + _name, _cmap.colors, cyclic=('twilight' in _name)) for _cat in ('MATLAB', 'GNUplot', 'GIST', 'Other'): for _name in CMAPS_TABLE[_cat]: mcm.cmap_d.pop(_name, None) @@ -3481,8 +3543,8 @@ def show_fonts(fonts=None, size=12): if not isinstance(mcolors._colors_full_map, _ColorMappingOverride): _map = _ColorMappingOverride(mcolors._colors_full_map) mcolors._colors_full_map = _map - mcolors.colorConverter.cache = _map.cache # re-instantiate - mcolors.colorConverter.colors = _map # re-instantiate + mcolors.colorConverter.cache = _map.cache # re-instantiate + mcolors.colorConverter.colors = _map # re-instantiate # Initialize customization folders and files @@ -3494,36 +3556,41 @@ def show_fonts(fonts=None, size=12): if not os.path.isdir(_rc_sub): os.mkdir(_rc_sub) -# Fill lists and dictionaries -cmaps = [] # track *downloaded* colormaps, user can then check this list -"""List of new registered colormap names.""" -cycles = [] # track *all* color cycles -"""List of registered color cycle names.""" -_colordict_unfiltered = {} # downloaded colors categorized by filename -colordict = {} # limit to 'sufficiently unique' color names -"""Registered color names by category.""" +#: List of new registered colormap names. +cmaps = [] # track *downloaded* colormaps + +#: List of registered color cycle names. +cycles = [] # track *all* color cycles + +#: Registered color names by category. +colordict = {} # limit to 'sufficiently unique' color names +_colordict_unfiltered = {} # downloaded colors categorized by filename + +#: Names of fonts added by ProPlot. fonts_proplot = [] -"""Names of fonts added by ProPlot.""" + +#: Names of fonts provided by matplotlib or your operating system. fonts_system = [] -"""Names of fonts provided by matplotlib or your operating system.""" + +#: All registered font names. fonts = [] -"""All registered font names.""" + +#: Dictionary of possible normalizers. See `Norm` for a table. +normalizers = { + 'none': mcolors.NoNorm, + 'null': mcolors.NoNorm, + 'zero': MidpointNorm, + 'midpoint': MidpointNorm, + 'segments': LinearSegmentedNorm, + 'segmented': LinearSegmentedNorm, + 'log': mcolors.LogNorm, + 'linear': mcolors.Normalize, + 'power': mcolors.PowerNorm, + 'symlog': mcolors.SymLogNorm, +} + +# Register stuff register_colors() register_cmaps() register_cycles() register_fonts() - -# Dictionary of normalizers -normalizers = { - 'none': mcolors.NoNorm, - 'null': mcolors.NoNorm, - 'zero': MidpointNorm, - 'midpoint': MidpointNorm, - 'segments': LinearSegmentedNorm, - 'segmented': LinearSegmentedNorm, - 'log': mcolors.LogNorm, - 'linear': mcolors.Normalize, - 'power': mcolors.PowerNorm, - 'symlog': mcolors.SymLogNorm, - } -"""Dictionary of possible normalizers. See `Norm` for a table.""" diff --git a/proplot/subplots.py b/proplot/subplots.py index c8e483478..06729d122 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -18,7 +18,7 @@ try: from matplotlib.backends.backend_macosx import FigureCanvasMac except ImportError: - FigureCanvasMac = type(None) # standin null type + FigureCanvasMac = type(None) # standin null type from . import projs, axes from .rctools import rc from .utils import _notNone, _counter, units @@ -31,7 +31,7 @@ 'GridSpec', 'show', 'subplots', 'SubplotSpec', - ] +] # Translation SIDE_TRANSLATE = { @@ -164,12 +164,10 @@ **kwargs Passed to `matplotlib.figure.Figure`. -""" +""" # noqa docstring.interpd.update(figure_doc=_figure_doc) -#-----------------------------------------------------------------------------# -# Helper classes and misc funcs -#-----------------------------------------------------------------------------# + def close(*args, **kwargs): """Call `matplotlib.pyplot.close`. This is included so you don't have to import `~matplotlib.pyplot`.""" @@ -184,6 +182,7 @@ def show(): it will be automatically displayed.""" plt.show() + class axes_grid(list): """List subclass and pseudo-2D array that is used as a container for the list of axes returned by `subplots`, lists of figure panels, and lists of @@ -375,28 +374,31 @@ def _iterator(*args, **kwargs): # for ax in self: # pass + class SubplotSpec(mgridspec.SubplotSpec): """ Matplotlib `~matplotlib.gridspec.SubplotSpec` subclass that adds 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})' + class GridSpec(mgridspec.GridSpec): """ Matplotlib `~matplotlib.gridspec.GridSpec` subclass that allows for grids with variable spacing between successive rows and columns of axes. """ - def __repr__(self): # do not show width and height ratios because we always fill them + def __repr__(self): # do not show width and height ratios nrows, ncols = self.get_geometry() return f'GridSpec({nrows}, {ncols})' 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): + left=None, right=None, bottom=None, top=None, + wspace=None, hspace=None, wratios=None, hratios=None, + width_ratios=None, height_ratios=None): """ Parameters ---------- @@ -427,17 +429,22 @@ def __init__(self, nrows=1, ncols=1, second column is twice as wide as the first column. """ # Attributes - self._figures = set() # figure tracker + 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) + 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) @@ -447,13 +454,16 @@ def __init__(self, nrows=1, ncols=1, 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.""" + 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, but got {len(space)} hspaces.') + 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): @@ -461,11 +471,13 @@ def _sanitize_wspace(self, space): 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, but got {len(space)} wspaces.') + 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 != None) + filter = (space is not None) self.wspace[filter] = space[filter] def add_figure(self, figure): @@ -473,7 +485,9 @@ def add_figure(self, figure): 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, you passed {type(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): @@ -504,7 +518,9 @@ def get_grid_positions(self, figure, raw=False): hspace = self._sanitize_hspace(0) else: if not isinstance(figure, Figure): - raise ValueError(f'Invalid figure {figure!r}. Must be a ProPlot Figure instance.') + 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) @@ -517,25 +533,25 @@ def get_grid_positions(self, figure, raw=False): # Calculate accumulated heights of columns tot_width = right - left tot_height = top - bottom - cell_h = tot_height / (nrows + hspace*(nrows-1)) + 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: cell_heights = [cell_h] * nrows - sep_heights = [0] + ([sep_h] * (nrows-1)) + 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)) + 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: cell_widths = [cell_w] * ncols - sep_widths = [0] + ([sep_w] * (ncols-1)) + 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 @@ -544,7 +560,8 @@ def get_grid_positions(self, figure, raw=False): 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.') + raise NotImplementedError( + f'ProPlot GridSpec does not interact with figure SubplotParams.') def get_hspace(self): """Return the vector of row spaces.""" @@ -571,7 +588,8 @@ def set_height_ratios(self, 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.') + raise ValueError( + f'GridSpec has {N} rows, but got {len(ratios)} height ratios.') super().set_height_ratios(self) def set_hspace(self, space): @@ -579,7 +597,7 @@ def set_hspace(self, space): 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 != None) + filter = (space is not None) self.hspace[filter] = space[filter] def set_margins(self, left, right, bottom, top): @@ -602,7 +620,9 @@ def set_width_ratios(self, ratios): if len(ratios) == 1: ratios = np.repeat(ratios, (N,)) if len(ratios) != N: - raise ValueError(f'GridSpec has {N} columns, but got {len(ratios)} width ratios.') + raise ValueError( + f'GridSpec has {N} columns, but ' + f'got {len(ratios)} width ratios.') super().set_width_ratios(self) def set_wspace(self, space): @@ -610,17 +630,19 @@ def set_wspace(self, space): 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 != None) + 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.') + 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): + 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 for every figure using this gridspec. @@ -648,15 +670,13 @@ def update(self, left=None, right=None, bottom=None, top=None, if hratios is not None: self.set_height_ratios(hratios) for figure in self._figures: - figure._geometryconfig._init() # in case gridspec values changed! + figure._geometryconfig._init() # in case gridspec values changed! for ax in figure.axes: ax.update_params() ax.set_position(ax.figbox) figure.stale = True -#-----------------------------------------------------------------------------# -# Figure class and geometry class -#-----------------------------------------------------------------------------# + _gridspec_doc = """ Apply the `GridSpec` to the figure or generate a new `GridSpec` instance with the positional and keyword arguments. For example, @@ -676,14 +696,16 @@ def update(self, left=None, right=None, bottom=None, top=None, Passed to `~matplotlib.figure.Figure.savefig`. """ + 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 + return round(num1 * hi) * lo == round(num2 * hi) * lo + def _get_panelargs(side, - share=None, width=None, space=None, - filled=False, figure=False): + share=None, width=None, space=None, + filled=False, figure=False): """Return default properties for new axes and figure panels.""" s = side[0] if s not in 'lrbt': @@ -703,44 +725,47 @@ def _get_panelargs(side, space = _get_space(key, share, pad=pad) return share, width, space, space_user + def _get_space(key, share=0, pad=None): """Return suitable default spacing given a shared axes setting.""" if key == 'left': space = units(_notNone(pad, rc['subplots.pad'])) + ( rc['ytick.major.size'] + rc['ytick.labelsize'] - + rc['ytick.major.pad'] + rc['axes.labelsize'])/72 + + rc['ytick.major.pad'] + rc['axes.labelsize']) / 72 elif key == 'right': space = units(_notNone(pad, rc['subplots.pad'])) elif key == 'bottom': space = units(_notNone(pad, rc['subplots.pad'])) + ( rc['xtick.major.size'] + rc['xtick.labelsize'] - + rc['xtick.major.pad'] + rc['axes.labelsize'])/72 + + rc['xtick.major.pad'] + rc['axes.labelsize']) / 72 elif key == 'top': space = units(_notNone(pad, rc['subplots.pad'])) + ( - rc['axes.titlepad'] + rc['axes.titlesize'])/72 + rc['axes.titlepad'] + rc['axes.titlesize']) / 72 elif key == 'wspace': space = (units(_notNone(pad, rc['subplots.axpad'])) - + rc['ytick.major.size']/72) + + rc['ytick.major.size'] / 72) if share < 3: - space += (rc['ytick.labelsize'] + rc['ytick.major.pad'])/72 + space += (rc['ytick.labelsize'] + rc['ytick.major.pad']) / 72 if share < 1: - space += rc['axes.labelsize']/72 + space += rc['axes.labelsize'] / 72 elif key == 'hspace': space = units(_notNone(pad, rc['subplots.axpad'])) + ( rc['axes.titlepad'] + rc['axes.titlesize'] - + rc['xtick.major.size'])/72 + + rc['xtick.major.size']) / 72 if share < 3: - space += (rc['xtick.labelsize'] + rc['xtick.major.pad'])/72 + space += (rc['xtick.labelsize'] + rc['xtick.major.pad']) / 72 if share < 0: - space += rc['axes.labelsize']/72 + space += rc['axes.labelsize'] / 72 else: raise KeyError(f'Invalid space key {key!r}.') return space + class _hide_labels(object): """Hides objects temporarily so they are ignored by the tight bounding box algorithm.""" # TODO: Remove this by overriding the tight bounding box calculation + def __init__(self, *args): self._labels = args @@ -752,6 +777,7 @@ def __exit__(self, *args): for label in self._labels: label.set_visible(True) + class EdgeStack(object): """ Container for groups of `~matplotlib.artist.Artist` objects stacked @@ -761,6 +787,7 @@ class EdgeStack(object): # def __init__(self, *args): # if not all(isinstance(arg, martist.Artst), args) + class GeometrySolver(object): """ ProPlot's answer to the matplotlib `~matplotlib.figure.SubplotParams` @@ -777,7 +804,9 @@ def __init__(self, figure): The figure instance associated with this geometry configuration. """ if not isinstance(figure, Figure): - raise ValueError(f'GeometrySolver() accepts only ProPlot Figure instances, you passed {type(figure)}.') + raise ValueError( + f'GeometrySolver() accepts only proplot.subplots.Figure ' + f'instances, you passed {type(figure)}.') self._figure = figure self._isinit = False @@ -796,12 +825,13 @@ def _init(self): # 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. + # 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.') + 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 @@ -811,8 +841,8 @@ def _init(self): 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) + 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() @@ -824,8 +854,8 @@ def _init(self): 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 + self.wpanels = [''] * ncols + self.hpanels = [''] * nrows # Indicate we are initialized self._isinit = True @@ -852,17 +882,19 @@ def resize(self): 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')] + 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')] + 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 @@ -884,59 +916,83 @@ def resize(self): 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)) + 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} and hratio={rhratio!r} for reference axes.') + 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 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! + # 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 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) + 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) + 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 + 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 + 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) + 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) + 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.") + 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(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.") + 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): + 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): + for idx, ratio in zip(np.where(wmask)[0], wratios_main): wratios[idx] = ratio # Update figure size and gridspec @@ -947,23 +1003,24 @@ def resize(self): wspace=wspace, hspace=hspace, width_ratios=wratios, height_ratios=hratios, left=left, bottom=bottom, right=right, top=top, - ) + ) else: 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, - ) + ) return self._gridspec - 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'GridSpec has not been initialized yet. Cannot update GeometrySolver.') + raise ValueError( + 'GridSpec has not been initialized yet.' + 'Cannot update GeometrySolver.') # Adjust aspect ratio ax = fig.get_ref_axes() @@ -978,7 +1035,7 @@ def update(self, renderer=None): elif xscale == 'log' and yscale == 'log': aspect = 1.0 / ax.get_data_ratio_log() else: - pass # matplotlib issues warning, forces aspect == 'auto' + 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() @@ -986,7 +1043,9 @@ def update(self, renderer=None): # Get renderer if not passed canvas = getattr(fig, 'canvas', None) if not hasattr(canvas, 'get_renderer'): - warnings.warn(f'Figure canvas has no get_renderer() method, cannot calculate positions.') + warnings.warn( + f'Figure canvas has no get_renderer() method, ' + f'cannot calculate positions.') renderer = canvas.get_renderer() canvas.renderer = renderer @@ -1026,7 +1085,7 @@ def _adjust_tight_layout(self, renderer): if not axs or not gridspec: return bbox = self.get_tightbbox(renderer) - bbox_orig = self.bbox_inches # original bbox + bbox_orig = self.bbox_inches # original bbox pad = self._pad axpad = self._axpad panelpad = self._panelpad @@ -1041,14 +1100,14 @@ def _adjust_tight_layout(self, renderer): # Get new subplot spacings, axes panel spacing, figure panel spacing spaces = [] - for (w, x, y, nacross, ispace) in zip('wh', 'xy', 'yx', - (nrows,ncols), (wspace,hspace)): + for (w, x, y, nacross, ispace) in zip( + 'wh', 'xy', 'yx', (nrows, ncols), (wspace, hspace)): # Determine which rows and columns correspond to 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 in enumerate(ispace): + for i, space in enumerate(ispace): # Figure out whether this is a normal space, or a # panel stack space/axes panel space pad = axpad @@ -1101,7 +1160,8 @@ def _adjust_tight_layout(self, renderer): x2 = min(ax._range_tightbbox(x)[0] for ax in group2) jspaces.append((x2 - x1) / self.dpi) if jspaces: - space = max(0, space - min(jspaces) + pad) # TODO: why max 0? + space = max( + 0, space - min(jspaces) + pad) # TODO: why max 0? jspace[i] = space spaces.append(jspace) @@ -1122,17 +1182,20 @@ def _align_axislabels(self, b=True): spanx, spany = self._spanx, self._spany alignx, aligny = self._alignx, self._aligny if ((spanx or alignx) and grpx) or ((spany or aligny) and grpy): - warnings.warn(f'Aligning *x* and *y* axis labels requires matplotlib >=3.1.0') + warnings.warn( + '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)): + 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: + if (not span and not align) or not isinstance( + ax, axes.XYAxes) or axis in tracker: continue - s = axis.get_label_position()[0] # top or bottom, left or right + # top or bottom, left or right + s = axis.get_label_position()[0] if s not in 'bl': continue axs = ax._get_side_axes(s) @@ -1143,7 +1206,8 @@ def _align_axislabels(self, b=True): axises = [getattr(ax, x + 'axis') for ax in axs] tracker.update(axises) for ax in axs[1:]: - grp.join(axs[0], ax) # copied from source code, add to grouper + # copied from source code, add to grouper + grp.join(axs[0], ax) if not span: continue @@ -1175,7 +1239,8 @@ def _align_axislabels(self, b=True): def _align_suplabels(self, renderer): """Adjust the position of row and column labels, and align figure - super title accounting for figure margins and axes and figure panels.""" + super title accounting for figure margins and axes and + figure panels.""" # Offset using tight bounding boxes # TODO: Super labels fail with popup backend!! Fix this # NOTE: Must use get_tightbbox so (1) this will work if tight layout @@ -1195,7 +1260,7 @@ def _align_suplabels(self, renderer): if s == 't' and suptitle_on: supaxs = axs with _hide_labels(*labels): - for i,(ax,label) in enumerate(zip(axs,labels)): + for i, (ax, label) in enumerate(zip(axs, labels)): label_on = label.get_text().strip() if not label_on: continue @@ -1260,6 +1325,7 @@ def figure(self): 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 @@ -1268,17 +1334,17 @@ class Figure(mfigure.Figure): 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, - tight_layout=None, constrained_layout=None, - **kwargs): + 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, + tight_layout=None, constrained_layout=None, + **kwargs): """ Parameters ---------- @@ -1292,7 +1358,11 @@ def __init__(self, """ # Initialize first if tight_layout or constrained_layout: - warnings.warn(f'Ignoring tight_layout={tight_layout} and contrained_layout={constrained_layout}. ProPlot uses its own tight layout algorithm, activated by default or with tight=True.') + warnings.warn( + 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 @@ -1334,20 +1404,34 @@ def __init__(self, if height is not None: spec.append(f'height={height!r}') spec = ', '.join(spec) - for name,value in zip(names,values): + for name, value in zip(names, values): if value is not None: - warnings.warn(f'You specified both {spec} and {name}={value!r}. Ignoring {name!r}.') + warnings.warn( + 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) + # 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)) + 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(f'Invalid spacing parameter. Must be scalar when passed to Figure().') + 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'])) @@ -1367,7 +1451,7 @@ def __init__(self, self._wpanels = None self._hpanels = None self.ref = ref - self.suptitle('') # add _suptitle attribute + self.suptitle('') # add _suptitle attribute @_counter def _add_axes_panel(self, ax, side, filled=False, **kwargs): @@ -1379,18 +1463,18 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): s = side[0] if s not in 'lrbt': raise ValueError(f'Invalid side {side!r}.') - if not self._gridspec: # needed for wpanels, hpanels, etc. + 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 + 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) + 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 + offset = (len(pgrid) * bool(pgrid)) + 1 if s in 'lr': iratio = (col1 - offset if s == 'l' else col2 + offset) idx1 = slice(row1, row2 + 1) @@ -1401,8 +1485,8 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): idx2 = slice(col1, col2 + 1) gridspec_prev = self._gridspec gs = self._insert_row_column(side, iratio, - width, space, space_orig, figure=False, - ) + width, space, space_orig, figure=False, + ) if gs is not gridspec_prev: if s == 't': idx1 += 1 @@ -1410,43 +1494,52 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): idx2 += 1 # Draw and setup panel - pax = self.add_subplot(gs[idx1,idx2], - main=False, projection='cartesian') + 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 + if not filled: # axis sharing and setup ax._share_setup() - axis = (pax.yaxis if side in ('left','right') else pax.xaxis) - getattr(axis, 'tick_' + side)() # sets tick and tick label positions intelligently + 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): + 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. + 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) + _, width, space, space_orig = _get_panelargs( + s, filled=True, figure=True, **kwargs) if s in 'lr': - for key,value in (('col',col),('cols',cols)): + for key, value in (('col', col), ('cols', cols)): if value is not None: - raise ValueError(f'Invalid keyword arg {key!r} for figure panel on side {side!r}.') - span = _notNone(span, row, rows, None, names=('span', 'row', 'rows')) + 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)): + for key, value in (('row', row), ('rows', rows)): if value is not None: - raise ValueError(f'Invalid keyword arg {key!r} for figure panel on side {side!r}.') - span = _notNone(span, col, cols, None, names=('span', 'col', 'cols')) + 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') @@ -1458,36 +1551,38 @@ def _add_figure_panel(self, side, # 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 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}. Coordinates must satisfy 1 <= c <= {nalong}.') - start, stop = span[0] - 1, span[1] # zero-indexed + 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 + 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 + 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 + 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 + if iratio in (-1, nacross): # add to array iarray = np.zeros((1, nalong), dtype=bool) - iarray[0,start:stop] = True + iarray[0, start:stop] = True array = np.concatenate((array, iarray), axis=0) setattr(self, '_' + s + 'array', array) @@ -1496,18 +1591,17 @@ def _add_figure_panel(self, side, if len(idxs) != nalong: raise RuntimeError('Wut?') if s in 'lr': - idx1 = slice(idxs[start], idxs[stop-1] + 1) + 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, - ) + 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') + 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 @@ -1557,13 +1651,14 @@ def _get_align_axes(self, side): 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._mainaxes if ax._range_gridspec(x)[idx] == edge] + axs = [ax for ax in self._mainaxes if ax._range_gridspec(x)[ + idx] == edge] ord = [ax._range_gridspec(y)[0] for ax in axs] - return [ax for _,ax in sorted(zip(ord, axs)) if ax.get_visible()] + 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, - ): + 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.""" @@ -1615,7 +1710,9 @@ def _insert_row_column(self, side, idx, # # 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)] + # 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! @@ -1640,7 +1737,9 @@ def _insert_row_column(self, side, idx, # elif tss is igs._subplot_spec: # igs._subplot_spec = ssnew # else: - # raise RuntimeError(f'Found unexpected GridSpecFromSubplotSpec nesting.') + # raise RuntimeError( + # f'Found unexpected GridSpecFromSubplotSpec nesting.' + # ) # # Update parent or child position # ax.update_params() # ax.set_position(ax.figbox) @@ -1688,9 +1787,9 @@ def _update_axislabels(self, axis=None, **kwargs): 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 + getattr(ax, x + 'axis').label.update(kwargs) # apply to main axes pax = getattr(ax, '_share' + x) - if pax is not None: # apply to panel? + if pax is not None: # apply to panel? getattr(pax, x + 'axis').label.update(kwargs) def _update_suplabels(self, ax, side, labels, **kwargs): @@ -1731,10 +1830,10 @@ def add_gridspec(self, *args, **kwargs): 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): + 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. @@ -1776,18 +1875,24 @@ def add_subplot(self, *args, 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(f'Integer subplot specification must be a three-digit number, not {args[0]!r}.') + 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: - warnings.warn(f'Ignoring sharex={sharex!r}. To toggle axes sharing, just pass sharex=num to figure() or subplots().') + warnings.warn( + f'Ignoring sharex={sharex!r}. To toggle axes sharing, ' + 'just pass sharex=num to figure() or subplots().') if sharey is not None: - warnings.warn(f'Ignoring sharey={sharey!r}. To toggle axes sharing, just pass sharey=num to figure() or subplots().') + warnings.warn( + f'Ignoring sharey={sharey!r}. To toggle axes sharing, ' + 'just pass sharey=num to figure() or subplots().') # Copied from SubplotBase __init__ # Interpret positional args @@ -1797,13 +1902,18 @@ def add_subplot(self, *args, 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.') + 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}.') + 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: @@ -1816,35 +1926,51 @@ def add_subplot(self, *args, 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 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.') + 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: # also covers geometry discrepancies - raise ValueError(f'Invalid subplotspec {args[0]!r}. Figure.add_subplot() only accepts SubplotSpec objects whose parent is the main gridspec.') + 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')) + 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: - warnings.warn(f'Ignoring input "map_projection" {kwargs["map_projection"]!r}.') + warnings.warn( + f'Ignoring input "map_projection" ' + f'{kwargs["map_projection"]!r}.') kwargs['map_projection'] = map_projection proj = 'basemap' if basemap else 'cartopy' @@ -1911,8 +2037,8 @@ def colorbar(self, *args, return ax.colorbar(*args, space=space, width=width, **kwargs) # Generate figure panel ax = self._add_figure_panel(loc, - space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols) + space=space, width=width, span=span, + row=row, col=col, rows=rows, cols=cols) return ax.colorbar(*args, loc='_fill', **kwargs) def get_alignx(self): @@ -1934,7 +2060,7 @@ def get_ref_axes(self): for ax in self._mainaxes: if ax.number == self.ref: return ax - return None # no error + return None # no error def get_sharex(self): """Return the *x* axis sharing level.""" @@ -2001,8 +2127,8 @@ def legend(self, *args, return ax.legend(*args, space=space, width=width, **kwargs) # Generate figure panel ax = self._add_figure_panel(loc, - space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols) + space=space, width=width, span=span, + row=row, col=col, rows=rows, cols=cols) return ax.legend(*args, loc='_fill', **kwargs) @_counter @@ -2064,7 +2190,8 @@ def save(self, filename, **kwargs): self._adjust_tight_layout(renderer) self._align_axislabels(True) else: - warnings.warn('Renderer is unknown, cannot adjust layout before saving.') + warnings.warn( + 'Renderer is unknown, cannot adjust layout before saving.') super().savefig(filename, **kwargs) def savefig(self, filename, **kwargs): @@ -2085,11 +2212,16 @@ def set_gridspec(self, *args, **kwargs): """This docstring is replaced below.""" # Create and apply the gridspec if self._gridspec is not None: - raise RuntimeError(f'The gridspec has already been declared and multiple GridSpecs are not allowed. Call Figure.get_gridspec() to retrieve it.') + 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(f'The gridspec must be a ProPlot GridSpec. Matplotlib gridspecs are not allowed.') + raise ValueError( + 'The gridspec must be a ProPlot GridSpec. Matplotlib ' + 'gridspecs are not allowed.') else: gs = GridSpec(*args, **kwargs) gs.add_figure(self) @@ -2103,7 +2235,12 @@ def set_sharex(self, value): """Set the *x* axis sharing level.""" value = int(value) if value not in range(4): - raise ValueError(f'Invalid sharing level sharex={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') + raise ValueError( + 'Invalid sharing level sharex={value!r}. ' + 'Axis sharing level can be 0 (no sharing), ' + '1 (sharing, but keep all labels), ' + '2 (sharing, only keep one set of tick labels), ' + 'or 3 (sharing, only keep one axis label).') self.stale = True self._sharex = value @@ -2111,7 +2248,12 @@ def set_sharey(self, value): """Set the *y* axis sharing level.""" value = int(value) if value not in range(4): - raise ValueError(f'Invalid sharing level sharey={value!r}. Axis sharing level can be 0 (no sharing), 1 (sharing, but keep all tick labels), and 2 (sharing, but only keep one set of tick labels).') + raise ValueError( + 'Invalid sharing level sharey={value!r}. ' + 'Axis sharing level can be 0 (no sharing), ' + '1 (sharing, but keep all labels), ' + '2 (sharing, only keep one set of tick labels), ' + 'or 3 (sharing, only keep one axis label).') self.stale = True self._sharey = value @@ -2127,7 +2269,8 @@ def set_spany(self, value): @property def gridspec(self): - """The single `GridSpec` instance used for all subplots in the figure.""" + """The single `GridSpec` instance used for all subplots + in the figure.""" return self._gridspec @property @@ -2140,7 +2283,8 @@ def ref(self): @ref.setter def ref(self, ref): if not isinstance(ref, Integral) or ref < 1: - raise ValueError(f'Invalid axes number {ref!r}. Must be integer >=1.') + raise ValueError( + f'Invalid axes number {ref!r}. Must be integer >=1.') self.stale = True self._ref = ref @@ -2150,9 +2294,7 @@ def ref(self, ref): add_gridspec.__doc__ = _gridspec_doc set_gridspec.__doc__ = _gridspec_doc -#-----------------------------------------------------------------------------# -# Main user interface and helper funcs -#-----------------------------------------------------------------------------# + def _axes_dict(naxs, value, kw=False, default=None): """Build a dictionary that looks like ``{1:value1, 2:value2, ...}`` or ``{1:{key1:value1, ...}, 2:{key2:value2, ...}, ...}`` for storing @@ -2191,17 +2333,21 @@ def _axes_dict(naxs, value, kw=False, default=None): else: kwargs[num] = default # Verify numbers - if {*range(1, naxs+1)} != {*kwargs.keys()}: - raise ValueError(f'Have {naxs} axes, but {value!r} has properties for axes {", ".join(map(repr, sorted(kwargs)))}.') + if {*range(1, naxs + 1)} != {*kwargs.keys()}: + raise ValueError( + f'Have {naxs} axes, but {value!r} has properties for axes ' + ', '.join(map(repr, sorted(kwargs))) + '.') return kwargs + 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}. ' + - 'Current options are: ' + ', '.join(JOURNAL_SPECS.keys())) + 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: @@ -2225,12 +2371,13 @@ def figure(**kwargs): """ 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 - ): + +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 axes or arbitrary grid of axes, analogous to `matplotlib.pyplot.subplots`. The axes can have arbitrary map @@ -2336,9 +2483,13 @@ def subplots(array=None, ncols=1, nrows=1, ref=1, order='C', 'one of {nums}.') nrows, ncols = array.shape # Get axes ranges from array - axids = [np.where(array == i) for i in np.sort(np.unique(array)) if i > 0] # 0 stands for empty - xrange = np.array([[x.min(), x.max()] for _,x in axids]) - yrange = np.array([[y.min(), y.max()] for y,_ in axids]) # range accounting for panels + axids = [ + np.where( + array == i) for i in np.sort( + np.unique(array)) if i > 0] # 0 stands for empty + xrange = np.array([[x.min(), x.max()] for _, x in axids]) + yrange = np.array([[y.min(), y.max()] + for y, _ in axids]) # range accounting for panels # Get basemap.Basemap or cartopy.crs.Projection instances for map proj = _notNone(projection, proj, None, names=('projection', 'proj')) @@ -2361,15 +2512,15 @@ def subplots(array=None, ncols=1, nrows=1, ref=1, order='C', 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 + wratios, hratios = wratios.tolist(), hratios.tolist() # also makes copy # 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, - left=left, right=right, bottom=bottom, top=top, - wratios=wratios, hratios=hratios) + left=left, right=right, bottom=bottom, top=top, + wratios=wratios, hratios=hratios) # Draw main subplots axs = naxs * [None] # list of axes @@ -2379,10 +2530,10 @@ def subplots(array=None, ncols=1, nrows=1, ref=1, order='C', x0, x1 = xrange[idx, 0], xrange[idx, 1] y0, y1 = yrange[idx, 0], yrange[idx, 1] # Draw subplot - 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]) + 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]) # Return figure and axes n = (ncols if order == 'C' else nrows) diff --git a/proplot/utils.py b/proplot/utils.py index 9ad965359..44dd85f09 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -14,23 +14,25 @@ except ImportError: # graceful fallback if IceCream isn't installed ic = lambda *a: None if not a else (a[0] if len(a) == 1 else a) # noqa __all__ = ['arange', 'edges', 'edges2d', 'units'] +NUMBER = re.compile('^([-+]?[0-9._]+([eE][-+]?[0-9_]+)?)(.*)$') BENCHMARK = False # change this to turn on benchmarking -NUMBER = re.compile('^([-+]?[0-9._]+([eE][-+]?[0-9_]+)?)(.*)$') -BENCHMARK = False class _benchmark(object): """Context object that can be used to time import statements.""" + def __init__(self, message): self.message = message def __enter__(self): if BENCHMARK: self.time = time.clock() + def __exit__(self, *args): if BENCHMARK: print(f'{self.message}: {time.clock() - self.time}s') + def _timer(func): """Decorator that prints the time a function takes to execute. See: https://stackoverflow.com/a/1594484/4970632""" @@ -56,12 +58,15 @@ def decorator(*args, **kwargs): if BENCHMARK: decorator.time += (time.clock() - t) decorator.count += 1 - print(f' {func.__name__}() cumulative time: {decorator.time}s ({decorator.count} calls)') + print( + f' {func.__name__}() cumulative time: ' + f'{decorator.time}s ({decorator.count} calls)') return res decorator.time = 0 decorator.count = 0 # initialize return decorator + def _notNone(*args, names=None): """Return the first non-``None`` value. This is used with keyword arg aliases and for setting default values. Ugly name but clear purpose. Pass @@ -87,11 +92,11 @@ def _notNone(*args, names=None): if name: kwargs[name] = arg if len(kwargs) > 1: - warnings.warn(f'Got conflicting or duplicate keyword args: {kwargs}. Using the first one.') + warnings.warn( + f'Got conflicting or duplicate keyword args: {kwargs}. ' + 'Using the first one.') return first -# Accessible for user - def arange(min_, *args): """Identical to `numpy.arange` but with inclusive endpoints. For @@ -123,6 +128,7 @@ def arange(min_, *args): max_ += step / 2 return np.arange(min_, max_, step) + def edges(Z, axis=-1): """ Calculate the approximate "edge" values along an arbitrary axis, given @@ -147,12 +153,13 @@ def edges(Z, axis=-1): """ Z = np.swapaxes(Z, axis, -1) Z = np.concatenate(( - Z[...,:1] - (Z[...,1] - Z[...,0])/2, - (Z[...,1:] + Z[...,:-1])/2, - Z[...,-1:] + (Z[...,-1] - Z[...,-2])/2, - ), axis=-1) + Z[..., :1] - (Z[..., 1] - Z[..., 0]) / 2, + (Z[..., 1:] + Z[..., :-1]) / 2, + Z[..., -1:] + (Z[..., -1] - Z[..., -2]) / 2, + ), axis=-1) return np.swapaxes(Z, axis, -1) + def edges2d(Z): """ Like `edges` but for 2d arrays. @@ -170,23 +177,24 @@ def edges2d(Z): """ Z = np.asarray(Z) ny, nx = Z.shape - Zb = np.zeros((ny+1, nx+1)) + Zb = np.zeros((ny + 1, nx + 1)) # Inner - Zb[1:-1, 1:-1] = 0.25 * (Z[1:, 1:] + Z[:-1, 1:] + - Z[1:, :-1] + Z[:-1, :-1]) + Zb[1:-1, 1:-1] = 0.25 * (Z[1:, 1:] + Z[:-1, 1:] + + Z[1:, :-1] + Z[:-1, :-1]) # Lower and upper - Zb[0] += edges(1.5*Z[0] - 0.5*Z[1]) - Zb[-1] += edges(1.5*Z[-1] - 0.5*Z[-2]) + Zb[0] += edges(1.5 * Z[0] - 0.5 * Z[1]) + Zb[-1] += edges(1.5 * Z[-1] - 0.5 * Z[-2]) # Left and right - Zb[:, 0] += edges(1.5*Z[:, 0] - 0.5*Z[:, 1]) - Zb[:, -1] += edges(1.5*Z[:, -1] - 0.5*Z[:, -2]) + Zb[:, 0] += edges(1.5 * Z[:, 0] - 0.5 * Z[:, 1]) + Zb[:, -1] += edges(1.5 * Z[:, -1] - 0.5 * Z[:, -2]) # Corners Zb[[0, 0, -1, -1], [0, -1, -1, 0]] *= 0.5 return Zb + def units(value, units='in', axes=None, figure=None, width=True): """ Convert values and lists of values between arbitrary physical units. This @@ -232,12 +240,12 @@ def units(value, units='in', axes=None, figure=None, width=True): width : bool, optional Whether to use the width or height for the axes and figure relative coordinates. - """ + """ # noqa # Font unit scales # NOTE: Delay font_manager import, because want to avoid rebuilding font # cache, which means import must come after TTFPATH added to environ # by styletools.register_fonts()! - small = rcParams['font.size'] # must be absolute + small = rcParams['font.size'] # must be absolute large = rcParams['axes.titlesize'] if isinstance(large, str): import matplotlib.font_manager as mfonts @@ -248,36 +256,41 @@ def units(value, units='in', axes=None, figure=None, width=True): # Scales for converting physical units to inches unit_dict = { 'in': 1.0, - 'm': 39.37, + 'm': 39.37, 'ft': 12.0, 'cm': 0.3937, 'mm': 0.03937, - 'pt': 1/72.0, - 'pc': 1/6.0, - 'em': small/72.0, - 'en': 0.5*small/72.0, - 'Em': large/72.0, - 'En': 0.5*large/72.0, + 'pt': 1 / 72.0, + 'pc': 1 / 6.0, + 'em': small / 72.0, + 'en': 0.5 * small / 72.0, + 'Em': large / 72.0, + 'En': 0.5 * large / 72.0, 'ly': 3.725e+17, - } + } # Scales for converting display units to inches # WARNING: In ipython shell these take the value 'figure' if not isinstance(rcParams['figure.dpi'], str): - unit_dict['px'] = 1/rcParams['figure.dpi'] # once generated by backend + # once generated by backend + unit_dict['px'] = 1 / rcParams['figure.dpi'] if not isinstance(rcParams['savefig.dpi'], str): - unit_dict['pp'] = 1/rcParams['savefig.dpi'] # once 'printed' i.e. saved + # once 'printed' i.e. saved + unit_dict['pp'] = 1 / rcParams['savefig.dpi'] # Scales relative to axes and figure objects - if axes is not None and hasattr(axes, 'get_size_inches'): # proplot axes - unit_dict['ax'] = axes.get_size_inches()[1-int(width)] + if axes is not None and hasattr(axes, 'get_size_inches'): # proplot axes + unit_dict['ax'] = axes.get_size_inches()[1 - int(width)] if figure is None: figure = getattr(axes, 'figure', None) - if figure is not None and hasattr(figure, 'get_size_inches'): # proplot axes - unit_dict['fig'] = fig.get_size_inches()[1-int(width)] + if figure is not None and hasattr( + figure, 'get_size_inches'): # proplot axes + unit_dict['fig'] = figure.get_size_inches()[1 - int(width)] # Scale for converting inches to arbitrary other unit try: scale = unit_dict[units] except KeyError: - raise ValueError(f'Invalid destination units {units!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') + raise ValueError( + f'Invalid destination units {units!r}. Valid units are ' + ', '.join(map(repr, unit_dict.keys())) + '.') # Convert units for each value in list result = [] @@ -287,15 +300,22 @@ def units(value, units='in', axes=None, figure=None, width=True): result.append(val) continue elif not isinstance(val, str): - raise ValueError(f'Size spec must be string or number or list thereof. Got {value!r}.') + raise ValueError( + f'Size spec must be string or number or list thereof. ' + 'Got {value!r}.') regex = NUMBER.match(val) if not regex: - raise ValueError(f'Invalid size spec {val!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') - number, _, units = regex.groups() # second group is exponential + raise ValueError( + f'Invalid size spec {val!r}. Valid units are ' + ', '.join(map(repr, unit_dict.keys())) + '.') + number, _, units = regex.groups() # second group is exponential try: - result.append(float(number) * (unit_dict[units]/scale if units else 1)) + result.append( + float(number) * (unit_dict[units] / scale if units else 1)) except (KeyError, ValueError): - raise ValueError(f'Invalid size spec {val!r}. Valid units are {", ".join(map(repr, unit_dict.keys()))}.') + raise ValueError( + f'Invalid size spec {val!r}. Valid units are ' + ', '.join(map(repr, unit_dict.keys())) + '.') if singleton: result = result[0] return result diff --git a/proplot/wrappers.py b/proplot/wrappers.py index f6c8c4a5e..4d5b9d9da 100644 --- a/proplot/wrappers.py +++ b/proplot/wrappers.py @@ -1351,7 +1351,8 @@ def text_wrapper( size = _notNone(fontsize, size, None, names=('fontsize', 'size')) if size is not None: kwargs['fontsize'] = utils.units(size, 'pt') - kwargs.setdefault('color', rc['text.color']) # text.color is ignored sometimes unless we apply this + # text.color is ignored sometimes unless we apply this + kwargs.setdefault('color', rc['text.color']) obj = func(self, x, y, text, transform=transform, **kwargs) # Optionally draw border around text @@ -2052,7 +2053,9 @@ def cmap_changer( # Label each box manually # See: https://stackoverflow.com/a/20998634/4970632 elif 'pcolor' in name: - obj.update_scalarmappable() # populates the _facecolors attribute, initially filled with just a single color + # populates the _facecolors attribute, initially filled with just a + # single color + obj.update_scalarmappable() labels_kw_ = {'size': rc['small'], 'ha': 'center', 'va': 'center'} labels_kw_.update(labels_kw) array = obj.get_array() @@ -2412,7 +2415,7 @@ def legend_wrapper( 'edgecolor': 'axes.edgecolor', 'facecolor': 'axes.facecolor', 'alpha': 'legend.framealpha', - }) + }) for key in (*outline,): if key != 'linewidth': if kwargs.get(key, None): @@ -2772,15 +2775,16 @@ def colorbar_wrapper( # native matplotlib axes width, height = self.figure.get_size_inches() if orientation == 'horizontal': - scale = 3 # em squares alotted for labels - length = width*abs(self.get_position().width) + scale = 3 # em squares alotted for labels + length = width * abs(self.get_position().width) fontsize = kw_ticklabels.get('size', rc['xtick.labelsize']) else: scale = 1 - length = height*abs(self.get_position().height) + length = height * abs(self.get_position().height) fontsize = kw_ticklabels.get('size', rc['ytick.labelsize']) - maxn = _notNone(maxn, int(length/(scale*fontsize/72))) - maxn_minor = _notNone(maxn_minor, int(length/(0.5*fontsize/72))) + maxn = _notNone(maxn, int(length / (scale * fontsize / 72))) + maxn_minor = _notNone(maxn_minor, int( + length / (0.5 * fontsize / 72))) # Get locator if tickminor and minorlocator is None: step = 1 + len(locator) // max(1, maxn_minor) From ff8793e518e12eb7cd2b18c4afd1b56bf73a17c0 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 2 Dec 2019 03:24:55 -0700 Subject: [PATCH 32/37] Fix RST hyperlinks --- proplot/axes.py | 4 ++-- proplot/axistools.py | 8 ++++---- proplot/projs.py | 12 ++++++------ proplot/rctools.py | 30 +++++++++++++++--------------- proplot/styletools.py | 12 ++++++------ proplot/subplots.py | 4 ++-- 6 files changed, 35 insertions(+), 35 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index 9ffc3b1db..2aab152f7 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -1384,8 +1384,8 @@ def parametric(self, *args, values=None, parametric coordinate ``values`` using the input colormap ``cmap``. Invoked when you pass ``cmap`` to `~matplotlib.axes.Axes.plot`. Returns a `~matplotlib.collections.LineCollection` instance. See - `this matplotlib example - `__. + `this matplotlib example \ +`__. Parameters ---------- diff --git a/proplot/axistools.py b/proplot/axistools.py index f7c285de1..4a64c97ec 100644 --- a/proplot/axistools.py +++ b/proplot/axistools.py @@ -1117,10 +1117,10 @@ def transform_non_affine(self, a): class MercatorLatitudeScale(_ScaleBase, mscale.ScaleBase): r""" - Axis scale that transforms coordinates as with latitude in the `Mercator - projection `__. Adapted - from `this matplotlib example - `__. + Axis scale that transforms coordinates as with latitude in the `Mercator \ +projection `__. + Adapted from `this matplotlib example \ +`__. The scale function is as follows. diff --git a/proplot/projs.py b/proplot/projs.py index e84e5affd..59defd66d 100644 --- a/proplot/projs.py +++ b/proplot/projs.py @@ -50,8 +50,8 @@ def Proj(name, basemap=False, **kwargs): PROJ.4 projection name shorthands, like in basemap. The following table lists the valid projection name shorthands, their - full names (with links to the relevant `PROJ.4 documentation - `__), + full names (with links to the relevant `PROJ.4 documentation \ +`__), and whether they are available in the cartopy and basemap packages. ``(added)`` indicates a projection class that ProPlot has "added" @@ -265,8 +265,8 @@ def threshold(self): # how finely to interpolate line data, etc. class KavrayskiyVII(_WarpedRectangularProjection): - """The `Kavrayskiy VII - `__ projection.""" + """The `Kavrayskiy VII \ +`__ projection.""" __name__ = 'kavrayskiyVII' #: Registered projection name. name = 'kavrayskiyVII' @@ -282,8 +282,8 @@ def threshold(self): class WinkelTripel(_WarpedRectangularProjection): - """The `Winkel tripel (Winkel III) - `__ projection.""" + """The `Winkel tripel (Winkel III) \ +`__ projection.""" __name__ = 'winkeltripel' #: Registered projection name. name = 'winkeltripel' diff --git a/proplot/rctools.py b/proplot/rctools.py index a27fb1735..c27e95ccf 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -454,12 +454,12 @@ def _sanitize_key(key): class rc_configurator(object): """ - Magical abstract class for managing matplotlib `rcParams - `__ and additional - ProPlot :ref:`rcParamsLong` and :ref:`rcParamsShort` settings. When - initialized, this loads defaults settings plus any user overrides in the - ``~/.proplotrc`` file. See the `~proplot.rctools` documentation for - details. + Magical abstract class for managing matplotlib + `rcParams `__ + and additional ProPlot :ref:`rcParamsLong` and :ref:`rcParamsShort` + settings. When initialized, this loads defaults settings plus any user + overrides in the ``~/.proplotrc`` file. See the `~proplot.rctools` + documentation for details. """ def __contains__(self, key): @@ -565,9 +565,9 @@ def __getattr__(self, attr): return self[attr] def __getitem__(self, key): - """Return the relevant `rcParams - `__, - :ref:`rcParamsLong`, and :ref:`rcParamsShort` setting.""" + """Return an `rcParams \ +`__, + :ref:`rcParamsLong`, or :ref:`rcParamsShort` setting.""" key = _sanitize_key(key) for kw in (rcParamsShort, rcParamsLong, rcParams): try: @@ -581,8 +581,8 @@ def __setattr__(self, attr, value): self[attr] = value def __setitem__(self, key, value): - """Modify the relevant `rcParams - `__, + """Modify an `rcParams \ +`__, :ref:`rcParamsLong`, and :ref:`rcParamsShort` setting(s).""" rc_short, rc_long, rc = _get_synced_params(key, value) rcParamsShort.update(rc_short) @@ -676,12 +676,12 @@ def context(self, *args, mode=0, **kwargs): "with as" block when called with ``context=True``. The options are as follows. - 0. All settings (`rcParams - `__, + 0. All settings (`rcParams \ +`__, :ref:`rcParamsLong`, and :ref:`rcParamsShort`) are returned, whether or not `~rc_configurator.context` has changed them. - 1. Unchanged `rcParams - `__ + 1. Unchanged `rcParams \ +`__ return ``None``. :ref:`rcParamsLong` and :ref:`rcParamsShort` are returned whether or not `~rc_configurator.context` has changed them. This is used in the `~proplot.axes.Axes.__init__` diff --git a/proplot/styletools.py b/proplot/styletools.py index 73e628ffa..f32bb833c 100644 --- a/proplot/styletools.py +++ b/proplot/styletools.py @@ -2974,8 +2974,8 @@ def register_colors(nmax=np.inf): def register_fonts(): """Add fonts packaged with ProPlot or saved to the ``~/.proplot/fonts`` folder, if they are not already added. Detects ``.ttf`` and ``.otf`` files - -- see `this link - `__ + -- see `this link \ +`__ for a guide on converting various other font file types to ``.ttf`` and ``.otf`` for use with matplotlib.""" # noqa # Add proplot path to TTFLIST and rebuild cache @@ -3089,8 +3089,8 @@ def show_channels( maxsat=500, width=100, axwidth=1.7): """ Visualize how the input colormap(s) vary with respect to the hue, chroma, - and luminance channels. Adapted from `this example - `__. + and luminance channels. Adapted from `this example \ +`__. Parameters ---------- @@ -3413,8 +3413,8 @@ def show_colors(nhues=17, minsat=0.2): def show_cmaps(*args, N=None, unknown='User', **kwargs): """ Generate a table of the registered colormaps or the input colormaps. - Adapted from `this example - `__. + Adapted from `this example \ +`__. Parameters ---------- diff --git a/proplot/subplots.py b/proplot/subplots.py index 0ad2d4675..0f19d8b8a 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -120,8 +120,8 @@ 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 - `__ + 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 From 956d1c87e3fa80c7d99652cb212bcc6f6629fe39 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Thu, 5 Dec 2019 17:35:18 -0700 Subject: [PATCH 33/37] Remove _hidelabels --- proplot/subplots.py | 106 ++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 59 deletions(-) diff --git a/proplot/subplots.py b/proplot/subplots.py index 0f19d8b8a..1064e2120 100644 --- a/proplot/subplots.py +++ b/proplot/subplots.py @@ -776,21 +776,6 @@ def _get_space(key, share=0, pad=None): return space -class _hidelabels(object): - """Hide objects temporarily so they are ignored by the tight bounding box - algorithm.""" - 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 EdgeStack(object): """ Container for groups of `~matplotlib.artist.Artist` objects stacked @@ -1271,51 +1256,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! From 9816145cd3005a262e96a76deb0a30dcb8abe723 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sat, 7 Dec 2019 20:04:39 -0700 Subject: [PATCH 34/37] Add xgridcolor/ygridcolor --- proplot/axes.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/proplot/axes.py b/proplot/axes.py index e5cae1100..44dc05ca0 100644 --- a/proplot/axes.py +++ b/proplot/axes.py @@ -1586,7 +1586,7 @@ def number(self, num): 'label', 'locator', 'formatter', 'ticks', 'ticklabels', 'minorlocator', 'minorticks', 'tickminor', 'ticklen', 'tickrange', 'tickdir', 'ticklabeldir', 'tickrotation', - 'bounds', 'margin', 'color', 'linewidth', 'grid', 'gridminor', + 'bounds', 'margin', 'color', 'linewidth', 'grid', 'gridminor', 'gridcolor', ) _dual_doc = """ @@ -1990,6 +1990,8 @@ def format( xbounds=None, ybounds=None, xmargin=None, ymargin=None, xcolor=None, ycolor=None, + xlinewidth=None, ylinewidth=None, + xgridcolor=None, ygridcolor=None, xticklen=None, yticklen=None, xlabel_kw=None, ylabel_kw=None, xscale_kw=None, yscale_kw=None, @@ -2107,6 +2109,10 @@ def format( Line width for the *x* and *y* axis spines and major ticks. Default is :rc:`linewidth`. Use e.g. ``ax.format(linewidth=2)`` to set for both axes. + xgridcolor, ygridcolor : color-spec, optional + Color for the *x* and *y* axis major and minor gridlines. + Default is :rc:`grid.color`. Use e.g. ``ax.format(gridcolor='r')`` + to set for both axes. xticklen, yticklen : float or str, optional Tick lengths for the *x* and *y* axis. Units are interpreted by `~proplot.utils.units`, with "points" as the numeric unit. Default @@ -2253,7 +2259,8 @@ def format( for ( x, axis, label, color, - linewidth, ticklen, + linewidth, gridcolor, + ticklen, margin, bounds, tickloc, spineloc, ticklabelloc, labelloc, @@ -2269,7 +2276,8 @@ def format( ) in zip( ('x', 'y'), (self.xaxis, self.yaxis), (xlabel, ylabel), (xcolor, ycolor), - (xlinewidth, ylinewidth), (xticklen, yticklen), + (xlinewidth, ylinewidth), (xgridcolor, ygridcolor), + (xticklen, yticklen), (xmargin, ymargin), (xbounds, ybounds), (xtickloc, ytickloc), (xspineloc, yspineloc), (xticklabelloc, yticklabelloc), (xlabelloc, ylabelloc), @@ -2400,6 +2408,8 @@ def _grid_dict(grid): key: value for key, value in kw_major.items() if key not in kw_grid}) # Changed rc settings + if gridcolor is not None: + kw['grid_color'] = gridcolor axis.set_tick_params(which=which, **kw_grid, **kw_ticks) # Tick and ticklabel properties that apply to major and minor From 2980023ff58f1c7c7dfba7e0f12024666c5b9321 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sun, 8 Dec 2019 00:32:25 -0700 Subject: [PATCH 35/37] Fix rc bugs, remove system proplotrc --- proplot/.proplotrc | 162 -------------------------------- proplot/rctools.py | 229 +++++++++++++++++++++++---------------------- 2 files changed, 116 insertions(+), 275 deletions(-) delete mode 100644 proplot/.proplotrc diff --git a/proplot/.proplotrc b/proplot/.proplotrc deleted file mode 100644 index 27b281668..000000000 --- a/proplot/.proplotrc +++ /dev/null @@ -1,162 +0,0 @@ -#----------------------- -# rcParamsShort defaults -#----------------------- -abc: False -align: False -alpha: 1 -autoreload: 2 -autosave: 30 -borders: False -cmap: fire -coast: False -color: k -cycle: colorblind -facecolor: w -fontname: 'Helvetica Neue' -geogrid: True -grid: True -gridminor: False -gridratio: 0.5 -inlinefmt: retina -innerborders: False -lakes: False -land: False -large: 10 -linewidth: 0.6 -lut: 256 -margin: 0.0 -matplotlib: auto -ocean: False -reso: lo -rgbcycle: False -rivers: False -share: 3 -small: 9 -span: True -tickdir: out -ticklen: 4.0 -ticklenratio: 0.5 -tickpad: 2.0 -tickratio: 0.8 -tight: True - -#---------------------- -# rcParamsLong defaults -#---------------------- -abc.border: True -abc.color: k -abc.linewidth: 1.5 -abc.loc: l -abc.size: # filled by 'large' -abc.style: a -abc.weight: bold -axes.alpha: # if empty, depends on 'savefig.transparent' setting -axes.formatter.timerotation: 90 -axes.formatter.zerotrim: True -axes.geogrid: True -axes.gridminor: True -borders.color: k -borders.linewidth: 0.6 -bottomlabel.color: k -bottomlabel.size: # filled by 'large' -bottomlabel.weight: bold -coast.color: k -coast.linewidth: 0.6 -colorbar.axespad: 0.5em -colorbar.extend: 1.3em -colorbar.framealpha: 0.8 -colorbar.frameon: True -colorbar.grid: False -colorbar.insetextend: 1em -colorbar.insetlength: 8em -colorbar.insetwidth: 1.2em -colorbar.length: 1 -colorbar.loc: right -colorbar.width: 1.5em -geoaxes.edgecolor: # filled by "color" -geoaxes.facecolor: # filled by "facecolor" -geoaxes.linewidth: # filled by "linewidth" -geogrid.alpha: 0.5 -geogrid.color: k -geogrid.labels: False -geogrid.labelsize: # filled by "small" -geogrid.latmax: 90 -geogrid.latstep: 20 -geogrid.linestyle: ':' -geogrid.linewidth: 1.0 -geogrid.lonstep: 30 -gridminor.alpha: # filled by "grid.alpha" -gridminor.color: # filled by "grid.color" -gridminor.linestyle: # filled by "grid.linewidth" -gridminor.linewidth: # filled by "grid.linewidth" x "gridratio" -image.edgefix: True -image.levels: 11 -innerborders.color: k -innerborders.linewidth: 0.6 -lakes.color: w -land.color: k -leftlabel.color: k -leftlabel.size: # filled by 'large' -leftlabel.weight: bold -ocean.color: w -rightlabel.color: k -rightlabel.size: # filled by 'large' -rightlabel.weight: bold -rivers.color: k -rivers.linewidth: 0.6 -subplots.axpad: 1em -subplots.axwidth: 18em # 2 inches -subplots.innerspace: 1.5em -subplots.pad: 0.5em -subplots.panelpad: 0.5em -subplots.panelspace: 1em -subplots.panelwidth: 4em # 0.45 inches -subplots.titlespace: 2em -subplots.xlabspace: 4em -subplots.ylabspace: 5.5em -suptitle.color: k -suptitle.size: # filled by 'large' -suptitle.weight: bold -tick.labelcolor: # filled by 'color' -tick.labelsize: # filled by 'small' -tick.labelweight: normal -title.border: True -title.color: k -title.linewidth: 1.5 -title.loc: c # centered above the axes -title.pad: 3.0 # copy -title.size: # filled by 'large' -title.weight: normal -toplabel.color: k -toplabel.size: # filled by 'large' -toplabel.weight: bold - -#--------------------------------------------------- -# rcParams defaults -# See: https://matplotlib.org/users/customizing.html -#--------------------------------------------------- -axes.labelpad: 3.0 -axes.titlepad: 3.0 -figure.autolayout: False -figure.facecolor: '#f2f2f2' -grid.alpha: 0.1 -grid.color: 'k' -grid.linestyle: '-' -grid.linewidth: 0.6 -hatch.color: k -hatch.linewidth: 0.6 -legend.borderaxespad: 0 -legend.columnspacing: 1.0 -legend.fancybox: False -legend.handletextpad: 0.5 -lines.linewidth: 1.3 -lines.markersize: 3.0 -mathtext.default: regular -savefig.bbox: standard -savefig.directory: -savefig.dpi: 300 -savefig.format: pdf -savefig.pad_inches: 0.0 -savefig.transparent: True -xtick.minor.visible: True -ytick.minor.visible: True diff --git a/proplot/rctools.py b/proplot/rctools.py index b02a614bb..a373f85cf 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -28,170 +28,171 @@ def get_ipython(): # Initialize defaultParamsShort = { - 'nbsetup': True, - 'format': 'retina', - 'autosave': 30, - 'autoreload': 2, 'abc': False, - 'share': 3, 'align': False, - 'span': True, - 'tight': True, - 'fontname': 'Helvetica Neue', + 'alpha': 1, + 'autoreload': 2, + 'autosave': 30, + 'borders': False, 'cmap': 'fire', - 'lut': 256, - 'cycle': 'colorblind', - 'rgbcycle': False, + 'coast': False, 'color': 'k', - 'alpha': 1, + 'cycle': 'colorblind', 'facecolor': 'w', - 'small': 8, - 'large': 9, - 'linewidth': 0.6, - 'margin': 0.0, + 'fontname': 'Helvetica Neue', + 'inlinefmt': 'retina', + 'geogrid': True, 'grid': True, 'gridminor': False, - 'ticklen': 4.0, - 'tickdir': 'out', - 'tickpad': 2.0, - 'tickratio': 0.8, - 'ticklenratio': 0.5, - 'tickminor': True, 'gridratio': 0.5, - 'reso': 'lo', - 'geogrid': True, + 'innerborders': False, + 'lakes': False, 'land': False, + 'large': 9, + 'linewidth': 0.6, + 'lut': 256, + 'margin': 0.0, + 'matplotlib': 'auto', + 'nbsetup': True, 'ocean': False, - 'coast': False, + 'reso': 'lo', + 'rgbcycle': False, 'rivers': False, - 'lakes': False, - 'borders': False, - 'innerborders': False, + 'share': 3, + 'small': 8, + 'span': True, + 'tickdir': 'out', + 'ticklen': 4.0, + 'ticklenratio': 0.5, + 'tickminor': True, + 'tickpad': 2.0, + 'tickratio': 0.8, + 'tight': True, } defaultParamsLong = { - 'title.loc': 'c', # centered above the axes - 'title.pad': 3.0, # copy + 'abc.border': True, + 'abc.color': 'k', + 'abc.linewidth': 1.5, 'abc.loc': 'l', # left side above the axes - 'abc.style': 'a', 'abc.size': None, # = large - 'abc.color': 'k', + 'abc.style': 'a', 'abc.weight': 'bold', - 'abc.border': True, - 'abc.linewidth': 1.5, - 'tick.labelsize': None, # = small - 'tick.labelcolor': None, # = color - 'tick.labelweight': 'normal', - 'title.size': None, # = large - 'title.color': 'k', - 'title.weight': 'normal', - 'title.border': True, - 'title.linewidth': 1.5, - 'suptitle.size': None, # = large - 'suptitle.color': 'k', - 'suptitle.weight': 'bold', - 'leftlabel.size': None, # = large - 'leftlabel.weight': 'bold', - 'leftlabel.color': 'k', - 'toplabel.size': None, # = large - 'toplabel.weight': 'bold', - 'toplabel.color': 'k', - 'rightlabel.size': None, # = large - 'rightlabel.weight': 'bold', - 'rightlabel.color': 'k', - 'bottomlabel.size': None, # = large - 'bottomlabel.weight': 'bold', - 'bottomlabel.color': 'k', - 'image.edgefix': True, - 'image.levels': 11, 'axes.facealpha': None, # if empty, depends on 'savefig.transparent' - 'axes.formatter.zerotrim': True, 'axes.formatter.timerotation': 90, - 'axes.gridminor': True, + 'axes.formatter.zerotrim': True, 'axes.geogrid': True, - 'gridminor.alpha': None, # = grid.alpha - 'gridminor.color': None, # = grid.color - 'gridminor.linestyle': None, # = grid.linewidth - 'gridminor.linewidth': None, # = grid.linewidth x gridratio + 'axes.gridminor': True, + 'borders.color': 'k', + 'borders.linewidth': 0.6, + 'bottomlabel.color': 'k', + 'bottomlabel.size': None, # = large + 'bottomlabel.weight': 'bold', + 'coast.color': 'k', + 'coast.linewidth': 0.6, + 'colorbar.extend': '1.3em', + 'colorbar.framealpha': 0.8, + 'colorbar.frameon': True, + 'colorbar.grid': False, + 'colorbar.insetextend': '1em', + 'colorbar.insetlength': '8em', + 'colorbar.insetpad': '0.5em', + 'colorbar.insetwidth': '1.2em', + 'colorbar.length': 1, + 'colorbar.loc': 'right', + 'colorbar.width': '1.5em', + 'geoaxes.edgecolor': None, # = color + 'geoaxes.facealpha': None, # = alpha + 'geoaxes.facecolor': None, # = facecolor + 'geoaxes.linewidth': None, # = linewidth + 'geogrid.alpha': 0.5, + 'geogrid.color': 'k', 'geogrid.labels': False, 'geogrid.labelsize': None, # = small 'geogrid.latmax': 90, - 'geogrid.lonstep': 30, 'geogrid.latstep': 20, - 'geogrid.alpha': 0.5, - 'geogrid.color': 'k', - 'geogrid.linewidth': 1.0, 'geogrid.linestyle': ':', - 'geoaxes.linewidth': None, # = linewidth - 'geoaxes.facecolor': None, # = facecolor - 'geoaxes.facealpha': None, # = alpha - 'geoaxes.edgecolor': None, # = color - 'land.color': 'k', - 'ocean.color': 'w', - 'lakes.color': 'w', - 'coast.color': 'k', - 'coast.linewidth': 0.6, - 'borders.color': 'k', - 'borders.linewidth': 0.6, + 'geogrid.linewidth': 1.0, + 'geogrid.lonstep': 30, + 'gridminor.alpha': None, # = grid.alpha + 'gridminor.color': None, # = grid.color + 'gridminor.linestyle': None, # = grid.linewidth + 'gridminor.linewidth': None, # = grid.linewidth x gridratio + 'image.edgefix': True, + 'image.levels': 11, 'innerborders.color': 'k', 'innerborders.linewidth': 0.6, + 'lakes.color': 'w', + 'land.color': 'k', + 'leftlabel.color': 'k', + 'leftlabel.size': None, # = large + 'leftlabel.weight': 'bold', + 'ocean.color': 'w', + 'rightlabel.color': 'k', + 'rightlabel.size': None, # = large + 'rightlabel.weight': 'bold', 'rivers.color': 'k', 'rivers.linewidth': 0.6, - 'colorbar.loc': 'right', - 'colorbar.grid': False, - 'colorbar.frameon': True, - 'colorbar.framealpha': 0.8, - 'colorbar.insetpad': '0.5em', - 'colorbar.extend': '1.3em', - 'colorbar.insetextend': '1em', - 'colorbar.length': 1, - 'colorbar.insetlength': '8em', - 'colorbar.width': '1.5em', - 'colorbar.insetwidth': '1.2em', + 'subplots.axpad': '1em', 'subplots.axwidth': '18em', - 'subplots.panelwidth': '4em', 'subplots.pad': '0.5em', - 'subplots.axpad': '1em', 'subplots.panelpad': '0.5em', + 'subplots.panelwidth': '4em', + 'suptitle.color': 'k', + 'suptitle.size': None, # = large + 'suptitle.weight': 'bold', + 'tick.labelcolor': None, # = color + 'tick.labelsize': None, # = small + 'tick.labelweight': 'normal', + 'title.border': True, + 'title.color': 'k', + 'title.linewidth': 1.5, + 'title.loc': 'c', # centered above the axes + 'title.pad': 3.0, # copy + 'title.size': None, # = large + 'title.weight': 'normal', + 'toplabel.color': 'k', + 'toplabel.size': None, # = large + 'toplabel.weight': 'bold', } defaultParams = { - 'axes.titleweight': 'normal', - 'axes.xmargin': 0.0, - 'axes.ymargin': 0.0, 'axes.grid': True, 'axes.labelpad': 3.0, 'axes.titlepad': 3.0, + 'axes.titleweight': 'normal', + 'axes.xmargin': 0.0, + 'axes.ymargin': 0.0, + 'figure.autolayout': False, 'figure.dpi': 90, 'figure.facecolor': '#f2f2f2', - 'figure.autolayout': False, - 'figure.titleweight': 'bold', 'figure.max_open_warning': 0, - 'grid.color': 'k', + 'figure.titleweight': 'bold', 'grid.alpha': 0.1, - 'grid.linewidth': 0.6, + 'grid.color': 'k', 'grid.linestyle': '-', + 'grid.linewidth': 0.6, 'hatch.color': 'k', 'hatch.linewidth': 0.6, - 'legend.frameon': True, - 'legend.framealpha': 0.8, + 'legend.borderaxespad': 0, + 'legend.borderpad': 0.5, + 'legend.columnspacing': 1.0, 'legend.fancybox': False, - 'legend.labelspacing': 0.5, - 'legend.handletextpad': 0.5, + 'legend.framealpha': 0.8, + 'legend.frameon': True, 'legend.handlelength': 1.5, - 'legend.columnspacing': 1.0, - 'legend.borderpad': 0.5, - 'legend.borderaxespad': 0, + 'legend.handletextpad': 0.5, + 'legend.labelspacing': 0.5, 'lines.linewidth': 1.3, 'lines.markersize': 3.0, 'mathtext.bf': 'sans:bold', - 'mathtext.it': 'sans:it', 'mathtext.default': 'regular', + 'mathtext.it': 'sans:it', + 'savefig.bbox': 'standard', 'savefig.directory': '', 'savefig.dpi': 300, 'savefig.facecolor': 'white', - 'savefig.transparent': True, 'savefig.format': 'pdf', - 'savefig.bbox': 'standard', 'savefig.pad_inches': 0.0, + 'savefig.transparent': True, 'xtick.minor.visible': True, 'ytick.minor.visible': True, } @@ -239,7 +240,7 @@ def _tabulate(rcdict): 'image.lut', ), 'alpha': ( # this is a custom setting - 'axes.alpha', + 'axes.facealpha', ), 'facecolor': ( 'axes.facecolor', 'geoaxes.facecolor' @@ -727,7 +728,10 @@ def __delattr__(self, *args): def __getattr__(self, attr): """Pass the attribute to `~rc_configurator.__getitem__` and return the result.""" - return self[attr] + if attr[:1] == '_': + return super().__getattr__(attr) + else: + return self[attr] def __getitem__(self, key): """Return an `rcParams \ @@ -1049,7 +1053,6 @@ def ipython_matplotlib(backend=None, fmt=None): return # For notebooks - rc._init = False ibackend = ('inline' if backend == 'auto' else backend) try: ipython.magic('matplotlib ' + ibackend) From bf03212c80b07ec8f3b21df7af4855fb311357fa Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 6 Jan 2020 06:23:14 -0700 Subject: [PATCH 36/37] Merge from master --- proplot/rctools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proplot/rctools.py b/proplot/rctools.py index 9b6ce0a9b..1d85e86ee 100644 --- a/proplot/rctools.py +++ b/proplot/rctools.py @@ -23,7 +23,7 @@ def get_ipython(): from .utils import _warn_proplot, _counter, _benchmark, units # Disable mathtext "missing glyph" warnings -import matplotlib.mathtext # noqa +import matplotlib.mathtext # noqa import logging logger = logging.getLogger('matplotlib.mathtext') logger.setLevel(logging.ERROR) # suppress warnings! From a96ddf03604f6682a2b5f815ef64a3cf80842a32 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Mon, 6 Jan 2020 15:33:14 -0700 Subject: [PATCH 37/37] Test commit