From 5a2221f4d64c49f5f9e40cfa985595a33f4c5339 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sat, 20 Jun 2020 18:47:46 -0600 Subject: [PATCH 1/4] Partial commit --- proplot/axes/base.py | 176 ++++++++++++++++++++++++++++++++++++++++--- proplot/axes/plot.py | 153 ------------------------------------- 2 files changed, 166 insertions(+), 163 deletions(-) diff --git a/proplot/axes/base.py b/proplot/axes/base.py index a728c0854..4b5aa9b56 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -24,6 +24,7 @@ _barh_wrapper, _boxplot_wrapper, _cmap_changer, + _concatenate_docstrings, _cycle_changer, _fill_between_wrapper, _fill_betweenx_wrapper, @@ -32,7 +33,6 @@ _hlines_wrapper, _indicate_error, _parametric_wrapper, - _plot_wrapper, _scatter_wrapper, _standardize_1d, _standardize_2d, @@ -1657,14 +1657,6 @@ def parametric( values : list of float The parametric values used to map points on the line to colors in the colormap. This can also be passed as a third positional argument. - cmap : colormap spec, optional - The colormap specifier, passed to `~proplot.constructor.Colormap`. - cmap_kw : dict, optional - Keyword arguments passed to `~proplot.constructor.Colormap`. - norm : normalizer spec, optional - The normalizer, passed to `~proplot.constructor.Norm`. - norm_kw : dict, optional - Keyword arguments passed to `~proplot.constructor.Norm`. interp : int, optional If greater than ``0``, we interpolate to additional points between the `values` coordinates. The number corresponds to the @@ -1673,6 +1665,9 @@ def parametric( scalex, scaley : bool, optional Whether the view limits are adapted to the data limits. The values are passed on to `~matplotlib.axes.Axes.autoscale_view`. + %(standardize_1d_kwargs)s + %(cycle_changer_kwargs)s + %(add_errorbars_kwargs)s Other parameters ---------------- @@ -1687,7 +1682,7 @@ def parametric( """ # Get x/y coordinates and values for points to the 'left' and 'right' # of each joint - x, y = args # standardized by parametric wrapper + x, y = _standardize_1d(args) interp # avoid U100 unused argument error (arg is handled by wrapper) coords = [] levels = edges(values) @@ -1731,6 +1726,167 @@ def parametric( hs.levels = levels # needed for other functions return hs + @_concatenate_docstrings + @docstring.add_snippets + def plot(self, *args, cmap=None, values=None, **kwargs): + """ + Parameters + ---------- + %(standardize_1d_args)s + cmap, values : optional + *Deprecated usage*. Passed to `~proplot.axes.Axes.cmapline`. + %(standardize_1d_kwargs)s + %(cycle_changer_kwargs)s + %(add_errorbars_kwargs)s + + Other parameters + ---------------- + **kwargs + `~matplotlib.lines.Line2D` properties. + """ + if len(args) > 3: # e.g. with fmt string + raise ValueError(f'Expected 1-3 positional args, got {len(args)}.') + if cmap is not None: + warnings._warn_proplot( + 'Drawing "parametric" plots with ax.plot(x, y, values=values, cmap=cmap) ' + 'is deprecated and will be removed in a future version. Please use ' + 'ax.parametric(x, y, values, cmap=cmap) instead.' + ) + return self.parametric(*args, cmap=cmap, values=values, **kwargs) + + # Draw lines + result = super().plot(*args, values=values, **kwargs) + + # Add sticky edges? No because there is no way to check whether "dependent variable" + # is x or y axis like with area/areax and bar/barh. Better to always have margin. + # for objs in result: + # if not isinstance(objs, tuple): + # objs = (objs,) + # for obj in objs: + # xdata = obj.get_xdata() + # obj.sticky_edges.x.extend((np.min(xdata), np.max(xdata))) + + return result + + @_concatenate_docstrings + @docstring.add_snippets + def scatter_wrapper( + self, *args, + s=None, size=None, markersize=None, + c=None, color=None, markercolor=None, smin=None, smax=None, + cmap=None, cmap_kw=None, norm=None, norm_kw=None, + vmin=None, vmax=None, N=None, levels=None, values=None, + symmetric=False, locator=None, locator_kw=None, + lw=None, linewidth=None, linewidths=None, + markeredgewidth=None, markeredgewidths=None, + edgecolor=None, edgecolors=None, + markeredgecolor=None, markeredgecolors=None, + **kwargs + ): + """ + Parameters + ---------- + %(standardize_1d_args)s + s, size, markersize : float or list of float, optional + The marker size(s). The units are scaled by `smin` and `smax`. + smin, smax : float, optional + The minimum and maximum marker size in units points ** 2 used to + scale the `s` array. If not provided, the marker sizes are equivalent + to the values in the `s` array. + c, color, markercolor : color-spec or list thereof, or array, optional + The marker fill color(s). If this is an array of scalar values, the + colors will be generated by passing the values through the `norm` + normalizer and drawing from the `cmap` colormap. + %(axes.cmap_changer)s + lw, linewidth, linewidths, markeredgewidth, markeredgewidths : \ + float or list thereof, optional + The marker edge width. + edgecolors, markeredgecolor, markeredgecolors : \ + color-spec or list thereof, optional + The marker edge color. + %(standardize_1d_kwargs)s + %(cycle_changer_kwargs)s + %(add_errorbars_kwargs)s + + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.axes.Axes.scatter`. + """ + # Manage input arguments + # NOTE: Parse 1d must come before this + nargs = len(args) + if len(args) > 4: + raise ValueError(f'Expected 1-4 positional args, got {nargs}.') + args = list(args) + if len(args) == 4: + c = args.pop(1) + if len(args) == 3: + s = args.pop(0) + + # Apply some aliases for keyword arguments + c = _not_none(c=c, color=color, markercolor=markercolor) + s = _not_none(s=s, size=size, markersize=markersize) + lw = _not_none( + lw=lw, linewidth=linewidth, linewidths=linewidths, + markeredgewidth=markeredgewidth, markeredgewidths=markeredgewidths, + ) + ec = _not_none( + edgecolor=edgecolor, edgecolors=edgecolors, + markeredgecolor=markeredgecolor, markeredgecolors=markeredgecolors, + ) + + # Get colormap + cmap_kw = cmap_kw or {} + if cmap is not None: + cmap = constructor.Colormap(cmap, **cmap_kw) + + # Get normalizer and levels + # NOTE: If the length of the c array != + ticks = None + carray = np.atleast_1d(c) + if ( + np.issubdtype(carray.dtype, np.number) + and not (carray.ndim == 2 and carray.shape[1] in (3, 4)) + ): + carray = carray.ravel() + norm, cmap, _, ticks = _build_discrete_norm( + carray, # sample data for getting suitable levels + N=N, levels=levels, values=values, + norm=norm, norm_kw=norm_kw, + locator=locator, locator_kw=locator_kw, + cmap=cmap, vmin=vmin, vmax=vmax, extend='neither', + symmetric=symmetric, + ) + + # Fix 2D arguments but still support scatter(x_vector, y_2d) usage + # NOTE: Since we are flattening vectors the coordinate metadata is meaningless, + # so converting to ndarray and stripping metadata is no problem. + # NOTE: numpy.ravel() preserves masked arrays + if len(args) == 2 and all(np.asarray(arg).squeeze().ndim > 1 for arg in args): + args = tuple(np.ravel(arg) for arg in args) + + # Scale s array + if np.iterable(s) and (smin is not None or smax is not None): + smin_true, smax_true = min(s), max(s) + if smin is None: + smin = smin_true + if smax is None: + smax = smax_true + s = ( + smin + (smax - smin) + * (np.array(s) - smin_true) / (smax_true - smin_true) + ) + obj = super().scatter( + *args, c=c, s=s, cmap=cmap, norm=norm, + linewidths=lw, edgecolors=ec, **kwargs + ) + if ticks is not None: + obj.ticks = ticks + return obj + + + def violins(self, *args, **kwargs): """ Alias for `~matplotlib.axes.Axes.violinplot`. diff --git a/proplot/axes/plot.py b/proplot/axes/plot.py index 86f41ad05..670e3606a 100644 --- a/proplot/axes/plot.py +++ b/proplot/axes/plot.py @@ -1420,159 +1420,6 @@ def parametric_wrapper(self, func, *args, interp=0, **kwargs): return func(self, x, y, values=values, **kwargs) -def plot_wrapper( - self, func, *args, cmap=None, values=None, **kwargs -): - """ - Calls `~proplot.axes.Axes.parametric` in certain cases (but this behavior - is now deprecated). - """ - if len(args) > 3: # e.g. with fmt string - raise ValueError(f'Expected 1-3 positional args, got {len(args)}.') - if cmap is not None: - warnings._warn_proplot( - 'Drawing "parametric" plots with ax.plot(x, y, values=values, cmap=cmap) ' - 'is deprecated and will be removed in a future version. Please use ' - 'ax.parametric(x, y, values, cmap=cmap) instead.' - ) - return self.parametric(*args, cmap=cmap, values=values, **kwargs) - - # Draw lines - result = func(self, *args, values=values, **kwargs) - - # Add sticky edges? No because there is no way to check whether "dependent variable" - # is x or y axis like with area/areax and bar/barh. Better to always have margin. - # for objs in result: - # if not isinstance(objs, tuple): - # objs = (objs,) - # for obj in objs: - # xdata = obj.get_xdata() - # obj.sticky_edges.x.extend((np.min(xdata), np.max(xdata))) - - return result - - -@docstring.add_snippets -def scatter_wrapper( - self, func, *args, - s=None, size=None, markersize=None, - c=None, color=None, markercolor=None, smin=None, smax=None, - cmap=None, cmap_kw=None, norm=None, norm_kw=None, - vmin=None, vmax=None, N=None, levels=None, values=None, - symmetric=False, locator=None, locator_kw=None, - lw=None, linewidth=None, linewidths=None, - markeredgewidth=None, markeredgewidths=None, - edgecolor=None, edgecolors=None, - markeredgecolor=None, markeredgecolors=None, - **kwargs -): - """ - Adds keyword arguments to `~matplotlib.axes.Axes.scatter` that are more - consistent with the `~matplotlib.axes.Axes.plot` keyword arguments and - supports `cmap_changer` features. - - Note - ---- - This function wraps {methods} - - Parameters - ---------- - s, size, markersize : float or list of float, optional - The marker size(s). The units are scaled by `smin` and `smax`. - smin, smax : float, optional - The minimum and maximum marker size in units points ** 2 used to - scale the `s` array. If not provided, the marker sizes are equivalent - to the values in the `s` array. - c, color, markercolor : color-spec or list thereof, or array, optional - The marker fill color(s). If this is an array of scalar values, the - colors will be generated by passing the values through the `norm` - normalizer and drawing from the `cmap` colormap. - %(axes.cmap_changer)s - lw, linewidth, linewidths, markeredgewidth, markeredgewidths : \ -float or list thereof, optional - The marker edge width. - edgecolors, markeredgecolor, markeredgecolors : \ -color-spec or list thereof, optional - The marker edge color. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.scatter`. - """ - # Manage input arguments - # NOTE: Parse 1d must come before this - nargs = len(args) - if len(args) > 4: - raise ValueError(f'Expected 1-4 positional args, got {nargs}.') - args = list(args) - if len(args) == 4: - c = args.pop(1) - if len(args) == 3: - s = args.pop(0) - - # Apply some aliases for keyword arguments - c = _not_none(c=c, color=color, markercolor=markercolor) - s = _not_none(s=s, size=size, markersize=markersize) - lw = _not_none( - lw=lw, linewidth=linewidth, linewidths=linewidths, - markeredgewidth=markeredgewidth, markeredgewidths=markeredgewidths, - ) - ec = _not_none( - edgecolor=edgecolor, edgecolors=edgecolors, - markeredgecolor=markeredgecolor, markeredgecolors=markeredgecolors, - ) - - # Get colormap - cmap_kw = cmap_kw or {} - if cmap is not None: - cmap = constructor.Colormap(cmap, **cmap_kw) - - # Get normalizer and levels - # NOTE: If the length of the c array != - ticks = None - carray = np.atleast_1d(c) - if ( - np.issubdtype(carray.dtype, np.number) - and not (carray.ndim == 2 and carray.shape[1] in (3, 4)) - ): - carray = carray.ravel() - norm, cmap, _, ticks = _build_discrete_norm( - carray, # sample data for getting suitable levels - N=N, levels=levels, values=values, - norm=norm, norm_kw=norm_kw, - locator=locator, locator_kw=locator_kw, - cmap=cmap, vmin=vmin, vmax=vmax, extend='neither', - symmetric=symmetric, - ) - - # Fix 2D arguments but still support scatter(x_vector, y_2d) usage - # NOTE: Since we are flattening vectors the coordinate metadata is meaningless, - # so converting to ndarray and stripping metadata is no problem. - # NOTE: numpy.ravel() preserves masked arrays - if len(args) == 2 and all(np.asarray(arg).squeeze().ndim > 1 for arg in args): - args = tuple(np.ravel(arg) for arg in args) - - # Scale s array - if np.iterable(s) and (smin is not None or smax is not None): - smin_true, smax_true = min(s), max(s) - if smin is None: - smin = smin_true - if smax is None: - smax = smax_true - s = ( - smin + (smax - smin) - * (np.array(s) - smin_true) / (smax_true - smin_true) - ) - obj = func( - self, *args, c=c, s=s, cmap=cmap, norm=norm, - linewidths=lw, edgecolors=ec, **kwargs - ) - if ticks is not None: - obj.ticks = ticks - return obj - - def stem_wrapper( self, func, *args, linefmt=None, basefmt=None, markerfmt=None, **kwargs ): From 66025ba23fb55ffb22a364ff0813ef8391445e70 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sun, 21 Jun 2020 04:25:42 -0600 Subject: [PATCH 2/4] Add dedicated axes methods --- docs/.proplotrc | 1 + proplot/axes/__init__.py | 4 +- proplot/axes/base.py | 1411 +++++++++++++++++++++---- proplot/axes/geo.py | 2 +- proplot/axes/plot.py | 1788 ++++++++------------------------ proplot/figure.py | 16 +- proplot/internals/docstring.py | 25 +- 7 files changed, 1660 insertions(+), 1587 deletions(-) diff --git a/docs/.proplotrc b/docs/.proplotrc index 9ce5a606f..81ca5da5d 100644 --- a/docs/.proplotrc +++ b/docs/.proplotrc @@ -1,3 +1,4 @@ # Use SVG because quality of examples is highest priority # Tested SVG vs. PNG and speeds are comparable! inlinefmt: svg +docstring.hardcopy: true diff --git a/proplot/axes/__init__.py b/proplot/axes/__init__.py index 065088a5d..e66000ec5 100644 --- a/proplot/axes/__init__.py +++ b/proplot/axes/__init__.py @@ -5,12 +5,11 @@ import matplotlib.projections as mproj from ..internals import warnings -from . import plot +from . import plot # noqa: F401 from .base import Axes # noqa: F401 from .cartesian import CartesianAxes from .geo import GeoAxes # noqa: F401 from .geo import BasemapAxes, CartopyAxes -from .plot import * # noqa: F401, F403 from .polar import PolarAxes XYAxes, ProjAxes = warnings._rename_objs( @@ -30,4 +29,3 @@ 'GeoAxes', 'CartopyAxes', 'BasemapAxes', 'ProjAxes', 'XYAxes', # deprecated ] -__all__.extend(plot.__all__) # document wrappers as part of proplot/axes submodule diff --git a/proplot/axes/base.py b/proplot/axes/base.py index 4b5aa9b56..030a522f0 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -3,6 +3,7 @@ The base axes class used for all ProPlot figures. """ import copy +import re from numbers import Integral, Number import matplotlib.axes as maxes @@ -17,32 +18,16 @@ from .. import gridspec as pgridspec from ..config import rc from ..internals import ic # noqa: F401 -from ..internals import _not_none, docstring, rcsetup, warnings -from ..utils import edges, units -from .plot import ( - _bar_wrapper, - _barh_wrapper, - _boxplot_wrapper, - _cmap_changer, - _concatenate_docstrings, - _cycle_changer, - _fill_between_wrapper, - _fill_betweenx_wrapper, - _get_transform, - _hist_wrapper, - _hlines_wrapper, - _indicate_error, - _parametric_wrapper, - _scatter_wrapper, - _standardize_1d, - _standardize_2d, - _stem_wrapper, - _text_wrapper, - _violinplot_wrapper, - _vlines_wrapper, - colorbar_wrapper, - legend_wrapper, +from ..internals import ( + _dummy_context, + _not_none, + _state_context, + docstring, + rcsetup, + warnings, ) +from ..utils import edges, units +from . import plot __all__ = ['Axes'] @@ -187,6 +172,150 @@ The panel axes. """ +_bar_docstring = """ +Parameters +---------- +%(plot.1d_args)s +width : array-like, optional + The width of the bars in step size-relative units. +{bottom} : array-like, optional + Coordinates for the {bottom} edge of the bars. +orientation : {{'vertical', 'horizontal'}}, optional + The orientation of the bars. +vert : bool, optional + Alternative to the `orientation` keyword arg. If ``False``, horizontal + bars are drawn. This is for consistency with + `~matplotlib.axes.Axes.boxplot` and `~matplotlib.axes.Axes.violinplot`. +stacked : bool, optional + Whether to stack columns of input data, or plot the bars side-by-side. +%(plot.negpos_args)s +lw, linewidth : float, optional + The edge width for the bar patches. +edgecolor : color-spec, optional + The edge color for the bar patches. +%(plot.cycle_args)s +%(plot.error_args)s + +Other parameters +---------------- +**kwargs + Passed to `~matplotlib.axes.Axes.bar{suffix}`. +""" +docstring.snippets['axes.bar'] = docstring.add_snippets( + _bar_docstring.format(x='x', height='height', bottom='bottom', suffix='') +) +docstring.snippets['axes.barh'] = docstring.add_snippets( + _bar_docstring.format(x='y', height='right', bottom='left', suffix='h') +) + +_boxplot_docstring = """ +Parameters +---------- +*args : array-like + The data array. +color : color-spec, optional + The color of all objects. +fill : bool, optional + Whether to fill the box with a color. +fillcolor : color-spec, optional + The fill color for the boxes. Default is the next color cycler color. +fillalpha : float, optional + The opacity of the boxes. Default is ``1``. +lw, linewidth : float, optional + The linewidth of all objects. +vert : bool, optional + If ``False``, box plots are drawn horizontally. +orientation : {{None, 'horizontal', 'vertical'}}, optional + Alternative to the native `vert` keyword arg. Controls orientation. +marker : marker-spec, optional + Marker style for the 'fliers', i.e. outliers. +markersize : float, optional + Marker size for the 'fliers', i.e. outliers. +boxcolor, capcolor, meancolor, mediancolor, whiskercolor : \ +color-spec, optional + The color of various boxplot components. These are shorthands so you + don't have to pass e.g. a ``boxprops`` dictionary. +boxlw, caplw, meanlw, medianlw, whiskerlw : float, optional + The line width of various boxplot components. These are shorthands so + you don't have to pass e.g. a ``boxprops`` dictionary. +%(plot.cycle_args)s + +Other parameters +---------------- +**kwargs + Passed to `matplotlib.axes.Axes.boxplot`. +""" +docstring.snippets['plot.boxplot'] = docstring.add_snippets(_boxplot_docstring) + +_fill_between_docstring = """ +Parameters +---------- +*args : ({y}1,), ({x}, {y}1), or ({x}, {y}1, {y}2) + The *{x}* and *{y}* coordinates. If `{x}` is not provided, it will be + inferred from `{y}1`. If `{y}1` and `{y}2` are provided, this function + will shade between respective columns of the arrays. The default value + for `{y}2` is ``0``. +stacked : bool, optional + Whether to "stack" successive columns of the `{y}1` array. If this is + ``True`` and `{y}2` was provided, it will be ignored. +%(plot.negpos_args)s +where : ndarray, optional + Boolean ndarray mask for points you want to shade. See `this example \ +`__. +lw, linewidth : float, optional + The edge width for the area patches. +edgecolor : color-spec, optional + The edge color for the area patches. +%(plot.cycle_args)s +%(plot.error_args)s + +Other parameters +---------------- +**kwargs + Passed to `~matplotlib.axes.Axes.fill_between`. +""" +docstring.snippets['axes.fill_between'] = docstring.add_snippets( + _fill_between_docstring.format(x='x', y='y', suffix='') +) +docstring.snippets['axes.fill_betweenx'] = docstring.add_snippets( + _fill_between_docstring.format(x='y', y='x', suffix='x') +) + +_violinplot_docstring = """ +Parameters +---------- +*args : 1D or 2D ndarray + The data array. +lw, linewidth : float, optional + The linewidth of the line objects. Default is ``1``. +edgecolor : color-spec, optional + The edge color for the violin patches. Default is ``'black'``. +fillcolor : color-spec, optional + The violin plot fill color. Default is the next color cycler color. +fillalpha : float, optional + The opacity of the violins. Default is ``1``. +vert : bool, optional + If ``False``, box plots are drawn horizontally. +orientation : {{None, 'horizontal', 'vertical'}}, optional + Alternative to the native `vert` keyword arg. Controls orientation. +boxrange, barrange : (float, float), optional + Percentile ranges for the thick and thin central bars. The defaults + are ``(25, 75)`` and ``(5, 95)``, respectively. +%(plot.cycle_args)s + +Other parameters +---------------- +**kwargs + Passed to `~matplotlib.axes.Axes.violinplot`. + +Warning +------- +It is also no longer possible to show minima and maxima with whiskers -- +while this is useful for `~matplotlib.axes.Axes.boxplot`\\ s it is +redundant for `~matplotlib.axes.Axes.violinplot`\\ s. +""" +docstring.snippets['plot.violinplot'] = docstring.add_snippets(_violinplot_docstring) + class Axes(maxes.Axes): """ @@ -429,6 +558,31 @@ def _get_title(self, loc): else: return getattr(self, '_' + loc.replace(' ', '_') + '_title') + def _get_transform(self, transform): + """ + Translate user input transform. + """ + try: + from cartopy.crs import CRS, PlateCarree + except ModuleNotFoundError: + CRS = PlateCarree = None + cartopy = getattr(self, 'name', '') == 'cartopy' + if ( + isinstance(transform, mtransforms.Transform) + or CRS and isinstance(transform, CRS) + ): + return transform + elif transform == 'figure': + return self.figure.transFigure + elif transform == 'axes': + return self.transAxes + elif transform == 'data': + return PlateCarree() if cartopy else self.transData + elif cartopy and transform == 'map': + return self.transData + else: + raise ValueError(f'Unknown transform {transform!r}.') + def _hide_panel(self): """ Hide axes contents but do *not* make the entire axes invisible. This @@ -1053,27 +1207,229 @@ def sanitize_kw(kw, loc): obj.set_text(title) self._title_loc = loc # assigns default loc on first run + @docstring.add_snippets def area(self, *args, **kwargs): """ - Alias for `~matplotlib.axes.Axes.fill_between`. + %(axes.fill_between)s """ # NOTE: *Cannot* assign area = axes.Axes.fill_between because the # wrapper won't be applied and for some reason it messes up # automodsumm, which tries to put the matplotlib docstring on website return self.fill_between(*args, **kwargs) + @docstring.add_snippets def areax(self, *args, **kwargs): """ - Alias for `~matplotlib.axes.Axes.fill_betweenx`. + %(axes.fill_betweenx)s """ return self.fill_betweenx(*args, **kwargs) + # bar = _bar_wrapper(_standardize_1d(_indicate_error(_cycle_changer( + @plot._concatenate_docstrings + @docstring.add_snippets + @plot._add_autoformat + def bar( + self, x=None, height=None, width=0.8, bottom=None, *, + vert=None, orientation='vertical', stacked=False, + lw=None, linewidth=None, edgecolor='black', + negpos=False, negcolor=None, poscolor=None, + **kwargs + ): + """ + %(axes.bar)s + """ + # Parse arguments + # WARNING: Implementation is really weird... we flip around arguments for + # horizontal plots only to flip them back in cycle_changer when iterating + # through columns. + if vert is not None: + orientation = 'vertical' if vert else 'horizontal' + if orientation == 'horizontal': + x, bottom = bottom, x + width, height = height, width + x, height = plot._parse_1d(x, height) + kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) + kwargs, kwargs_error = plot._parse_error(**kwargs) + + # Parse args + # TODO: Stacked feature is implemented in `cycle_changer`, but makes more + # sense do document here; figure out way to move it here? + if kwargs.get('left', None) is not None: + warnings._warn_proplot( + 'bar() keyword "left" is deprecated. Use "x" instead.' + ) + x = kwargs.pop('left') + if x is None and height is None: + raise ValueError('bar() requires at least 1 positional argument, got 0.') + elif height is None: + x, height = None, x + args = (x, height) + linewidth = _not_none(lw=lw, linewidth=linewidth, default=rc['patch.linewidth']) + kwargs.update({ + 'width': width, 'bottom': bottom, 'stacked': stacked, + 'orientation': orientation, 'linewidth': linewidth, 'edgecolor': edgecolor, + }) + + # Call func + # NOTE: This *must* also be wrapped by cycle_changer, which ultimately + # permutes back the x/bottom args for horizontal bars! Need to clean up. + if negpos and kwargs.get('color', None) is None: + # Draw negative and positive bars + # NOTE: cycle_changer makes bar widths *relative* to step size between + # x coordinates to cannot just omit data. Instead make some height nan. + msg = 'bar() argument {}={!r} is incompatible with negpos=True. Ignoring.' + stacked = kwargs.pop('stacked', None) + if stacked: + warnings._warn_proplot(msg.format('stacked', stacked)) + height = np.asarray(height) + if height.ndim > 1: + raise ValueError('bar() heights with negpos=True must be 1D.') + height1 = height.copy().astype(np.float64) + height1[height >= 0] = np.nan + height2 = height.copy().astype(np.float64) + height2[height < 0] = np.nan + negcolor = _not_none(negcolor, rc['negcolor']) + poscolor = _not_none(poscolor, rc['poscolor']) + obj1 = super().bar(x, height1, color=negcolor, **kwargs) + obj2 = super().bar(x, height2, color=poscolor, **kwargs) + objs = (obj1, obj2) + else: + # Draw simple bars + objs = super().bar(*args, **kwargs) + + # Add error bars, legend, and/or colorbar + errobjs = plot._indicate_error(objs, x, height, **kwargs_error) + plot._add_legend_colorbar(objs, errobjs=errobjs, **kwargs_legend_colorbar) + + return plot._merge_objs_errobjs(objs, errobjs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def barh(self, y=None, right=None, width=0.8, left=None, **kwargs): + """ + %(axes.barh)s + """ + # Converts y-->bottom, left-->x, width-->height, height-->width. + # Convert back to (x, bottom, width, height) so we can pass stuff + # through cycle_changer. + # NOTE: ProPlot calls second positional argument 'right' so that 'width' + # means the width of *bars*. + # NOTE: You *must* do juggling of barh keyword order --> bar keyword order + # --> barh keyword order, because horizontal hist passes arguments to bar + # directly and will not use a 'barh' method with overridden argument order! + height = _not_none(height=kwargs.pop('height', None), width=width, default=0.8) + kwargs.setdefault('orientation', 'horizontal') + if y is None and width is None: + raise ValueError('barh() requires at least 1 positional argument, got 0.') + return self.bar(x=left, width=right, height=height, bottom=y, **kwargs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def barbs(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s + + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().barbs(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + @docstring.add_snippets def boxes(self, *args, **kwargs): """ - Alias for `~matplotlib.axes.Axes.boxplot`. + %(axes.boxplot)s """ return self.boxplot(*args, **kwargs) + # boxplot = _boxplot_wrapper(_standardize_1d(_cycle_changer( + @plot._concatenate_docstrings + @docstring.add_snippets + @plot._add_autoformat + def boxplot( + self, *args, + color='k', fill=True, fillcolor=None, fillalpha=0.7, + lw=None, linewidth=None, orientation=None, + marker=None, markersize=None, + boxcolor=None, boxlw=None, + capcolor=None, caplw=None, + meancolor=None, meanlw=None, + mediancolor=None, medianlw=None, + whiskercolor=None, whiskerlw=None, + fliercolor=None, flierlw=None, + **kwargs + ): + """ + %(axes.boxplot)s + """ + # Parse arguments + x, y = plot._parse_1d(*args) + kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) + if orientation is not None: + if orientation == 'horizontal': + kwargs['vert'] = False + elif orientation != 'vertical': + raise ValueError( + 'Orientation must be "horizontal" or "vertical", ' + f'got {orientation!r}.' + ) + + # Call function + objs = super().boxplot(*args, **kwargs) + if not args: + return objs + + # Modify results + # TODO: Pass props keyword args instead? Maybe does not matter. + lw = _not_none(lw=lw, linewidth=linewidth, default=0.8) + if fillcolor is None: + cycler = next(self._get_lines.prop_cycler) + fillcolor = cycler.get('color', None) + for key, icolor, ilw in ( + ('boxes', boxcolor, boxlw), + ('caps', capcolor, caplw), + ('whiskers', whiskercolor, whiskerlw), + ('means', meancolor, meanlw), + ('medians', mediancolor, medianlw), + ('fliers', fliercolor, flierlw), + ): + if key not in objs: # possible if not rendered + continue + artists = objs[key] + ilw = _not_none(ilw, lw) + icolor = _not_none(icolor, color) + for artist in artists: + if icolor is not None: + artist.set_color(icolor) + artist.set_markeredgecolor(icolor) + if ilw is not None: + artist.set_linewidth(ilw) + artist.set_markeredgewidth(ilw) + if key == 'boxes' and fill: + patch = mpatches.PathPatch( + artist.get_path(), color=fillcolor, + alpha=fillalpha, linewidth=0) + self.add_artist(patch) + if key == 'fliers': + if marker is not None: + artist.set_marker(marker) + if markersize is not None: + artist.set_markersize(markersize) + + # Add legend or colorbar + plot._add_legend_colorbar(objs, **kwargs_legend_colorbar) + + return objs + + @plot._concatenate_docstrings + @docstring.add_snippets def colorbar( self, *args, loc=None, pad=None, length=None, shrink=None, width=None, space=None, frame=None, frameon=None, @@ -1082,10 +1438,11 @@ def colorbar( ): """ Add an *inset* colorbar or *outer* colorbar along the outside edge of - the axes. See `~proplot.axes.colorbar_wrapper` for details. + the axes. Parameters ---------- + %(plot.colorbar_args)s loc : str, optional The colorbar location. Default is :rc:`colorbar.loc`. The following location keys are valid: @@ -1140,11 +1497,12 @@ def colorbar( :rc:`colorbar.framealpha`, :rc:`axes.linewidth`, :rc:`axes.edgecolor`, and :rc:`axes.facecolor`, respectively. + %(plot.colorbar_kwargs)s Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.colorbar_wrapper`. + **kwargs + Passed to `~matplotlib.axes.Axes.colorbar`. """ # TODO: add option to pad inset away from axes edge! # TODO: get "best" colorbar location from legend algorithm. @@ -1326,15 +1684,165 @@ def colorbar( kwargs.setdefault('extendsize', extend) # Generate colorbar - return colorbar_wrapper(ax, *args, **kwargs) + return plot._add_colorbar(ax, *args, **kwargs) + @plot._concatenate_docstrings + @docstring.add_snippets + def contour(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s + + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().contour(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + @plot._concatenate_docstrings + @docstring.add_snippets + def contourf(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s + + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().contourf(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + # fill_between = _fill_between_wrapper(_standardize_1d(_cycle_changer( + def _fill_between_apply( + self, xy, *args, + negcolor=None, poscolor=None, negpos=False, + lw=None, linewidth=None, + **kwargs + ): + """ + Helper function that powers `fill_between` and `fill_betweenx`. + """ + # Parse input arguments as follows: + # * Permit using 'x', 'y1', and 'y2' or 'y', 'x1', and 'x2' as + # keyword arguments. + # * When negpos is True, instead of using fill_between(x, y1, y2=0) as default, + # make the default fill_between(x, y1=0, y2). + x, y = xy + kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) + func = super().fill_between if x == 'x' else super().fill_betweenx + name = func.__name__ + args = list(args) + if x in kwargs: # keyword 'x' + args.insert(0, kwargs.pop(x)) + if len(args) == 1: + args.insert(0, np.arange(len(args[0]))) + for yi in (y + '1', y + '2'): + if yi in kwargs: # keyword 'y' + args.append(kwargs.pop(yi)) + if len(args) == 2: + args.append(0) + elif len(args) == 3: + if kwargs.get('stacked', False): + warnings._warn_proplot( + f'{name}() cannot have three positional arguments ' + 'with negpos=True. Ignoring third argument.' + ) + else: + raise ValueError(f'Expected 2-3 positional args, got {len(args)}.') + + # Modify default properties + # Set default edge width for patches to zero + kwargs['linewidth'] = _not_none(lw=lw, linewidth=linewidth, default=0) + + # Draw patches + xv, y1, y2 = args + xv = plot._to_arraylike(xv) + y1 = plot._to_arraylike(y1) + y2 = plot._to_arraylike(y2) + if negpos and kwargs.get('color', None) is None: + # Plot negative and positive patches + msg = name + ' argument {}={!r} is incompatible with negpos=True. Ignoring.' + where = kwargs.pop('where', None) + if where is not None: + warnings._warn_proplot(msg.format('where', where)) + stacked = kwargs.pop('stacked', None) + if stacked: + warnings._warn_proplot(msg.format('stacked', stacked)) + kwargs.setdefault('interpolate', True) + if np.asarray(y1).ndim > 1 or np.asarray(y2).ndim > 2: + raise ValueError(f'{name} arguments with negpos=True must be 1D.') + where1 = y1 < y2 + where2 = y1 >= y2 + negcolor = _not_none(negcolor, rc['negcolor']) + poscolor = _not_none(poscolor, rc['poscolor']) + obj1 = func(xv, y1, y2, where=where1, color=negcolor, **kwargs) + obj2 = func(xv, y1, y2, where=where2, color=poscolor, **kwargs) + result = objs = (obj1, obj2) # may be tuple of tuples due to cycle_changer + + else: + # Plot basic patches + result = func(xv, y1, y2, **kwargs) + objs = (result,) + + # Add sticky edges in x-direction, and sticky edges in y-direction *only* + # if one of the y limits is scalar. This should satisfy most users. + xsides = (np.min(xv), np.max(xv)) + ysides = [] + if y1.size == 1: + ysides.append(np.asarray(y1).item()) + if y2.size == 1: + ysides.append(np.asarray(y2).item()) + for iobjs in objs: + if not isinstance(iobjs, tuple): + iobjs = (iobjs,) + for obj in iobjs: + getattr(obj.sticky_edges, x).extend(xsides) + getattr(obj.sticky_edges, y).extend(ysides) + + # Add legend or colorbar + plot._add_legend_colorbar(objs, **kwargs_legend_colorbar) + + return result + + @plot._concatenate_docstrings + @docstring.add_snippets + @plot._add_autoformat + def fill_between(self, *args, **kwargs): + """ + %(axes.fill_between)s + """ + return self._fill_between_apply('xy', *args, **kwargs) + + @plot._concatenate_docstrings + @docstring.add_snippets + @plot._add_autoformat + def fill_betweenx(self, *args, **kwargs): + """ + %(axes.fill_betweenx)s + """ + return self._fill_between_apply('yx', *args, **kwargs) + + @plot._concatenate_docstrings + @docstring.add_snippets def legend(self, *args, loc=None, width=None, space=None, **kwargs): """ Add an *inset* legend or *outer* legend along the edge of the axes. - See `~proplot.axes.legend_wrapper` for details. Parameters ---------- + %(plot.legend_args)s loc : int or str, optional The legend location. The following location keys are valid: @@ -1369,11 +1877,12 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): box. Units are interpreted by `~proplot.utils.units`. When :rcraw:`tight` is ``True``, this is adjusted automatically. Otherwise, the default is :rc:`subplots.panelpad`. + %(plot.legend_kwargs)s Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.legend_wrapper`. + **kwargs + Passed to `~matplotlib.axes.Axes.legend`. """ if loc != 'fill': loc = self._loc_translate(loc, 'legend') @@ -1413,7 +1922,7 @@ def legend(self, *args, loc=None, width=None, space=None, **kwargs): raise ValueError(f'Invalid panel side {side!r}.') # Draw legend - return legend_wrapper(self, *args, loc=loc, **kwargs) + return plot._add_legend(self, *args, loc=loc, **kwargs) def draw(self, renderer=None, *args, **kwargs): # Perform extra post-processing steps @@ -1436,14 +1945,18 @@ def get_tightbbox(self, renderer, *args, **kwargs): self._tightbbox = bbox return bbox + @docstring.add_snippets def heatmap(self, *args, aspect=None, **kwargs): """ - Pass all arguments to `~matplotlib.axes.Axes.pcolormesh` then apply - settings that are suitable for heatmaps: square grid boxes by default, - major ticks at the center of each grid box, no minor ticks, and no gridlines. + Generate a heatmap. + + All arguments are passed to `~proplot.axes.Axes.pcolormesh` and settings + suitable for heatmap are applied: square grid boxes by default, major + ticks at the center of each grid box, no minor ticks, and no gridlines. Parameters ---------- + %(plot.2d_args)s aspect : {'equal', 'auto'} or float, optional Controls the aspect ratio of the axes. The aspect is of particular relevance for heatmaps since it may distort the heatmap, i.e. a grid box @@ -1454,8 +1967,10 @@ def heatmap(self, *args, aspect=None, **kwargs): - ``'equal'``: Ensures an aspect ratio of 1. Grid boxes will be square. - ``'auto'``: The axes is kept fixed and the aspect is adjusted so - that the data fit in the axes. In general, this will result in non-square - grid boxes. + that the data fit in the axes. In general, this will result in + non-square grid boxes. + %(plot.auto_colorbar)s + %(plot.cmap_args)s """ obj = self.pcolormesh(*args, **kwargs) aspect = _not_none(aspect, rc['image.aspect']) @@ -1472,6 +1987,163 @@ def heatmap(self, *args, aspect=None, **kwargs): ) return obj + # hexbin = _standardize_1d(_cmap_changer( + @plot._concatenate_docstrings + @docstring.add_snippets + def hexbin(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_1d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().hexbin(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + # hist = _hist_wrapper(_standardize_1d(_cycle_changer( + @plot._concatenate_docstrings + @docstring.add_snippets + @plot._add_autoformat + def hist(self, x, bins=None, **kwargs): + """ + Add histogram(s). + + Parameters + ---------- + x : array-like + The input data. + bins : int or array-like, optional + The number of bins or the bin boundaries. + + Other parameters + ---------------- + %(plot.autoformat)s + %(plot.cycle_args)s + **kwargs + Passed to `matplotlib.axes.Axes.hist`. + """ + # Parse input args + _, x = plot._parse_1d(x) + + # Call function + with _state_context(self, _absolute_bar_width=True): + return super().hist(x, bins=bins, **kwargs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def hist2d(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.cmap_args)s + """ + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().hist2d(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + def _draw_lines( + self, xy, *args, negpos=False, negcolor=None, poscolor=None, **kwargs + ): + """ + Parse lines arguments. Support automatic *x* coordinates and default + "minima" at zero. + """ + # Use default "base" position of zero + x, y = xy + func = self.vlines if x == 'x' else self.hlines + args = list(args) + if x in kwargs: + args.insert(0, kwargs.pop(x)) + for suffix in ('min', 'max'): + key = y + suffix + if key in kwargs: + args.append(kwargs.pop(key)) + if len(args) == 1: + x = np.arange(len(np.atleast_1d(args[0]))) + args.insert(0, x) + if len(args) == 2: + args.insert(1, 0.0) + elif len(args) != 3: + raise TypeError('lines() requires 1 to 3 positional arguments.') + + # Parse positional arguments + x, y1, y2 = plot._parse_1d(*args) + + # Support "negative" and "positive" lines + if negpos and kwargs.get('color', None) is None: + y1 = plot._to_arraylike(y1) + y2 = plot._to_arraylike(y2) + y1array = plot._to_ndarray(y1) + y2array = plot._to_ndarray(y2) + + # Negative colors + mask = y2array >= y1array # positive + y1neg = y1.copy() + y2neg = y2.copy() + if mask.size == 1: + if mask.item(): + y1neg = y2neg = np.nan + else: + if y1.size > 1: + plot._to_indexer(y1neg)[mask] = np.nan + if y2.size > 1: + plot._to_indexer(y2neg)[mask] = np.nan + color = _not_none(negcolor, rc['negcolor']) + negobj = func(x, y1neg, y2neg, color=color, **kwargs) + + # Positive colors + mask = y2array < y1array # negative + y1pos = y1.copy() + y2pos = y2.copy() + if mask.size == 1: + if mask.item(): + y1pos = y2pos = np.nan + else: + if y1.size > 1: + plot._to_indexer(y1pos)[mask] = np.nan + if y2.size > 1: + plot._to_indexer(y2pos)[mask] = np.nan + color = _not_none(poscolor, rc['poscolor']) + posobj = func(x, y1pos, y2pos, color=color, **kwargs) + result = (negobj, posobj) + else: + result = func(x, y1, y2, **kwargs) + + return result + + # hlines = _standardize_1d(_hlines_wrapper( + @plot._concatenate_docstrings + @docstring.add_snippets + def hlines(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + + Other parameters + ---------------- + %(plot.negpos_args)s + """ + return self._draw_lines('yx', *args, **kwargs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def imshow(self, *args, **kwargs): + """ + Parameters + ---------------- + %(plot.cmap_args)s + """ + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().imshow(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + @docstring.add_snippets def inset(self, *args, **kwargs): """ @@ -1493,7 +2165,7 @@ def inset_axes( if not transform: transform = self.transAxes else: - transform = _get_transform(self, transform) + transform = self._get_transform(transform) label = kwargs.pop('label', 'inset_axes') proj = _not_none(proj=proj, projection=projection) proj_kw = _not_none(proj_kw=proj_kw, projection_kw=projection_kw, default={}) @@ -1620,6 +2292,19 @@ def indicate_inset_zoom( self._inset_zoom_data = (rectpatch, connects) return rectpatch, connects + @plot._concatenate_docstrings + @docstring.add_snippets + def matshow(self, *args, **kwargs): + """ + Parameters + ---------------- + %(plot.cmap_args)s + """ + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().matshow(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + @docstring.add_snippets def panel(self, side, **kwargs): """ @@ -1635,14 +2320,9 @@ def panel_axes(self, side, **kwargs): side = self._loc_translate(side, 'panel') return self.figure._add_axes_panel(self, side, **kwargs) - @_parametric_wrapper - @_standardize_1d - @_cmap_changer + @plot._concatenate_docstrings def parametric( - self, *args, values=None, - cmap=None, norm=None, interp=0, - scalex=True, scaley=True, - **kwargs + self, *args, values=None, interp=0, scalex=True, scaley=True, **kwargs ): """ Draw a line whose color changes as a function of the parametric @@ -1665,9 +2345,9 @@ def parametric( scalex, scaley : bool, optional Whether the view limits are adapted to the data limits. The values are passed on to `~matplotlib.axes.Axes.autoscale_view`. - %(standardize_1d_kwargs)s - %(cycle_changer_kwargs)s - %(add_errorbars_kwargs)s + %(plot.autoformat)s + %(plot.cycle_kwargs)s + %(plot.error_kwargs)s Other parameters ---------------- @@ -1680,9 +2360,54 @@ def parametric( The parametric line. See `this matplotlib example \ `__. """ + # Parse positional arguments + # NOTE: This wrapper is required so that + # WARNING: So far this only works for 1D *x* and *y* coordinates. + # Cannot draw multiple colormap lines at once + if len(args) == 3: + x, y, values = args + elif values is not None: + if len(args) == 1: + y = np.asarray(args[0]) + x = np.arange(y.shape[-1]) + elif len(args) == 2: + x, y = args + else: + raise ValueError( + f'1 to 3 positional arguments required, got {len(args)}.' + ) + else: + raise ValueError('Missing required keyword argument "values".') + cmap, norm, kwargs = plot._parse_cmap_norm(**kwargs) + + # Verify shapes + x, y, values = np.atleast_1d(x), np.atleast_1d(y), np.atleast_1d(values) + if ( + any(_.ndim != 1 for _ in (x, y, values)) + or len({x.size, y.size, values.size}) > 1 + ): + raise ValueError( + f'x {x.shape}, y {y.shape}, and values {values.shape} ' + 'must be 1-dimensional and have the same size.' + ) + + # Interpolate values to allow for smooth gradations between values + # (interp=False) or color switchover halfway between points + # (interp=True). Then optionally interpolate the colormap values. + if interp > 0: + xorig, yorig, vorig = x, y, values + x, y, values = [], [], [] + for j in range(xorig.shape[0] - 1): + idx = slice(None) + if j + 1 < xorig.shape[0] - 1: + idx = slice(None, -1) + x.extend(np.linspace(xorig[j], xorig[j + 1], interp + 2)[idx].flat) + y.extend(np.linspace(yorig[j], yorig[j + 1], interp + 2)[idx].flat) + values.extend(np.linspace(vorig[j], vorig[j + 1], interp + 2)[idx].flat) + x, y, values = np.array(x), np.array(y), np.array(values) + # Get x/y coordinates and values for points to the 'left' and 'right' # of each joint - x, y = _standardize_1d(args) interp # avoid U100 unused argument error (arg is handled by wrapper) coords = [] levels = edges(values) @@ -1706,13 +2431,13 @@ def parametric( # Create LineCollection and update with values # NOTE: Default capstyle is butt but this may look weird with vector graphics - hs = mcollections.LineCollection( + obj = mcollections.LineCollection( coords, cmap=cmap, norm=norm, linestyles='-', capstyle='butt', joinstyle='miter', ) values = np.asarray(values) - hs.set_array(values) - hs.update({ + obj.set_array(values) + obj.update({ key: value for key, value in kwargs.items() if key not in ('color',) }) @@ -1720,27 +2445,107 @@ def parametric( # Add collection with some custom attributes # NOTE: Modern API uses self._request_autoscale_view but this is # backwards compatible to earliest matplotlib versions. - self.add_collection(hs) + self.add_collection(obj) self.autoscale_view(scalex=scalex, scaley=scaley) - hs.values = values - hs.levels = levels # needed for other functions - return hs + obj.values = values + obj.levels = levels # needed for other functions + return obj + + @plot._concatenate_docstrings + @docstring.add_snippets + def pcolor(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s + + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().pcolor(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + @plot._concatenate_docstrings + @docstring.add_snippets + def pcolorfast(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s + + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().pcolorfast(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + @plot._concatenate_docstrings + @docstring.add_snippets + def pcolormesh(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s - @_concatenate_docstrings + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().pcolormesh(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + # pie = _standardize_1d(_cycle_changer( + @plot._concatenate_docstrings @docstring.add_snippets + def pie(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + %(plot.cycle_args)s + """ + args = plot._parse_1d(*args) + kwargs = plot._parse_cycle(**kwargs) + return super().pie(*args, **kwargs) + + # plot = _plot_wrapper(_standardize_1d(_indicate_error(_cycle_changer( + @plot._concatenate_docstrings + @docstring.add_snippets + @plot._add_autoformat def plot(self, *args, cmap=None, values=None, **kwargs): """ Parameters ---------- - %(standardize_1d_args)s - cmap, values : optional - *Deprecated usage*. Passed to `~proplot.axes.Axes.cmapline`. - %(standardize_1d_kwargs)s - %(cycle_changer_kwargs)s - %(add_errorbars_kwargs)s + %(plot.1d_args)s + + Returns + ------- + tuple of `matplotlib.lines.Line2D` + The lines. Other parameters ---------------- + cmap, values : optional + *Deprecated usage*. Passed to `~proplot.axes.Axes.cmapline`. + %(plot.auto_colorbar)s + %(plot.auto_legend)s + %(plot.autoformat)s + %(plot.cycle_kwargs)s + %(plot.error_kwargs)s **kwargs `~matplotlib.lines.Line2D` properties. """ @@ -1748,17 +2553,23 @@ def plot(self, *args, cmap=None, values=None, **kwargs): raise ValueError(f'Expected 1-3 positional args, got {len(args)}.') if cmap is not None: warnings._warn_proplot( - 'Drawing "parametric" plots with ax.plot(x, y, values=values, cmap=cmap) ' + 'Drawing parametric lines with ax.plot(x, y, values=values, cmap=cmap) ' 'is deprecated and will be removed in a future version. Please use ' 'ax.parametric(x, y, values, cmap=cmap) instead.' ) return self.parametric(*args, cmap=cmap, values=values, **kwargs) + # Parse arguments + x, y = plot._parse_1d(*args) + kwargs, kwargs_error = plot._parse_error(**kwargs) + kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) + # Draw lines - result = super().plot(*args, values=values, **kwargs) + objs = super().plot(x, y, values=values, **kwargs) - # Add sticky edges? No because there is no way to check whether "dependent variable" - # is x or y axis like with area/areax and bar/barh. Better to always have margin. + # Add sticky edges? No because there is no way to check whether "dependent + # variable" is x or y axis like with area/areax and bar/barh. Better to always + # have margin. # for objs in result: # if not isinstance(objs, tuple): # objs = (objs,) @@ -1766,15 +2577,42 @@ def plot(self, *args, cmap=None, values=None, **kwargs): # xdata = obj.get_xdata() # obj.sticky_edges.x.extend((np.min(xdata), np.max(xdata))) - return result + # Add error bars, legend, and/or colorbar + errobjs = plot._indicate_error(objs, x, y, **kwargs_error) + plot._add_legend_colorbar(objs, errobjs=errobjs, **kwargs_legend_colorbar) + + return plot._merge_objs_errobjs(objs, errobjs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def quiver(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s + + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().quiver(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable - @_concatenate_docstrings + # scatter = _scatter_wrapper(_standardize_1d(_indicate_error(_cycle_changer( + @plot._concatenate_docstrings @docstring.add_snippets - def scatter_wrapper( + @plot._add_autoformat + def scatter( self, *args, s=None, size=None, markersize=None, - c=None, color=None, markercolor=None, smin=None, smax=None, - cmap=None, cmap_kw=None, norm=None, norm_kw=None, + c=None, color=None, markercolor=None, + smin=None, smax=None, + cmap=None, cmap_kw=None, + norm=None, norm_kw=None, vmin=None, vmax=None, N=None, levels=None, values=None, symmetric=False, locator=None, locator_kw=None, lw=None, linewidth=None, linewidths=None, @@ -1786,30 +2624,36 @@ def scatter_wrapper( """ Parameters ---------- - %(standardize_1d_args)s + %(plot.1d_args)s s, size, markersize : float or list of float, optional The marker size(s). The units are scaled by `smin` and `smax`. - smin, smax : float, optional - The minimum and maximum marker size in units points ** 2 used to - scale the `s` array. If not provided, the marker sizes are equivalent - to the values in the `s` array. c, color, markercolor : color-spec or list thereof, or array, optional The marker fill color(s). If this is an array of scalar values, the colors will be generated by passing the values through the `norm` normalizer and drawing from the `cmap` colormap. - %(axes.cmap_changer)s + + Returns + ------- + `matplotlib.collections.PathCollection` or tuple thereof + The scatter paths. + + Other parameters + ---------------- + smin, smax : float, optional + The minimum and maximum marker size in units points ** 2 used to + scale the `s` array. If not provided, the marker sizes are equivalent + to the values in the `s` array. + %(plot.cmap_args)s + %(plot.auto_colorbar)s + %(plot.auto_legend)s + %(plot.cycle_args)s lw, linewidth, linewidths, markeredgewidth, markeredgewidths : \ float or list thereof, optional The marker edge width. edgecolors, markeredgecolor, markeredgecolors : \ color-spec or list thereof, optional The marker edge color. - %(standardize_1d_kwargs)s - %(cycle_changer_kwargs)s - %(add_errorbars_kwargs)s - - Other parameters - ---------------- + %(plot.error_args)s **kwargs Passed to `~matplotlib.axes.Axes.scatter`. """ @@ -1823,6 +2667,9 @@ def scatter_wrapper( c = args.pop(1) if len(args) == 3: s = args.pop(0) + x, y = plot._parse_1d(*args) + kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) + kwargs, kwargs_error = plot._parse_error(**kwargs) # Apply some aliases for keyword arguments c = _not_none(c=c, color=color, markercolor=markercolor) @@ -1842,7 +2689,6 @@ def scatter_wrapper( cmap = constructor.Colormap(cmap, **cmap_kw) # Get normalizer and levels - # NOTE: If the length of the c array != ticks = None carray = np.atleast_1d(c) if ( @@ -1850,7 +2696,7 @@ def scatter_wrapper( and not (carray.ndim == 2 and carray.shape[1] in (3, 4)) ): carray = carray.ravel() - norm, cmap, _, ticks = _build_discrete_norm( + norm, cmap, _, ticks = plot._build_discrete_norm( carray, # sample data for getting suitable levels N=N, levels=levels, values=values, norm=norm, norm_kw=norm_kw, @@ -1866,7 +2712,7 @@ def scatter_wrapper( if len(args) == 2 and all(np.asarray(arg).squeeze().ndim > 1 for arg in args): args = tuple(np.ravel(arg) for arg in args) - # Scale s array + # Scale sizes array if np.iterable(s) and (smin is not None or smax is not None): smin_true, smax_true = min(s), max(s) if smin is None: @@ -1877,22 +2723,294 @@ def scatter_wrapper( smin + (smax - smin) * (np.array(s) - smin_true) / (smax_true - smin_true) ) - obj = super().scatter( + + # Draw scatterplot + objs = super().scatter( *args, c=c, s=s, cmap=cmap, norm=norm, linewidths=lw, edgecolors=ec, **kwargs ) if ticks is not None: - obj.ticks = ticks + objs.ticks = ticks + + # Add error bars, legend, and/or colorbar + errobjs = plot._indicate_error(objs, x, y, **kwargs_error) + plot._add_legend_colorbar(objs, errobjs=errobjs, **kwargs_legend_colorbar) + + return plot._merge_objs_errobjs(objs, errobjs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def spy(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.cmap_args)s + """ + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().spy(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + # stem = _standardize_1d(_stem_wrapper( + def stem(self, *args, linefmt=None, basefmt=None, markerfmt=None, **kwargs): + # Parse arguments + args = plot._parse_1d(*args) + + # Set default colors + # NOTE: 'fmt' strings can only be 2 to 3 characters and include color shorthands + # like 'r' or cycle colors like 'C0'. Cannot use full color names. + # NOTE: Matplotlib defaults try to make a 'reddish' color the base and 'bluish' + # color the stems. To make this more robust we temporarily replace the cycler + # with a negcolor/poscolor cycler, otherwise try to point default colors to the + # blush 'C0' and reddish 'C1' from the new default 'colorblind' cycler. + if not any( + isinstance(fmt, str) and re.match(r'\AC[0-9]', fmt) + for fmt in (linefmt, basefmt, markerfmt) + ): + cycle = constructor.Cycle((rc['negcolor'], rc['poscolor']), name='_neg_pos') + context = rc.context({'axes.prop_cycle': cycle}) + else: + context = _dummy_context() + + # Add stem lines with bluish stem color and reddish base color + # Make `use_line_collection` the default to suppress annoying warning message. + with context: + kwargs['linefmt'] = _not_none(linefmt, 'C0-') + kwargs['basefmt'] = _not_none(basefmt, 'C1-') + kwargs['markerfmt'] = _not_none(markerfmt, linefmt[:-1] + 'o') + kwargs.setdefault('use_line_collection', True) + try: + return super().stem(*args, **kwargs) + except TypeError: + kwargs.pop('use_line_collection') # old version + return super().stem(*args, **kwargs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def step(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + %(plot.cycle_args)s + """ + args = plot._parse_1d(*args) + kwargs = plot._parse_cycle(**kwargs) + return super().step(*args, **kwargs) + + @plot._concatenate_docstrings + @docstring.add_snippets + def streamplot(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.2d_args)s + + Other parameters + ---------------- + %(plot.auto_colorbar)s + %(plot.cmap_args)s + """ + args = plot._parse_2d(*args) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().streamplot(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + def text( + self, + x=0, y=0, text='', transform='data', + family=None, fontfamily=None, fontname=None, fontsize=None, size=None, + border=False, bordercolor='w', borderwidth=2, borderinvert=False, + **kwargs + ): + """ + Add text to the axes with an optional border. + + Parameters + ---------- + x, y : float + The *x* and *y* coordinates for the text. + text : str + The text string. + transform : {{'data', 'axes', 'figure'}} or \ + `~matplotlib.transforms.Transform`, optional + The transform used to interpret `x` and `y`. Can be a + `~matplotlib.transforms.Transform` object or a string representing the + `~matplotlib.axes.Axes.transData`, `~matplotlib.axes.Axes.transAxes`, + or `~matplotlib.figure.Figure.transFigure` transforms. Default is + ``'data'``, i.e. the text is positioned in data coordinates. + fontsize, size : float or str, optional + The font size. If float, units are inches. If string, units are + interpreted by `~proplot.utils.units`. + fontname, fontfamily, family : str, optional + The font name (e.g. ``'Fira Math'``) or font family name (e.g. + ``'serif'``). Matplotlib falls back to the system default if not found. + fontweight, weight, fontstyle, style, fontvariant, variant : str, optional + Additional font properties. See `~matplotlib.text.Text` for details. + border : bool, optional + Whether to draw border around text. + borderwidth : float, optional + The width of the text border. Default is ``2`` points. + bordercolor : color-spec, optional + The color of the text border. Default is ``'w'``. + borderinvert : bool, optional + If ``True``, the text and border colors are swapped. + + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.axes.Axes.text`. + """ + # Parse input args + # NOTE: Previously issued warning if fontname did not match any of names + # in ttflist but this would result in warning for e.g. family='sans-serif'. + # Matplotlib font API makes it very difficult to inject warning in + # correct place. Simpler to just + # NOTE: Do not emit warning if user supplied conflicting properties + # because matplotlib has like 100 conflicting text properties for which + # it doesn't emit warnings. Prefer not to fix all of them. + fontsize = _not_none(fontsize, size) + fontfamily = _not_none(fontname, fontfamily, family) + if fontsize is not None: + try: + rc._scale_font(fontsize) # *validate* but do not translate + except KeyError: + fontsize = units(fontsize, 'pt') + kwargs['fontsize'] = fontsize + if fontfamily is not None: + kwargs['fontfamily'] = fontfamily + if not transform: + transform = self.transData + else: + transform = self._get_transform(transform) + + # Apply monkey patch to text object + # TODO: Why only support this here, and not in arbitrary places throughout + # rest of matplotlib API? Units engine needs better implementation. + obj = super().text(x, y, text, transform=transform, **kwargs) + obj.update = plot._update_text.__get__(obj) + obj.update({ + 'border': border, + 'bordercolor': bordercolor, + 'borderinvert': borderinvert, + 'borderwidth': borderwidth, + }) return obj + @plot._concatenate_docstrings + @docstring.add_snippets + def tricontour(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.cmap_args)s + """ + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().tricontour(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + @plot._concatenate_docstrings + @docstring.add_snippets + def tricontourf(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.cmap_args)s + """ + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().tricontourf(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + + @plot._concatenate_docstrings + @docstring.add_snippets + def tripcolor(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.cmap_args)s + """ + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + mappable = super().tripcolor(*args, **kwargs) + plot._auto_colorbar(mappable, **kwargs_colorbar) + return mappable + @docstring.add_snippets def violins(self, *args, **kwargs): """ - Alias for `~matplotlib.axes.Axes.violinplot`. + %(axes.violinplot)s """ return self.violinplot(*args, **kwargs) + # violinplot = _violinplot_wrapper(_standardize_1d(_indicate_error(_cycle_changer( + # see: https://matplotlib.org/3.1.0/gallery/statistics/customized_violin.html + @plot._concatenate_docstrings + @docstring.add_snippets + def violinplot( + self, *args, + lw=None, linewidth=None, fillcolor=None, edgecolor='black', + fillalpha=0.7, orientation=None, + **kwargs + ): + """ + %(axes.violinplot)s + """ + # Orientation and checks + x, y = plot._parse_1d(*args) + kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) + if orientation is not None: + if orientation == 'horizontal': + kwargs['vert'] = False + elif orientation != 'vertical': + raise ValueError( + 'Orientation must be "horizontal" or "vertical", ' + f'got {orientation!r}.' + ) + + # Sanitize input + kwargs['lw'] = _not_none(lw=lw, linewidth=linewidth, default=0.8) + if kwargs.pop('showextrema', None): + warnings._warn_proplot('Ignoring showextrema=True.') + if 'showmeans' in kwargs: + kwargs.setdefault('means', kwargs.pop('showmeans')) + if 'showmedians' in kwargs: + kwargs.setdefault('medians', kwargs.pop('showmedians')) + kwargs.setdefault('capsize', 0) + + # Call function + result = obj = super().violinplot( + *args, showmeans=False, showmedians=False, showextrema=False, **kwargs, + ) + + # Modify body settings + if args: + if isinstance(result, (list, tuple)): + obj = result[0] + for artist in obj['bodies']: + artist.set_alpha(fillalpha) + artist.set_edgecolor(edgecolor) + artist.set_linewidths(lw) + if fillcolor is not None: + artist.set_facecolor(fillcolor) + return result + + # vlines = _standardize_1d(_vlines_wrapper( + @plot._concatenate_docstrings + @docstring.add_snippets + def vlines(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + + Other parameters + ---------------- + %(plot.negpos_args)s + """ + return self._draw_lines('xy', *args, **kwargs) + def _iter_axes(self, panels=None, hidden=False, children=False): """ Return a list of axes and child panel axes. @@ -1940,108 +3058,3 @@ def number(self, num): # For consistency with _left_title, _upper_left_title, etc. _center_title = property(lambda self: self.title) - - # Wrapped by special functions - # Also support redirecting to Basemap methods - text = _text_wrapper( - maxes.Axes.text - ) - plot = _plot_wrapper(_standardize_1d(_indicate_error(_cycle_changer( - maxes.Axes.plot - )))) - scatter = _scatter_wrapper(_standardize_1d(_indicate_error(_cycle_changer( - maxes.Axes.scatter - )))) - bar = _bar_wrapper(_standardize_1d(_indicate_error(_cycle_changer( - maxes.Axes.bar - )))) - barh = _barh_wrapper( - maxes.Axes.barh - ) # calls self.bar - hist = _hist_wrapper(_standardize_1d(_cycle_changer( - maxes.Axes.hist - ))) - boxplot = _boxplot_wrapper(_standardize_1d(_cycle_changer( - maxes.Axes.boxplot - ))) - violinplot = _violinplot_wrapper(_standardize_1d(_indicate_error(_cycle_changer( - maxes.Axes.violinplot - )))) - fill_between = _fill_between_wrapper(_standardize_1d(_cycle_changer( - maxes.Axes.fill_between - ))) - fill_betweenx = _fill_betweenx_wrapper(_standardize_1d(_cycle_changer( - maxes.Axes.fill_betweenx - ))) - - # Wrapped by cycle wrapper and standardized - pie = _standardize_1d(_cycle_changer( - maxes.Axes.pie - )) - step = _standardize_1d(_cycle_changer( - maxes.Axes.step - )) - - # Wrapped by standardizer - stem = _standardize_1d(_stem_wrapper( - maxes.Axes.stem - )) - hlines = _standardize_1d(_hlines_wrapper( - maxes.Axes.hlines - )) - vlines = _standardize_1d(_vlines_wrapper( - maxes.Axes.vlines - )) - - # Wrapped by cmap wrapper and standardized - # Also support redirecting to Basemap methods - hexbin = _standardize_1d(_cmap_changer( - maxes.Axes.hexbin - )) - contour = _standardize_2d(_cmap_changer( - maxes.Axes.contour - )) - contourf = _standardize_2d(_cmap_changer( - maxes.Axes.contourf - )) - pcolor = _standardize_2d(_cmap_changer( - maxes.Axes.pcolor - )) - pcolormesh = _standardize_2d(_cmap_changer( - maxes.Axes.pcolormesh - )) - pcolorfast = _standardize_2d(_cmap_changer( - maxes.Axes.pcolorfast # WARNING: not available in cartopy and basemap - )) - quiver = _standardize_2d(_cmap_changer( - maxes.Axes.quiver - )) - streamplot = _standardize_2d(_cmap_changer( - maxes.Axes.streamplot - )) - barbs = _standardize_2d(_cmap_changer( - maxes.Axes.barbs - )) - imshow = _cmap_changer( - maxes.Axes.imshow - ) - - # Wrapped only by cmap wrapper - tripcolor = _cmap_changer( - maxes.Axes.tripcolor - ) - tricontour = _cmap_changer( - maxes.Axes.tricontour - ) - tricontourf = _cmap_changer( - maxes.Axes.tricontourf - ) - hist2d = _cmap_changer( - maxes.Axes.hist2d - ) - spy = _cmap_changer( - maxes.Axes.spy - ) - matshow = _cmap_changer( - maxes.Axes.matshow - ) diff --git a/proplot/axes/geo.py b/proplot/axes/geo.py index 746067a52..b8905acc7 100644 --- a/proplot/axes/geo.py +++ b/proplot/axes/geo.py @@ -1276,8 +1276,8 @@ def __init__(self, *args, map_projection=None, **kwargs): extent = [180 - lon0, 180 + lon0, -90, 90] # fallback # Initialize axes + self._called_from_basemap = False # used to override plotting methods self._map_boundary = None # start with empty map boundary - self._has_recurred = False # use this to override plotting methods self._lonlines_major = None # store gridliner objects this way self._lonlines_minor = None self._latlines_major = None diff --git a/proplot/axes/plot.py b/proplot/axes/plot.py index 670e3606a..4b4408144 100644 --- a/proplot/axes/plot.py +++ b/proplot/axes/plot.py @@ -30,7 +30,7 @@ from .. import constructor from ..config import rc from ..internals import ic # noqa: F401 -from ..internals import _dummy_context, _not_none, _state_context, docstring, warnings +from ..internals import _not_none, _state_context, docstring, warnings from ..utils import edges, edges2d, to_rgb, to_xyz, units try: @@ -38,41 +38,131 @@ except ModuleNotFoundError: PlateCarree = object -__all__ = [ - 'bar_wrapper', - 'barh_wrapper', - 'boxplot_wrapper', - 'cmap_changer', - 'colorbar_wrapper', - 'cycle_changer', - 'default_latlon', - 'default_transform', - 'fill_between_wrapper', - 'fill_betweenx_wrapper', - # 'hist_wrapper', # very minor changes - 'hlines_wrapper', - 'indicate_error', - 'legend_wrapper', - # 'parametric_wrapper', # full documentation is on Axes method - # 'plot_wrapper', # the only functionality provided by this wrapper is deprecated - 'scatter_wrapper', - 'standardize_1d', - 'standardize_2d', - # 'stem_wrapper', # very minor changes - 'text_wrapper', - 'violinplot_wrapper', - 'vlines_wrapper', -] - -docstring.snippets['standardize.autoformat'] = """ + +docstring.snippets['plot.1d_args'] = """ +*args : (x,) or (x, y) + The data passed as positional arguments. Interpreted as follows: + + * If only *y* data are passed, try to infer the *x* data from the + `~pandas.Series` or `~pandas.DataFrame` indices or the `~xarray.DataArray` + coordinate variables. Otherwise, use ``np.arange(0, y.shape[0])``. + * If a 2D array of *y* data are passed, each column of data is + plotted in succession (except for ``boxplot`` and ``violinplot``, where + each column is interpreted as a separate distribution). + + ProPlot also tries to infer the default metadata from the *x* and *y* data + containers; see `autoformat` for details. +""" + +docstring.snippets['plot.2d_args'] = """ +*args : (z1, ...) or (x, y, z1, ...) + The data passed as positional arguments. Interpreted as follows: + + * If only *z* data are passed, try to infer the *x* and *y* data from + the `~pandas.DataFrame` indices and columns or the `~xarray.DataArray` + coordinate variable. Otherwise, use ``np.arange(0, y.shape[0])`` and + ``np.arange(0, y.shape[1])``. + * For ``pcolor`` and ``pcolormesh``, coordinate *edges* are calculated + if *centers* were provided using `~proplot.utils.edges` or + `~proplot.utils.edges2d`. For all other methods, coordinate *centers* + are calculated if *edges* were provided. +""" + +docstring.snippets['plot.2d_kwargs'] = """ +order : {{'C', 'F'}}, optional + If ``'C'``, arrays should be shaped ``(y, x)``. If ``'F'``, arrays + should be shaped ``(x, y)``. Default is ``'C'``. +globe : bool, optional + *For `~proplot.axes.GeoAxes` only*. Whether to ensure global coverage. + Default is ``False``. When ``True`` this does the following: + + #. Interpolates input data to the North and South poles by setting the data + values at the poles to the mean from latitudes nearest each pole. + #. Makes meridional coverage "circular", i.e. the last longitude coordinate + equals the first longitude coordinate plus 360\N{DEGREE SIGN}. + #. (*For `~proplot.axes.BasemapAxes` only*.) 1D longitude vectors are cycled to + fit within the map edges. For example, if the projection central longitude + is 90\N{DEGREE SIGN}, the data is shifted so that it spans -90\N{DEGREE SIGN} + to 270\N{DEGREE SIGN}. + +extend : {{'neither', 'min', 'max', 'both'}}, optional + Where to assign unique colors to out-of-bounds data and draw + "extensions" (triangles, by default) on the colorbar. +edgefix : bool, optional + Whether to fix the the `white-lines-between-filled-contours \ +`__ + and `white-lines-between-pcolor-rectangles \ +`__ + issues. This slows down figure rendering by a bit. Default is + :rc:`image.edgefix`. +labels : bool, optional + For `~matplotlib.axes.Axes.contour`, whether to add contour labels + with `~matplotlib.axes.Axes.clabel`. For `~matplotlib.axes.Axes.pcolor` + or `~matplotlib.axes.Axes.pcolormesh`, whether to add labels to the + center of grid boxes. In the latter case, the text will be black + when the luminance of the underlying grid box color is >50%%, and + white otherwise. +labels_kw : dict-like, optional + Ignored if `labels` is ``False``. Extra keyword args for the labels. + For `~matplotlib.axes.Axes.contour`, passed to + `~matplotlib.axes.Axes.clabel`. For `~matplotlib.axes.Axes.pcolor` + or `~matplotlib.axes.Axes.pcolormesh`, passed to + `~matplotlib.axes.Axes.text`. +fmt : format-spec, optional + Passed to the `~proplot.constructor.Norm` constructor, used to format + number labels. You can also use the `precision` keyword arg. +precision : int, optional + Maximum number of decimal places for the number labels. + Number labels are generated with the + `~proplot.ticker.SimpleFormatter` formatter, which allows us to + limit the precision. +lw, linewidth, linewidths + The width of `~matplotlib.axes.Axes.contour` lines and + `~proplot.axes.Axes.parametric` lines, or the width of lines + *between* `~matplotlib.axes.Axes.pcolor` boxes, + `~matplotlib.axes.Axes.pcolormesh` boxes, and + `~matplotlib.axes.Axes.contourf` filled contours. +ls, linestyle, linestyles + As above, but for the line style. +color, colors, edgecolor, edgecolors + As above, but for the line color. For `~matplotlib.axes.Axes.contourf` + plots, if you provide `colors` without specifying the `linewidths` + or `linestyles`, this argument is used to manually specify the *fill + colors*. See the `~matplotlib.axes.Axes.contourf` documentation for + details. +""" + +docstring.snippets['plot.autoformat'] = """ autoformat : bool, optional - Whether *x* axis labels, *y* axis labels, axis formatters, axes titles, - colorbar labels, and legend labels are automatically configured when - a `~pandas.Series`, `~pandas.DataFrame` or `~xarray.DataArray` is passed + Whether to automatically modify *x* axis labels, *y* axis labels, axis + formatters, axes titles, colorbar labels, and legend labels when + a `~pandas.Series`, `~pandas.DataFrame`, or `~xarray.DataArray` is passed to the plotting command. Default is :rc:`autoformat`. """ -docstring.snippets['axes.cmap_changer'] = """ +docstring.snippets['plot.auto_colorbar'] = """ +colorbar : bool, int, or str, optional + If not ``None``, this is a location specifying where to draw an *inset* + or *panel* colorbar from the resulting object. If ``True``, the + default location is used. Valid locations are described in + `~proplot.axes.Axes.colorbar`. +colorbar_kw : dict-like, optional + Ignored if `colorbar` is ``None``. Extra keyword args for our call + to `~proplot.axes.Axes.colorbar`. +""" + +docstring.snippets['plot.auto_legend'] = """ +legend : bool, int, or str, optional + If not ``None``, this is a location specifying where to draw an *inset* + or *panel* legend from the resulting handle(s). If ``True``, the + default location is used. Valid locations are described in + `~proplot.axes.Axes.legend`. +legend_kw : dict-like, optional + Ignored if `legend` is ``None``. Extra keyword args for our call + to `~proplot.axes.Axes.legend`. +""" + +docstring.snippets['plot.cmap_args'] = """ cmap : colormap spec, optional The colormap specifer, passed to the `~proplot.constructor.Colormap` constructor. @@ -113,104 +203,236 @@ Passed to `~proplot.constructor.Locator`. """ -_area_docstring = """ -Supports overlaying and stacking successive columns of data, and permits -using different colors for "negative" and "positive" regions. - -Note ----- -This function wraps `~matplotlib.axes.Axes.fill_between{suffix}` and -`~proplot.axes.Axes.area{suffix}`. - -Parameters ----------- -*args : ({y}1,), ({x}, {y}1), or ({x}, {y}1, {y}2) - The *{x}* and *{y}* coordinates. If `{x}` is not provided, it will be - inferred from `{y}1`. If `{y}1` and `{y}2` are provided, this function - will shade between respective columns of the arrays. The default value - for `{y}2` is ``0``. -stacked : bool, optional - Whether to "stack" successive columns of the `{y}1` array. If this is - ``True`` and `{y}2` was provided, it will be ignored. -negpos : bool, optional - Whether to shade where `{y}1` is greater than `{y}2` with the color - `poscolor`, and where `{y}1` is less than `{y}2` with the color - `negcolor`. For example, to shade positive values red and negative values - blue, use ``ax.fill_between{suffix}({x}, {y}, negpos=True)``. -negcolor, poscolor : color-spec, optional - Colors to use for the negative and positive shaded regions. Ignored if `negpos` - is ``False``. Defaults are :rc:`negcolor` and :rc:`poscolor`. -where : ndarray, optional - Boolean ndarray mask for points you want to shade. See `this example \ -`__. -lw, linewidth : float, optional - The edge width for the area patches. -edgecolor : color-spec, optional - The edge color for the area patches. - -Other parameters ----------------- -**kwargs - Passed to `~matplotlib.axes.Axes.fill_between`. +docstring.snippets['plot.cmap_note'] = """ +The `~proplot.colors.DiscreteNorm` normalizer, used with all colormap +plots, makes sure that your levels always span the full range of colors +in the colormap, whether `extend` is set to ``'min'``, ``'max'``, +``'neither'``, or ``'both'``. By default, when `extend` is not ``'both'``, +matplotlib seems to just cut off the most intense colors (reserved for +coloring "out of bounds" data), even though they are not being used. + +This could also be done by limiting the number of colors in the colormap +lookup table by selecting a smaller ``N`` (see +`~matplotlib.colors.LinearSegmentedColormap`). Instead, we prefer to +always build colormaps with high resolution lookup tables, and leave it +to the `~matplotlib.colors.Normalize` instance to handle discretization +of the color selections. """ -docstring.snippets['axes.fill_between'] = _area_docstring.format( - x='x', y='y', suffix='', -) -docstring.snippets['axes.fill_betweenx'] = _area_docstring.format( - x='y', y='x', suffix='x', -) - -_bar_docstring = """ -Supports grouping and stacking successive columns of data, and changes -the default bar style. - -Note ----- -This function wraps `~matplotlib.axes.Axes.bar{suffix}`. - -Parameters ----------- -{x}, {height}, width, {bottom} : float or list of float, optional - The dimensions of the bars. If the *{x}* coordinates are not provided, - they are set to ``np.arange(0, len(height))``. Note that the units - for `width` are now *relative*. -orientation : {{'vertical', 'horizontal'}}, optional - The orientation of the bars. -vert : bool, optional - Alternative to the `orientation` keyword arg. If ``False``, horizontal - bars are drawn. This is for consistency with - `~matplotlib.axes.Axes.boxplot` and `~matplotlib.axes.Axes.violinplot`. -stacked : bool, optional - Whether to stack columns of input data, or plot the bars side-by-side. -negpos : bool, optional - Whether to shade bars greater than zero with `poscolor` and bars less - than zero with `negcolor`. -negcolor, poscolor : color-spec, optional - Colors to use for the negative and positive bars. Ignored if `negpos` - is ``False``. Defaults are :rc:`negcolor` and :rc:`poscolor`. -lw, linewidth : float, optional - The edge width for the bar patches. -edgecolor : color-spec, optional - The edge color for the bar patches. - -Other parameters ----------------- -**kwargs - Passed to `~matplotlib.axes.Axes.bar{suffix}`. + +docstring.snippets['plot.cycle_args'] = """ +cycle : cycle-spec, optional + The cycle specifer, passed to the `~proplot.constructor.Cycle` + constructor. If the returned list of colors is unchanged from the + current axes color cycler, the axes cycle will **not** be reset to the + first position. +cycle_kw : dict-like, optional + Passed to `~proplot.constructor.Cycle`. +label : float or str, optional + The legend label to be used for this plotted element. +labels, values : list of float or list of str, optional + Used with 2D input arrays. The legend labels or colorbar coordinates + for each column in the array. Can be numeric or string, and must match + the number of columns in the 2D array. +errobjs : `~matplotlib.artist.Artist` or list thereof, optional + Error bar objects to add to the legend. This is used internally and + should not be necessary for users. See `indicate_error`. +""" + +docstring.snippets['plot.colorbar_args'] = """ +mappable : mappable, list of plot handles, list of color-spec, or colormap-spec + There are four options here: + + 1. A mappable object. Basically, any object with a ``get_cmap`` method, + like the objects returned by `~matplotlib.axes.Axes.contourf` and + `~matplotlib.axes.Axes.pcolormesh`. + 2. A list of "plot handles". Basically, any object with a ``get_color`` + method, like `~matplotlib.lines.Line2D` instances. A colormap will + be generated from the colors of these objects, and colorbar levels + will be selected using `values`. If `values` is ``None``, we try + to infer them by converting the handle labels returned by + `~matplotlib.artist.Artist.get_label` to `float`. Otherwise, it is + set to ``np.linspace(0, 1, len(mappable))``. + 3. A list of hex strings, color string names, or RGB tuples. A colormap + will be generated from these colors, and colorbar levels will be + selected using `values`. If `values` is ``None``, it is set to + ``np.linspace(0, 1, len(mappable))``. + 4. A `~matplotlib.colors.Colormap` instance. In this case, a colorbar + will be drawn using this colormap and with levels determined by + `values`. If `values` is ``None``, it is set to + ``np.linspace(0, 1, cmap.N)``. + +values : list of float, optional + Ignored if `mappable` is a mappable object. This maps each color or + plot handle in the `mappable` list to numeric values, from which a + colormap and normalizer are constructed. +norm : normalizer spec, optional + Ignored if `values` is ``None``. The normalizer for converting `values` + to colormap colors. Passed to `~proplot.constructor.Norm`. +norm_kw : dict-like, optional + The normalizer settings. Passed to `~proplot.constructor.Norm`. +""" + +docstring.snippets['plot.colorbar_kwargs'] = """ +extend : {None, 'neither', 'both', 'min', 'max'}, optional + Direction for drawing colorbar "extensions" (i.e. references to + out-of-bounds data with a unique color). These are triangles by + default. If ``None``, we try to use the ``extend`` attribute on the + mappable object. If the attribute is unavailable, we use ``'neither'``. +extendsize : float or str, optional + The length of the colorbar "extensions" in *physical units*. + If float, units are inches. If string, units are interpreted + by `~proplot.utils.units`. Default is :rc:`colorbar.insetextend` + for inset colorbars and :rc:`colorbar.extend` for outer colorbars. +reverse : bool, optional + Whether to reverse the direction of the colorbar. +tickloc, ticklocation : {'bottom', 'top', 'left', 'right'}, optional + Where to draw tick marks on the colorbar. +tickminor : bool, optional + Whether to add minor ticks to the colorbar with + `~matplotlib.colorbar.ColorbarBase.minorticks_on`. +grid : bool, optional + Whether to draw "gridlines" between each level of the colorbar. + Default is :rc:`colorbar.grid`. +label, title : str, optional + The colorbar label. The `title` keyword is also accepted for + consistency with `legend`. +locator, ticks : locator spec, optional + Used to determine the colorbar tick positions. Passed to the + `~proplot.constructor.Locator` constructor. +maxn : int, optional + Used if `locator` is ``None``. Determines the maximum number of levels + that are ticked. Default depends on the colorbar length relative + to the font size. The keyword name "maxn" is meant to mimic + the `~matplotlib.ticker.MaxNLocator` class name. +locator_kw : dict-like, optional + The locator settings. Passed to `~proplot.constructor.Locator`. +minorlocator, minorticks, maxn_minor, minorlocator_kw + As with `locator`, `maxn`, and `locator_kw`, but for the minor ticks. +formatter, ticklabels : formatter spec, optional + The tick label format. Passed to the `~proplot.constructor.Formatter` + constructor. +formatter_kw : dict-like, optional + The formatter settings. Passed to `~proplot.constructor.Formatter`. +rotation : float, optional + The tick label rotation. Default is ``0``. +edgecolor, linewidth : optional + The edge color and line width for the colorbar outline. +labelsize, labelweight, labelcolor : optional + The font size, weight, and color for colorbar label text. +ticklabelsize, ticklabelweight, ticklabelcolor : optional + The font size, weight, and color for colorbar tick labels. +orientation : {{'horizontal', 'vertical'}}, optional + The colorbar orientation. You should not have to explicitly set this. +""" + +docstring.snippets['plot.error_args'] = """ +means : bool, optional + Whether to plot the means of each column in the input data. +medians : bool, optional + Whether to plot the medians of each column in the input data. +barstds : (float, float) or bool, optional + Standard deviation multiples for *thin error bars* with optional whiskers + (i.e. caps). If ``True``, the default standard deviation multiples ``(-3, 3)`` + are used. This argument is only valid if `means` or `medians` is ``True``. +barpctiles : (float, float) or bool, optional + As with `barstds`, but instead using *percentiles* for the error bars. + The percentiles are calculated with `numpy.percentile`. If ``True``, the + default percentiles ``(0, 100)`` are used. +bardata : 2 x N array or 1D array, optional + If shape is 2 x N these are the lower and upper bounds for the thin error bars. + If array is 1D these are the absolute, symmetric deviations from the central + points. This should be used if `means` and `medians` are both ``False`` (i.e. + you did not provide dataset columns from which statistical properties can be + calculated automatically). +boxstds, boxpctiles, boxdata : optional + As with `barstds`, `barpctiles`, and `bardata`, but for *thicker error bars* + representing a smaller interval than the thin error bars. If `boxstds` is + ``True``, the default standard deviation multiples ``(-1, 1)`` are used. + If `boxpctiles` is ``True``, the default percentile multiples ``(25, 75)`` + are used (i.e. the interquartile range). When boxes and bars are combined, this + has the effect of drawing miniature box-and-whisker plots. +shadestds, shadepctiles, shadedata : optional + As with `barstds`, `barpctiles`, and `bardata`, but using *shading* to indicate + the error range. If `shadestds` is ``True``, the default standard deviation + multiples ``(-2, 2)`` are used. If `shadepctiles` is ``True``, the default + percentile multiples ``(10, 90)`` are used. Shading is generally useful for + `~matplotlib.axes.Axes.plot` plots and not `~matplotlib.axes.Axes.bar` plots. +fadestds, fadepctiles, fadedata : optional + As with `shadestds`, `shadepctiles`, and `shadedata`, but for an additional, + more faded, *secondary* shaded region. If `fadestds` is ``True``, the default + standard deviation multiples ``(-3, 3)`` are used. If `fadepctiles` is ``True``, + the default percentile multiples ``(0, 100)`` are used. +barcolor, boxcolor, shadecolor, fadecolor : color-spec, optional + Colors for the different error indicators. For error bars, the default is + ``'k'``. For shading, the default behavior is to inherit color from the + primary `~matplotlib.artist.Artist`. +shadelabel, fadelabel : bool or str, optional + Labels for the shaded regions to be used as separate legend entries. To toggle + labels "on" and apply a *default* label, use e.g. ``shadelabel=True``. To apply + a *custom* label, use e.g. ``shadelabel='label'``. Otherwise, the shading is + drawn underneath the line and/or marker in the legend entry. +barlinewidth, boxlinewidth, barlw, boxlw : float, optional + Line widths for the thin and thick error bars, in points. The defaults + are ``barlw=0.8`` and ``boxlw=4 * barlw``. +boxmarker : bool, optional + Whether to draw a small marker in the middle of the box denoting the mean or + median position. Ignored if `boxes` is ``False``. Default is ``True``. +boxmarkercolor : color-spec, optional + Color for the `boxmarker` marker. Default is ``'w'``. +capsize : float, optional + The cap size for thin error bars in points. +barzorder, boxzorder, shadezorder, fadezorder : float, optional + The "zorder" for the thin error bars, thick error bars, and shading. """ -docstring.snippets['axes.bar'] = _bar_docstring.format( - x='x', height='height', bottom='bottom', suffix='', -) -docstring.snippets['axes.barh'] = _bar_docstring.format( - x='y', height='right', bottom='left', suffix='h', -) - -docstring.snippets['axes.lines'] = """ + +docstring.snippets['plot.legend_args'] = """ +handles : list of `~matplotlib.artist.Artist`, optional + List of artists instances, or list of lists of artist instances (see + the `center` keyword). If ``None``, the artists are retrieved with + `~matplotlib.axes.Axes.get_legend_handles_labels`. +labels : list of str, optional + Matching list of string labels, or list of lists of string labels (see + the `center` keywod). If ``None``, the labels are retrieved by calling + `~matplotlib.artist.Artist.get_label` on each + `~matplotlib.artist.Artist` in `handles`. +""" + +docstring.snippets['legend_kwargs'] = """ +ncol, ncols : int, optional + The number of columns. `ncols` is an alias, added + for consistency with `~matplotlib.pyplot.subplots`. +order : {'C', 'F'}, optional + Whether legend handles are drawn in row-major (``'C'``) or column-major + (``'F'``) order. Analagous to `numpy.array` ordering. For some reason + ``'F'`` was the original matplotlib default. Default is ``'C'``. +center : bool, optional + Whether to center each legend row individually. If ``True``, we + actually draw successive single-row legends stacked on top of each + other. + + If ``None``, we infer this setting from `handles`. Default is ``True`` + if `handles` is a list of lists; each sublist is used as a *row* + in the legend. Otherwise, default is ``False``. +label, title : str, optional + The legend title. The `label` keyword is also accepted, for consistency + with `colorbar`. +fontsize, fontweight, fontcolor : optional + The font size, weight, and color for legend text. +color, lw, linewidth, linestyle, dashes, marker, markersize : property-spec, optional + Properties used to override the legend handles. For example, if you + want a legend that describes variations in line style ignoring + variations in color, you might want to use ``color='k'``. For now this + does not include `facecolor`, `edgecolor`, and `alpha`, because + `~matplotlib.axes.Axes.legend` uses these keyword args to modify the + frame properties. +""" + +docstring.snippets['plot.negpos_args'] = """ negpos : bool, optional - Whether to color lines greater than zero with `poscolor` and lines less - than zero with `negcolor`. + Whether to color regions greater than zero with `poscolor` and + regions less than zero with `negcolor`. negcolor, poscolor : color-spec, optional - Colors to use for the negative and positive lines. Ignored if `negpos` + Colors to use for the negative and positive regions. Ignored if `negpos` is ``False``. Defaults are :rc:`negcolor` and :rc:`poscolor`. """ @@ -222,48 +444,50 @@ def _concatenate_docstrings(func): that ProPlot documentation has no "other parameters", notes, or examples sections. """ - # NOTE: Originally had idea to use numpydoc.docscrape.NumpyDocString to - # interpolate docstrings but *enormous* number of assupmtions would go into - # this. And simple is better than complex. + # NOTE: Originally had idea to use numpydoc.docscrape.NumpyDocString to interpolate + # docstrings but *enormous* number of assupmtions would go into this. # Get matplotlib axes func - # If current func has no docstring just blindly copy matplotlib one name = func.__name__ - orig = getattr(maxes.Axes, name) - odoc = inspect.getdoc(orig) - if not odoc: # should never happen + func_orig = getattr(maxes.Axes, name, None) + if not func_orig: # should never happen + return func + doc_orig = inspect.getdoc(func_orig) + if not doc_orig: # should never happen return func - # Prepend summary and potentially bail - # TODO: Does this break anything on sphinx website? - fdoc = inspect.getdoc(func) or '' # also dedents - regex = re.search(r'\.( | *\n|\Z)', odoc) + # Prepend summary + doc = inspect.getdoc(func) or '' # also dedents + regex = re.search(r'\.( | *\n|\Z)', doc_orig) if regex: - fdoc = odoc[:regex.start() + 1] + '\n\n' + fdoc + doc = doc_orig[:regex.start() + 1] + '\n\n' + doc + + # Do not concatenate when running sphinx if rc['docstring.hardcopy']: # True when running sphinx - func.__doc__ = fdoc + func.__doc__ = doc return func # Obfuscate signature by converting to *args **kwargs. Note this does # not change behavior of function! Copy parameters from a dummy function # because I'm too lazy to figure out inspect.Parameters API # See: https://stackoverflow.com/a/33112180/4970632 - dsig = inspect.signature(lambda *args, **kwargs: None) - fsig = inspect.signature(func) + sig = inspect.signature(func) + sig_obfuscated = inspect.signature(lambda *args, **kwargs: None) func.__signature__ = ( - fsig.replace(parameters=tuple(dsig.parameters.values())) + sig.replace(parameters=tuple(sig_obfuscated.parameters.values())) ) # Concatenate docstrings and copy summary # Make sure different sections are very visible + nequal = '=' * len(name) doc = f""" -================================{'=' * len(name)} +================================{nequal} proplot.axes.Axes.{name} documentation -================================{'=' * len(name)} -{fdoc} -==================================={'=' * len(name)} +================================{nequal} +{doc} +==================================={nequal} matplotlib.axes.Axes.{name} documentation -==================================={'=' * len(name)} -{odoc} +==================================={nequal} +{doc_orig} """ func.__doc__ = doc @@ -289,7 +513,7 @@ def _load_objects(): _load_objects() -# Make keywords for styling cmap_changer-overridden plots *consistent* +# Make keywords for styling cmap_args-overridden plots *consistent* # TODO: Consider deprecating linewidth and linestyle interpretation. Think # these already have flexible interpretation for all plotting funcs. STYLE_ARGS_TRANSLATE = { @@ -407,37 +631,33 @@ def _to_ndarray(data): return np.atleast_1d(getattr(data, 'values', data)) -def default_latlon(self, func, *args, latlon=True, **kwargs): +def _default_latlon(self, func, *args, latlon=None, **kwargs): """ Makes ``latlon=True`` the default for basemap plots. This means you no longer have to pass ``latlon=True`` if your data coordinates are longitude and latitude. - - Note - ---- - This function wraps {methods} for `~proplot.axes.BasemapAxes`. """ - return func(self, *args, latlon=latlon, **kwargs) + @functools.wraps(func) + def wrapper(*args, latlon=None, **kwargs): + if latlon is None: + latlon = True + return func(*args, latlon=latlon, **kwargs) + return wrapper -def default_transform(self, func, *args, transform=None, **kwargs): +def _default_transform(self, func, *args, transform=None, **kwargs): """ Makes ``transform=cartopy.crs.PlateCarree()`` the default for cartopy plots. This means you no longer have to pass ``transform=cartopy.crs.PlateCarree()`` if your data coordinates are longitude and latitude. - - Note - ---- - This function wraps {methods} for `~proplot.axes.CartopyAxes`. """ - # Apply default transform - # TODO: Do some cartopy methods reset backgroundpatch or outlinepatch? - # Deleted comment reported this issue - if transform is None: - transform = PlateCarree() - result = func(self, *args, transform=transform, **kwargs) - return result + @functools.wraps(func) + def wrapper(*args, transform=None, **kwargs): + if transform is None: + transform = PlateCarree() + return func(*args, transform=transform, **kwargs) + return wrapper def _axis_labels_title(data, axis=None, units=True): @@ -483,31 +703,9 @@ def _axis_labels_title(data, axis=None, units=True): return data, str(label).strip() -@docstring.add_snippets -def standardize_1d(self, func, *args, autoformat=None, **kwargs): +def _parse_1d(self, func, *args, autoformat=None, **kwargs): """ - Interpret positional arguments for the "1D" plotting methods so usage is - consistent. Positional arguments are standardized as follows: - - * If a 2D array is passed, the corresponding plot command is called for - each column of data (except for ``boxplot`` and ``violinplot``, in which - case each column is interpreted as a distribution). - * If *x* and *y* or *latitude* and *longitude* coordinates were not - provided, and a `~pandas.DataFrame` or `~xarray.DataArray`, we - try to infer them from the metadata. Otherwise, - ``np.arange(0, data.shape[0])`` is used. - - Parameters - ---------- - %(standardize.autoformat)s - - See also - -------- - cycle_changer - - Note - ---- - This function wraps {methods} + Standardize the positional arguments for 1D data. """ # Sanitize input # TODO: Add exceptions for methods other than 'hist'? @@ -689,48 +887,11 @@ def _interp_poles(y, Z): return y, Z -@docstring.add_snippets -def standardize_2d( +def _parse_2d( self, func, *args, autoformat=None, order='C', globe=False, **kwargs ): """ - Interpret positional arguments for the "2D" plotting methods so usage is - consistent. Positional arguments are standardized as follows: - - * If *x* and *y* or *latitude* and *longitude* coordinates were not - provided, and a `~pandas.DataFrame` or `~xarray.DataArray` is passed, we - try to infer them from the metadata. Otherwise, ``np.arange(0, data.shape[0])`` - and ``np.arange(0, data.shape[1])`` are used. - * For ``pcolor`` and ``pcolormesh``, coordinate *edges* are calculated - if *centers* were provided. For all other methods, coordinate *centers* - are calculated if *edges* were provided. - - Parameters - ---------- - %(standardize.autoformat)s - order : {{'C', 'F'}}, optional - If ``'C'``, arrays should be shaped ``(y, x)``. If ``'F'``, arrays - should be shaped ``(x, y)``. Default is ``'C'``. - globe : bool, optional - Whether to ensure global coverage for `~proplot.axes.GeoAxes` plots. - Default is ``False``. When set to ``True`` this does the following: - - #. Interpolates input data to the North and South poles by setting the data - values at the poles to the mean from latitudes nearest each pole. - #. Makes meridional coverage "circular", i.e. the last longitude coordinate - equals the first longitude coordinate plus 360\N{DEGREE SIGN}. - #. For `~proplot.axes.BasemapAxes`, 1D longitude vectors are also cycled to - fit within the map edges. For example, if the projection central longitude - is 90\N{DEGREE SIGN}, the data is shifted so that it spans -90\N{DEGREE SIGN} - to 270\N{DEGREE SIGN}. - - See also - -------- - cmap_changer - - Note - ---- - This function wraps {methods} + Standardize the positional arguments for 2D data. """ # Sanitize input name = func.__name__ @@ -850,7 +1011,7 @@ def standardize_2d( # Auto axes title and colorbar label # NOTE: Do not overwrite existing title! # NOTE: Must apply default colorbar label *here* rather than in - # cmap_changer in case metadata is stripped by globe=True. + # cmap_args in case metadata is stripped by globe=True. colorbar_kw = kwargs.pop('colorbar_kw', None) or {} if autoformat: _, colorbar_label = _axis_labels_title(Zs[0], units=True) @@ -1079,7 +1240,7 @@ def _get_error_data( return err, label -def _deprecate_add_errorbars(func): +def _deprecate_indicate_error(func): """ Translate old-style keyword arguments to new-style in way that is too complex for _rename_kwargs. Use a decorator to avoid call signature pollution. @@ -1109,8 +1270,8 @@ def wrapper( return wrapper -@_deprecate_add_errorbars -def indicate_error( +@_deprecate_indicate_error +def _indicate_error( self, func, *args, medians=False, means=False, boxdata=None, bardata=None, shadedata=None, fadedata=None, @@ -1124,83 +1285,7 @@ def indicate_error( **kwargs ): """ - Adds support for drawing error bars and error shading on-the-fly. - Includes options for interpreting columns of data as *samples*, - representing the mean or median of each sample with lines, points, or - bars, and drawing error bars representing percentile ranges or standard - deviation multiples for each sample. Also supports specifying error - bar data explicitly. - - Note - ---- - This function wraps {methods} - - Parameters - ---------- - *args - The input data. - means : bool, optional - Whether to plot the means of each column in the input data. - medians : bool, optional - Whether to plot the medians of each column in the input data. - barstds : (float, float) or bool, optional - Standard deviation multiples for *thin error bars* with optional whiskers - (i.e. caps). If ``True``, the default standard deviation multiples ``(-3, 3)`` - are used. This argument is only valid if `means` or `medians` is ``True``. - barpctiles : (float, float) or bool, optional - As with `barstds`, but instead using *percentiles* for the error bars. - The percentiles are calculated with `numpy.percentile`. If ``True``, the - default percentiles ``(0, 100)`` are used. - bardata : 2 x N array or 1D array, optional - If shape is 2 x N these are the lower and upper bounds for the thin error bars. - If array is 1D these are the absolute, symmetric deviations from the central - points. This should be used if `means` and `medians` are both ``False`` (i.e. - you did not provide dataset columns from which statistical properties can be - calculated automatically). - boxstds, boxpctiles, boxdata : optional - As with `barstds`, `barpctiles`, and `bardata`, but for *thicker error bars* - representing a smaller interval than the thin error bars. If `boxstds` is - ``True``, the default standard deviation multiples ``(-1, 1)`` are used. - If `boxpctiles` is ``True``, the default percentile multiples ``(25, 75)`` - are used (i.e. the interquartile range). When boxes and bars are combined, this - has the effect of drawing miniature box-and-whisker plots. - shadestds, shadepctiles, shadedata : optional - As with `barstds`, `barpctiles`, and `bardata`, but using *shading* to indicate - the error range. If `shadestds` is ``True``, the default standard deviation - multiples ``(-2, 2)`` are used. If `shadepctiles` is ``True``, the default - percentile multiples ``(10, 90)`` are used. Shading is generally useful for - `~matplotlib.axes.Axes.plot` plots and not `~matplotlib.axes.Axes.bar` plots. - fadestds, fadepctiles, fadedata : optional - As with `shadestds`, `shadepctiles`, and `shadedata`, but for an additional, - more faded, *secondary* shaded region. If `fadestds` is ``True``, the default - standard deviation multiples ``(-3, 3)`` are used. If `fadepctiles` is ``True``, - the default percentile multiples ``(0, 100)`` are used. - barcolor, boxcolor, shadecolor, fadecolor : color-spec, optional - Colors for the different error indicators. For error bars, the default is - ``'k'``. For shading, the default behavior is to inherit color from the - primary `~matplotlib.artist.Artist`. - shadelabel, fadelabel : bool or str, optional - Labels for the shaded regions to be used as separate legend entries. To toggle - labels "on" and apply a *default* label, use e.g. ``shadelabel=True``. To apply - a *custom* label, use e.g. ``shadelabel='label'``. Otherwise, the shading is - drawn underneath the line and/or marker in the legend entry. - barlinewidth, boxlinewidth, barlw, boxlw : float, optional - Line widths for the thin and thick error bars, in points. The defaults - are ``barlw=0.8`` and ``boxlw=4 * barlw``. - boxmarker : bool, optional - Whether to draw a small marker in the middle of the box denoting the mean or - median position. Ignored if `boxes` is ``False``. Default is ``True``. - boxmarkercolor : color-spec, optional - Color for the `boxmarker` marker. Default is ``'w'``. - capsize : float, optional - The cap size for thin error bars in points. - barzorder, boxzorder, shadezorder, fadezorder : float, optional - The "zorder" for the thin error bars, thick error bars, and shading. - - Returns - ------- - h, err1, err2, ... - The original plot object and the error bar or shading objects. + Add error bars and/or error shading on-the-fly. """ name = func.__name__ x, data, *args = args @@ -1369,613 +1454,6 @@ def indicate_error( return result -def parametric_wrapper(self, func, *args, interp=0, **kwargs): - """ - Calls `~proplot.axes.Axes.parametric` and optionally interpolates values before - they get passed to `cmap_changer` and the colormap boundaries are drawn. - """ - # Parse input arguments - # NOTE: This wrapper is required so that - # WARNING: So far this only works for 1D *x* and *y* coordinates. - # Cannot draw multiple colormap lines at once - if len(args) == 3: - x, y, values = args - elif 'values' in kwargs: - values = kwargs.pop('values') - if len(args) == 1: - y = np.asarray(args[0]) - x = np.arange(y.shape[-1]) - elif len(args) == 2: - x, y = args - else: - raise ValueError(f'1 to 3 positional arguments required, got {len(args)}.') - else: - raise ValueError('Missing required keyword argument "values".') - x, y, values = np.atleast_1d(x), np.atleast_1d(y), np.atleast_1d(values) - if ( - any(_.ndim != 1 for _ in (x, y, values)) - or len({x.size, y.size, values.size}) > 1 - ): - raise ValueError( - f'x {x.shape}, y {y.shape}, and values {values.shape} ' - 'must be 1-dimensional and have the same size.' - ) - - # Interpolate values to allow for smooth gradations between values - # (interp=False) or color switchover halfway between points - # (interp=True). Then optionally interpolate the colormap values. - if interp > 0: - xorig, yorig, vorig = x, y, values - x, y, values = [], [], [] - for j in range(xorig.shape[0] - 1): - idx = slice(None) - if j + 1 < xorig.shape[0] - 1: - idx = slice(None, -1) - x.extend(np.linspace(xorig[j], xorig[j + 1], interp + 2)[idx].flat) - y.extend(np.linspace(yorig[j], yorig[j + 1], interp + 2)[idx].flat) - values.extend(np.linspace(vorig[j], vorig[j + 1], interp + 2)[idx].flat) - x, y, values = np.array(x), np.array(y), np.array(values) - - # Call main function - return func(self, x, y, values=values, **kwargs) - - -def stem_wrapper( - self, func, *args, linefmt=None, basefmt=None, markerfmt=None, **kwargs -): - """ - Make `use_line_collection` the default to suppress annoying warning message. - """ - # Set default colors - # NOTE: 'fmt' strings can only be 2 to 3 characters and include color shorthands - # like 'r' or cycle colors like 'C0'. Cannot use full color names. - # NOTE: Matplotlib defaults try to make a 'reddish' color the base and 'bluish' - # color the stems. To make this more robust we temporarily replace the cycler - # with a negcolor/poscolor cycler, otherwise try to point default colors to the - # blush 'C0' and reddish 'C1' from the new default 'colorblind' cycler. - if not any( - isinstance(fmt, str) and re.match(r'\AC[0-9]', fmt) - for fmt in (linefmt, basefmt, markerfmt) - ): - cycle = constructor.Cycle((rc['negcolor'], rc['poscolor']), name='_neg_pos') - context = rc.context({'axes.prop_cycle': cycle}) - else: - context = _dummy_context() - - # Add stem lines with bluish stem color and reddish base color - with context: - kwargs['linefmt'] = _not_none(linefmt, 'C0-') - kwargs['basefmt'] = _not_none(basefmt, 'C1-') - kwargs['markerfmt'] = _not_none(markerfmt, linefmt[:-1] + 'o') - kwargs.setdefault('use_line_collection', True) - try: - return func(self, *args, **kwargs) - except TypeError: - kwargs.pop('use_line_collection') # old version - return func(self, *args, **kwargs) - - -def _draw_lines( - self, func, *args, negpos=False, negcolor=None, poscolor=None, **kwargs -): - """ - Parse lines arguments. Support automatic *x* coordinates and default - "minima" at zero. - """ - # Parse positional arguments, use default "base" position of zero - x = 'x' if func.__name__ == 'vlines' else 'y' - y = 'y' if x == 'x' else 'x' - args = list(args) - if x in kwargs: - args.insert(0, kwargs.pop(x)) - for suffix in ('min', 'max'): - key = y + suffix - if key in kwargs: - args.append(kwargs.pop(key)) - if len(args) == 1: - x = np.arange(len(np.atleast_1d(args[0]))) - args.insert(0, x) - if len(args) == 2: - args.insert(1, 0.0) - elif len(args) != 3: - raise TypeError('lines() requires 1 to 3 positional arguments.') - - # Support "negative" and "positive" lines - x, y1, y2 = args - if negpos and kwargs.get('color', None) is None: - y1 = _to_arraylike(y1) - y2 = _to_arraylike(y2) - y1array = _to_ndarray(y1) - y2array = _to_ndarray(y2) - - # Negative colors - mask = y2array >= y1array # positive - y1neg = y1.copy() - y2neg = y2.copy() - if mask.size == 1: - if mask.item(): - y1neg = y2neg = np.nan - else: - if y1.size > 1: - _to_indexer(y1neg)[mask] = np.nan - if y2.size > 1: - _to_indexer(y2neg)[mask] = np.nan - color = _not_none(negcolor, rc['negcolor']) - negobj = func(self, x, y1neg, y2neg, color=color, **kwargs) - - # Positive colors - mask = y2array < y1array # negative - y1pos = y1.copy() - y2pos = y2.copy() - if mask.size == 1: - if mask.item(): - y1pos = y2pos = np.nan - else: - if y1.size > 1: - _to_indexer(y1pos)[mask] = np.nan - if y2.size > 1: - _to_indexer(y2pos)[mask] = np.nan - color = _not_none(poscolor, rc['poscolor']) - posobj = func(self, x, y1pos, y2pos, color=color, **kwargs) - - # Return both objects - return (negobj, posobj) - else: - return func(self, x, y1, y2, **kwargs) - - -@docstring.add_snippets -def hlines_wrapper(self, func, *args, **kwargs): - """ - Plot horizontal lines with flexible positional arguments and optionally - use different colors for "negative" and "positive" lines. - - Parameters - ---------- - %(axes.lines)s - - Note - ---- - This function wraps {methods} - """ - return _draw_lines(self, func, *args, **kwargs) - - -@docstring.add_snippets -def vlines_wrapper(self, func, *args, **kwargs): - """ - Plot vertical lines with flexible positional arguments and optionally - use different colors for "negative" and "positive" lines. - - Parameters - ---------- - %(axes.lines)s - - Note - ---- - This function wraps {methods} - """ - return _draw_lines(self, func, *args, **kwargs) - - -def _fill_between_apply( - self, func, *args, - negcolor=None, poscolor=None, negpos=False, - lw=None, linewidth=None, - **kwargs -): - """ - Helper function that powers `fill_between` and `fill_betweenx`. - """ - # Parse input arguments as follows: - # * Permit using 'x', 'y1', and 'y2' or 'y', 'x1', and 'x2' as - # keyword arguments. - # * When negpos is True, instead of using fill_between(x, y1, y2=0) as default, - # make the default fill_between(x, y1=0, y2). - x = 'y' if 'x' in func.__name__ else 'x' - y = 'x' if x == 'y' else 'y' - args = list(args) - if x in kwargs: # keyword 'x' - args.insert(0, kwargs.pop(x)) - if len(args) == 1: - args.insert(0, np.arange(len(args[0]))) - for yi in (y + '1', y + '2'): - if yi in kwargs: # keyword 'y' - args.append(kwargs.pop(yi)) - if len(args) == 2: - args.append(0) - elif len(args) == 3: - if kwargs.get('stacked', False): - warnings._warn_proplot( - f'{func.__name__} cannot have three positional arguments ' - f'with negpos=True. Ignoring third argument.' - ) - else: - raise ValueError(f'Expected 2-3 positional args, got {len(args)}.') - - # Modify default properties - # Set default edge width for patches to zero - kwargs['linewidth'] = _not_none(lw=lw, linewidth=linewidth, default=0) - - # Draw patches - xv, y1, y2 = args - xv = _to_arraylike(xv) - y1 = _to_arraylike(y1) - y2 = _to_arraylike(y2) - if negpos and kwargs.get('color', None) is None: - # Plot negative and positive patches - name = func.__name__ - message = name + ' argument {}={!r} is incompatible with negpos=True. Ignoring.' - where = kwargs.pop('where', None) - if where is not None: - warnings._warn_proplot(message.format('where', where)) - stacked = kwargs.pop('stacked', None) - if stacked: - warnings._warn_proplot(message.format('stacked', stacked)) - kwargs.setdefault('interpolate', True) - if np.asarray(y1).ndim > 1 or np.asarray(y2).ndim > 2: - raise ValueError(f'{name} arguments with negpos=True must be 1D.') - where1 = y1 < y2 - where2 = y1 >= y2 - negcolor = _not_none(negcolor, rc['negcolor']) - poscolor = _not_none(poscolor, rc['poscolor']) - obj1 = func(self, xv, y1, y2, where=where1, color=negcolor, **kwargs) - obj2 = func(self, xv, y1, y2, where=where2, color=poscolor, **kwargs) - result = objs = (obj1, obj2) # may be tuple of tuples due to cycle_changer - - else: - # Plot basic patches - result = func(self, xv, y1, y2, **kwargs) - objs = (result,) - - # Add sticky edges in x-direction, and sticky edges in y-direction *only* - # if one of the y limits is scalar. This should satisfy most users. - xsides = (np.min(xv), np.max(xv)) - ysides = [] - if y1.size == 1: - ysides.append(np.asarray(y1).item()) - if y2.size == 1: - ysides.append(np.asarray(y2).item()) - for iobjs in objs: - if not isinstance(iobjs, tuple): - iobjs = (iobjs,) - for obj in iobjs: - getattr(obj.sticky_edges, x).extend(xsides) - getattr(obj.sticky_edges, y).extend(ysides) - - return result - - -@docstring.add_snippets -def fill_between_wrapper(self, func, *args, **kwargs): - """ - %(axes.fill_between)s - """ - return _fill_between_apply(self, func, *args, **kwargs) - - -@docstring.add_snippets -def fill_betweenx_wrapper(self, func, *args, **kwargs): - """ - %(axes.fill_betweenx)s - """ - return _fill_between_apply(self, func, *args, **kwargs) - - -def hist_wrapper(self, func, x, bins=None, **kwargs): - """ - Forces `bar_wrapper` to interpret `width` as literal rather than relative - to step size and enforces all arguments after `bins` are keyword-only. - """ - with _state_context(self, _absolute_bar_width=True): - return func(self, x, bins=bins, **kwargs) - - -@docstring.add_snippets -def bar_wrapper( - self, func, x=None, height=None, width=0.8, bottom=None, *, - vert=None, orientation='vertical', stacked=False, - lw=None, linewidth=None, edgecolor='black', - negpos=False, negcolor=None, poscolor=None, - **kwargs -): - """ - %(axes.bar)s - """ - # Parse arguments - # WARNING: Implementation is really weird... we flip around arguments for horizontal - # plots only to flip them back in cycle_changer when iterating through columns. - if vert is not None: - orientation = 'vertical' if vert else 'horizontal' - if orientation == 'horizontal': - x, bottom = bottom, x - width, height = height, width - - # Parse args - # TODO: Stacked feature is implemented in `cycle_changer`, but makes more - # sense do document here; figure out way to move it here? - if kwargs.get('left', None) is not None: - warnings._warn_proplot('bar() keyword "left" is deprecated. Use "x" instead.') - x = kwargs.pop('left') - if x is None and height is None: - raise ValueError('bar() requires at least 1 positional argument, got 0.') - elif height is None: - x, height = None, x - args = (x, height) - linewidth = _not_none(lw=lw, linewidth=linewidth, default=rc['patch.linewidth']) - kwargs.update({ - 'width': width, 'bottom': bottom, 'stacked': stacked, - 'orientation': orientation, 'linewidth': linewidth, 'edgecolor': edgecolor, - }) - - # Call func - # NOTE: This *must* also be wrapped by cycle_changer, which ultimately - # permutes back the x/bottom args for horizontal bars! Need to clean up. - if negpos and kwargs.get('color', None) is None: - # Draw negative and positive bars - # NOTE: cycle_changer makes bar widths *relative* to step size between - # x coordinates to cannot just omit data. Instead make some height nan. - message = 'bar() argument {}={!r} is incompatible with negpos=True. Ignoring.' - stacked = kwargs.pop('stacked', None) - if stacked: - warnings._warn_proplot(message.format('stacked', stacked)) - height = np.asarray(height) - if height.ndim > 1: - raise ValueError('bar() heights with negpos=True must be 1D.') - height1 = height.copy().astype(np.float64) - height1[height >= 0] = np.nan - height2 = height.copy().astype(np.float64) - height2[height < 0] = np.nan - negcolor = _not_none(negcolor, rc['negcolor']) - poscolor = _not_none(poscolor, rc['poscolor']) - obj1 = func(self, x, height1, color=negcolor, **kwargs) - obj2 = func(self, x, height2, color=poscolor, **kwargs) - result = (obj1, obj2) - else: - # Draw simple bars - result = func(self, *args, **kwargs) - return result - - -@docstring.add_snippets -def barh_wrapper(self, func, y=None, right=None, width=0.8, left=None, **kwargs): - """ - %(axes.barh)s - """ - # Converts y-->bottom, left-->x, width-->height, height-->width. - # Convert back to (x, bottom, width, height) so we can pass stuff - # through cycle_changer. - # NOTE: ProPlot calls second positional argument 'right' so that 'width' - # means the width of *bars*. - # NOTE: You *must* do juggling of barh keyword order --> bar keyword order - # --> barh keyword order, because horizontal hist passes arguments to bar - # directly and will not use a 'barh' method with overridden argument order! - func # avoid U100 error - height = _not_none(height=kwargs.pop('height', None), width=width, default=0.8) - kwargs.setdefault('orientation', 'horizontal') - if y is None and width is None: - raise ValueError('barh() requires at least 1 positional argument, got 0.') - return self.bar(x=left, width=right, height=height, bottom=y, **kwargs) - - -def boxplot_wrapper( - self, func, *args, - color='k', fill=True, fillcolor=None, fillalpha=0.7, - lw=None, linewidth=None, orientation=None, - marker=None, markersize=None, - boxcolor=None, boxlw=None, - capcolor=None, caplw=None, - meancolor=None, meanlw=None, - mediancolor=None, medianlw=None, - whiskercolor=None, whiskerlw=None, - fliercolor=None, flierlw=None, - **kwargs -): - """ - Adds convenient keyword arguments and changes the default boxplot style. - - Note - ---- - This function wraps {methods} - - Parameters - ---------- - *args : 1D or 2D ndarray - The data array. - color : color-spec, optional - The color of all objects. - fill : bool, optional - Whether to fill the box with a color. - fillcolor : color-spec, optional - The fill color for the boxes. Default is the next color cycler color. - fillalpha : float, optional - The opacity of the boxes. Default is ``1``. - lw, linewidth : float, optional - The linewidth of all objects. - vert : bool, optional - If ``False``, box plots are drawn horizontally. - orientation : {{None, 'horizontal', 'vertical'}}, optional - Alternative to the native `vert` keyword arg. Controls orientation. - marker : marker-spec, optional - Marker style for the 'fliers', i.e. outliers. - markersize : float, optional - Marker size for the 'fliers', i.e. outliers. - boxcolor, capcolor, meancolor, mediancolor, whiskercolor : \ -color-spec, optional - The color of various boxplot components. These are shorthands so you - don't have to pass e.g. a ``boxprops`` dictionary. - boxlw, caplw, meanlw, medianlw, whiskerlw : float, optional - The line width of various boxplot components. These are shorthands so - you don't have to pass e.g. a ``boxprops`` dictionary. - - Other parameters - ---------------- - **kwargs - Passed to the matplotlib plotting method. - """ - # Call function - if len(args) > 2: - raise ValueError(f'Expected 1-2 positional args, got {len(args)}.') - if orientation is not None: - if orientation == 'horizontal': - kwargs['vert'] = False - elif orientation != 'vertical': - raise ValueError( - 'Orientation must be "horizontal" or "vertical", ' - f'got {orientation!r}.' - ) - obj = func(self, *args, **kwargs) - if not args: - return obj - - # Modify results - # TODO: Pass props keyword args instead? Maybe does not matter. - lw = _not_none(lw=lw, linewidth=linewidth, default=0.8) - if fillcolor is None: - cycler = next(self._get_lines.prop_cycler) - fillcolor = cycler.get('color', None) - for key, icolor, ilw in ( - ('boxes', boxcolor, boxlw), - ('caps', capcolor, caplw), - ('whiskers', whiskercolor, whiskerlw), - ('means', meancolor, meanlw), - ('medians', mediancolor, medianlw), - ('fliers', fliercolor, flierlw), - ): - if key not in obj: # possible if not rendered - continue - artists = obj[key] - ilw = _not_none(ilw, lw) - icolor = _not_none(icolor, color) - for artist in artists: - if icolor is not None: - artist.set_color(icolor) - artist.set_markeredgecolor(icolor) - if ilw is not None: - artist.set_linewidth(ilw) - artist.set_markeredgewidth(ilw) - if key == 'boxes' and fill: - patch = mpatches.PathPatch( - artist.get_path(), color=fillcolor, - alpha=fillalpha, linewidth=0) - self.add_artist(patch) - if key == 'fliers': - if marker is not None: - artist.set_marker(marker) - if markersize is not None: - artist.set_markersize(markersize) - return obj - - -def violinplot_wrapper( - self, func, *args, - lw=None, linewidth=None, fillcolor=None, edgecolor='black', - fillalpha=0.7, orientation=None, - **kwargs -): - """ - Adds convenient keyword arguments and changes the default violinplot style - to match `this matplotlib example \ -`__. - It is also no longer possible to show minima and maxima with whiskers -- - while this is useful for `~matplotlib.axes.Axes.boxplot`\\ s it is - redundant for `~matplotlib.axes.Axes.violinplot`\\ s. - - Note - ---- - This function wraps {methods} - - Parameters - ---------- - *args : 1D or 2D ndarray - The data array. - lw, linewidth : float, optional - The linewidth of the line objects. Default is ``1``. - edgecolor : color-spec, optional - The edge color for the violin patches. Default is ``'black'``. - fillcolor : color-spec, optional - The violin plot fill color. Default is the next color cycler color. - fillalpha : float, optional - The opacity of the violins. Default is ``1``. - vert : bool, optional - If ``False``, box plots are drawn horizontally. - orientation : {{None, 'horizontal', 'vertical'}}, optional - Alternative to the native `vert` keyword arg. Controls orientation. - boxrange, barrange : (float, float), optional - Percentile ranges for the thick and thin central bars. The defaults - are ``(25, 75)`` and ``(5, 95)``, respectively. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.violinplot`. - """ - # Orientation and checks - if len(args) > 2: - raise ValueError(f'Expected 1-2 positional args, got {len(args)}.') - if orientation is not None: - if orientation == 'horizontal': - kwargs['vert'] = False - elif orientation != 'vertical': - raise ValueError( - 'Orientation must be "horizontal" or "vertical", ' - f'got {orientation!r}.' - ) - - # Sanitize input - lw = _not_none(lw=lw, linewidth=linewidth, default=0.8) - if kwargs.pop('showextrema', None): - warnings._warn_proplot('Ignoring showextrema=True.') - if 'showmeans' in kwargs: - kwargs.setdefault('means', kwargs.pop('showmeans')) - if 'showmedians' in kwargs: - kwargs.setdefault('medians', kwargs.pop('showmedians')) - kwargs.setdefault('capsize', 0) - result = obj = func( - self, *args, - showmeans=False, showmedians=False, showextrema=False, lw=lw, **kwargs - ) - if not args: - return result - - # Modify body settings - if isinstance(result, (list, tuple)): - obj = result[0] - for artist in obj['bodies']: - artist.set_alpha(fillalpha) - artist.set_edgecolor(edgecolor) - artist.set_linewidths(lw) - if fillcolor is not None: - artist.set_facecolor(fillcolor) - return result - - -def _get_transform(self, transform): - """ - Translates user input transform. Also used in an axes method. - """ - try: - from cartopy.crs import CRS - except ModuleNotFoundError: - CRS = None - cartopy = getattr(self, 'name', '') == 'cartopy' - if ( - isinstance(transform, mtransforms.Transform) - or CRS and isinstance(transform, CRS) - ): - return transform - elif transform == 'figure': - return self.figure.transFigure - elif transform == 'axes': - return self.transAxes - elif transform == 'data': - return PlateCarree() if cartopy else self.transData - elif cartopy and transform == 'map': - return self.transData - else: - raise ValueError(f'Unknown transform {transform!r}.') - - def _update_text(self, props): """ Monkey patch that adds pseudo "border" properties to text objects @@ -2003,93 +1481,6 @@ def _update_text(self, props): return type(self).update(self, props) -def text_wrapper( - self, func, - x=0, y=0, text='', transform='data', - family=None, fontfamily=None, fontname=None, fontsize=None, size=None, - border=False, bordercolor='w', borderwidth=2, borderinvert=False, - **kwargs -): - """ - Enables specifying `tranform` with a string name and adds a feature for - drawing borders around text. - - Note - ---- - This function wraps {methods} - - Parameters - ---------- - x, y : float - The *x* and *y* coordinates for the text. - text : str - The text string. - transform : {{'data', 'axes', 'figure'}} or \ -`~matplotlib.transforms.Transform`, optional - The transform used to interpret `x` and `y`. Can be a - `~matplotlib.transforms.Transform` object or a string representing the - `~matplotlib.axes.Axes.transData`, `~matplotlib.axes.Axes.transAxes`, - or `~matplotlib.figure.Figure.transFigure` transforms. Default is - ``'data'``, i.e. the text is positioned in data coordinates. - fontsize, size : float or str, optional - The font size. If float, units are inches. If string, units are - interpreted by `~proplot.utils.units`. - fontname, fontfamily, family : str, optional - The font name (e.g. ``'Fira Math'``) or font family name (e.g. - ``'serif'``). Matplotlib falls back to the system default if not found. - fontweight, weight, fontstyle, style, fontvariant, variant : str, optional - Additional font properties. See `~matplotlib.text.Text` for details. - border : bool, optional - Whether to draw border around text. - borderwidth : float, optional - The width of the text border. Default is ``2`` points. - bordercolor : color-spec, optional - The color of the text border. Default is ``'w'``. - borderinvert : bool, optional - If ``True``, the text and border colors are swapped. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.text`. - """ - # Parse input args - # NOTE: Previously issued warning if fontname did not match any of names - # in ttflist but this would result in warning for e.g. family='sans-serif'. - # Matplotlib font API makes it very difficult to inject warning in - # correct place. Simpler to just - # NOTE: Do not emit warning if user supplied conflicting properties - # because matplotlib has like 100 conflicting text properties for which - # it doesn't emit warnings. Prefer not to fix all of them. - fontsize = _not_none(fontsize, size) - fontfamily = _not_none(fontname, fontfamily, family) - if fontsize is not None: - try: - rc._scale_font(fontsize) # *validate* but do not translate - except KeyError: - fontsize = units(fontsize, 'pt') - kwargs['fontsize'] = fontsize - if fontfamily is not None: - kwargs['fontfamily'] = fontfamily - if not transform: - transform = self.transData - else: - transform = _get_transform(self, transform) - - # Apply monkey patch to text object - # TODO: Why only support this here, and not in arbitrary places throughout - # rest of matplotlib API? Units engine needs better implementation. - obj = func(self, x, y, text, transform=transform, **kwargs) - obj.update = _update_text.__get__(obj) - obj.update({ - 'border': border, - 'bordercolor': bordercolor, - 'borderinvert': borderinvert, - 'borderwidth': borderwidth, - }) - return obj - - def _iter_legend_objects(objs): """ Retrieve the (object, label) pairs for objects with actual labels @@ -2107,7 +1498,7 @@ def _iter_legend_objects(objs): yield from _iter_legend_objects(obj) -def cycle_changer( +def _parse_cycle( self, func, *args, cycle=None, cycle_kw=None, label=None, labels=None, values=None, @@ -2117,53 +1508,8 @@ def cycle_changer( **kwargs ): """ - Adds features for controlling colors in the property cycler and drawing - legends or colorbars in one go. - - Note - ---- - This function wraps {methods} - - This wrapper also *standardizes acceptable input* -- these methods now all - accept 2D arrays holding columns of data, and *x*-coordinates are always - optional. Note this alters the behavior of `~matplotlib.axes.Axes.boxplot` - and `~matplotlib.axes.Axes.violinplot`, which now compile statistics on - *columns* of data instead of *rows*. - Parameters ---------- - cycle : cycle-spec, optional - The cycle specifer, passed to the `~proplot.constructor.Cycle` - constructor. If the returned list of colors is unchanged from the - current axes color cycler, the axes cycle will **not** be reset to the - first position. - cycle_kw : dict-like, optional - Passed to `~proplot.constructor.Cycle`. - label : float or str, optional - The legend label to be used for this plotted element. - labels, values : list of float or list of str, optional - Used with 2D input arrays. The legend labels or colorbar coordinates - for each column in the array. Can be numeric or string, and must match - the number of columns in the 2D array. - legend : bool, int, or str, optional - If not ``None``, this is a location specifying where to draw an *inset* - or *panel* legend from the resulting handle(s). If ``True``, the - default location is used. Valid locations are described in - `~proplot.axes.Axes.legend`. - legend_kw : dict-like, optional - Ignored if `legend` is ``None``. Extra keyword args for our call - to `~proplot.axes.Axes.legend`. - colorbar : bool, int, or str, optional - If not ``None``, this is a location specifying where to draw an *inset* - or *panel* colorbar from the resulting handle(s). If ``True``, the - default location is used. Valid locations are described in - `~proplot.axes.Axes.colorbar`. - colorbar_kw : dict-like, optional - Ignored if `colorbar` is ``None``. Extra keyword args for our call - to `~proplot.axes.Axes.colorbar`. - errobjs : `~matplotlib.artist.Artist` or list thereof, optional - Error bar objects to add to the legend. This is used internally and - should not be necessary for users. See `indicate_error`. Other parameters ---------------- @@ -2674,8 +2020,7 @@ def _build_discrete_norm( @warnings._rename_kwargs('0.6', centers='values') -@docstring.add_snippets -def cmap_changer( +def _parse_cmap( self, func, *args, extend=None, cmap=None, cmap_kw=None, norm=None, norm_kw=None, vmin=None, vmax=None, N=None, levels=None, values=None, @@ -2688,82 +2033,7 @@ def cmap_changer( **kwargs ): """ - Adds several new keyword args and features for specifying the colormap, - levels, and normalizers. Uses the `~proplot.colors.DiscreteNorm` - normalizer to bin data into discrete color levels (see notes). - - Note - ---- - This function wraps {methods} - - Parameters - ---------- - extend : {{'neither', 'min', 'max', 'both'}}, optional - Where to assign unique colors to out-of-bounds data and draw - "extensions" (triangles, by default) on the colorbar. - %(axes.cmap_changer)s - edgefix : bool, optional - Whether to fix the the `white-lines-between-filled-contours \ -`__ - and `white-lines-between-pcolor-rectangles \ -`__ - issues. This slows down figure rendering by a bit. Default is - :rc:`image.edgefix`. - labels : bool, optional - For `~matplotlib.axes.Axes.contour`, whether to add contour labels - with `~matplotlib.axes.Axes.clabel`. For `~matplotlib.axes.Axes.pcolor` - or `~matplotlib.axes.Axes.pcolormesh`, whether to add labels to the - center of grid boxes. In the latter case, the text will be black - when the luminance of the underlying grid box color is >50%%, and - white otherwise. - labels_kw : dict-like, optional - Ignored if `labels` is ``False``. Extra keyword args for the labels. - For `~matplotlib.axes.Axes.contour`, passed to - `~matplotlib.axes.Axes.clabel`. For `~matplotlib.axes.Axes.pcolor` - or `~matplotlib.axes.Axes.pcolormesh`, passed to - `~matplotlib.axes.Axes.text`. - fmt : format-spec, optional - Passed to the `~proplot.constructor.Norm` constructor, used to format - number labels. You can also use the `precision` keyword arg. - precision : int, optional - Maximum number of decimal places for the number labels. - Number labels are generated with the - `~proplot.ticker.SimpleFormatter` formatter, which allows us to - limit the precision. - colorbar : bool, int, or str, optional - If not ``None``, this is a location specifying where to draw an *inset* - or *panel* colorbar from the resulting mappable. If ``True``, the - default location is used. Valid locations are described in - `~proplot.axes.Axes.colorbar`. - colorbar_kw : dict-like, optional - Ignored if `colorbar` is ``None``. Extra keyword args for our call - to `~proplot.axes.Axes.colorbar`. - - Other parameters - ---------------- - lw, linewidth, linewidths - The width of `~matplotlib.axes.Axes.contour` lines and - `~proplot.axes.Axes.parametric` lines. Also the width of lines - *between* `~matplotlib.axes.Axes.pcolor` boxes, - `~matplotlib.axes.Axes.pcolormesh` boxes, and - `~matplotlib.axes.Axes.contourf` filled contours. - ls, linestyle, linestyles - As above, but for the line style. - color, colors, edgecolor, edgecolors - As above, but for the line color. For `~matplotlib.axes.Axes.contourf` - plots, if you provide `colors` without specifying the `linewidths` - or `linestyles`, this argument is used to manually specify the *fill - colors*. See the `~matplotlib.axes.Axes.contourf` documentation for - details. - *args, **kwargs - Passed to the matplotlib plotting method. - - See also - -------- - standardize_2d - proplot.constructor.Colormap - proplot.constructor.Norm - proplot.colors.DiscreteNorm + Interpret colormap keyword arguments. """ name = func.__name__ autoformat = rc['autoformat'] # possibly manipulated by standardize_[12]d @@ -2998,7 +2268,7 @@ def _iter_legend_children(children): yield obj -def legend_wrapper( +def _add_legend( self, handles=None, labels=None, *, ncol=None, ncols=None, center=None, order='C', loc=None, label=None, title=None, fontsize=None, fontweight=None, fontcolor=None, @@ -3007,76 +2277,7 @@ def legend_wrapper( **kwargs ): """ - Adds useful features for controlling legends, including "centered-row" - legends. - - Note - ---- - This function wraps `proplot.axes.Axes.legend` - and `proplot.figure.Figure.legend`. - - Parameters - ---------- - handles : list of `~matplotlib.artist.Artist`, optional - List of artists instances, or list of lists of artist instances (see - the `center` keyword). If ``None``, the artists are retrieved with - `~matplotlib.axes.Axes.get_legend_handles_labels`. - labels : list of str, optional - Matching list of string labels, or list of lists of string labels (see - the `center` keywod). If ``None``, the labels are retrieved by calling - `~matplotlib.artist.Artist.get_label` on each - `~matplotlib.artist.Artist` in `handles`. - ncol, ncols : int, optional - The number of columns. `ncols` is an alias, added - for consistency with `~matplotlib.pyplot.subplots`. - order : {'C', 'F'}, optional - Whether legend handles are drawn in row-major (``'C'``) or column-major - (``'F'``) order. Analagous to `numpy.array` ordering. For some reason - ``'F'`` was the original matplotlib default. Default is ``'C'``. - center : bool, optional - Whether to center each legend row individually. If ``True``, we - actually draw successive single-row legends stacked on top of each - other. - - If ``None``, we infer this setting from `handles`. Default is ``True`` - if `handles` is a list of lists; each sublist is used as a *row* - in the legend. Otherwise, default is ``False``. - loc : int or str, optional - The legend location. The following location keys are valid: - - ================== ================================================ - Location Valid keys - ================== ================================================ - "best" possible ``0``, ``'best'``, ``'b'``, ``'i'``, ``'inset'`` - upper right ``1``, ``'upper right'``, ``'ur'`` - upper left ``2``, ``'upper left'``, ``'ul'`` - lower left ``3``, ``'lower left'``, ``'ll'`` - lower right ``4``, ``'lower right'``, ``'lr'`` - center left ``5``, ``'center left'``, ``'cl'`` - center right ``6``, ``'center right'``, ``'cr'`` - lower center ``7``, ``'lower center'``, ``'lc'`` - upper center ``8``, ``'upper center'``, ``'uc'`` - center ``9``, ``'center'``, ``'c'`` - ================== ================================================ - - label, title : str, optional - The legend title. The `label` keyword is also accepted, for consistency - with `colorbar`. - fontsize, fontweight, fontcolor : optional - The font size, weight, and color for legend text. - color, lw, linewidth, marker, linestyle, dashes, markersize : \ -property-spec, optional - Properties used to override the legend handles. For example, if you - want a legend that describes variations in line style ignoring - variations in color, you might want to use ``color='k'``. For now this - does not include `facecolor`, `edgecolor`, and `alpha`, because - `~matplotlib.axes.Axes.legend` uses these keyword args to modify the - frame properties. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.legend`. + Draw a legend with extra features. """ # Parse input args # TODO: Legend entries for colormap or scatterplot objects! Idea is we @@ -3112,7 +2313,7 @@ def legend_wrapper( # Handle and text properties that are applied after-the-fact # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds - # shading in legend entry. This change is not noticable in other situations. + # shading in legend entry. This change is not noticeable in other situations. kw_text = {} for key, value in ( ('color', fontcolor), @@ -3419,7 +2620,7 @@ def legend_wrapper( return legs[0] if len(legs) == 1 else tuple(legs) -def colorbar_wrapper( +def _add_colorbar( self, mappable, values=None, extend=None, extendsize=None, title=None, label=None, @@ -3437,102 +2638,7 @@ def colorbar_wrapper( **kwargs ): """ - Adds useful features for controlling colorbars. - - Note - ---- - This function wraps `proplot.axes.Axes.colorbar` - and `proplot.figure.Figure.colorbar`. - - Parameters - ---------- - mappable : mappable, list of plot handles, list of color-spec, \ -or colormap-spec - There are four options here: - - 1. A mappable object. Basically, any object with a ``get_cmap`` method, - like the objects returned by `~matplotlib.axes.Axes.contourf` and - `~matplotlib.axes.Axes.pcolormesh`. - 2. A list of "plot handles". Basically, any object with a ``get_color`` - method, like `~matplotlib.lines.Line2D` instances. A colormap will - be generated from the colors of these objects, and colorbar levels - will be selected using `values`. If `values` is ``None``, we try - to infer them by converting the handle labels returned by - `~matplotlib.artist.Artist.get_label` to `float`. Otherwise, it is - set to ``np.linspace(0, 1, len(mappable))``. - 3. A list of hex strings, color string names, or RGB tuples. A colormap - will be generated from these colors, and colorbar levels will be - selected using `values`. If `values` is ``None``, it is set to - ``np.linspace(0, 1, len(mappable))``. - 4. A `~matplotlib.colors.Colormap` instance. In this case, a colorbar - will be drawn using this colormap and with levels determined by - `values`. If `values` is ``None``, it is set to - ``np.linspace(0, 1, cmap.N)``. - - values : list of float, optional - Ignored if `mappable` is a mappable object. This maps each color or - plot handle in the `mappable` list to numeric values, from which a - colormap and normalizer are constructed. - norm : normalizer spec, optional - Ignored if `values` is ``None``. The normalizer for converting `values` - to colormap colors. Passed to `~proplot.constructor.Norm`. - norm_kw : dict-like, optional - The normalizer settings. Passed to `~proplot.constructor.Norm`. - extend : {None, 'neither', 'both', 'min', 'max'}, optional - Direction for drawing colorbar "extensions" (i.e. references to - out-of-bounds data with a unique color). These are triangles by - default. If ``None``, we try to use the ``extend`` attribute on the - mappable object. If the attribute is unavailable, we use ``'neither'``. - extendsize : float or str, optional - The length of the colorbar "extensions" in *physical units*. - If float, units are inches. If string, units are interpreted - by `~proplot.utils.units`. Default is :rc:`colorbar.insetextend` - for inset colorbars and :rc:`colorbar.extend` for outer colorbars. - reverse : bool, optional - Whether to reverse the direction of the colorbar. - tickloc, ticklocation : {'bottom', 'top', 'left', 'right'}, optional - Where to draw tick marks on the colorbar. - tickminor : bool, optional - Whether to add minor ticks to the colorbar with - `~matplotlib.colorbar.ColorbarBase.minorticks_on`. - grid : bool, optional - Whether to draw "gridlines" between each level of the colorbar. - Default is :rc:`colorbar.grid`. - label, title : str, optional - The colorbar label. The `title` keyword is also accepted for - consistency with `legend`. - locator, ticks : locator spec, optional - Used to determine the colorbar tick positions. Passed to the - `~proplot.constructor.Locator` constructor. - maxn : int, optional - Used if `locator` is ``None``. Determines the maximum number of levels - that are ticked. Default depends on the colorbar length relative - to the font size. The keyword name "maxn" is meant to mimic - the `~matplotlib.ticker.MaxNLocator` class name. - locator_kw : dict-like, optional - The locator settings. Passed to `~proplot.constructor.Locator`. - minorlocator, minorticks, maxn_minor, minorlocator_kw - As with `locator`, `maxn`, and `locator_kw`, but for the minor ticks. - formatter, ticklabels : formatter spec, optional - The tick label format. Passed to the `~proplot.constructor.Formatter` - constructor. - formatter_kw : dict-like, optional - The formatter settings. Passed to `~proplot.constructor.Formatter`. - rotation : float, optional - The tick label rotation. Default is ``0``. - edgecolor, linewidth : optional - The edge color and line width for the colorbar outline. - labelsize, labelweight, labelcolor : optional - The font size, weight, and color for colorbar label text. - ticklabelsize, ticklabelweight, ticklabelcolor : optional - The font size, weight, and color for colorbar tick labels. - orientation : {{'horizontal', 'vertical'}}, optional - The colorbar orientation. You should not have to explicitly set this. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.figure.Figure.colorbar`. + Draw a colorbar with extra features. """ # NOTE: There is a weird problem with colorbars when simultaneously # passing levels and norm object to a mappable; fixed by passing vmin/vmax @@ -3876,6 +2982,18 @@ def colorbar_wrapper( return cb +def _add_autoformat(func): + """ + Decorator that adds an `autoformat` keyword and executed the rest + of the block inside a `plot.config.RcConfiguroator.context`. + """ + @functools.wraps(func) + def wrapper(*args, autoformat=None, **kwargs): + autoformat = _not_none(autoformat, rc['autoformat']) + with rc.context(autoformat=autoformat): + return func(*args, **kwargs) + + def _basemap_redirect(func): """ Docorator that calls the basemap version of the function of the @@ -3899,83 +3017,13 @@ def _basemap_norecurse(func): See `this post https://stackoverflow.com/a/37675810/4970632`__. """ name = func.__name__ - func._called_from_basemap = False @functools.wraps(func) def wrapper(self, *args, **kwargs): - if func._called_from_basemap: + if self._called_from_basemap: result = getattr(maxes.Axes, name)(self, *args, **kwargs) else: - with _state_context(func, _called_from_basemap=True): + with _state_context(self, _called_from_basemap=True): result = func(self, *args, **kwargs) return result return wrapper - - -def _generate_decorator(driver): - """ - Generate generic wrapper decorator and dynamically modify the docstring - to list methods wrapped by this function. Also set `__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. - """ - driver._docstring_orig = driver.__doc__ or '' - driver._methods_wrapped = [] - proplot_methods = ('parametric', 'heatmap', 'area', 'areax') - - def decorator(func): - # Define wrapper and suppress documentation - # We only document wrapper functions, not the methods they wrap - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - return driver(self, func, *args, **kwargs) - name = func.__name__ - if name not in proplot_methods: - wrapper.__doc__ = None - - # List wrapped methods in the driver function docstring - # Prevents us from having to both explicitly apply decorators in - # axes.py and explicitly list functions *again* in this file - docstring = driver._docstring_orig - if '{methods}' in docstring: - if name in proplot_methods: - link = f'`~proplot.axes.Axes.{name}`' - else: - link = f'`~matplotlib.axes.Axes.{name}`' - methods = driver._methods_wrapped - if link not in methods: - methods.append(link) - string = ( - ', '.join(methods[:-1]) - + ',' * int(len(methods) > 2) # Oxford comma bitches - + ' and ' * int(len(methods) > 1) - + methods[-1] - ) - driver.__doc__ = docstring.format(methods=string) - return wrapper - return decorator - - -# Auto generated decorators. Each wrapper internally calls func(self, ...) somewhere. -_bar_wrapper = _generate_decorator(bar_wrapper) -_barh_wrapper = _generate_decorator(barh_wrapper) -_default_latlon = _generate_decorator(default_latlon) -_boxplot_wrapper = _generate_decorator(boxplot_wrapper) -_default_transform = _generate_decorator(default_transform) -_cmap_changer = _generate_decorator(cmap_changer) -_cycle_changer = _generate_decorator(cycle_changer) -_fill_between_wrapper = _generate_decorator(fill_between_wrapper) -_fill_betweenx_wrapper = _generate_decorator(fill_betweenx_wrapper) -_hist_wrapper = _generate_decorator(hist_wrapper) -_hlines_wrapper = _generate_decorator(hlines_wrapper) -_indicate_error = _generate_decorator(indicate_error) -_parametric_wrapper = _generate_decorator(parametric_wrapper) -_plot_wrapper = _generate_decorator(plot_wrapper) -_scatter_wrapper = _generate_decorator(scatter_wrapper) -_standardize_1d = _generate_decorator(standardize_1d) -_standardize_2d = _generate_decorator(standardize_2d) -_stem_wrapper = _generate_decorator(stem_wrapper) -_text_wrapper = _generate_decorator(text_wrapper) -_violinplot_wrapper = _generate_decorator(violinplot_wrapper) -_vlines_wrapper = _generate_decorator(vlines_wrapper) diff --git a/proplot/figure.py b/proplot/figure.py index 85e84924b..737e57e44 100644 --- a/proplot/figure.py +++ b/proplot/figure.py @@ -12,7 +12,7 @@ from . import gridspec as pgridspec from .config import rc from .internals import ic # noqa: F401 -from .internals import _dummy_context, _not_none, _state_context, warnings +from .internals import _dummy_context, _not_none, _state_context, docstring, warnings from .utils import units __all__ = ['Figure'] @@ -1101,6 +1101,7 @@ def auto_layout(self, renderer=None, resize=None, aspect=None, tight=None): self._align_axis_labels(True) self._align_subplot_super_labels(renderer) + @docstring.add_snippets def colorbar( self, *args, loc='r', width=None, space=None, @@ -1114,6 +1115,7 @@ def colorbar( Parameters ---------- + %(plot.colorbar_args)s loc : str, optional The colorbar location. Valid location keys are as follows. @@ -1146,11 +1148,12 @@ def colorbar( width : float or str, optional The colorbar width. Units are interpreted by `~proplot.utils.units`. Default is :rc:`colorbar.width`. + %(plot.colorbar_kwargs)s Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.colorbar_wrapper`. + **kwargs + Passed to `~matplotlib.axes.Axes.colorbar`. """ ax = kwargs.pop('ax', None) cax = kwargs.pop('cax', None) @@ -1171,6 +1174,7 @@ def colorbar( ) return ax.colorbar(*args, loc='fill', **kwargs) + @docstring.add_snippets def legend( self, *args, loc='r', width=None, space=None, @@ -1184,6 +1188,7 @@ def legend( Parameters ---------- + %(plot.legend_args)s loc : str, optional The legend location. Valid location keys are as follows. @@ -1213,11 +1218,12 @@ def legend( by `~proplot.utils.units`. By default, this is adjusted automatically in the "tight layout" calculation, or is :rc:`subplots.panelpad` if "tight layout" is turned off. + %(plot.legend_kwargs)s Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.legend_wrapper`. + **kwargs + Passed to `~matplotlib.axes.Axes.legend`. """ ax = kwargs.pop('ax', None) diff --git a/proplot/internals/docstring.py b/proplot/internals/docstring.py index b8697f68a..c860ccdda 100644 --- a/proplot/internals/docstring.py +++ b/proplot/internals/docstring.py @@ -8,12 +8,19 @@ snippets = {} -def add_snippets(func): - """Decorator that dedents docstrings with `inspect.getdoc` and adds - un-indented snippets from the global `snippets` dictionary. This function - uses ``%(name)s`` substitution rather than `str.format` substitution so - that the `snippets` keys can be invalid variable names.""" - func.__doc__ = inspect.getdoc(func) - if func.__doc__: - func.__doc__ %= {key: value.strip() for key, value in snippets.items()} - return func +def add_snippets(arg): + """ + Add un-indented snippets from the global `snippets` dictionary to the input + string or function docstring. Also *dedent* the docstring with `inspect.getdoc` + before adding snippets if the input is a function. This function uses C-style + ``%(name)s`` substitution rather than `str.format` substitution so that the + `snippets` keys do not have to be valid identifiers. + """ + snippets_stripped = {key: value.strip() for key, value in snippets.items()} + if isinstance(arg, str): + arg %= snippets_stripped + else: + arg.__doc__ = inspect.getdoc(arg) + if arg.__doc__: + arg.__doc__ %= snippets_stripped + return arg From 23fca2a2cd8581bc11b1dbfa9e47c2e01e534f5b Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sun, 21 Jun 2020 05:24:23 -0600 Subject: [PATCH 3/4] Fix basemap and cartopy wrappers --- proplot/axes/base.py | 233 +++++++++++++++++++++++-------------------- proplot/axes/geo.py | 228 ++++++++++++++++++++++++------------------ proplot/axes/plot.py | 46 --------- 3 files changed, 256 insertions(+), 251 deletions(-) diff --git a/proplot/axes/base.py b/proplot/axes/base.py index 030a522f0..464e09c23 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -350,35 +350,43 @@ def __init__(self, *args, number=None, main=False, **kwargs): # Ensure isDefault_minloc enabled at start, needed for dual axes self.xaxis.isDefault_minloc = self.yaxis.isDefault_minloc = True - # Properties self._auto_format = None # manipulated by wrapper functions + self._abc_loc = None self._abc_text = None self._abc_border_kwargs = {} # abs border properties + self._title_loc = None # location of main title self._title_pad = rc['axes.titlepad'] # format() can overwrite self._title_pad_active = None self._title_border_kwargs = {} # title border properties + self._above_top_panels = True # TODO: add rc prop? self._bottom_panels = [] self._top_panels = [] self._left_panels = [] self._right_panels = [] + self._tightbbox = None # bounding boxes are saved + self._panel_hidden = False # True when "filled" with cbar/legend self._panel_parent = None self._panel_share = False self._panel_sharex_group = False self._panel_sharey_group = False self._panel_side = None + self._inset_parent = None self._inset_zoom = False self._inset_zoom_data = None + self._alty_child = None self._altx_child = None self._alty_parent = None self._altx_parent = None + self.number = number # for abc numbering + if main: self.figure._axes_main.append(self) @@ -681,6 +689,15 @@ def inset_locator(ax, renderer): return bb return inset_locator + def _plot_redirect(self, name, *args, **kwargs): + """ + Redirect to the associated basemap method if possible. + """ + if getattr(self, 'name', '') == 'basemap': + return getattr(self.projection, name)(*args, ax=self, **kwargs) + else: + return getattr(maxes.Axes, name)(self, *args, **kwargs) + def _range_gridspec(self, x): """ Return the column or row gridspec range for the axes. @@ -1338,7 +1355,7 @@ def barbs(self, *args, **kwargs): """ args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().barbs(*args, **kwargs) + mappable = self._plot_redirect('barbs', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable @@ -1701,7 +1718,7 @@ def contour(self, *args, **kwargs): """ args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().contour(*args, **kwargs) + mappable = self._plot_redirect('contour', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable @@ -1720,10 +1737,15 @@ def contourf(self, *args, **kwargs): """ args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().contourf(*args, **kwargs) + mappable = self._plot_redirect('contourf', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable + def draw(self, renderer=None, *args, **kwargs): + # Perform extra post-processing steps + self._reassign_title() + super().draw(renderer, *args, **kwargs) + # fill_between = _fill_between_wrapper(_standardize_1d(_cycle_changer( def _fill_between_apply( self, xy, *args, @@ -1834,101 +1856,6 @@ def fill_betweenx(self, *args, **kwargs): """ return self._fill_between_apply('yx', *args, **kwargs) - @plot._concatenate_docstrings - @docstring.add_snippets - def legend(self, *args, loc=None, width=None, space=None, **kwargs): - """ - Add an *inset* legend or *outer* legend along the edge of the axes. - - Parameters - ---------- - %(plot.legend_args)s - loc : int or str, optional - The legend location. The following location keys are valid: - - .. _legend_table: - - ================== ======================================= - Location Valid keys - ================== ======================================= - outer left ``'left'``, ``'l'`` - outer right ``'right'``, ``'r'`` - outer bottom ``'bottom'``, ``'b'`` - outer top ``'top'``, ``'t'`` - "best" inset ``'best'``, ``'inset'``, ``'i'``, ``0`` - upper right inset ``'upper right'``, ``'ur'``, ``1`` - upper left inset ``'upper left'``, ``'ul'``, ``2`` - lower left inset ``'lower left'``, ``'ll'``, ``3`` - lower right inset ``'lower right'``, ``'lr'``, ``4`` - center left inset ``'center left'``, ``'cl'``, ``5`` - center right inset ``'center right'``, ``'cr'``, ``6`` - lower center inset ``'lower center'``, ``'lc'``, ``7`` - upper center inset ``'upper center'``, ``'uc'``, ``8`` - center inset ``'center'``, ``'c'``, ``9`` - "filled" ``'fill'`` - ================== ======================================= - - width : float or str, optional - For outer legends only. The space allocated for the legend box. - This does nothing if :rcraw:`tight` is ``True``. Units are - interpreted by `~proplot.utils.units`. - space : float or str, optional - For outer legends only. The space between the axes and the legend - box. Units are interpreted by `~proplot.utils.units`. - When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. - %(plot.legend_kwargs)s - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.legend`. - """ - if loc != 'fill': - loc = self._loc_translate(loc, 'legend') - if isinstance(loc, np.ndarray): - loc = loc.tolist() - - # Generate panel - if loc in ('left', 'right', 'top', 'bottom'): - ax = self.panel_axes(loc, width=width, space=space, filled=True) - return ax.legend(*args, loc='fill', **kwargs) - - # Fill - if loc == 'fill': - # Hide content - self._hide_panel() - - # Try to make handles and stuff flush against the axes edge - kwargs.setdefault('borderaxespad', 0) - frameon = _not_none( - kwargs.get('frame', None), kwargs.get('frameon', None), - rc['legend.frameon'] - ) - if not frameon: - kwargs.setdefault('borderpad', 0) - - # Apply legend location - side = self._panel_side - if side == 'bottom': - loc = 'upper center' - elif side == 'right': - loc = 'center left' - elif side == 'left': - loc = 'center right' - elif side == 'top': - loc = 'lower center' - else: - raise ValueError(f'Invalid panel side {side!r}.') - - # Draw legend - return plot._add_legend(self, *args, loc=loc, **kwargs) - - def draw(self, renderer=None, *args, **kwargs): - # Perform extra post-processing steps - self._reassign_title() - super().draw(renderer, *args, **kwargs) - def get_size_inches(self): # Return the width and height of the axes in inches. width, height = self.figure.get_size_inches() @@ -2000,7 +1927,7 @@ def hexbin(self, *args, **kwargs): """ args = plot._parse_1d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().hexbin(*args, **kwargs) + mappable = self._plot_redirect('hexbin', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable @@ -2140,7 +2067,7 @@ def imshow(self, *args, **kwargs): %(plot.cmap_args)s """ kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().imshow(*args, **kwargs) + mappable = self._plot_redirect('imshow', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable @@ -2292,6 +2219,96 @@ def indicate_inset_zoom( self._inset_zoom_data = (rectpatch, connects) return rectpatch, connects + @plot._concatenate_docstrings + @docstring.add_snippets + def legend(self, *args, loc=None, width=None, space=None, **kwargs): + """ + Add an *inset* legend or *outer* legend along the edge of the axes. + + Parameters + ---------- + %(plot.legend_args)s + loc : int or str, optional + The legend location. The following location keys are valid: + + .. _legend_table: + + ================== ======================================= + Location Valid keys + ================== ======================================= + outer left ``'left'``, ``'l'`` + outer right ``'right'``, ``'r'`` + outer bottom ``'bottom'``, ``'b'`` + outer top ``'top'``, ``'t'`` + "best" inset ``'best'``, ``'inset'``, ``'i'``, ``0`` + upper right inset ``'upper right'``, ``'ur'``, ``1`` + upper left inset ``'upper left'``, ``'ul'``, ``2`` + lower left inset ``'lower left'``, ``'ll'``, ``3`` + lower right inset ``'lower right'``, ``'lr'``, ``4`` + center left inset ``'center left'``, ``'cl'``, ``5`` + center right inset ``'center right'``, ``'cr'``, ``6`` + lower center inset ``'lower center'``, ``'lc'``, ``7`` + upper center inset ``'upper center'``, ``'uc'``, ``8`` + center inset ``'center'``, ``'c'``, ``9`` + "filled" ``'fill'`` + ================== ======================================= + + width : float or str, optional + For outer legends only. The space allocated for the legend box. + This does nothing if :rcraw:`tight` is ``True``. Units are + interpreted by `~proplot.utils.units`. + space : float or str, optional + For outer legends only. The space between the axes and the legend + box. Units are interpreted by `~proplot.utils.units`. + When :rcraw:`tight` is ``True``, this is adjusted automatically. + Otherwise, the default is :rc:`subplots.panelpad`. + %(plot.legend_kwargs)s + + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.axes.Axes.legend`. + """ + if loc != 'fill': + loc = self._loc_translate(loc, 'legend') + if isinstance(loc, np.ndarray): + loc = loc.tolist() + + # Generate panel + if loc in ('left', 'right', 'top', 'bottom'): + ax = self.panel_axes(loc, width=width, space=space, filled=True) + return ax.legend(*args, loc='fill', **kwargs) + + # Fill + if loc == 'fill': + # Hide content + self._hide_panel() + + # Try to make handles and stuff flush against the axes edge + kwargs.setdefault('borderaxespad', 0) + frameon = _not_none( + kwargs.get('frame', None), kwargs.get('frameon', None), + rc['legend.frameon'] + ) + if not frameon: + kwargs.setdefault('borderpad', 0) + + # Apply legend location + side = self._panel_side + if side == 'bottom': + loc = 'upper center' + elif side == 'right': + loc = 'center left' + elif side == 'left': + loc = 'center right' + elif side == 'top': + loc = 'lower center' + else: + raise ValueError(f'Invalid panel side {side!r}.') + + # Draw legend + return plot._add_legend(self, *args, loc=loc, **kwargs) + @plot._concatenate_docstrings @docstring.add_snippets def matshow(self, *args, **kwargs): @@ -2466,7 +2483,7 @@ def pcolor(self, *args, **kwargs): """ args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().pcolor(*args, **kwargs) + mappable = self._plot_redirect('pcolor', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable @@ -2504,7 +2521,7 @@ def pcolormesh(self, *args, **kwargs): """ args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().pcolormesh(*args, **kwargs) + mappable = self._plot_redirect('pcolormesh', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable @@ -2565,7 +2582,7 @@ def plot(self, *args, cmap=None, values=None, **kwargs): kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) # Draw lines - objs = super().plot(x, y, values=values, **kwargs) + objs = self._plot_redirect('plot', x, y, values=values, **kwargs) # Add sticky edges? No because there is no way to check whether "dependent # variable" is x or y axis like with area/areax and bar/barh. Better to always @@ -2598,7 +2615,7 @@ def quiver(self, *args, **kwargs): """ args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().quiver(*args, **kwargs) + mappable = self._plot_redirect('quiver', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable @@ -2725,8 +2742,8 @@ def scatter( ) # Draw scatterplot - objs = super().scatter( - *args, c=c, s=s, cmap=cmap, norm=norm, + objs = self._plot_redirect( + 'scatter', *args, c=c, s=s, cmap=cmap, norm=norm, linewidths=lw, edgecolors=ec, **kwargs ) if ticks is not None: @@ -2813,7 +2830,7 @@ def streamplot(self, *args, **kwargs): """ args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) - mappable = super().streamplot(*args, **kwargs) + mappable = self._plot_redirect('streamplot', *args, **kwargs) plot._auto_colorbar(mappable, **kwargs_colorbar) return mappable diff --git a/proplot/axes/geo.py b/proplot/axes/geo.py index b8905acc7..8babc6879 100644 --- a/proplot/axes/geo.py +++ b/proplot/axes/geo.py @@ -4,7 +4,6 @@ """ import copy -import matplotlib.axes as maxes import matplotlib.axis as maxis import matplotlib.path as mpath import matplotlib.text as mtext @@ -17,20 +16,7 @@ from ..internals import ic # noqa: F401 from ..internals import _not_none, _version, _version_cartopy, docstring, warnings from . import base -from .plot import ( - _basemap_norecurse, - _basemap_redirect, - _cmap_changer, - _cycle_changer, - _default_latlon, - _default_transform, - _indicate_error, - _plot_wrapper, - _scatter_wrapper, - _standardize_1d, - _standardize_2d, - _text_wrapper, -) +from .plot import _basemap_norecurse try: import cartopy.crs as ccrs @@ -737,13 +723,15 @@ def __init__(self, *args, autoextent=None, map_projection=None, **kwargs): elif isinstance(map_projection, pcrs.SouthPolarGnomonic): boundinglat = -30 - # Initialize axes self._boundinglat = None # NOTE: must start at None so _update_extent acts self._map_projection = map_projection # cartopy also does this + self._gridlines_major = None self._gridlines_minor = None + self._lonaxis = _LonAxis(self, projection=map_projection) self._lataxis = _LatAxis(self, latmax=latmax, projection=map_projection) + super().__init__(*args, map_projection=map_projection, **kwargs) # Apply circular map boundary for polar projections. Apply default @@ -1162,50 +1150,73 @@ def projection(self, map_projection): raise ValueError('Projection must be a cartopy.crs.CRS instance.') self._map_projection = map_projection - # Wrapped methods - # TODO: Remove this duplication! - # NOTE: Do not wrap fill_between because it is broken in cartopy - if GeoAxesBase is not object: - text = _text_wrapper( - GeoAxesBase.text - ) - fill = _default_transform(GeoAxesBase.fill) - plot = _default_transform(_plot_wrapper(_standardize_1d( - _indicate_error(_cycle_changer(GeoAxesBase.plot)) - ))) - scatter = _default_transform(_scatter_wrapper(_standardize_1d( - _indicate_error(_cycle_changer(GeoAxesBase.scatter)) - ))) - contour = _default_transform(_standardize_2d(_cmap_changer( - GeoAxesBase.contour - ))) - contourf = _default_transform(_standardize_2d(_cmap_changer( - GeoAxesBase.contourf - ))) - pcolor = _default_transform(_standardize_2d(_cmap_changer( - GeoAxesBase.pcolor - ))) - pcolormesh = _default_transform(_standardize_2d(_cmap_changer( - GeoAxesBase.pcolormesh - ))) - quiver = _default_transform(_standardize_2d(_cmap_changer( - GeoAxesBase.quiver - ))) - streamplot = _default_transform(_standardize_2d(_cmap_changer( - GeoAxesBase.streamplot - ))) - barbs = _default_transform(_standardize_2d(_cmap_changer( - GeoAxesBase.barbs - ))) - tripcolor = _default_transform(_cmap_changer( - GeoAxesBase.tripcolor - )) - tricontour = _default_transform(_cmap_changer( - GeoAxesBase.tricontour - )) - tricontourf = _default_transform(_cmap_changer( - GeoAxesBase.tricontourf - )) + # Undocumented plotting overrides + # TODO: Clean way to inject 'transform' argument + def barbs(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().barbs(*args, transform=transform, **kwargs) + + def contour(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().contour(*args, transform=transform, **kwargs) + + def contourf(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().contourf(*args, transform=transform, **kwargs) + + def fill(self, *args, transform=None, **kwargs): + # Apply default transform + # NOTE: Do not wrap fill_between because it is broken in cartopy. + transform = _not_none(transform, ccrs.PlateCarree()) + return super().fill(*args, transform=transform, **kwargs) + + def plot(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().plot(*args, transform=transform, **kwargs) + + def pcolor(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().pcolor(*args, transform=transform, **kwargs) + + def pcolormesh(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().pcolormesh(*args, transform=transform, **kwargs) + + def quiver(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().quiver(*args, transform=transform, **kwargs) + + def scatter(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().scatter(*args, transform=transform, **kwargs) + + def streamplot(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().streamplot(*args, transform=transform, **kwargs) + + def tricontour(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().tricontour(*args, transform=transform, **kwargs) + + def tricontourf(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().tricontourf(*args, transform=transform, **kwargs) + + def tripcolor(self, *args, transform=None, **kwargs): + # Apply default transform + transform = _not_none(transform, ccrs.PlateCarree()) + return super().tripcolor(*args, transform=transform, **kwargs) class BasemapAxes(GeoAxes): @@ -1257,8 +1268,7 @@ def __init__(self, *args, map_projection=None, **kwargs): raise ValueError( 'BasemapAxes requires map_projection=basemap.Basemap' ) - map_projection = copy.copy(map_projection) - self._map_projection = map_projection + self._map_projection = map_projection = copy.copy(map_projection) lon0 = self._get_lon0() if map_projection.projection in self._proj_polar: latmax = 80 # default latmax for gridlines @@ -1275,16 +1285,19 @@ def __init__(self, *args, map_projection=None, **kwargs): if any(_ is None for _ in extent): extent = [180 - lon0, 180 + lon0, -90, 90] # fallback - # Initialize axes self._called_from_basemap = False # used to override plotting methods + self._map_boundary = None # start with empty map boundary + self._lonlines_major = None # store gridliner objects this way self._lonlines_minor = None self._latlines_major = None self._latlines_minor = None + self._lonaxis = _LonAxis(self) self._lataxis = _LatAxis(self, latmax=latmax) self._set_view_intervals(extent) + super().__init__(*args, **kwargs) def _get_lon0(self): @@ -1498,37 +1511,58 @@ def projection(self, map_projection): raise ValueError('Projection must be a basemap.Basemap instance.') self._map_projection = map_projection - # Wrapped methods - plot = _basemap_norecurse(_default_latlon(_plot_wrapper(_standardize_1d( - _indicate_error(_cycle_changer(_basemap_redirect(maxes.Axes.plot))) - )))) - scatter = _basemap_norecurse(_default_latlon(_scatter_wrapper(_standardize_1d( - _indicate_error(_cycle_changer(_basemap_redirect(maxes.Axes.scatter))) - )))) - contour = _basemap_norecurse(_default_latlon(_standardize_2d(_cmap_changer( - _basemap_redirect(maxes.Axes.contour) - )))) - contourf = _basemap_norecurse(_default_latlon(_standardize_2d(_cmap_changer( - _basemap_redirect(maxes.Axes.contourf) - )))) - pcolor = _basemap_norecurse(_default_latlon(_standardize_2d(_cmap_changer( - _basemap_redirect(maxes.Axes.pcolor) - )))) - pcolormesh = _basemap_norecurse(_default_latlon(_standardize_2d(_cmap_changer( - _basemap_redirect(maxes.Axes.pcolormesh) - )))) - quiver = _basemap_norecurse(_default_latlon(_standardize_2d(_cmap_changer( - _basemap_redirect(maxes.Axes.quiver) - )))) - streamplot = _basemap_norecurse(_default_latlon(_standardize_2d(_cmap_changer( - _basemap_redirect(maxes.Axes.streamplot) - )))) - barbs = _basemap_norecurse(_default_latlon(_standardize_2d(_cmap_changer( - _basemap_redirect(maxes.Axes.barbs) - )))) - hexbin = _basemap_norecurse(_standardize_1d(_cmap_changer( - _basemap_redirect(maxes.Axes.hexbin) - ))) - imshow = _basemap_norecurse(_cmap_changer( - _basemap_redirect(maxes.Axes.imshow) - )) + # Undocumented plotting overrides + @_basemap_norecurse + def barbs(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().barbs(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def contour(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().contour(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def contourf(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().contourf(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def hexbin(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().hexbin(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def imshow(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().imshow(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def pcolor(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().pcolor(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def pcolormesh(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().pcolormesh(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def plot(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().plot(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def quiver(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().quiver(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def scatter(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().scatter(*args, latlon=latlon, **kwargs) + + @_basemap_norecurse + def streamplot(self, *args, latlon=True, **kwargs): + # Use latlon=True by default + return super().streamplot(*args, latlon=latlon, **kwargs) diff --git a/proplot/axes/plot.py b/proplot/axes/plot.py index 4b4408144..d27922f73 100644 --- a/proplot/axes/plot.py +++ b/proplot/axes/plot.py @@ -631,35 +631,6 @@ def _to_ndarray(data): return np.atleast_1d(getattr(data, 'values', data)) -def _default_latlon(self, func, *args, latlon=None, **kwargs): - """ - Makes ``latlon=True`` the default for basemap plots. - This means you no longer have to pass ``latlon=True`` if your data - coordinates are longitude and latitude. - """ - @functools.wraps(func) - def wrapper(*args, latlon=None, **kwargs): - if latlon is None: - latlon = True - return func(*args, latlon=latlon, **kwargs) - return wrapper - - -def _default_transform(self, func, *args, transform=None, **kwargs): - """ - Makes ``transform=cartopy.crs.PlateCarree()`` the default - for cartopy plots. This means you no longer have to - pass ``transform=cartopy.crs.PlateCarree()`` if your data - coordinates are longitude and latitude. - """ - @functools.wraps(func) - def wrapper(*args, transform=None, **kwargs): - if transform is None: - transform = PlateCarree() - return func(*args, transform=transform, **kwargs) - return wrapper - - def _axis_labels_title(data, axis=None, units=True): """ Get data and label for pandas or xarray objects or their coordinates along axis @@ -2994,23 +2965,6 @@ def wrapper(*args, autoformat=None, **kwargs): return func(*args, **kwargs) -def _basemap_redirect(func): - """ - Docorator that calls the basemap version of the function of the - same name. This must be applied as the innermost decorator. - """ - name = func.__name__ - - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - if getattr(self, 'name', '') == 'basemap': - return getattr(self.projection, name)(*args, ax=self, **kwargs) - else: - return func(self, *args, **kwargs) - wrapper.__doc__ = None - return wrapper - - def _basemap_norecurse(func): """ Decorator to prevent recursion in basemap method overrides. From cd4b17d39fbdf8facd0036c7f0e6546b513bef99 Mon Sep 17 00:00:00 2001 From: Luke Davis Date: Sun, 21 Jun 2020 15:00:49 -0600 Subject: [PATCH 4/4] Stash progress --- proplot/axes/base.py | 75 +- proplot/axes/geo.py | 56 +- proplot/axes/plot.py | 4223 +++++++++++++++++++++--------------------- 3 files changed, 2184 insertions(+), 2170 deletions(-) diff --git a/proplot/axes/base.py b/proplot/axes/base.py index 464e09c23..8dc2aa183 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -696,7 +696,7 @@ def _plot_redirect(self, name, *args, **kwargs): if getattr(self, 'name', '') == 'basemap': return getattr(self.projection, name)(*args, ax=self, **kwargs) else: - return getattr(maxes.Axes, name)(self, *args, **kwargs) + return getattr(super(), name)(self, *args, **kwargs) def _range_gridspec(self, x): """ @@ -1244,7 +1244,7 @@ def areax(self, *args, **kwargs): # bar = _bar_wrapper(_standardize_1d(_indicate_error(_cycle_changer( @plot._concatenate_docstrings @docstring.add_snippets - @plot._add_autoformat + @plot._with_autoformat def bar( self, x=None, height=None, width=0.8, bottom=None, *, vert=None, orientation='vertical', stacked=False, @@ -1268,6 +1268,23 @@ def bar( kwargs, kwargs_legend_colorbar = plot._parse_cycle(**kwargs) kwargs, kwargs_error = plot._parse_error(**kwargs) + # Get step size for bar plots + # WARNING: This will fail for non-numeric non-datetime64 singleton + # datatypes but this is good enough for vast majority of cases. + if not stacked and not getattr(self, '_absolute_bar_width', False): + x_test = np.atleast_1d(_to_ndarray(x)) + if len(x_test) >= 2: + x_step = x_test[1:] - x_test[:-1] + x_step = np.concatenate((x_step, x_step[-1:])) + elif x_test.dtype == np.datetime64: + x_step = np.timedelta64(1, 'D') + else: + x_step = np.array(0.5) + if np.issubdtype(x_test.dtype, np.datetime64): + # Avoid integer timedelta truncation + x_step = x_step.astype('timedelta64[ns]') + width = width * x_step / ncols + # Parse args # TODO: Stacked feature is implemented in `cycle_changer`, but makes more # sense do document here; figure out way to move it here? @@ -1369,7 +1386,7 @@ def boxes(self, *args, **kwargs): # boxplot = _boxplot_wrapper(_standardize_1d(_cycle_changer( @plot._concatenate_docstrings @docstring.add_snippets - @plot._add_autoformat + @plot._with_autoformat def boxplot( self, *args, color='k', fill=True, fillcolor=None, fillalpha=0.7, @@ -1716,10 +1733,19 @@ def contour(self, *args, **kwargs): %(plot.auto_colorbar)s %(plot.cmap_args)s """ + # Parse arguments args = plot._parse_2d(*args) kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) + kwargs, kwargs_label = plot._parse_labels(**kwargs) + + # Call main function mappable = self._plot_redirect('contour', *args, **kwargs) + + # Post-processing + plot._auto_contour_labels(mappable, **kwargs_label) plot._auto_colorbar(mappable, **kwargs_colorbar) + plot._edgefix_contour(mappable) + return mappable @plot._concatenate_docstrings @@ -1840,7 +1866,7 @@ def _fill_between_apply( @plot._concatenate_docstrings @docstring.add_snippets - @plot._add_autoformat + @plot._with_autoformat def fill_between(self, *args, **kwargs): """ %(axes.fill_between)s @@ -1849,7 +1875,7 @@ def fill_between(self, *args, **kwargs): @plot._concatenate_docstrings @docstring.add_snippets - @plot._add_autoformat + @plot._with_autoformat def fill_betweenx(self, *args, **kwargs): """ %(axes.fill_betweenx)s @@ -1934,7 +1960,7 @@ def hexbin(self, *args, **kwargs): # hist = _hist_wrapper(_standardize_1d(_cycle_changer( @plot._concatenate_docstrings @docstring.add_snippets - @plot._add_autoformat + @plot._with_autoformat def hist(self, x, bins=None, **kwargs): """ Add histogram(s). @@ -2395,7 +2421,7 @@ def parametric( ) else: raise ValueError('Missing required keyword argument "values".') - cmap, norm, kwargs = plot._parse_cmap_norm(**kwargs) + kwargs, kwargs_colorbar = plot._parse_cmap(**kwargs) # Verify shapes x, y, values = np.atleast_1d(x), np.atleast_1d(y), np.atleast_1d(values) @@ -2446,27 +2472,26 @@ def parametric( coords.append(np.concatenate((pleft, pright), axis=0)) coords = np.array(coords) - # Create LineCollection and update with values + # Add LineCollection and update with values # NOTE: Default capstyle is butt but this may look weird with vector graphics - obj = mcollections.LineCollection( - coords, cmap=cmap, norm=norm, - linestyles='-', capstyle='butt', joinstyle='miter', - ) - values = np.asarray(values) - obj.set_array(values) - obj.update({ - key: value for key, value in kwargs.items() - if key not in ('color',) - }) + # NOTE: Calling set_array is what triggers LineCollection to determine + # colors using ScalarMappable and normalizer. + kwargs.setdefault('capstyle', 'butt') + kwargs.setdefault('joinstyle', 'miter') + kwargs.setdefault('linestyles', '-') + mappable = mcollections.LineCollection(coords, **kwargs) + mappable.set_array(values) + self.add_collection(mappable) + self.autoscale_view(scalex=scalex, scaley=scaley) # Add collection with some custom attributes # NOTE: Modern API uses self._request_autoscale_view but this is # backwards compatible to earliest matplotlib versions. - self.add_collection(obj) - self.autoscale_view(scalex=scalex, scaley=scaley) - obj.values = values - obj.levels = levels # needed for other functions - return obj + mappable.values = values + mappable.levels = levels # needed for other functions + plot._auto_colorbar(mappable, **kwargs_colorbar) + + return mappable @plot._concatenate_docstrings @docstring.add_snippets @@ -2542,7 +2567,7 @@ def pie(self, *args, **kwargs): # plot = _plot_wrapper(_standardize_1d(_indicate_error(_cycle_changer( @plot._concatenate_docstrings @docstring.add_snippets - @plot._add_autoformat + @plot._with_autoformat def plot(self, *args, cmap=None, values=None, **kwargs): """ Parameters @@ -2622,7 +2647,7 @@ def quiver(self, *args, **kwargs): # scatter = _scatter_wrapper(_standardize_1d(_indicate_error(_cycle_changer( @plot._concatenate_docstrings @docstring.add_snippets - @plot._add_autoformat + @plot._with_autoformat def scatter( self, *args, s=None, size=None, markersize=None, diff --git a/proplot/axes/geo.py b/proplot/axes/geo.py index 8babc6879..91a9914b2 100644 --- a/proplot/axes/geo.py +++ b/proplot/axes/geo.py @@ -4,6 +4,7 @@ """ import copy +import matplotlib.axes as maxes import matplotlib.axis as maxis import matplotlib.path as mpath import matplotlib.text as mtext @@ -14,9 +15,15 @@ from .. import crs as pcrs from ..config import rc from ..internals import ic # noqa: F401 -from ..internals import _not_none, _version, _version_cartopy, docstring, warnings +from ..internals import ( + _not_none, + _state_context, + _version, + _version_cartopy, + docstring, + warnings, +) from . import base -from .plot import _basemap_norecurse try: import cartopy.crs as ccrs @@ -1317,6 +1324,16 @@ def _iter_gridlines(dict_): for obj in pj: yield obj + def _plot_norecurse(self, name, *args, **kwargs): + """ + Call the plotting method and avoid recursion. + """ + if self._called_from_basemap: + return getattr(maxes.Axes, name)(self, *args, **kwargs) + else: + with _state_context(self, _called_from_basemap=True): + return getattr(super(), name)(self, *args, **kwargs) + def _update_extent(self, lonlim=None, latlim=None, boundinglat=None): """ No-op. Map bounds cannot be changed in basemap. @@ -1512,57 +1529,48 @@ def projection(self, map_projection): self._map_projection = map_projection # Undocumented plotting overrides - @_basemap_norecurse + # NOTE: Simpler to document latlon=True and transform=PlateCarree() + # default behavior in 'Note' boxes on all base plotting methods. def barbs(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().barbs(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('barbs', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def contour(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().contour(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('contour', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def contourf(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().contourf(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('contourf', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def hexbin(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().hexbin(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('hexbin', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def imshow(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().imshow(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('imshow', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def pcolor(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().pcolor(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('pcolor', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def pcolormesh(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().pcolormesh(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('pcolormesh', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def plot(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().plot(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('plot', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def quiver(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().quiver(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('quiver', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def scatter(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().scatter(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('scatter', *args, latlon=latlon, **kwargs) - @_basemap_norecurse def streamplot(self, *args, latlon=True, **kwargs): # Use latlon=True by default - return super().streamplot(*args, latlon=latlon, **kwargs) + return self._plot_norecurse('streamplot', *args, latlon=latlon, **kwargs) diff --git a/proplot/axes/plot.py b/proplot/axes/plot.py index d27922f73..83d0d74c9 100644 --- a/proplot/axes/plot.py +++ b/proplot/axes/plot.py @@ -30,7 +30,7 @@ from .. import constructor from ..config import rc from ..internals import ic # noqa: F401 -from ..internals import _not_none, _state_context, docstring, warnings +from ..internals import _not_none, docstring, warnings from ..utils import edges, edges2d, to_rgb, to_xyz, units try: @@ -95,27 +95,6 @@ `__ issues. This slows down figure rendering by a bit. Default is :rc:`image.edgefix`. -labels : bool, optional - For `~matplotlib.axes.Axes.contour`, whether to add contour labels - with `~matplotlib.axes.Axes.clabel`. For `~matplotlib.axes.Axes.pcolor` - or `~matplotlib.axes.Axes.pcolormesh`, whether to add labels to the - center of grid boxes. In the latter case, the text will be black - when the luminance of the underlying grid box color is >50%%, and - white otherwise. -labels_kw : dict-like, optional - Ignored if `labels` is ``False``. Extra keyword args for the labels. - For `~matplotlib.axes.Axes.contour`, passed to - `~matplotlib.axes.Axes.clabel`. For `~matplotlib.axes.Axes.pcolor` - or `~matplotlib.axes.Axes.pcolormesh`, passed to - `~matplotlib.axes.Axes.text`. -fmt : format-spec, optional - Passed to the `~proplot.constructor.Norm` constructor, used to format - number labels. You can also use the `precision` keyword arg. -precision : int, optional - Maximum number of decimal places for the number labels. - Number labels are generated with the - `~proplot.ticker.SimpleFormatter` formatter, which allows us to - limit the precision. lw, linewidth, linewidths The width of `~matplotlib.axes.Axes.contour` lines and `~proplot.axes.Axes.parametric` lines, or the width of lines @@ -162,6 +141,27 @@ to `~proplot.axes.Axes.legend`. """ +docstring.snippets['plot.auto_labels'] = """ +labels : bool, optional + Whether to add labels. For contour plots, labels are added with + `~matplotlib.axes.Axes.clabel`. For pcolor plots, labels are added + with `~matplotlib.axes.Axes.text` by placing text at the center of + each grid box. For filled contour and pcolor plots, the text will + be colored black when the luminance of the underlying color is >50%% + and white otherwise. +labels_kw : dict-like, optional + Ignored if `labels` is ``False``. Extra keyword arguments for the labels. + For contour plots, these are passed to `~matplotlib.axes.Axes.clabel`. + or applied to the `~matplotlib.text.Text` objects. For pcolor plots, these + can only be `~matplotlib.text.Text` properties. +precision : int, optional + Maximum number of decimal places for the number labels. Trailing + zeros will be trimmed by default. +fmt : format-spec, optional + Passed to the `~proplot.constructor.Norm` constructor, used to format + number labels. You can also use the `precision` keyword arg. +""" + docstring.snippets['plot.cmap_args'] = """ cmap : colormap spec, optional The colormap specifer, passed to the `~proplot.constructor.Colormap` @@ -605,6 +605,35 @@ def _is_string(data): return len(data) and isinstance(_to_ndarray(data).flat[0], str) +def _iter_legend_children(children): + """ + Iterate recursively through `_children` attributes of various `HPacker`, + `VPacker`, and `DrawingArea` classes. + """ + for obj in children: + if hasattr(obj, '_children'): + yield from _iter_legend_children(obj._children) + else: + yield obj + + +def _iter_legend_objects(objs): + """ + Retrieve the (object, label) pairs for objects with actual labels + from nested lists and tuples of objects. + """ + # Account for (1) multiple columns of data, (2) functions that return + # multiple values (e.g. hist() returns (bins, values, patches)), and + # (3) matplotlib.Collection list subclasses. + if hasattr(objs, 'get_label'): + label = objs.get_label() + if label and label[:1] != '_': + yield (objs, label) + elif isinstance(objs, (list, tuple)): + for obj in objs: + yield from _iter_legend_objects(obj) + + def _to_arraylike(data): """ Convert list of lists to array-like type. @@ -674,2286 +703,2256 @@ def _axis_labels_title(data, axis=None, units=True): return data, str(label).strip() -def _parse_1d(self, func, *args, autoformat=None, **kwargs): +def _update_text(self, props): """ - Standardize the positional arguments for 1D data. + Monkey patch that adds pseudo "border" properties to text objects + without wrapping the entire class. We override update to facilitate + updating inset titles. """ - # Sanitize input - # TODO: Add exceptions for methods other than 'hist'? - name = func.__name__ - autoformat = _not_none(autoformat, rc['autoformat']) - _load_objects() - if not args: - return func(self, *args, **kwargs) - elif len(args) == 1: - x = None - y, *args = args - elif len(args) <= 4: # max signature is x, y, z, color - x, y, *args = args - else: - raise ValueError( - f'{name}() takes up to 4 positional arguments but {len(args)} was given.' - ) - vert = kwargs.get('vert', None) - if vert is not None: - orientation = ('vertical' if vert else 'horizontal') - else: - orientation = kwargs.get('orientation', 'vertical') - - # Iterate through list of ys that we assume are identical - # Standardize based on the first y input - if len(args) >= 1 and 'fill_between' in name: - ys, args = (y, args[0]), args[1:] - else: - ys = (y,) - ys = [_to_arraylike(y) for y in ys] + props = props.copy() # shallow copy + border = props.pop('border', None) + bordercolor = props.pop('bordercolor', 'w') + borderinvert = props.pop('borderinvert', False) + borderwidth = props.pop('borderwidth', 2) + if border: + facecolor, bgcolor = self.get_color(), bordercolor + if borderinvert: + facecolor, bgcolor = bgcolor, facecolor + kwargs = { + 'linewidth': borderwidth, + 'foreground': bgcolor, + 'joinstyle': 'miter', + } + self.update({ + 'color': facecolor, + 'path_effects': [mpatheffects.Stroke(**kwargs), mpatheffects.Normal()], + }) + return type(self).update(self, props) - # Auto x coords - y = ys[0] # test the first y input - if x is None: - axis = int( - name in ('hist', 'boxplot', 'violinplot') - or any(kwargs.get(s, None) for s in ('means', 'medians')) - ) - x, _ = _axis_labels_title(y, axis=axis) - x = _to_arraylike(x) - if x.ndim != 1: - raise ValueError( - f'x coordinates must be 1-dimensional, but got {x.ndim}.' - ) - # Auto formatting - x_index = None # index version of 'x' - if not hasattr(self, 'projection'): - # First handle string-type x-coordinates - kw = {} - xname = 'y' if orientation == 'horizontal' else 'x' - yname = 'x' if xname == 'y' else 'y' - if _is_string(x): - if name in ('hist',): - kwargs.setdefault('labels', list(x)) - else: - x_index = np.arange(len(x)) - kw[xname + 'locator'] = mticker.FixedLocator(x_index) - kw[xname + 'formatter'] = mticker.IndexFormatter(x) - kw[xname + 'minorlocator'] = mticker.NullLocator() - if name == 'boxplot': # otherwise IndexFormatter is overridden - kwargs['labels'] = x +def _add_colorbar( + self, mappable, values=None, + extend=None, extendsize=None, + title=None, label=None, + grid=None, tickminor=None, + reverse=False, tickloc=None, ticklocation=None, + locator=None, ticks=None, maxn=None, maxn_minor=None, + minorlocator=None, minorticks=None, + locator_kw=None, minorlocator_kw=None, + formatter=None, ticklabels=None, formatter_kw=None, rotation=None, + norm=None, norm_kw=None, # normalizer to use when passing colors/lines + orientation='horizontal', + edgecolor=None, linewidth=None, + labelsize=None, labelweight=None, labelcolor=None, + ticklabelsize=None, ticklabelweight=None, ticklabelcolor=None, + **kwargs +): + """ + Draw a colorbar with extra features. + """ + # NOTE: There is a weird problem with colorbars when simultaneously + # passing levels and norm object to a mappable; fixed by passing vmin/vmax + # instead of levels. (see: https://stackoverflow.com/q/40116968/4970632). + # NOTE: Often want levels instead of vmin/vmax, while simultaneously + # using a Normalize (for example) to determine colors between the levels + # (see: https://stackoverflow.com/q/42723538/4970632). Workaround makes + # sure locators are in vmin/vmax range exclusively; cannot match values. + # NOTE: In legend_wrapper() we try to add to the objects accepted by + # legend() using handler_map. We can't really do anything similar for + # colorbars; input must just be insnace of mixin class cm.ScalarMappable + # Mutable args + norm_kw = norm_kw or {} + formatter_kw = formatter_kw or {} + locator_kw = locator_kw or {} + minorlocator_kw = minorlocator_kw or {} - # Next handle labels if 'autoformat' is on - # NOTE: Do not overwrite existing labels! - if autoformat: - # Ylabel - y, label = _axis_labels_title(y) - iname = xname if name in ('hist',) else yname - if label and not getattr(self, f'get_{iname}label')(): - # For histograms, this label is used for *x* coordinates - kw[iname + 'label'] = label - if name not in ('hist',): - # Xlabel - x, label = _axis_labels_title(x) - if label and not getattr(self, f'get_{xname}label')(): - kw[xname + 'label'] = label - # Reversed axis - if name not in ('scatter',): - if x_index is None and len(x) > 1 and x[1] < x[0]: - kw[xname + 'reverse'] = True + # Parse input args + label = _not_none(title=title, label=label) + locator = _not_none(ticks=ticks, locator=locator) + minorlocator = _not_none(minorticks=minorticks, minorlocator=minorlocator) + ticklocation = _not_none(tickloc=tickloc, ticklocation=ticklocation) + formatter = _not_none(ticklabels=ticklabels, formatter=formatter) - # Appply - if kw: - self.format(**kw) + # Colorbar kwargs + # WARNING: PathCollection scatter objects have an extend method! + # WARNING: Matplotlib 3.3 deprecated 'extend' parameter passed to colorbar() + # but *also* fails to read 'extend' parameter when added to a pcolor mappable! + # Need to figure out workaround! + grid = _not_none(grid, rc['colorbar.grid']) + if extend is None: + if isinstance(getattr(mappable, 'extend', None), str): + extend = mappable.extend or 'neither' + else: + extend = 'neither' + kwargs.update({ + 'cax': self, + 'use_gridspec': True, + 'orientation': orientation, + 'spacing': 'uniform', + 'extend': extend, + }) + kwargs.setdefault('drawedges', grid) - # Standardize args - if x_index is not None: - x = x_index - if name in ('boxplot', 'violinplot'): - ys = [_to_ndarray(yi) for yi in ys] # store naked array - kwargs['positions'] = x + # Text property keyword args + kw_label = {} + for key, value in ( + ('size', labelsize), + ('weight', labelweight), + ('color', labelcolor), + ): + if value is not None: + kw_label[key] = value + kw_ticklabels = {} + for key, value in ( + ('size', ticklabelsize), + ('weight', ticklabelweight), + ('color', ticklabelcolor), + ('rotation', rotation), + ): + if value is not None: + kw_ticklabels[key] = value - # Basemap shift x coordiantes without shifting y, we fix this! - if getattr(self, 'name', '') == 'basemap' and kwargs.get('latlon', None): - ix, iys = x, [] - xmin, xmax = self.projection.lonmin, self.projection.lonmax - for y in ys: - # Ensure data is monotonic and falls within map bounds - ix, iy = _enforce_bounds(*_fix_latlon(x, y), xmin, xmax) - iys.append(iy) - x, ys = ix, iys + # Special case where auto colorbar is generated from 1d methods, a list is + # always passed, but some 1d methods (scatter) do have colormaps. + if ( + np.iterable(mappable) + and len(mappable) == 1 + and hasattr(mappable[0], 'get_cmap') + ): + mappable = mappable[0] - # WARNING: For some functions, e.g. boxplot and violinplot, we *require* - # cycle_changer is also applied so it can strip 'x' input. - with rc.context(autoformat=autoformat): - return func(self, x, *ys, *args, **kwargs) + # For container objects, we just assume color is the same for every item. + # Works for ErrorbarContainer, StemContainer, BarContainer. + if ( + np.iterable(mappable) + and len(mappable) > 0 + and all(isinstance(obj, mcontainer.Container) for obj in mappable) + ): + mappable = [obj[0] for obj in mappable] + # Test if we were given a mappable, or iterable of stuff; note Container + # and PolyCollection matplotlib classes are iterable. + cmap = None + if not isinstance(mappable, (martist.Artist, mcontour.ContourSet)): + # A colormap instance + # TODO: Pass remaining arguments through Colormap()? This is really + # niche usage so maybe not necessary. + if isinstance(mappable, mcolors.Colormap): + # NOTE: 'Values' makes no sense if this is just a colormap. Just + # use unique color for every segmentdata / colors color. + cmap = mappable + values = np.linspace(0, 1, cmap.N) -def _enforce_bounds(x, y, xmin, xmax): - """ - Ensure data for basemap plots is restricted between the minimum and - maximum longitude of the projection. Input is the ``x`` and ``y`` - coordinates. The ``y`` coordinates are rolled along the rightmost axis. - """ - if x.ndim != 1: - return x, y - # Roll in same direction if some points on right-edge extend - # more than 360 above min longitude; *they* should be on left side - lonroll = np.where(x > xmin + 360)[0] # tuple of ids - if lonroll.size: # non-empty - roll = x.size - lonroll.min() - x = np.roll(x, roll) - y = np.roll(y, roll, axis=-1) - x[:roll] -= 360 # make monotonic + # List of colors + elif np.iterable(mappable) and all( + isinstance(obj, str) or (np.iterable(obj) and len(obj) in (3, 4)) + for obj in mappable + ): + colors = list(mappable) + cmap = mcolors.ListedColormap(colors, '_no_name') + if values is None: + values = np.arange(len(colors)) + locator = _not_none(locator, values) # tick *all* values by default - # Set NaN where data not in range xmin, xmax. Must be done - # for regional smaller projections or get weird side-effects due - # to having valid data way outside of the map boundaries - y = y.copy() - if x.size - 1 == y.shape[-1]: # test western/eastern grid cell edges - y[..., (x[1:] < xmin) | (x[:-1] > xmax)] = np.nan - elif x.size == y.shape[-1]: # test the centers and pad by one for safety - where = np.where((x < xmin) | (x > xmax))[0] - y[..., where[1:-1]] = np.nan - return x, y + # List of artists + # NOTE: Do not check for isinstance(Artist) in case it is an mpl collection + elif np.iterable(mappable) and all( + hasattr(obj, 'get_color') or hasattr(obj, 'get_facecolor') + for obj in mappable + ): + # Generate colormap from colors and infer tick labels + colors = [] + for obj in mappable: + if hasattr(obj, 'get_color'): + color = obj.get_color() + else: + color = obj.get_facecolor() + if isinstance(color, np.ndarray): + color = color.squeeze() # e.g. scatter plot + if color.ndim != 1: + raise ValueError( + 'Cannot make colorbar from list of artists ' + f'with more than one color: {color!r}.' + ) + colors.append(to_rgb(color)) + cmap = mcolors.ListedColormap(colors, '_no_name') + + # Try to infer tick values and tick labels from Artist labels + if values is None: + # Get object labels and values (avoid overwriting colorbar 'label') + labs = [] + values = [] + for obj in mappable: + lab = value = None + if hasattr(obj, 'get_label'): + lab = obj.get_label() or None + if lab and lab[:1] == '_': # intended to be ignored by legend + lab = None + if lab: + try: + value = float(lab) + except (TypeError, ValueError): + pass + labs.append(lab) + values.append(value) + # Use default values if labels are non-numeric (numeric labels are + # common when making on-the-fly colorbars). Try to use object labels + # for ticks with default vertical rotation, like datetime axes. + if any(value is None for value in values): + values = np.arange(len(mappable)) + if formatter is None and any(lab is not None for lab in labs): + formatter = labs # use these fixed values for ticks + if orientation == 'horizontal': + kw_ticklabels.setdefault('rotation', 90) + locator = _not_none(locator, values) # tick *all* values by default + else: + raise ValueError( + 'Input mappable must be a matplotlib artist, ' + 'list of objects, list of colors, or colormap. ' + f'Got {mappable!r}.' + ) -def _fix_latlon(x, y): - """ - Ensure longitudes are monotonic and make `~numpy.ndarray` copies so the - contents can be modified. Ignores 2D coordinate arrays. - """ - # Sanitization and bail if 2d - if x.ndim == 1: - x = ma.array(x) - if y.ndim == 1: - y = ma.array(y) - if x.ndim != 1 or all(x < x[0]): # skip monotonic backwards data - return x, y - # Enforce monotonic longitudes - lon1 = x[0] - while True: - filter_ = (x < lon1) - if filter_.sum() == 0: - break - x[filter_] += 360 - return x, y + # Build ad hoc ScalarMappable object from colors + if cmap is not None: + if np.iterable(mappable) and len(values) != len(mappable): + raise ValueError( + f'Passed {len(values)} values, but only {len(mappable)} ' + f'objects or colors.' + ) + norm, *_ = _build_discrete_norm( + values=values, extend='neither', + cmap=cmap, norm=norm, norm_kw=norm_kw, + ) + mappable = mcm.ScalarMappable(norm, cmap) + # Try to get tick locations from *levels* or from *values* rather than + # random points along the axis. + # NOTE: Do not necessarily want e.g. minor tick locations at logminor + # for LogNorm! In _build_discrete_norm we sometimes select evenly spaced + # levels in log-space *between* powers of 10, so logminor ticks would be + # misaligned with levels. + if locator is None: + locator = getattr(mappable, 'ticks', None) + if locator is None: + # This should only happen if user calls plotting method on native + # matplotlib axes. + if isinstance(norm, mcolors.LogNorm): + locator = 'log' + elif isinstance(norm, mcolors.SymLogNorm): + locator = 'symlog' + locator_kw.setdefault('linthresh', norm.linthresh) + else: + locator = 'auto' -def _interp_poles(y, Z): - """ - Add data points on the poles as the average of highest latitude data. - """ - # Get means - with np.errstate(all='ignore'): - p1 = Z[0, :].mean() # pole 1, make sure is not 0D DataArray! - p2 = Z[-1, :].mean() # pole 2 - if hasattr(p1, 'item'): - p1 = np.asscalar(p1) # happens with DataArrays - if hasattr(p2, 'item'): - p2 = np.asscalar(p2) - # Concatenate - ps = (-90, 90) if (y[0] < y[-1]) else (90, -90) - Z1 = np.repeat(p1, Z.shape[1])[None, :] - Z2 = np.repeat(p2, Z.shape[1])[None, :] - y = ma.concatenate((ps[:1], y, ps[1:])) - Z = ma.concatenate((Z1, Z, Z2), axis=0) - return y, Z + elif not isinstance(locator, mticker.Locator): + # Get default maxn, try to allot 2em squares per label maybe? + # NOTE: Cannot use Axes.get_size_inches because this is a + # 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) + fontsize = kw_ticklabels.get('size', rc['xtick.labelsize']) + else: + scale = 1 + length = height * abs(self.get_position().height) + fontsize = kw_ticklabels.get('size', rc['ytick.labelsize']) + fontsize = rc._scale_font(fontsize) + maxn = _not_none(maxn, int(length / (scale * fontsize / 72))) + maxn_minor = _not_none( + maxn_minor, int(length / (0.5 * fontsize / 72)) + ) + # Get locator + if tickminor and minorlocator is None: + step = 1 + len(locator) // max(1, maxn_minor) + minorlocator = locator[::step] + step = 1 + len(locator) // max(1, maxn) + locator = locator[::step] -def _parse_2d( - self, func, *args, autoformat=None, order='C', globe=False, **kwargs -): - """ - Standardize the positional arguments for 2D data. - """ - # Sanitize input - name = func.__name__ - autoformat = _not_none(autoformat, rc['autoformat']) - _load_objects() - if not args: - return func(self, *args, **kwargs) - elif len(args) > 5: - raise ValueError( - f'{name}() takes up to 5 positional arguments but {len(args)} was given.' - ) - x, y = None, None - if len(args) > 2: - x, y, *args = args + # Get extend triangles in physical units + width, height = self.figure.get_size_inches() + if orientation == 'horizontal': + scale = width * abs(self.get_position().width) + else: + scale = height * abs(self.get_position().height) + extendsize = units(_not_none(extendsize, rc['colorbar.extend'])) + extendsize = extendsize / (scale - 2 * extendsize) - # Ensure DataArray, DataFrame or ndarray - Zs = [] - for Z in args: - Z = _to_arraylike(Z) - if Z.ndim != 2: - raise ValueError(f'Z must be 2-dimensional, got shape {Z.shape}.') - Zs.append(Z) - if not all(Zs[0].shape == Z.shape for Z in Zs): - raise ValueError( - f'Zs must be same shape, got shapes {[Z.shape for Z in Zs]}.' - ) + # Draw the colorbar + # NOTE: Set default formatter here because we optionally apply a FixedFormatter + # using *labels* from handle input. + locator = constructor.Locator(locator, **locator_kw) + formatter = constructor.Formatter(_not_none(formatter, 'auto'), **formatter_kw) + kwargs.update({ + 'ticks': locator, + 'format': formatter, + 'ticklocation': ticklocation, + 'extendfrac': extendsize + }) + mappable.extend = extend # matplotlib >=3.3 + cb = self.figure.colorbar(mappable, **kwargs) + axis = self.xaxis if orientation == 'horizontal' else self.yaxis - # Retrieve coordinates - if x is None and y is None: - Z = Zs[0] - if order == 'C': - idx, idy = 1, 0 + # The minor locator + # TODO: Document the improved minor locator functionality! + # NOTE: Colorbar._use_auto_colorbar_locator() is never True because we use + # the custom DiscreteNorm normalizer. Colorbar._ticks() always called. + if minorlocator is None: + if tickminor: + cb.minorticks_on() else: - idx, idy = 0, 1 - # x = np.arange(Z.shape[idx]) - # y = np.arange(Z.shape[idy]) - if isinstance(Z, ndarray): - x = np.arange(Z.shape[idx]) - y = np.arange(Z.shape[idy]) - elif isinstance(Z, DataArray): # DataArray - x = Z.coords[Z.dims[idx]] - y = Z.coords[Z.dims[idy]] - else: # DataFrame; never Series or Index because these are 1d - if order == 'C': - x = Z.columns - y = Z.index - else: - x = Z.index - y = Z.columns + cb.minorticks_off() + elif not hasattr(cb, '_ticker'): + warnings._warn_proplot( + 'Matplotlib colorbar API has changed. ' + f'Cannot use custom minor tick locator {minorlocator!r}.' + ) + cb.minorticks_on() # at least turn them on + else: + # Set the minor ticks just like matplotlib internally sets the + # major ticks. Private API is the only way! + minorlocator = constructor.Locator(minorlocator, **minorlocator_kw) + ticks, *_ = cb._ticker(minorlocator, mticker.NullFormatter()) + axis.set_ticks(ticks, minor=True) + axis.set_ticklabels([], minor=True) - # Optionally re-order - # TODO: Double check this - if order == 'F': - x, y = x.T, y.T # in case they are 2-dimensional - Zs = tuple(Z.T for Z in Zs) - elif order != 'C': + # Label and tick label settings + # WARNING: Must use colorbar set_label to set text, calling set_text on + # the axis will do nothing! + if label is not None: + cb.set_label(label) + axis.label.update(kw_label) + for obj in axis.get_ticklabels(): + obj.update(kw_ticklabels) + + # Ticks + xy = axis.axis_name + for which in ('minor', 'major'): + kw = rc.category(xy + 'tick.' + which) + kw.pop('visible', None) + if edgecolor: + kw['color'] = edgecolor + if linewidth: + kw['width'] = linewidth + axis.set_tick_params(which=which, **kw) + axis.set_ticks_position(ticklocation) + + # Fix alpha-blending issues. + # Cannot set edgecolor to 'face' if alpha non-zero because blending will + # occur, will get colored lines instead of white ones. Need manual blending + # NOTE: For some reason cb solids uses listed colormap with always 1.0 + # alpha, then alpha is applied after. + # See: https://stackoverflow.com/a/35672224/4970632 + cmap = cb.cmap + if not cmap._isinit: + cmap._init() + if any(cmap._lut[:-1, 3] < 1): + warnings._warn_proplot( + f'Using manual alpha-blending for {cmap.name!r} colorbar solids.' + ) + # Generate "secret" copy of the colormap! + lut = cmap._lut.copy() + cmap = mcolors.Colormap('_cbar_fix', N=cmap.N) + cmap._isinit = True + cmap._init = lambda: None + # Manually fill lookup table with alpha-blended RGB colors! + for i in range(lut.shape[0] - 1): + alpha = lut[i, 3] + lut[i, :3] = (1 - alpha) * 1 + alpha * lut[i, :3] # blend *white* + lut[i, 3] = 1 + cmap._lut = lut + # Update colorbar + cb.cmap = cmap + cb.draw_all() + + # Fix colorbar outline + kw_outline = { + 'edgecolor': _not_none(edgecolor, rc['axes.edgecolor']), + 'linewidth': _not_none(linewidth, rc['axes.linewidth']), + } + if cb.outline is not None: + cb.outline.update(kw_outline) + if cb.dividers is not None: + cb.dividers.update(kw_outline) + + # *Never* rasterize because it causes misalignment with border lines + if cb.solids: + cb.solids.set_rasterized(False) + cb.solids.set_linewidth(0.4) + cb.solids.set_edgecolor('face') + + # Invert the axis if descending DiscreteNorm + norm = mappable.norm + if getattr(norm, '_descending', None): + axis.set_inverted(True) + if reverse: # potentially double reverse, although that would be weird... + axis.set_inverted(True) + return cb + + +def _add_legend( + self, handles=None, labels=None, *, ncol=None, ncols=None, + center=None, order='C', loc=None, label=None, title=None, + fontsize=None, fontweight=None, fontcolor=None, + color=None, marker=None, lw=None, linewidth=None, + dashes=None, linestyle=None, markersize=None, frameon=None, frame=None, + **kwargs +): + """ + Draw a legend with extra features. + """ + # Parse input args + # TODO: Legend entries for colormap or scatterplot objects! Idea is we + # pass a scatter plot or contourf or whatever, and legend is generated by + # drawing patch rectangles or markers using data values and their + # corresponding cmap colors! For scatterplots just test get_facecolor() + # to see if it contains more than one color. + # TODO: It is *also* often desirable to label a colormap object with + # one data value. Maybe add a legend option for the *number of samples* + # or the *sample points* when drawing legends for colormap objects. + # Look into "legend handlers", might just want to add own handlers by + # passing handler_map to legend() and get_legend_handles_labels(). + if order not in ('F', 'C'): raise ValueError( f'Invalid order {order!r}. Choose from ' '"C" (row-major, default) and "F" (column-major).' ) + ncol = _not_none(ncols=ncols, ncol=ncol) + title = _not_none(label=label, title=title) + frameon = _not_none( + frame=frame, frameon=frameon, default=rc['legend.frameon'] + ) + if handles is not None and not np.iterable(handles): # e.g. a mappable object + handles = [handles] + if labels is not None and (not np.iterable(labels) or isinstance(labels, str)): + labels = [labels] + if title is not None: + kwargs['title'] = title + if frameon is not None: + kwargs['frameon'] = frameon + if fontsize is not None: + kwargs['fontsize'] = rc._scale_font(fontsize) - # Check coordinates - x, y = _to_arraylike(x), _to_arraylike(y) - if x.ndim != y.ndim: + # Handle and text properties that are applied after-the-fact + # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds + # shading in legend entry. This change is not noticeable in other situations. + kw_text = {} + for key, value in ( + ('color', fontcolor), + ('weight', fontweight), + ): + if value is not None: + kw_text[key] = value + kw_handle = {'solid_capstyle': 'butt'} + for key, value in ( + ('color', color), + ('marker', marker), + ('linewidth', lw), + ('linewidth', linewidth), + ('markersize', markersize), + ('linestyle', linestyle), + ('dashes', dashes), + ): + if value is not None: + kw_handle[key] = value + + # Legend box properties + outline = rc.fill( + { + 'linewidth': 'axes.linewidth', + 'edgecolor': 'axes.edgecolor', + 'facecolor': 'axes.facecolor', + 'alpha': 'legend.framealpha', + } + ) + for key in (*outline,): + if key != 'linewidth': + if kwargs.get(key, None): + outline.pop(key, None) + + # Get axes for legend handle detection + # TODO: Update this when no longer use "filled panels" for outer legends + axs = [self] + if self._panel_hidden: + if self._panel_parent: # axes panel + axs = list(self._panel_parent._iter_axes(hidden=False, children=True)) + else: + axs = list(self.figure._iter_axes(hidden=False, children=True)) + + # Handle list of lists (centered row legends) + # NOTE: Avoid very common plot() error where users draw individual lines + # with plot() and add singleton tuples to a list of handles. If matplotlib + # gets a list like this but gets no 'labels' argument, it raises error. + list_of_lists = False + if handles is not None: + handles = [ + handle[0] if type(handle) is tuple and len(handle) == 1 else handle + for handle in handles + ] + list_of_lists = any(type(handle) in (list, np.ndarray) for handle in handles) + if handles is not None and labels is not None and len(handles) != len(labels): raise ValueError( - f'x coordinates are {x.ndim}-dimensional, ' - f'but y coordinates are {y.ndim}-dimensional.' + f'Got {len(handles)} handles and {len(labels)} labels.' ) - for s, array in zip(('x', 'y'), (x, y)): - if array.ndim not in (1, 2): + if list_of_lists: + if any(not np.iterable(_) for _ in handles): + raise ValueError(f'Invalid handles={handles!r}.') + if not labels: + labels = [None] * len(handles) + elif not all(np.iterable(_) and not isinstance(_, str) for _ in labels): + # e.g. handles=[obj1, [obj2, obj3]] requires labels=[lab1, [lab2, lab3]] raise ValueError( - f'{s} coordinates are {array.ndim}-dimensional, ' - f'but must be 1 or 2-dimensional.' + f'Invalid labels={labels!r} for handles={handles!r}.' ) - # Auto axis labels - # TODO: Check whether isinstance(GeoAxes) instead of checking projection attribute - kw = {} - xi = yi = None - if not hasattr(self, 'projection'): - # First handle string-type x and y-coordinates - if _is_string(x): - xi = np.arange(len(x)) - kw['xlocator'] = mticker.FixedLocator(xi) - kw['xformatter'] = mticker.IndexFormatter(x) - kw['xminorlocator'] = mticker.NullLocator() - if _is_string(y): - yi = np.arange(len(y)) - kw['ylocator'] = mticker.FixedLocator(yi) - kw['yformatter'] = mticker.IndexFormatter(y) - kw['yminorlocator'] = mticker.NullLocator() - - # Handle labels if 'autoformat' is on - # NOTE: Do not overwrite existing labels! - if autoformat: - for key, xy in zip(('xlabel', 'ylabel'), (x, y)): - # Axis label - _, label = _axis_labels_title(xy) - if label and not getattr(self, f'get_{key}')(): - kw[key] = label - # Reversed axis - if ( - len(xy) > 1 - and all(isinstance(xy, Number) for xy in xy[:2]) - and xy[1] < xy[0] - ): - kw[key[0] + 'reverse'] = True - if kw: - self.format(**kw) + # Parse handles and legends with native matplotlib parser + if not list_of_lists: + if isinstance(handles, np.ndarray): + handles = handles.tolist() + if isinstance(labels, np.ndarray): + labels = labels.tolist() + handles, labels, *_ = mlegend._parse_legend_args( + axs, handles=handles, labels=labels, + ) + pairs = list(zip(handles, labels)) + else: + pairs = [] + for ihandles, ilabels in zip(handles, labels): + if isinstance(ihandles, np.ndarray): + ihandles = ihandles.tolist() + if isinstance(ilabels, np.ndarray): + ilabels = ilabels.tolist() + ihandles, ilabels, *_ = mlegend._parse_legend_args( + axs, handles=ihandles, labels=ilabels, + ) + pairs.append(list(zip(handles, labels))) - # Use *index coordinates* from here on out if input was array of strings - if xi is not None: - x = xi - if yi is not None: - y = yi + # Manage pairs in context of 'center' option + center = _not_none(center, list_of_lists) + if not center and list_of_lists: # standardize format based on input + list_of_lists = False # no longer is list of lists + pairs = [pair for ipairs in pairs for pair in ipairs] + elif center and not list_of_lists: + list_of_lists = True + ncol = _not_none(ncol, 3) + pairs = [ + pairs[i * ncol:(i + 1) * ncol] for i in range(len(pairs)) + ] # to list of iterables + ncol = None + if list_of_lists: # remove empty lists, pops up in some examples + pairs = [ipairs for ipairs in pairs if ipairs] - # Auto axes title and colorbar label - # NOTE: Do not overwrite existing title! - # NOTE: Must apply default colorbar label *here* rather than in - # cmap_args in case metadata is stripped by globe=True. - colorbar_kw = kwargs.pop('colorbar_kw', None) or {} - if autoformat: - _, colorbar_label = _axis_labels_title(Zs[0], units=True) - colorbar_kw.setdefault('label', colorbar_label) - kwargs['colorbar_kw'] = colorbar_kw + # Bail if no pairs + if not pairs: + return mlegend.Legend(self, [], [], ncol=ncol, loc=loc, **kwargs) - # Enforce edges - if name in ('pcolor', 'pcolormesh', 'pcolorfast'): - Z = Zs[0] # already enforced that shapes must be identical (see above) - xlen, ylen = x.shape[-1], y.shape[0] - if Z.ndim != 2: - raise ValueError( - f'Input arrays must be 2D, instead got shape {Z.shape}.' - ) - elif Z.shape[1] == xlen and Z.shape[0] == ylen: - # Get edges given centers - if all(z.ndim == 1 and z.size > 1 and _is_number(z) for z in (x, y)): - x = edges(x) - y = edges(y) - else: - if ( - x.ndim == 2 and x.shape[0] > 1 and x.shape[1] > 1 - and _is_number(x) - ): - x = edges2d(x) - if ( - y.ndim == 2 and y.shape[0] > 1 and y.shape[1] > 1 - and _is_number(y) - ): - y = edges2d(y) - elif Z.shape[1] != xlen - 1 or Z.shape[0] != ylen - 1: - raise ValueError( - f'Input shapes x {x.shape} and y {y.shape} must match ' - f'Z centers {Z.shape} or ' - f'Z borders {tuple(i+1 for i in Z.shape)}.' + # Individual legend + legs = [] + width, height = self.get_size_inches() + if not center: + # Optionally change order + # See: https://stackoverflow.com/q/10101141/4970632 + # Example: If 5 columns, but final row length 3, columns 0-2 have + # N rows but 3-4 have N-1 rows. + ncol = _not_none(ncol, 3) + if order == 'C': + split = [ # split into rows + pairs[i * ncol:(i + 1) * ncol] + for i in range(len(pairs) // ncol + 1) + ] + nrowsmax = len(split) # max possible row count + nfinalrow = len(split[-1]) # columns in final row + nrows = ( + [nrowsmax] * nfinalrow + [nrowsmax - 1] * (ncol - nfinalrow) ) + fpairs = [] + for col, nrow in enumerate(nrows): # iterate through cols + fpairs.extend(split[row][col] for row in range(nrow)) + pairs = fpairs - # Enforce centers + # Draw legend + leg = mlegend.Legend(self, *zip(*pairs), ncol=ncol, loc=loc, **kwargs) + legs = [leg] + + # Legend with centered rows, accomplished by drawing separate legends for + # each row. The label spacing/border spacing will be exactly replicated. else: - Z = Zs[0] # already enforced that shapes must be identical (see above) - xlen, ylen = x.shape[-1], y.shape[0] - if Z.ndim != 2: - raise ValueError( - f'Input arrays must be 2d, instead got shape {Z.shape}.' + # Message when overriding some properties + overridden = [] + kwargs.pop('frameon', None) # then add back later! + for override in ('bbox_transform', 'bbox_to_anchor'): + prop = kwargs.pop(override, None) + if prop is not None: + overridden.append(override) + if ncol is not None: + warnings._warn_proplot( + 'Detected list of *lists* of legend handles. ' + 'Ignoring user input property "ncol".' ) - elif Z.shape[1] == xlen - 1 and Z.shape[0] == ylen - 1: - # Get centers given edges. - if all(z.ndim == 1 and z.size > 1 and _is_number(z) for z in (x, y)): - x = 0.5 * (x[1:] + x[:-1]) - y = 0.5 * (y[1:] + y[:-1]) - else: - if ( - x.ndim == 2 and x.shape[0] > 1 and x.shape[1] > 1 - and _is_number(x) - ): - x = 0.25 * (x[:-1, :-1] + x[:-1, 1:] + x[1:, :-1] + x[1:, 1:]) - if ( - y.ndim == 2 and y.shape[0] > 1 and y.shape[1] > 1 - and _is_number(y) - ): - y = 0.25 * (y[:-1, :-1] + y[:-1, 1:] + y[1:, :-1] + y[1:, 1:]) - elif Z.shape[1] != xlen or Z.shape[0] != ylen: + if overridden: + warnings._warn_proplot( + 'Ignoring user input properties ' + + ', '.join(map(repr, overridden)) + + ' for centered-row legend.' + ) + + # Determine space we want sub-legend to occupy as fraction of height + # NOTE: Empirical testing shows spacing fudge factor necessary to + # exactly replicate the spacing of standard aligned legends. + fontsize = kwargs.get('fontsize', None) or rc['legend.fontsize'] + fontsize = rc._scale_font(fontsize) + spacing = kwargs.get('labelspacing', None) or rc['legend.labelspacing'] + if pairs: + interval = 1 / len(pairs) # split up axes + interval = (((1 + spacing * 0.85) * fontsize) / 72) / height + + # Iterate and draw + # NOTE: We confine possible bounding box in *y*-direction, but do not + # confine it in *x*-direction. Matplotlib will automatically move + # left-to-right if you request this. + ymin, ymax = None, None + if order == 'F': + raise NotImplementedError( + 'When center=True, ProPlot vertically stacks successive ' + 'single-row legends. Column-major (order="F") ordering ' + 'is un-supported.' + ) + loc = _not_none(loc, 'upper center') + if not isinstance(loc, str): raise ValueError( - f'Input shapes x {x.shape} and y {y.shape} ' - f'must match Z centers {Z.shape} ' - f'or Z borders {tuple(i+1 for i in Z.shape)}.' + f'Invalid location {loc!r} for legend with center=True. ' + 'Must be a location *string*.' + ) + elif loc == 'best': + warnings._warn_proplot( + 'For centered-row legends, cannot use "best" location. ' + 'Using "upper center" instead.' ) - # Cartopy projection axes - if ( - getattr(self, 'name', '') == 'cartopy' - and isinstance(kwargs.get('transform', None), PlateCarree) - ): - x, y = _fix_latlon(x, y) - ix, iZs = x, [] - for Z in Zs: - if globe and x.ndim == 1 and y.ndim == 1: - # Fix holes over poles by *interpolating* there - y, Z = _interp_poles(y, Z) + # Iterate through sublists + for i, ipairs in enumerate(pairs): + if i == 1: + kwargs.pop('title', None) + if i >= 1 and title is not None: + i += 1 # extra space! - # Fix seams by ensuring circular coverage. Unlike basemap, - # cartopy can plot across map edges. - if x[0] % 360 != (x[-1] + 360) % 360: - ix = ma.concatenate((x, [x[0] + 360])) - Z = ma.concatenate((Z, Z[:, :1]), axis=1) - iZs.append(Z) - x, Zs = ix, iZs + # Legend position + if 'upper' in loc: + y1 = 1 - (i + 1) * interval + y2 = 1 - i * interval + elif 'lower' in loc: + y1 = (len(pairs) + i - 2) * interval + y2 = (len(pairs) + i - 1) * interval + else: # center + y1 = 0.5 + interval * len(pairs) / 2 - (i + 1) * interval + y2 = 0.5 + interval * len(pairs) / 2 - i * interval + ymin = min(y1, _not_none(ymin, y1)) + ymax = max(y2, _not_none(ymax, y2)) - # Basemap projection axes - elif getattr(self, 'name', '') == 'basemap' and kwargs.get('latlon', None): - # Fix grid - xmin, xmax = self.projection.lonmin, self.projection.lonmax - x, y = _fix_latlon(x, y) - ix, iZs = x, [] - for Z in Zs: - # Ensure data is within map bounds - ix, Z = _enforce_bounds(x, Z, xmin, xmax) + # Draw legend + bbox = mtransforms.Bbox([[0, y1], [1, y2]]) + leg = mlegend.Legend( + self, *zip(*ipairs), loc=loc, ncol=len(ipairs), + bbox_transform=self.transAxes, bbox_to_anchor=bbox, + frameon=False, **kwargs + ) + legs.append(leg) - # Globe coverage fixes - if globe and ix.ndim == 1 and y.ndim == 1: - # Fix holes over poles by interpolating there (equivalent to - # simple mean of highest/lowest latitude points) - y, Z = _interp_poles(y, Z) + # Add legends manually so matplotlib does not remove old ones + for leg in legs: + self.add_artist(leg) + leg.legendPatch.update(outline) # or get_frame() - # Fix seams at map boundary; 3 scenarios here: - # Have edges (e.g. for pcolor), and they fit perfectly against - # basemap seams. Does not augment size. - if ix[0] == xmin and ix.size - 1 == Z.shape[1]: - pass # do nothing - # Have edges (e.g. for pcolor), and the projection edge is - # in-between grid cell boundaries. Augments size by 1. - elif ix.size - 1 == Z.shape[1]: # just add grid cell - ix = ma.append(xmin, ix) - ix[-1] = xmin + 360 - Z = ma.concatenate((Z[:, -1:], Z), axis=1) - # Have centers (e.g. for contourf), and we need to interpolate - # to left/right edges of the map boundary. Augments size by 2. - elif ix.size == Z.shape[1]: - xi = np.array([ix[-1], ix[0] + 360]) # x - if xi[0] != xi[1]: - Zq = ma.concatenate((Z[:, -1:], Z[:, :1]), axis=1) - xq = xmin + 360 - Zq = ( - Zq[:, :1] * (xi[1] - xq) + Zq[:, 1:] * (xq - xi[0]) - ) / (xi[1] - xi[0]) - ix = ma.concatenate(([xmin], ix, [xmin + 360])) - Z = ma.concatenate((Zq, Z, Zq), axis=1) - else: - raise ValueError( - 'Unexpected shape of longitude, latitude, and/or data array(s).' - ) - iZs.append(Z) - x, Zs = ix, iZs + # Apply *overrides* to legend elements + # WARNING: legendHandles only contains the *first* artist per legend because + # HandlerBase.legend_artist() called in Legend._init_legend_box() only + # returns the first artist. Instead we try to iterate through offset boxes. + # TODO: Remove this feature? Idea was this lets users create *categorical* + # legends in clunky way, e.g. entries denoting *colors* and entries denoting + # *markers*. But would be better to add capacity for categorical labels in a + # *single* legend like seaborn rather than multiple legends. + for leg in legs: + try: + children = leg._legend_handle_box._children + except AttributeError: # older versions maybe? + children = [] + for obj in _iter_legend_children(children): + # account for mixed legends, e.g. line on top of + # error bounds shading. + if isinstance(obj, mtext.Text): + leg.update(kw_text) + else: + for key, value in kw_handle.items(): + getattr(obj, f'set_{key}', lambda value: None)(value) - # Convert to projection coordinates - if x.ndim == 1 and y.ndim == 1: - x, y = np.meshgrid(x, y) - x, y = self.projection(x, y) - kwargs['latlon'] = False + # Draw manual fancy bounding box for un-aligned legend + # WARNING: The matplotlib legendPatch transform is the default transform, + # i.e. universal coordinates in points. Means we have to transform + # mutation scale into transAxes sizes. + # WARNING: Tempting to use legendPatch for everything but for some reason + # coordinates are messed up. In some tests all coordinates were just result + # of get window extent multiplied by 2 (???). Anyway actual box is found in + # _legend_box attribute, which is accessed by get_window_extent. + if center and frameon: + if len(legs) == 1: + # Use builtin frame + legs[0].set_frame_on(True) + else: + # Get coordinates + renderer = self.figure._get_renderer() + bboxs = [ + leg.get_window_extent(renderer).transformed(self.transAxes.inverted()) + for leg in legs + ] + xmin = min(bbox.xmin for bbox in bboxs) + xmax = max(bbox.xmax for bbox in bboxs) + ymin = min(bbox.ymin for bbox in bboxs) + ymax = max(bbox.ymax for bbox in bboxs) + fontsize = (fontsize / 72) / width # axes relative units + fontsize = renderer.points_to_pixels(fontsize) - # Finally return result - with rc.context(autoformat=autoformat): - return func(self, x, y, *Zs, **kwargs) + # Draw and format patch + patch = mpatches.FancyBboxPatch( + (xmin, ymin), xmax - xmin, ymax - ymin, + snap=True, zorder=4.5, + mutation_scale=fontsize, + transform=self.transAxes + ) + if kwargs.get('fancybox', rc['legend.fancybox']): + patch.set_boxstyle('round', pad=0, rounding_size=0.2) + else: + patch.set_boxstyle('square', pad=0) + patch.set_clip_on(False) + patch.update(outline) + self.add_artist(patch) + # Add shadow + # TODO: This does not work, figure out + if kwargs.get('shadow', rc['legend.shadow']): + shadow = mpatches.Shadow(patch, 20, -20) + self.add_artist(shadow) -def _get_error_data( - data, y, errdata=None, stds=None, pctiles=False, - stds_default=None, pctiles_default=None, - means_or_medians=True, absolute=False, label=False, -): + # Add patch to list + legs = (patch, *legs) + + # Append attributes and return, and set clip property!!! This is critical + # for tight bounding box calcs! + for leg in legs: + leg.set_clip_on(False) + return legs[0] if len(legs) == 1 else tuple(legs) + + +def _add_labels(func): """ - Return values that can be passed to the `~matplotlib.axes.Axes.errorbar` - `xerr` and `yerr` keyword args. + Add labels to the contour plot. """ - # Parse arguments - # NOTE: Have to guard against "truth value of an array is ambiguous" errors - if not isinstance(stds, ARRAY_TYPES): - if stds in (1, True): - stds = stds_default - elif stds in (0, False): - stds = None - if not isinstance(pctiles, ARRAY_TYPES): - if pctiles in (1, True): - pctiles = pctiles_default - elif pctiles in (0, False): - pctiles = None + @functools.wraps(func) + def wrapper( + self, *args, fmt=None, labels=None, labels_kw=None, precision=None, **kwargs, + ): + # Call main funtion + name = func.__name__ + obj = func(self, *args, **kwargs) + if not labels: + return obj - # Incompatible settings - if stds is not None and pctiles is not None: - warnings._warn_proplot( - 'You passed both a standard deviation range and a percentile range for ' - 'drawing error indicators. Using the former.' - ) - pctiles = None - if not means_or_medians and (stds is not None or pctiles is not None): - raise ValueError( - 'To automatically compute standard deviations or percentiles on columns ' - 'of data you must pass means=True or medians=True.' - ) - if means_or_medians and errdata is not None: - stds = pctiles = None - warnings._warn_proplot( - 'You explicitly provided the error bounds but also requested ' - 'automatically calculating means or medians on data columns. ' - 'It may make more sense to use the "stds" or "pctiles" keyword args ' - 'and have *proplot* calculate the error bounds.' - ) + # Default formatter + labels_kw = labels_kw or {} + fmt = _not_none(labels_kw.pop('fmt', None), fmt, 'simple') + fmt = constructor.Formatter(fmt, precision=precision) - # Compute error data in format that can be passed to matplotlib.axes.Axes.errorbar() - # NOTE: Include option to pass symmetric deviation from central points - y = _to_ndarray(y) - data = _to_ndarray(data) - if errdata is not None: - label_default = 'error range' - err = _to_ndarray(errdata) - if ( - err.ndim not in (1, 2) - or err.shape[-1] != y.shape[-1] - or err.ndim == 2 and err.shape[0] != 2 + # Add contour labels + if name in ('contour', 'tricontour', 'contourf', 'tricontourf'): + cobj = obj + cmap = obj.get_cmap() + norm = obj.get_norm() + levels = obj.levels + colors = None + if name in ('contourf', 'tricontourf'): + lums = [to_xyz(cmap(norm(level)), 'hcl')[2] for level in levels] + cobj = self.contour(*args, levels=levels, linewidths=0) + colors = ['w' if lum < 50 else 'k' for lum in lums] + text_kw = {} + for key in tuple(labels_kw): # allow dict to change size + if key in ( + 'levels', 'fontsize', 'colors', 'inline', 'inline_spacing', + 'manual', 'rightside_up', 'use_clabeltext', + ): + text_kw[key] = labels_kw.pop(key) + labels_kw.setdefault('colors', colors) + labels_kw.setdefault('inline_spacing', 3) + labels_kw.setdefault('fontsize', rc['text.labelsize']) + labs = cobj.clabel(fmt=fmt, **labels_kw) + for lab in labs: + lab.update(text_kw) + + # Add gridbox labels + # See: https://stackoverflow.com/a/20998634/4970632 + elif name in ( + 'pcolor', 'pcolormesh', 'pcolorfast', 'tripcolor', 'tripcolormesh' ): - raise ValueError( - f'errdata must have shape (2, {y.shape[-1]}), but got {err.shape}.' - ) - if err.ndim == 1: - abserr = err - err = np.empty((2, err.size)) - err[0, :] = y - abserr # translated back to absolute deviations below - err[1, :] = y + abserr - elif stds is not None: - label_default = fr'{stds[1]}$\sigma$ range' - err = y + np.std(data, axis=0)[None, :] * np.asarray(stds)[:, None] - elif pctiles is not None: - label_default = f'{pctiles[0]}-{pctiles[1]} percentile range' - err = np.percentile(data, pctiles, axis=0) - else: - raise ValueError('You must provide error bounds.') - if label == True: # noqa: E712 e.g. 1, 1.0, True - label = label_default - elif not label: - label = None - if not absolute: - err = err - y - err[0, :] *= -1 # absolute deviations from central points + # Populate the _facecolors attribute, which is initially filled + # with just a single color + obj.update_scalarmappable() - # Return data with default legend entry - return err, label + # Get text positions and colors + labels_kw_ = {'size': rc['text.labelsize'], 'ha': 'center', 'va': 'center'} + labels_kw_.update(labels_kw) + array = obj.get_array() + paths = obj.get_paths() + colors = np.asarray(obj.get_facecolors()) + edgecolors = np.asarray(obj.get_edgecolors()) + if len(colors) == 1: # weird flex but okay + colors = np.repeat(colors, len(array), axis=0) + if len(edgecolors) == 1: + edgecolors = np.repeat(edgecolors, len(array), axis=0) + for i, (color, path, num) in enumerate(zip(colors, paths, array)): + if not np.isfinite(num): + edgecolors[i, :] = 0 + continue + bbox = path.get_extents() + x = (bbox.xmin + bbox.xmax) / 2 + y = (bbox.ymin + bbox.ymax) / 2 + if 'color' not in labels_kw: + _, _, lum = to_xyz(color, 'hcl') + if lum < 50: + color = 'w' + else: + color = 'k' + labels_kw_['color'] = color + self.text(x, y, fmt(num), **labels_kw_) + obj.set_edgecolors(edgecolors) + + else: + raise ValueError(f'Invalid wrapped function: {name}()') + + return obj + + return wrapper -def _deprecate_indicate_error(func): +def _auto_colorbar_legend(func): """ - Translate old-style keyword arguments to new-style in way that is too complex - for _rename_kwargs. Use a decorator to avoid call signature pollution. + Add a colorbar or legend from the resulting plot. """ @functools.wraps(func) def wrapper( - *args, - bars=None, boxes=None, barstd=None, boxstd=None, barrange=None, boxrange=None, - **kwargs + self, *args, + colorbar=None, colorbar_kw=None, legend=None, legend_kw=None, + label=None, labels=None, values=None, + **kwargs, ): - for (prefix, b, std, span) in zip( - ('bar', 'box'), (bars, boxes), (barstd, boxstd), (barrange, boxrange), - ): - if b is not None or std is not None or span is not None: - warnings._warn_proplot( - f"Keyword args '{prefix}s', '{prefix}std', and '{prefix}range' " - 'are deprecated and will be removed in a future version. ' - f"Please use '{prefix}stds' or '{prefix}pctiles' instead." + # Parse input args + name = func.__name__ + autoformat = rc['autoformat'] + legend_kw = legend_kw or {} + colorbar_kw = colorbar_kw or {} + labels = _not_none( + values=values, + labels=labels, + label=label, + legend_kw_labels=legend_kw.pop('labels', None), + ) + if name in ('pie',): # add x coordinates as default pie chart labels + labels = _not_none(labels, x) # TODO: move to pie wrapper? + colorbar_legend_label = None # for colorbar or legend + + # Handle legend labels. Several scenarios: + # 1. Always prefer input labels + # 2. Always add labels if this is a *named* dimension. + # 3. Even if not *named* dimension add labels if labels are string + # WARNING: Most methods that accept 2D arrays use columns of data, but when + # pandas DataFrame passed to hist, boxplot, or violinplot, rows of data + # assumed! This is fixed in parse_1d by converting to values. + sample = args[-1] + ncols = 1 + if name in ('pie', 'boxplot', 'violinplot'): + # Functions handle multiple labels on their own + if labels is not None: + kwargs['labels'] = labels # error raised down the line + else: + # Get column count and sanitize labels + ncols = 1 if y.ndim == 1 else y.shape[1] + if not np.iterable(labels) or isinstance(labels, str): + labels = [labels] * ncols + if len(labels) != ncols: + raise ValueError( + f'Got {ncols} columns in data array, but {len(labels)} labels.' ) - if span is None and b: # means 'use the default range' - span = b - if std: - kwargs.setdefault(prefix + 'stds', span) - else: - kwargs.setdefault(prefix + 'pctiles', span) - return func(*args, **kwargs) - return wrapper + # Get automatic legend labels and legend title + # NOTE: Only apply labels if they are string labels *or* the + # legend or colorbar has a title (latter is more common for colorbars) + if autoformat: + ilabels, colorbar_legend_label = _axis_labels_title(sample, axis=1) + ilabels = _to_ndarray(ilabels) # may be empty! + for i, (ilabel, label) in enumerate(zip(ilabels, labels)): + if label is None and (colorbar_legend_label or isinstance(ilabel, str)): + labels[i] = ilabel + + # Sanitize labels + # WARNING: Must convert labels to string here because e.g. scatter() applies + # default label if input is False-ey. So numeric '0' would be overridden. + if labels is None: + labels = [''] * ncols + else: + labels = [str(_not_none(label, '')) for label in labels] + + # Call main function + objs = func(self, *args, **kwargs) + + # Add colorbar + if colorbar: + # Add handles + loc = self._loc_translate(colorbar, 'colorbar', allow_manual=False) + if loc not in self._auto_colorbar: + self._auto_colorbar[loc] = ([], {}) + self._auto_colorbar[loc][0].extend(objs) + + # Add keywords + if loc != 'fill': + colorbar_kw.setdefault('loc', loc) + if colorbar_legend_label: + colorbar_kw.setdefault('label', colorbar_legend_label) + self._auto_colorbar[loc][1].update(colorbar_kw) + + # Add legend + if legend: + # Get error objects. If they have separate label, allocate separate + # legend entry. If not, try to combine with current legend entry. + if type(errobjs) not in (list, tuple): + errobjs = (errobjs,) + errobjs = list(filter(None, errobjs)) + errobjs_join = [obj for obj in errobjs if not obj.get_label()] + errobjs_separate = [obj for obj in errobjs if obj.get_label()] + + # Get legend objects + # NOTE: It is not yet possible to draw error bounds *and* draw lines + # with multiple columns of data. + # NOTE: Put error bounds objects *before* line objects in the tuple, + # so that line gets drawn on top of bounds. + legobjs = objs.copy() + if errobjs_join: + legobjs = [(*legobjs, *errobjs_join)[::-1]] + legobjs.extend(errobjs_separate) + try: + legobjs, labels = list(zip(*_iter_legend_objects(legobjs))) + except ValueError: + legobjs = labels = () + + # Add handles and labels + # NOTE: Important to add labels as *keyword* so users can override + # NOTE: Use legend(handles, labels) syntax so we can assign labels + # for tuples of artists. Otherwise they are label-less. + loc = self._loc_translate(legend, 'legend', allow_manual=False) + if loc not in self._auto_legend: + self._auto_legend[loc] = ([], {'labels': []}) + self._auto_legend[loc][0].extend(legobjs) + self._auto_legend[loc][1]['labels'].extend(labels) + + # Add other keywords + if loc != 'fill': + legend_kw.setdefault('loc', loc) + if colorbar_legend_label: + legend_kw.setdefault('label', colorbar_legend_label) + self._auto_legend[loc][1].update(legend_kw) -@_deprecate_indicate_error -def _indicate_error( - self, func, *args, - medians=False, means=False, - boxdata=None, bardata=None, shadedata=None, fadedata=None, - boxstds=None, barstds=None, shadestds=None, fadestds=None, - boxpctiles=None, barpctiles=None, shadepctiles=None, fadepctiles=None, - boxmarker=True, boxmarkercolor='white', - boxcolor=None, barcolor=None, shadecolor=None, fadecolor=None, - shadelabel=False, fadelabel=False, shadealpha=0.4, fadealpha=0.2, - boxlinewidth=None, boxlw=None, barlinewidth=None, barlw=None, capsize=None, - boxzorder=2.5, barzorder=2.5, shadezorder=1.5, fadezorder=1.5, - **kwargs + +def _build_discrete_norm( + data=None, N=None, levels=None, values=None, + norm=None, norm_kw=None, locator=None, locator_kw=None, + cmap=None, vmin=None, vmax=None, extend=None, symmetric=False, + minlength=2, ): """ - Add error bars and/or error shading on-the-fly. - """ - name = func.__name__ - x, data, *args = args - x = _to_arraylike(x) - data = _to_arraylike(data) - - # Get means or medians for plotting - # NOTE: We can *only* use pctiles and stds if one of these was true - # TODO: Add support for 3D arrays. - y = data - bars = any(_ is not None for _ in (barstds, barpctiles, bardata)) - boxes = any(_ is not None for _ in (boxstds, boxpctiles, boxdata)) - shading = any(_ is not None for _ in (shadestds, shadepctiles, shadedata)) - fading = any(_ is not None for _ in (fadestds, fadepctiles, fadedata)) - if means or medians: - # Take means or medians while preserving metadata for legends - # NOTE: Permit 3d array with error dimension coming first - if not (bars or boxes or shading or fading): - bars = boxes = True # toggle these on - barstds = boxstds = True # error bars and boxes with default stdev ranges - if data.ndim != 2: - raise ValueError( - f'Need 2D data array for means=True or medians=True, ' - f'got {data.ndim}D array.' - ) - keep = {} - if DataArray is not ndarray and isinstance(data, DataArray): - keep['keep_attrs'] = True - if means: - y = data.mean(axis=0, **keep) - elif medians: - if hasattr(data, 'quantile'): # DataFrame and DataArray - y = data.quantile(0.5, axis=0, **keep) - if Series is not ndarray and isinstance(y, Series): - y.name = '' # do not set name to quantile number - else: - y = np.percentile(data, 50, axis=0, **keep) - if getattr(data, 'name', '') and not getattr(y, 'name', ''): - y.name = data.name # copy DataFrame name to Series name - - # Infer width of error elements - # NOTE: violinplot_wrapper passes some invalid keyword args with expectation - # that indicate_error wrapper pops them and uses them for error bars. - lw = None - if name == 'bar': - lw = _not_none(kwargs.get('linewidth', None), kwargs.get('lw', None)) - elif name == 'violinplot': - lw = _not_none(kwargs.pop('linewidth', None), kwargs.pop('lw', None)) - lw = _not_none(lw, 0.8) - barlw = _not_none(barlinewidth=barlinewidth, barlw=barlw, default=lw) - boxlw = _not_none(boxlinewidth=boxlinewidth, boxlw=boxlw, default=4 * barlw) - capsize = _not_none(capsize, 3.0) + Build a `~proplot.colors.DiscreteNorm` or `~proplot.colors.BoundaryNorm` + from the input arguments. This automatically calculates "nice" level + boundaries if they were not provided. - # Infer color for error bars - edgecolor = None - if name == 'bar': - edgecolor = kwargs.get('edgecolor', None) - elif name == 'violinplot': - edgecolor = kwargs.pop('edgecolor', None) - edgecolor = _not_none(edgecolor, 'k') - barcolor = _not_none(barcolor, edgecolor) - boxcolor = _not_none(boxcolor, barcolor) + Parameters + ---------- + data, vmin, vmax, levels, values + Used to determine the level boundaries. + norm, norm_kw + Passed to `~proplot.constructor.Norm`. + locator, locator_kw + Passed to `~proplot.constructor.Locator`. + minlength : int + The minimum length for level lists. - # Infer color for shading - shadecolor_infer = shadecolor is None - shadecolor = _not_none( - shadecolor, kwargs.get('color', None), kwargs.get('facecolor', None), edgecolor + Returns + ------- + norm : `matplotlib.colors.Normalize` + The normalizer. + ticks : `numpy.ndarray` or `matplotlib.locator.Locator` + The axis locator or the tick location candidates. + """ + # Parse flexible keyword args + norm_kw = norm_kw or {} + locator_kw = locator_kw or {} + levels = _not_none( + N=N, levels=levels, norm_kw_levels=norm_kw.pop('levels', None), + default=rc['image.levels'] ) - fadecolor_infer = fadecolor is None - fadecolor = _not_none(fadecolor, shadecolor) + vmin = _not_none(vmin=vmin, norm_kw_vmin=norm_kw.pop('vmin', None)) + vmax = _not_none(vmax=vmax, norm_kw_vmax=norm_kw.pop('vmax', None)) + if norm == 'segments': # TODO: remove + norm = 'segmented' - # Draw dark and light shading - vert = kwargs.get('vert', kwargs.get('orientation', 'vertical') == 'vertical') - axis = 'y' if vert else 'x' # yerr - errargs = (x, y) if vert else (y, x) - errobjs = [] - means_or_medians = means or medians - if fading: - err, label = _get_error_data( - data, y, fadedata, fadestds, fadepctiles, - stds_default=(-3, 3), pctiles_default=(0, 100), absolute=True, - means_or_medians=means_or_medians, label=fadelabel, - ) - errfunc = self.fill_between if vert else self.fill_betweenx - errobj = errfunc( - x, *err, linewidth=0, color=fadecolor, - alpha=fadealpha, zorder=fadezorder, - ) - errobj.set_label(label) - errobjs.append(errobj) - if shading: - err, label = _get_error_data( - data, y, shadedata, shadestds, shadepctiles, - stds_default=(-2, 2), pctiles_default=(10, 90), absolute=True, - means_or_medians=means_or_medians, label=shadelabel, - ) - errfunc = self.fill_between if vert else self.fill_betweenx - errobj = errfunc( - x, *err, linewidth=0, color=shadecolor, - alpha=shadealpha, zorder=shadezorder, - ) - errobj.set_label(label) # shadelabel=False - errobjs.append(errobj) + # NOTE: Matplotlib colorbar algorithm *cannot* handle descending levels + # so this function reverses them and adds special attribute to the + # normalizer. Then colorbar_wrapper reads this attribute and flips the + # axis and the colormap direction. + # Check input levels and values + for key, val in (('levels', levels), ('values', values)): + if not np.iterable(val): + continue + if len(val) < minlength or len(val) >= 2 and any( + np.sign(np.diff(val)) != np.sign(val[1] - val[0]) + ): + raise ValueError( + f'{key!r} must be monotonically increasing or decreasing ' + f'and at least length {minlength}, got {val}.' + ) - # Draw thin error bars and thick error boxes - if boxes: - err, label = _get_error_data( - data, y, boxdata, boxstds, boxpctiles, - stds_default=(-1, 1), pctiles_default=(25, 75), - means_or_medians=means_or_medians, - ) - if boxmarker: - self.scatter(*errargs, s=boxlw, marker='o', color=boxmarkercolor, zorder=5) - errkw = {axis + 'err': err} - errobj = self.errorbar( - *errargs, color=boxcolor, linewidth=boxlw, linestyle='none', - capsize=0, zorder=boxzorder, **errkw, - ) - errobjs.append(errobj) - if bars: # now impossible to make thin bar width different from cap width! - err, label = _get_error_data( - data, y, bardata, barstds, barpctiles, - stds_default=(-3, 3), pctiles_default=(0, 100), - means_or_medians=means_or_medians, - ) - errkw = {axis + 'err': err} - errobj = self.errorbar( - *errargs, color=barcolor, linewidth=barlw, linestyle='none', - markeredgecolor=barcolor, markeredgewidth=barlw, - capsize=capsize, zorder=barzorder, **errkw + # Get level edges from level centers + ticks = None + if isinstance(values, Number): + levels = np.atleast_1d(values)[0] + 1 + elif np.iterable(values) and len(values) == 1: + levels = [values[0] - 1, values[0] + 1] # weird but why not + elif np.iterable(values) and len(values) > 1: + # Try to generate levels such that a LinearSegmentedNorm will + # place values ticks at the center of each colorbar level. + # utils.edges works only for evenly spaced values arrays. + # We solve for: (x1 + x2)/2 = y --> x2 = 2*y - x1 + # with arbitrary starting point x1. We also start the algorithm + # on the end with *smaller* differences. + if norm is None or norm == 'segmented': + reverse = abs(values[-1] - values[-2]) < abs(values[1] - values[0]) + if reverse: + values = values[::-1] + levels = [values[0] - (values[1] - values[0]) / 2] + for val in values: + levels.append(2 * val - levels[-1]) + if reverse: + levels = levels[::-1] + if any(np.sign(np.diff(levels)) != np.sign(levels[1] - levels[0])): + levels = edges(values) # backup plan, weird tick locations + # Generate levels by finding in-between points in the + # normalized numeric space, e.g. LogNorm space. + else: + inorm = constructor.Norm(norm, **norm_kw) + levels = inorm.inverse(edges(inorm(values))) + elif values is not None: + raise ValueError( + f'Unexpected input values={values!r}. ' + 'Must be integer or list of numbers.' ) - errobjs.append(errobj) - - # Call main function - # NOTE: Provide error objects for inclusion in legend, but *only* provide - # the shading. Never want legend entries for error bars. - xy = (x, data) if name == 'violinplot' else (x, y) - kwargs.setdefault('errobjs', errobjs[:int(shading + fading)]) - result = obj = func(self, *xy, *args, **kwargs) - - # Apply inferrred colors to objects - if type(result) in (tuple, list): # avoid BarContainer - obj = result[0] - i = 0 - for b, infer in zip((fading, shading), (fadecolor_infer, shadecolor_infer)): - if b and infer: - if hasattr(obj, 'get_facecolor'): - color = obj.get_facecolor() - elif hasattr(obj, 'get_color'): - color = obj.get_color() - else: - color = None - if color is not None: - errobjs[i].set_facecolor(color) - i += 1 - # Return objects - # NOTE: This should not affect internal matplotlib calls to these funcs - # NOTE: Avoid expanding matplolib collections that are list subclasses here - if errobjs: - if type(result) in (tuple, list): # e.g. result of plot - return (*result, *errobjs) + # Get default normalizer + # Only use LinearSegmentedNorm if necessary, because it is slow + descending = False + if np.iterable(levels): + if len(levels) == 1: + norm = mcolors.Normalize(vmin=levels[0] - 1, vmax=levels[0] + 1) else: - return (result, *errobjs) - else: - return result + levels, descending = pcolors._check_levels(levels) + if norm is None: + norm = 'linear' + if np.iterable(levels) and len(levels) > 2: + steps = np.abs(np.diff(levels)) + eps = np.mean(steps) / 1e3 + if np.any(np.abs(np.diff(steps)) >= eps): + norm = 'segmented' + if norm == 'segmented': + if not np.iterable(levels): + norm = 'linear' # has same result + else: + norm_kw['levels'] = levels + norm = constructor.Norm(norm, **norm_kw) + # Use the locator to determine levels + # Mostly copied from the hidden contour.ContourSet._autolev + # NOTE: Subsequently, we *only* use the locator to determine ticks if + # *levels* and *values* were not passed. + if isinstance(norm, mcolors.BoundaryNorm): + # Get levels from bounds + # TODO: Test this feature? + # NOTE: No warning because we get here internally? + levels = norm.boundaries + elif np.iterable(values): + # Prefer ticks in center + ticks = np.asarray(values) + elif np.iterable(levels): + # Prefer ticks on level edges + ticks = np.asarray(levels) + else: + # Determine levels automatically + N = levels + if locator is not None: + locator = constructor.Locator(locator, **locator_kw) + ticks = locator + elif isinstance(norm, mcolors.LogNorm): + locator = mticker.LogLocator(**locator_kw) + ticks = locator + elif isinstance(norm, mcolors.SymLogNorm): + locator_kw.setdefault('linthresh', norm.linthresh) + locator = mticker.SymmetricalLogLocator(**locator_kw) + ticks = locator + else: + locator_kw.setdefault('symmetric', symmetric) + locator = mticker.MaxNLocator(N, min_n_ticks=1, **locator_kw) -def _update_text(self, props): - """ - Monkey patch that adds pseudo "border" properties to text objects - without wrapping the entire class. We override update to facilitate - updating inset titles. - """ - props = props.copy() # shallow copy - border = props.pop('border', None) - bordercolor = props.pop('bordercolor', 'w') - borderinvert = props.pop('borderinvert', False) - borderwidth = props.pop('borderwidth', 2) - if border: - facecolor, bgcolor = self.get_color(), bordercolor - if borderinvert: - facecolor, bgcolor = bgcolor, facecolor - kwargs = { - 'linewidth': borderwidth, - 'foreground': bgcolor, - 'joinstyle': 'miter', - } - self.update({ - 'color': facecolor, - 'path_effects': [mpatheffects.Stroke(**kwargs), mpatheffects.Normal()], - }) - return type(self).update(self, props) + # Get locations + automin = vmin is None + automax = vmax is None + if automin or automax: + data = ma.masked_invalid(data, copy=False) + if automin: + vmin = float(data.min()) + if automax: + vmax = float(data.max()) + if vmin == vmax or ma.is_masked(vmin) or ma.is_masked(vmax): + vmin, vmax = 0, 1 + try: + levels = locator.tick_values(vmin, vmax) + except RuntimeError: # too-many-ticks error + levels = np.linspace(vmin, vmax, N) # TODO: _autolev used N+1 + # Trim excess levels the locator may have supplied + # NOTE: This part is mostly copied from _autolev + if not locator_kw.get('symmetric', None): + i0, i1 = 0, len(levels) # defaults + under, = np.where(levels < vmin) + if len(under): + i0 = under[-1] + if not automin or extend in ('min', 'both'): + i0 += 1 # permit out-of-bounds data + over, = np.where(levels > vmax) + if len(over): + i1 = over[0] + 1 if len(over) else len(levels) + if not automax or extend in ('max', 'both'): + i1 -= 1 # permit out-of-bounds data + if i1 - i0 < 3: + i0, i1 = 0, len(levels) # revert + levels = levels[i0:i1] -def _iter_legend_objects(objs): - """ - Retrieve the (object, label) pairs for objects with actual labels - from nested lists and tuples of objects. - """ - # Account for (1) multiple columns of data, (2) functions that return - # multiple values (e.g. hist() returns (bins, values, patches)), and - # (3) matplotlib.Collection list subclasses. - if hasattr(objs, 'get_label'): - label = objs.get_label() - if label and label[:1] != '_': - yield (objs, label) - elif isinstance(objs, (list, tuple)): - for obj in objs: - yield from _iter_legend_objects(obj) + # Compare the no. of levels we *got* (levels) to what we *wanted* (N) + # If we wanted more than 2 times the result, then add nn - 1 extra + # levels in-between the returned levels *in normalized space*. + # Example: A LogNorm gives too few levels, so we select extra levels + # here, but use the locator for determining tick locations. + nn = N // len(levels) + if nn >= 2: + olevels = norm(levels) + nlevels = [] + for i in range(len(levels) - 1): + l1, l2 = olevels[i], olevels[i + 1] + nlevels.extend(np.linspace(l1, l2, nn + 1)[:-1]) + nlevels.append(olevels[-1]) + levels = norm.inverse(nlevels) + # Use auto-generated levels for ticks if still None + if ticks is None: + ticks = levels -def _parse_cycle( - self, func, *args, - cycle=None, cycle_kw=None, - label=None, labels=None, values=None, - legend=None, legend_kw=None, - colorbar=None, colorbar_kw=None, - errobjs=None, - **kwargs -): - """ - Parameters - ---------- + # Generate DiscreteNorm and update "child" norm with vmin and vmax from + # levels. This lets the colorbar set tick locations properly! + if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1: + norm = pcolors.DiscreteNorm( + levels, cmap=cmap, norm=norm, descending=descending + ) + if descending: + cmap = cmap.reversed() + return norm, cmap, levels, ticks - Other parameters - ---------------- - *args, **kwargs - Passed to the matplotlib plotting method. - See also - -------- - standardize_1d - proplot.constructor.Cycle - proplot.constructor.Colors +def _fix_edges(func): """ - # Parse positional args - # NOTE: Requires standardize_1d wrapper before reaching this. Also note - # that the 'x' coordinates are sometimes ignored below. - name = func.__name__ - if not args: - return func(self, *args, **kwargs) - x, y, *args = args - ys = (y,) - if len(args) >= 1 and name in ('fill_between', 'fill_betweenx'): - ys, args = (y, args[0]), args[1:] - # Parse keyword args - autoformat = rc['autoformat'] # possibly manipulated by standardize_[12]d - barh = stacked = False - cycle_kw = cycle_kw or {} - legend_kw = legend_kw or {} - colorbar_kw = colorbar_kw or {} - labels = _not_none( - values=values, - labels=labels, - label=label, - legend_kw_labels=legend_kw.pop('labels', None), - ) - if name in ('pie',): # add x coordinates as default pie chart labels - labels = _not_none(labels, x) # TODO: move to pie wrapper? - colorbar_legend_label = None # for colorbar or legend - if name in ('bar', 'fill_between', 'fill_betweenx'): - stacked = kwargs.pop('stacked', False) - if name in ('bar',): - barh = kwargs.get('orientation', None) == 'horizontal' - width = kwargs.pop('width', 0.8) # 'width' for bar *and* barh (see bar_wrapper) - bottom = 'x' if barh else 'bottom' - kwargs.setdefault(bottom, 0) # 'x' required even though 'y' isn't for bar plots - - # Determine and temporarily set cycler - # NOTE: Axes cycle has no getter, only set_prop_cycle, which sets a - # prop_cycler attribute on the hidden _get_lines and _get_patches_for_fill - # objects. This is the only way to query current axes cycler! Should not - # wrap set_prop_cycle because would get messy and fragile. - # NOTE: The _get_lines cycler is an *itertools cycler*. Has no length, so - # we must cycle over it with next(). We try calling next() the same number - # of times as the length of input cycle. If the input cycle *is* in fact - # the same, below does not reset the color position, cycles us to start! - if cycle is not None or cycle_kw: - # Get the new cycler - cycle_args = () if cycle is None else (cycle,) - if y.ndim > 1 and y.shape[1] > 1: # default samples count - cycle_kw.setdefault('N', y.shape[1]) - cycle = constructor.Cycle(*cycle_args, **cycle_kw) - - # Get the original property cycle - # NOTE: Matplotlib saves itertools.cycle(cycler), not the original - # cycler object, so we must build up the keys again. - i = 0 - by_key = {} - cycle_orig = self._get_lines.prop_cycler - for i in range(len(cycle)): # use the cycler object length as a guess - prop = next(cycle_orig) - for key, value in prop.items(): - if key not in by_key: - by_key[key] = set() - if isinstance(value, (list, np.ndarray)): - value = tuple(value) - by_key[key].add(value) + Fix white lines between filled contours/mesh and fix issues with colormaps + that are not perfectly opaque. + """ + @functools.wraps(func) + def wrapper(self, *args, edgefix=None, **kwargs): + # Call main function + obj = func(self, *args, **kwargs) + + # 0.4pt is thick enough to hide lines but thin enough to not add "dots" in + # corner of pcolor plots. + # See: https://github.com/jklymak/contourfIssues + # See: https://stackoverflow.com/q/15003353/4970632 + cmap = obj.get_cmap() + if not cmap._isinit: + cmap._init() + edgecolor = 'face' if all(cmap._lut[:-1, 3] == 1) else 'none' + if isinstance(mcontour.ContourSet): + for contour in obj.collections: + contour.set_edgecolor(edgecolor) + contour.set_linewidth(0.4) + contour.set_linestyle('-') + else: + if hasattr(obj, 'set_linewidth'): # not always true for pcolorfast + obj.set_linewidth(0.4) + if hasattr(obj, 'set_edgecolor'): # not always true for pcolorfast + obj.set_edgecolor(edgecolor) - # Reset property cycler if it differs - reset = set(by_key) != set(cycle.by_key()) - if not reset: # test individual entries - for key, value in cycle.by_key().items(): - if by_key[key] != set(value): - reset = True - break - if reset: - self.set_prop_cycle(cycle) + return obj - # Custom property cycler additions - # NOTE: By default matplotlib uses _get_patches_for_fill.get_next_color - # for scatter next scatter color, but cannot get anything else! We simultaneously - # iterate through the _get_lines property cycler and apply relevant properties. - apply_from_cycler = set() # which keys to apply from property cycler - if name in ('scatter',): - # Figure out which props should be updated - prop_keys = set(self._get_lines._prop_keys) - {'color', 'linestyle', 'dashes'} - for key, prop in ( - ('markersize', 's'), - ('linewidth', 'linewidths'), - ('markeredgewidth', 'linewidths'), - ('markeredgecolor', 'edgecolors'), - ('alpha', 'alpha'), - ('marker', 'marker'), - ): - prop = kwargs.get(prop, None) - if key in prop_keys and prop is None: # if key in cycler and property unset - apply_from_cycler.add(key) + return wrapper - # Handle legend labels. Several scenarios: - # 1. Always prefer input labels - # 2. Always add labels if this is a *named* dimension. - # 3. Even if not *named* dimension add labels if labels are string - # WARNING: Most methods that accept 2D arrays use columns of data, but when - # pandas DataFrame passed to hist, boxplot, or violinplot, rows of data - # assumed! This is fixed in parse_1d by converting to values. - y1 = ys[0] - ncols = 1 - if name in ('pie', 'boxplot', 'violinplot'): - # Functions handle multiple labels on their own - if labels is not None: - kwargs['labels'] = labels # error raised down the line - else: - # Get column count and sanitize labels - ncols = 1 if y.ndim == 1 else y.shape[1] - if not np.iterable(labels) or isinstance(labels, str): - labels = [labels] * ncols - if len(labels) != ncols: - raise ValueError( - f'Got {ncols} columns in data array, but {len(labels)} labels.' - ) - # Get automatic legend labels and legend title - # NOTE: Only apply labels if they are string labels *or* the - # legend or colorbar has a title (latter is more common for colorbars) - if autoformat: - ilabels, colorbar_legend_label = _axis_labels_title(y1, axis=1) - ilabels = _to_ndarray(ilabels) # may be empty! - for i, (ilabel, label) in enumerate(zip(ilabels, labels)): - if label is None and (colorbar_legend_label or isinstance(ilabel, str)): - labels[i] = ilabel - - # Sanitize labels - # WARNING: Must convert labels to string here because e.g. scatter() applies - # default label if input is False-ey. So numeric '0' would be overridden. - if labels is None: - labels = [''] * ncols - else: - labels = [str(_not_none(label, '')) for label in labels] +def _indicate_error_data( + data, y, errdata=None, stds=None, pctiles=False, + stds_default=None, pctiles_default=None, + means_or_medians=True, absolute=False, label=False, +): + """ + Return values that can be passed to the `~matplotlib.axes.Axes.errorbar` + `xerr` and `yerr` keyword args. + """ + # Parse arguments + # NOTE: Have to guard against "truth value of an array is ambiguous" errors + if not isinstance(stds, ARRAY_TYPES): + if stds in (1, True): + stds = stds_default + elif stds in (0, False): + stds = None + if not isinstance(pctiles, ARRAY_TYPES): + if pctiles in (1, True): + pctiles = pctiles_default + elif pctiles in (0, False): + pctiles = None - # Get step size for bar plots - # WARNING: This will fail for non-numeric non-datetime64 singleton - # datatypes but this is good enough for vast majority of most cases. - if name in ('bar',): - if not stacked and not getattr(self, '_absolute_bar_width', False): - x_test = np.atleast_1d(_to_ndarray(x)) - if len(x_test) >= 2: - x_step = x_test[1:] - x_test[:-1] - x_step = np.concatenate((x_step, x_step[-1:])) - elif x_test.dtype == np.datetime64: - x_step = np.timedelta64(1, 'D') - else: - x_step = np.array(0.5) - if np.issubdtype(x_test.dtype, np.datetime64): - # Avoid integer timedelta truncation - x_step = x_step.astype('timedelta64[ns]') - width = width * x_step / ncols - key = 'height' if barh else 'width' - kwargs[key] = width + # Incompatible settings + if stds is not None and pctiles is not None: + warnings._warn_proplot( + 'You passed both a standard deviation range and a percentile range for ' + 'drawing error indicators. Using the former.' + ) + pctiles = None + if not means_or_medians and (stds is not None or pctiles is not None): + raise ValueError( + 'To automatically compute standard deviations or percentiles on columns ' + 'of data you must pass means=True or medians=True.' + ) + if means_or_medians and errdata is not None: + stds = pctiles = None + warnings._warn_proplot( + 'You explicitly provided the error bounds but also requested ' + 'automatically calculating means or medians on data columns. ' + 'It may make more sense to use the "stds" or "pctiles" keyword args ' + 'and have *proplot* calculate the error bounds.' + ) - # Plot susccessive columns - objs = [] - for i in range(ncols): - # Prop cycle properties - kw = kwargs.copy() - if apply_from_cycler: - props = next(self._get_lines.prop_cycler) - for key in apply_from_cycler: - value = props[key] - if key in ('size', 'markersize'): - key = 's' - elif key in ('linewidth', 'markeredgewidth'): # translate - key = 'linewidths' - elif key == 'markeredgecolor': - key = 'edgecolors' - kw[key] = value + # Compute error data in format that can be passed to matplotlib.axes.Axes.errorbar() + # NOTE: Include option to pass symmetric deviation from central points + y = _to_ndarray(y) + data = _to_ndarray(data) + if errdata is not None: + label_default = 'error range' + err = _to_ndarray(errdata) + if ( + err.ndim not in (1, 2) + or err.shape[-1] != y.shape[-1] + or err.ndim == 2 and err.shape[0] != 2 + ): + raise ValueError( + f'errdata must have shape (2, {y.shape[-1]}), but got {err.shape}.' + ) + if err.ndim == 1: + abserr = err + err = np.empty((2, err.size)) + err[0, :] = y - abserr # translated back to absolute deviations below + err[1, :] = y + abserr + elif stds is not None: + label_default = fr'{stds[1]}$\sigma$ range' + err = y + np.std(data, axis=0)[None, :] * np.asarray(stds)[:, None] + elif pctiles is not None: + label_default = f'{pctiles[0]}-{pctiles[1]} percentile range' + err = np.percentile(data, pctiles, axis=0) + else: + raise ValueError('You must provide error bounds.') + if label == True: # noqa: E712 e.g. 1, 1.0, True + label = label_default + elif not label: + label = None + if not absolute: + err = err - y + err[0, :] *= -1 # absolute deviations from central points - # Get x coordinates for bar plot - ix = x # samples - if name in ('bar',): # adjust - if not stacked: - offset = width * (i - 0.5 * (ncols - 1)) - ix = x + offset - elif stacked and y1.ndim > 1: - key = 'x' if barh else 'bottom' - kw[key] = _to_indexer(y1)[:, :i].sum(axis=1) + # Return data with default legend entry + return err, label - # Get y coordinates and labels - if name in ('pie', 'boxplot', 'violinplot'): - # Only ever have one y value, cannot have legend labels - iys = (y1,) - else: - # The coordinates - # WARNING: If stacked=True then we always *ignore* second - # argument passed to fill_between. Warning should be issued - # by fill_between_wrapper in this case. - if stacked and name in ('fill_between', 'fill_betweenx'): - iys = tuple( - y1 if y1.ndim == 1 - else _to_indexer(y1)[:, :ii].sum(axis=1) - for ii in (i, i + 1) +def _indicate_error_deprecate(func): + """ + Translate old-style keyword arguments to new-style in way that is too complex + for _rename_kwargs. Use a decorator to avoid call signature pollution. + """ + @functools.wraps(func) + def wrapper( + *args, + bars=None, boxes=None, barstd=None, boxstd=None, barrange=None, boxrange=None, + **kwargs + ): + for (prefix, b, std, span) in zip( + ('bar', 'box'), (bars, boxes), (barstd, boxstd), (barrange, boxrange), + ): + if b is not None or std is not None or span is not None: + warnings._warn_proplot( + f"Keyword args '{prefix}s', '{prefix}std', and '{prefix}range' " + 'are deprecated and will be removed in a future version. ' + f"Please use '{prefix}stds' or '{prefix}pctiles' instead." ) + if span is None and b: # means 'use the default range' + span = b + if std: + kwargs.setdefault(prefix + 'stds', span) else: - iys = tuple( - y_i if y_i.ndim == 1 else _to_indexer(y_i)[:, i] - for y_i in ys - ) - kw['label'] = labels[i] or '' - - # Build coordinate arguments - ixy = () - if barh: # special case, use kwargs only! - kw.update({'bottom': ix, 'width': iys[0]}) - elif name in ('pie', 'hist', 'boxplot', 'violinplot'): - ixy = iys - else: # has x-coordinates, and maybe more than one y - ixy = (ix, *iys) - obj = func(self, *ixy, *args, **kw) - if type(obj) in (list, tuple) and len(obj) == 1: - obj = obj[0] - objs.append(obj) - - # Add colorbar - if colorbar: - # Add handles - loc = self._loc_translate(colorbar, 'colorbar', allow_manual=False) - if loc not in self._auto_colorbar: - self._auto_colorbar[loc] = ([], {}) - self._auto_colorbar[loc][0].extend(objs) - - # Add keywords - if loc != 'fill': - colorbar_kw.setdefault('loc', loc) - if colorbar_legend_label: - colorbar_kw.setdefault('label', colorbar_legend_label) - self._auto_colorbar[loc][1].update(colorbar_kw) - - # Add legend - if legend: - # Get error objects. If they have separate label, allocate separate - # legend entry. If not, try to combine with current legend entry. - if type(errobjs) not in (list, tuple): - errobjs = (errobjs,) - errobjs = list(filter(None, errobjs)) - errobjs_join = [obj for obj in errobjs if not obj.get_label()] - errobjs_separate = [obj for obj in errobjs if obj.get_label()] - - # Get legend objects - # NOTE: It is not yet possible to draw error bounds *and* draw lines - # with multiple columns of data. - # NOTE: Put error bounds objects *before* line objects in the tuple, - # so that line gets drawn on top of bounds. - legobjs = objs.copy() - if errobjs_join: - legobjs = [(*legobjs, *errobjs_join)[::-1]] - legobjs.extend(errobjs_separate) - try: - legobjs, labels = list(zip(*_iter_legend_objects(legobjs))) - except ValueError: - legobjs = labels = () - - # Add handles and labels - # NOTE: Important to add labels as *keyword* so users can override - # NOTE: Use legend(handles, labels) syntax so we can assign labels - # for tuples of artists. Otherwise they are label-less. - loc = self._loc_translate(legend, 'legend', allow_manual=False) - if loc not in self._auto_legend: - self._auto_legend[loc] = ([], {'labels': []}) - self._auto_legend[loc][0].extend(legobjs) - self._auto_legend[loc][1]['labels'].extend(labels) - - # Add other keywords - if loc != 'fill': - legend_kw.setdefault('loc', loc) - if colorbar_legend_label: - legend_kw.setdefault('label', colorbar_legend_label) - self._auto_legend[loc][1].update(legend_kw) - - # Return - # WARNING: Make sure plot always returns tuple of objects, and bar always - # returns singleton unless we have bulk drawn bar plots! Other matplotlib - # methods call these internally and expect a certain output format! - if name == 'plot': - return tuple(objs) # always return tuple of objects - elif name in ('boxplot', 'violinplot'): - return objs[0] # always return singleton - else: - return objs[0] if len(objs) == 1 else tuple(objs) + kwargs.setdefault(prefix + 'pctiles', span) + return func(*args, **kwargs) + return wrapper -def _build_discrete_norm( - data=None, N=None, levels=None, values=None, - norm=None, norm_kw=None, locator=None, locator_kw=None, - cmap=None, vmin=None, vmax=None, extend=None, symmetric=False, - minlength=2, +@_indicate_error_deprecate +def _indicate_error( + self, func, *args, + medians=False, means=False, + boxdata=None, bardata=None, shadedata=None, fadedata=None, + boxstds=None, barstds=None, shadestds=None, fadestds=None, + boxpctiles=None, barpctiles=None, shadepctiles=None, fadepctiles=None, + boxmarker=True, boxmarkercolor='white', + boxcolor=None, barcolor=None, shadecolor=None, fadecolor=None, + shadelabel=False, fadelabel=False, shadealpha=0.4, fadealpha=0.2, + boxlinewidth=None, boxlw=None, barlinewidth=None, barlw=None, capsize=None, + boxzorder=2.5, barzorder=2.5, shadezorder=1.5, fadezorder=1.5, + **kwargs ): """ - Build a `~proplot.colors.DiscreteNorm` or `~proplot.colors.BoundaryNorm` - from the input arguments. This automatically calculates "nice" level - boundaries if they were not provided. - - Parameters - ---------- - data, vmin, vmax, levels, values - Used to determine the level boundaries. - norm, norm_kw - Passed to `~proplot.constructor.Norm`. - locator, locator_kw - Passed to `~proplot.constructor.Locator`. - minlength : int - The minimum length for level lists. - - Returns - ------- - norm : `matplotlib.colors.Normalize` - The normalizer. - ticks : `numpy.ndarray` or `matplotlib.locator.Locator` - The axis locator or the tick location candidates. + Add error bars and/or error shading on-the-fly. """ - # Parse flexible keyword args - norm_kw = norm_kw or {} - locator_kw = locator_kw or {} - levels = _not_none( - N=N, levels=levels, norm_kw_levels=norm_kw.pop('levels', None), - default=rc['image.levels'] - ) - vmin = _not_none(vmin=vmin, norm_kw_vmin=norm_kw.pop('vmin', None)) - vmax = _not_none(vmax=vmax, norm_kw_vmax=norm_kw.pop('vmax', None)) - if norm == 'segments': # TODO: remove - norm = 'segmented' + name = func.__name__ + x, data, *args = args + x = _to_arraylike(x) + data = _to_arraylike(data) - # NOTE: Matplotlib colorbar algorithm *cannot* handle descending levels - # so this function reverses them and adds special attribute to the - # normalizer. Then colorbar_wrapper reads this attribute and flips the - # axis and the colormap direction. - # Check input levels and values - for key, val in (('levels', levels), ('values', values)): - if not np.iterable(val): - continue - if len(val) < minlength or len(val) >= 2 and any( - np.sign(np.diff(val)) != np.sign(val[1] - val[0]) - ): + # Get means or medians for plotting + # NOTE: We can *only* use pctiles and stds if one of these was true + # TODO: Add support for 3D arrays. + y = data + bars = any(_ is not None for _ in (barstds, barpctiles, bardata)) + boxes = any(_ is not None for _ in (boxstds, boxpctiles, boxdata)) + shading = any(_ is not None for _ in (shadestds, shadepctiles, shadedata)) + fading = any(_ is not None for _ in (fadestds, fadepctiles, fadedata)) + if means or medians: + # Take means or medians while preserving metadata for legends + # NOTE: Permit 3d array with error dimension coming first + if not (bars or boxes or shading or fading): + bars = boxes = True # toggle these on + barstds = boxstds = True # error bars and boxes with default stdev ranges + if data.ndim != 2: raise ValueError( - f'{key!r} must be monotonically increasing or decreasing ' - f'and at least length {minlength}, got {val}.' + f'Need 2D data array for means=True or medians=True, ' + f'got {data.ndim}D array.' ) + keep = {} + if DataArray is not ndarray and isinstance(data, DataArray): + keep['keep_attrs'] = True + if means: + y = data.mean(axis=0, **keep) + elif medians: + if hasattr(data, 'quantile'): # DataFrame and DataArray + y = data.quantile(0.5, axis=0, **keep) + if Series is not ndarray and isinstance(y, Series): + y.name = '' # do not set name to quantile number + else: + y = np.percentile(data, 50, axis=0, **keep) + if getattr(data, 'name', '') and not getattr(y, 'name', ''): + y.name = data.name # copy DataFrame name to Series name - # Get level edges from level centers - ticks = None - if isinstance(values, Number): - levels = np.atleast_1d(values)[0] + 1 - elif np.iterable(values) and len(values) == 1: - levels = [values[0] - 1, values[0] + 1] # weird but why not - elif np.iterable(values) and len(values) > 1: - # Try to generate levels such that a LinearSegmentedNorm will - # place values ticks at the center of each colorbar level. - # utils.edges works only for evenly spaced values arrays. - # We solve for: (x1 + x2)/2 = y --> x2 = 2*y - x1 - # with arbitrary starting point x1. We also start the algorithm - # on the end with *smaller* differences. - if norm is None or norm == 'segmented': - reverse = abs(values[-1] - values[-2]) < abs(values[1] - values[0]) - if reverse: - values = values[::-1] - levels = [values[0] - (values[1] - values[0]) / 2] - for val in values: - levels.append(2 * val - levels[-1]) - if reverse: - levels = levels[::-1] - if any(np.sign(np.diff(levels)) != np.sign(levels[1] - levels[0])): - levels = edges(values) # backup plan, weird tick locations - # Generate levels by finding in-between points in the - # normalized numeric space, e.g. LogNorm space. - else: - inorm = constructor.Norm(norm, **norm_kw) - levels = inorm.inverse(edges(inorm(values))) - elif values is not None: - raise ValueError( - f'Unexpected input values={values!r}. ' - 'Must be integer or list of numbers.' - ) + # Infer width of error elements + # NOTE: violinplot_wrapper passes some invalid keyword args with expectation + # that indicate_error wrapper pops them and uses them for error bars. + lw = None + if name == 'bar': + lw = _not_none(kwargs.get('linewidth', None), kwargs.get('lw', None)) + elif name == 'violinplot': + lw = _not_none(kwargs.pop('linewidth', None), kwargs.pop('lw', None)) + lw = _not_none(lw, 0.8) + barlw = _not_none(barlinewidth=barlinewidth, barlw=barlw, default=lw) + boxlw = _not_none(boxlinewidth=boxlinewidth, boxlw=boxlw, default=4 * barlw) + capsize = _not_none(capsize, 3.0) - # Get default normalizer - # Only use LinearSegmentedNorm if necessary, because it is slow - descending = False - if np.iterable(levels): - if len(levels) == 1: - norm = mcolors.Normalize(vmin=levels[0] - 1, vmax=levels[0] + 1) - else: - levels, descending = pcolors._check_levels(levels) - if norm is None: - norm = 'linear' - if np.iterable(levels) and len(levels) > 2: - steps = np.abs(np.diff(levels)) - eps = np.mean(steps) / 1e3 - if np.any(np.abs(np.diff(steps)) >= eps): - norm = 'segmented' - if norm == 'segmented': - if not np.iterable(levels): - norm = 'linear' # has same result - else: - norm_kw['levels'] = levels - norm = constructor.Norm(norm, **norm_kw) + # Infer color for error bars + edgecolor = None + if name == 'bar': + edgecolor = kwargs.get('edgecolor', None) + elif name == 'violinplot': + edgecolor = kwargs.pop('edgecolor', None) + edgecolor = _not_none(edgecolor, 'k') + barcolor = _not_none(barcolor, edgecolor) + boxcolor = _not_none(boxcolor, barcolor) - # Use the locator to determine levels - # Mostly copied from the hidden contour.ContourSet._autolev - # NOTE: Subsequently, we *only* use the locator to determine ticks if - # *levels* and *values* were not passed. - if isinstance(norm, mcolors.BoundaryNorm): - # Get levels from bounds - # TODO: Test this feature? - # NOTE: No warning because we get here internally? - levels = norm.boundaries - elif np.iterable(values): - # Prefer ticks in center - ticks = np.asarray(values) - elif np.iterable(levels): - # Prefer ticks on level edges - ticks = np.asarray(levels) - else: - # Determine levels automatically - N = levels - if locator is not None: - locator = constructor.Locator(locator, **locator_kw) - ticks = locator - elif isinstance(norm, mcolors.LogNorm): - locator = mticker.LogLocator(**locator_kw) - ticks = locator - elif isinstance(norm, mcolors.SymLogNorm): - locator_kw.setdefault('linthresh', norm.linthresh) - locator = mticker.SymmetricalLogLocator(**locator_kw) - ticks = locator - else: - locator_kw.setdefault('symmetric', symmetric) - locator = mticker.MaxNLocator(N, min_n_ticks=1, **locator_kw) + # Infer color for shading + shadecolor_infer = shadecolor is None + shadecolor = _not_none( + shadecolor, kwargs.get('color', None), kwargs.get('facecolor', None), edgecolor + ) + fadecolor_infer = fadecolor is None + fadecolor = _not_none(fadecolor, shadecolor) - # Get locations - automin = vmin is None - automax = vmax is None - if automin or automax: - data = ma.masked_invalid(data, copy=False) - if automin: - vmin = float(data.min()) - if automax: - vmax = float(data.max()) - if vmin == vmax or ma.is_masked(vmin) or ma.is_masked(vmax): - vmin, vmax = 0, 1 - try: - levels = locator.tick_values(vmin, vmax) - except RuntimeError: # too-many-ticks error - levels = np.linspace(vmin, vmax, N) # TODO: _autolev used N+1 + # Draw dark and light shading + vert = kwargs.get('vert', kwargs.get('orientation', 'vertical') == 'vertical') + axis = 'y' if vert else 'x' # yerr + errargs = (x, y) if vert else (y, x) + errobjs = [] + means_or_medians = means or medians + if fading: + err, label = _indicate_error_data( + data, y, fadedata, fadestds, fadepctiles, + stds_default=(-3, 3), pctiles_default=(0, 100), absolute=True, + means_or_medians=means_or_medians, label=fadelabel, + ) + errfunc = self.fill_between if vert else self.fill_betweenx + errobj = errfunc( + x, *err, linewidth=0, color=fadecolor, + alpha=fadealpha, zorder=fadezorder, + ) + errobj.set_label(label) + errobjs.append(errobj) + if shading: + err, label = _indicate_error_data( + data, y, shadedata, shadestds, shadepctiles, + stds_default=(-2, 2), pctiles_default=(10, 90), absolute=True, + means_or_medians=means_or_medians, label=shadelabel, + ) + errfunc = self.fill_between if vert else self.fill_betweenx + errobj = errfunc( + x, *err, linewidth=0, color=shadecolor, + alpha=shadealpha, zorder=shadezorder, + ) + errobj.set_label(label) # shadelabel=False + errobjs.append(errobj) - # Trim excess levels the locator may have supplied - # NOTE: This part is mostly copied from _autolev - if not locator_kw.get('symmetric', None): - i0, i1 = 0, len(levels) # defaults - under, = np.where(levels < vmin) - if len(under): - i0 = under[-1] - if not automin or extend in ('min', 'both'): - i0 += 1 # permit out-of-bounds data - over, = np.where(levels > vmax) - if len(over): - i1 = over[0] + 1 if len(over) else len(levels) - if not automax or extend in ('max', 'both'): - i1 -= 1 # permit out-of-bounds data - if i1 - i0 < 3: - i0, i1 = 0, len(levels) # revert - levels = levels[i0:i1] + # Draw thin error bars and thick error boxes + if boxes: + err, label = _indicate_error_data( + data, y, boxdata, boxstds, boxpctiles, + stds_default=(-1, 1), pctiles_default=(25, 75), + means_or_medians=means_or_medians, + ) + if boxmarker: + self.scatter(*errargs, s=boxlw, marker='o', color=boxmarkercolor, zorder=5) + errkw = {axis + 'err': err} + errobj = self.errorbar( + *errargs, color=boxcolor, linewidth=boxlw, linestyle='none', + capsize=0, zorder=boxzorder, **errkw, + ) + errobjs.append(errobj) + if bars: # now impossible to make thin bar width different from cap width! + err, label = _indicate_error_data( + data, y, bardata, barstds, barpctiles, + stds_default=(-3, 3), pctiles_default=(0, 100), + means_or_medians=means_or_medians, + ) + errkw = {axis + 'err': err} + errobj = self.errorbar( + *errargs, color=barcolor, linewidth=barlw, linestyle='none', + markeredgecolor=barcolor, markeredgewidth=barlw, + capsize=capsize, zorder=barzorder, **errkw + ) + errobjs.append(errobj) - # Compare the no. of levels we *got* (levels) to what we *wanted* (N) - # If we wanted more than 2 times the result, then add nn - 1 extra - # levels in-between the returned levels *in normalized space*. - # Example: A LogNorm gives too few levels, so we select extra levels - # here, but use the locator for determining tick locations. - nn = N // len(levels) - if nn >= 2: - olevels = norm(levels) - nlevels = [] - for i in range(len(levels) - 1): - l1, l2 = olevels[i], olevels[i + 1] - nlevels.extend(np.linspace(l1, l2, nn + 1)[:-1]) - nlevels.append(olevels[-1]) - levels = norm.inverse(nlevels) + # Call main function + # NOTE: Provide error objects for inclusion in legend, but *only* provide + # the shading. Never want legend entries for error bars. + xy = (x, data) if name == 'violinplot' else (x, y) + kwargs.setdefault('errobjs', errobjs[:int(shading + fading)]) + result = obj = func(self, *xy, *args, **kwargs) - # Use auto-generated levels for ticks if still None - if ticks is None: - ticks = levels + # Apply inferrred colors to objects + if type(result) in (tuple, list): # avoid BarContainer + obj = result[0] + i = 0 + for b, infer in zip((fading, shading), (fadecolor_infer, shadecolor_infer)): + if b and infer: + if hasattr(obj, 'get_facecolor'): + color = obj.get_facecolor() + elif hasattr(obj, 'get_color'): + color = obj.get_color() + else: + color = None + if color is not None: + errobjs[i].set_facecolor(color) + i += 1 - # Generate DiscreteNorm and update "child" norm with vmin and vmax from - # levels. This lets the colorbar set tick locations properly! - # TODO: Move these to DiscreteNorm? - if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1: - norm = pcolors.DiscreteNorm( - levels, cmap=cmap, norm=norm, descending=descending, unique=extend, - ) - if descending: - cmap = cmap.reversed() - return norm, cmap, levels, ticks + # Return objects + # NOTE: This should not affect internal matplotlib calls to these funcs + # NOTE: Avoid expanding matplolib collections that are list subclasses here + if errobjs: + if type(result) in (tuple, list): # e.g. result of plot + return (*result, *errobjs) + else: + return (result, *errobjs) + else: + return result -@warnings._rename_kwargs('0.6', centers='values') -def _parse_cmap( - self, func, *args, extend=None, - cmap=None, cmap_kw=None, norm=None, norm_kw=None, - vmin=None, vmax=None, N=None, levels=None, values=None, - symmetric=False, locator=None, locator_kw=None, - edgefix=None, labels=False, labels_kw=None, fmt=None, precision=2, - colorbar=False, colorbar_kw=None, - lw=None, linewidth=None, linewidths=None, - ls=None, linestyle=None, linestyles=None, - color=None, colors=None, edgecolor=None, edgecolors=None, - **kwargs -): +def _parse_1d(self, func, *args, **kwargs): """ - Interpret colormap keyword arguments. + Standardize the positional arguments for 1D data. """ + # Sanitize input + # TODO: Add exceptions for methods other than 'hist'? name = func.__name__ - autoformat = rc['autoformat'] # possibly manipulated by standardize_[12]d + autoformat = rc['autoformat'] + _load_objects() if not args: return func(self, *args, **kwargs) + elif len(args) == 1: + x = None + y, *args = args + elif len(args) <= 4: # max signature is x, y, z, color + x, y, *args = args + else: + raise ValueError( + f'{name}() takes up to 4 positional arguments but {len(args)} was given.' + ) + vert = kwargs.get('vert', None) + if vert is not None: + orientation = ('vertical' if vert else 'horizontal') + else: + orientation = kwargs.get('orientation', 'vertical') - # Mutable inputs - cmap_kw = cmap_kw or {} - norm_kw = norm_kw or {} - labels_kw = labels_kw or {} - locator_kw = locator_kw or {} - colorbar_kw = colorbar_kw or {} + # Iterate through list of ys that we assume are identical + # Standardize based on the first y input + if len(args) >= 1 and 'fill_between' in name: + ys, args = (y, args[0]), args[1:] + else: + ys = (y,) + ys = [_to_arraylike(y) for y in ys] - # Flexible user input - Z_sample = args[-1] - edgefix = _not_none(edgefix, rc['image.edgefix']) - linewidths = _not_none(lw=lw, linewidth=linewidth, linewidths=linewidths) - linestyles = _not_none(ls=ls, linestyle=linestyle, linestyles=linestyles) - colors = _not_none( - color=color, colors=colors, edgecolor=edgecolor, edgecolors=edgecolors, - ) + # Auto x coords + y = ys[0] # test the first y input + if x is None: + axis = int( + name in ('hist', 'boxplot', 'violinplot') + or any(kwargs.get(s, None) for s in ('means', 'medians')) + ) + x, _ = _axis_labels_title(y, axis=axis) + x = _to_arraylike(x) + if x.ndim != 1: + raise ValueError( + f'x coordinates must be 1-dimensional, but got {x.ndim}.' + ) - # Get colormap, but do not use cmap when 'colors' are passed to contour() - # or to contourf() -- the latter only when 'linewidths' and 'linestyles' - # are also *not* passed. This wrapper lets us add "edges" to contourf - # plots by calling contour() after contourf() if 'linewidths' or - # 'linestyles' are explicitly passed, but do not want to disable the - # native matplotlib feature for manually coloring filled contours. - # https://matplotlib.org/3.1.1/api/_as_gen/matplotlib.axes.Axes.contourf - add_contours = ( - name in ('contourf', 'tricontourf') - and (linewidths is not None or linestyles is not None) - ) - no_cmap = colors is not None and ( - name in ('contour', 'tricontour') - or name in ('contourf', 'tricontourf') and not add_contours - ) - if no_cmap: - if cmap is not None: - warnings._warn_proplot( - f'Ignoring input colormap cmap={cmap!r}, using input colors ' - f'colors={colors!r} instead.' - ) - cmap = None - if name in ('contourf', 'tricontourf'): - kwargs['colors'] = colors # this was not done above - colors = None - else: - if cmap is None: - if name == 'spy': - cmap = pcolors.ListedColormap(['w', 'k'], '_binary') + # Auto formatting + x_index = None # index version of 'x' + if not hasattr(self, 'projection'): + # First handle string-type x-coordinates + kw = {} + xname = 'y' if orientation == 'horizontal' else 'x' + yname = 'x' if xname == 'y' else 'y' + if _is_string(x): + if name in ('hist',): + kwargs.setdefault('labels', list(x)) else: - cmap = rc['image.cmap'] - cmap = constructor.Colormap(cmap, **cmap_kw) - if getattr(cmap, '_cyclic', None) and extend != 'neither': - warnings._warn_proplot( - f'Cyclic colormap requires extend="neither". ' - f'Overriding user input extend={extend!r}.' - ) - extend = None - - # Translate standardized keyword arguments back into the keyword args - # accepted by native matplotlib methods. Also disable edgefix if user want - # to customize the "edges". - style_kw = STYLE_ARGS_TRANSLATE.get(name, None) - for key, value in ( - ('colors', colors), - ('linewidths', linewidths), - ('linestyles', linestyles) - ): - if value is None or add_contours: - continue - if not style_kw or key not in style_kw: # no known conversion table - raise TypeError(f'{name}() got an unexpected keyword argument {key!r}') - edgefix = False # disable edgefix when specifying borders! - kwargs[style_kw[key]] = value + x_index = np.arange(len(x)) + kw[xname + 'locator'] = mticker.FixedLocator(x_index) + kw[xname + 'formatter'] = mticker.IndexFormatter(x) + kw[xname + 'minorlocator'] = mticker.NullLocator() + if name == 'boxplot': # otherwise IndexFormatter is overridden + kwargs['labels'] = x - # Build colormap normalizer and update keyword args - # NOTE: Standard algorithm for obtaining default levels does not work - # for hexbin, because it colors *counts*, not data values! - ticks = None - if cmap is not None and name not in ('hexbin',): - norm, cmap, levels, ticks = _build_discrete_norm( - Z_sample, # sample data for getting suitable levels - N=N, levels=levels, values=values, - norm=norm, norm_kw=norm_kw, - locator=locator, locator_kw=locator_kw, - cmap=cmap, vmin=vmin, vmax=vmax, extend=extend, - symmetric=symmetric, - minlength=(1 if name in ('contour', 'tricontour') else 2), - ) - if not no_cmap: - kwargs['cmap'] = cmap - if norm is not None: - kwargs['norm'] = norm - if name in ('contour', 'contourf', 'tricontour', 'tricontourf'): - kwargs['levels'] = levels - kwargs['extend'] = extend - if name in ('parametric',): - kwargs['values'] = values + # Next handle labels if 'autoformat' is on + # NOTE: Do not overwrite existing labels! + if autoformat: + # Ylabel + y, label = _axis_labels_title(y) + iname = xname if name in ('hist',) else yname + if label and not getattr(self, f'get_{iname}label')(): + # For histograms, this label is used for *x* coordinates + kw[iname + 'label'] = label + if name not in ('hist',): + # Xlabel + x, label = _axis_labels_title(x) + if label and not getattr(self, f'get_{xname}label')(): + kw[xname + 'label'] = label + # Reversed axis + if name not in ('scatter',): + if x_index is None and len(x) > 1 and x[1] < x[0]: + kw[xname + 'reverse'] = True - # Call function, possibly twice to add 'edges' to contourf plot - obj = func(self, *args, **kwargs) - if not isinstance(obj, tuple): # hist2d - obj.extend = extend # normally 'extend' is just for contour/contourf - if ticks is not None: - obj.ticks = ticks # a Locator or ndarray used for controlling ticks - if add_contours: - colors = _not_none(colors, 'k') - self.contour( - *args, levels=levels, linewidths=linewidths, - linestyles=linestyles, colors=colors - ) + # Appply + if kw: + self.format(**kw) - # Apply labels - # TODO: Add quiverkey to this! - if labels: - # Formatting for labels - fmt = _not_none(labels_kw.pop('fmt', None), fmt, 'simple') - fmt = constructor.Formatter(fmt, precision=precision) + # Standardize args + if x_index is not None: + x = x_index + if name in ('boxplot', 'violinplot'): + ys = [_to_ndarray(yi) for yi in ys] # store naked array + kwargs['positions'] = x - # Use clabel method - if name in ('contour', 'contourf', 'tricontour', 'tricontourf'): - cobj = obj - colors = None - if name in ('contourf', 'tricontourf'): - lums = [to_xyz(cmap(norm(level)), 'hcl')[2] for level in levels] - cobj = self.contour(*args, levels=levels, linewidths=0) - colors = ['w' if lum < 50 else 'k' for lum in lums] - text_kw = {} - for key in (*labels_kw,): # allow dict to change size - if key not in ( - 'levels', 'fontsize', 'colors', 'inline', 'inline_spacing', - 'manual', 'rightside_up', 'use_clabeltext', - ): - text_kw[key] = labels_kw.pop(key) - labels_kw.setdefault('colors', colors) - labels_kw.setdefault('inline_spacing', 3) - labels_kw.setdefault('fontsize', rc['text.labelsize']) - labs = cobj.clabel(fmt=fmt, **labels_kw) - for lab in labs: - lab.update(text_kw) + # Basemap shift x coordiantes without shifting y, we fix this! + if getattr(self, 'name', '') == 'basemap' and kwargs.get('latlon', None): + ix, iys = x, [] + xmin, xmax = self.projection.lonmin, self.projection.lonmax + for y in ys: + # Ensure data is monotonic and falls within map bounds + x, y = _parse_2d_monotonic_lons(x, y) + ix, iy = _parse_2d_enforce_bounds(x, y, xmin, xmax) + iys.append(iy) + x, ys = ix, iys - # Label each box manually - # See: https://stackoverflow.com/a/20998634/4970632 - elif name in ('pcolor', 'pcolormesh', 'pcolorfast'): - # Populate the _facecolors attribute, which is initially filled - # with just a single color - obj.update_scalarmappable() + # WARNING: For some functions, e.g. boxplot and violinplot, we *require* + # cycle_changer is also applied so it can strip 'x' input. + return func(self, x, *ys, *args, **kwargs) - # Get text positions and colors - labels_kw_ = {'size': rc['text.labelsize'], 'ha': 'center', 'va': 'center'} - labels_kw_.update(labels_kw) - array = obj.get_array() - paths = obj.get_paths() - colors = np.asarray(obj.get_facecolors()) - edgecolors = np.asarray(obj.get_edgecolors()) - if len(colors) == 1: # weird flex but okay - colors = np.repeat(colors, len(array), axis=0) - if len(edgecolors) == 1: - edgecolors = np.repeat(edgecolors, len(array), axis=0) - for i, (color, path, num) in enumerate(zip(colors, paths, array)): - if not np.isfinite(num): - edgecolors[i, :] = 0 - continue - bbox = path.get_extents() - x = (bbox.xmin + bbox.xmax) / 2 - y = (bbox.ymin + bbox.ymax) / 2 - if 'color' not in labels_kw: - _, _, lum = to_xyz(color, 'hcl') - if lum < 50: - color = 'w' - else: - color = 'k' - labels_kw_['color'] = color - self.text(x, y, fmt(num), **labels_kw_) - obj.set_edgecolors(edgecolors) - else: - raise RuntimeError(f'Not possible to add labels to {name!r} plot.') - - # Fix white lines between filled contours/mesh and fix issues with colormaps - # that are not perfectly opaque. 0.4pt is thick enough to hide lines but thin - # enough to not add "dots" in corner of pcolor plots. - # See: https://github.com/jklymak/contourfIssues - # See: https://stackoverflow.com/q/15003353/4970632 - if edgefix and name in ( - 'pcolor', 'pcolormesh', 'pcolorfast', 'tripcolor', 'contourf', 'tricontourf' - ): - cmap = obj.get_cmap() - if not cmap._isinit: - cmap._init() - if all(cmap._lut[:-1, 3] == 1): # skip for cmaps with transparency - edgecolor = 'face' - else: - edgecolor = 'none' - if name in ('pcolor', 'pcolormesh', 'pcolorfast', 'tripcolor'): - if hasattr(obj, 'set_linewidth'): # not always true for pcolorfast - obj.set_linewidth(0.4) - if hasattr(obj, 'set_edgecolor'): # not always true for pcolorfast - obj.set_edgecolor(edgecolor) - else: - for contour in obj.collections: - contour.set_edgecolor(edgecolor) - contour.set_linewidth(0.4) - contour.set_linestyle('-') - # Optionally add colorbar - if colorbar: - loc = self._loc_translate(colorbar, 'colorbar', allow_manual=False) - if autoformat: - _, label = _axis_labels_title(Z_sample) # last one is data, we assume - if label: - colorbar_kw.setdefault('label', label) - if name in ('parametric',) and values is not None: - colorbar_kw.setdefault('values', values) - if loc != 'fill': - colorbar_kw.setdefault('loc', loc) - self.colorbar(obj, **colorbar_kw) +def _parse_2d_interp_poles(y, Z): + """ + Add data points on the poles as the average of highest latitude data. + """ + # Get means + with np.errstate(all='ignore'): + p1 = Z[0, :].mean() # pole 1, make sure is not 0D DataArray! + p2 = Z[-1, :].mean() # pole 2 + if hasattr(p1, 'item'): + p1 = np.asscalar(p1) # happens with DataArrays + if hasattr(p2, 'item'): + p2 = np.asscalar(p2) + # Concatenate + ps = (-90, 90) if (y[0] < y[-1]) else (90, -90) + Z1 = np.repeat(p1, Z.shape[1])[None, :] + Z2 = np.repeat(p2, Z.shape[1])[None, :] + y = ma.concatenate((ps[:1], y, ps[1:])) + Z = ma.concatenate((Z1, Z, Z2), axis=0) + return y, Z - return obj +def _parse_2d_enforce_bounds(x, y, xmin, xmax): + """ + Ensure data for basemap plots is restricted between the minimum and + maximum longitude of the projection. Input is the ``x`` and ``y`` + coordinates. The ``y`` coordinates are rolled along the rightmost axis. + """ + if x.ndim != 1: + return x, y + # Roll in same direction if some points on right-edge extend + # more than 360 above min longitude; *they* should be on left side + lonroll = np.where(x > xmin + 360)[0] # tuple of ids + if lonroll.size: # non-empty + roll = x.size - lonroll.min() + x = np.roll(x, roll) + y = np.roll(y, roll, axis=-1) + x[:roll] -= 360 # make monotonic -def _iter_legend_children(children): + # Set NaN where data not in range xmin, xmax. Must be done + # for regional smaller projections or get weird side-effects due + # to having valid data way outside of the map boundaries + y = y.copy() + if x.size - 1 == y.shape[-1]: # test western/eastern grid cell edges + y[..., (x[1:] < xmin) | (x[:-1] > xmax)] = np.nan + elif x.size == y.shape[-1]: # test the centers and pad by one for safety + where = np.where((x < xmin) | (x > xmax))[0] + y[..., where[1:-1]] = np.nan + return x, y + + +def _parse_2d_monotonic_lons(x, y): """ - Iterate recursively through `_children` attributes of various `HPacker`, - `VPacker`, and `DrawingArea` classes. + Ensure longitudes are monotonic and make `~numpy.ndarray` copies so the + contents can be modified. Ignores 2D coordinate arrays. """ - for obj in children: - if hasattr(obj, '_children'): - yield from _iter_legend_children(obj._children) - else: - yield obj + # Sanitization and bail if 2d + if x.ndim == 1: + x = ma.array(x) + if y.ndim == 1: + y = ma.array(y) + if x.ndim != 1 or all(x < x[0]): # skip monotonic backwards data + return x, y + # Enforce monotonic longitudes + lon1 = x[0] + while True: + filter_ = (x < lon1) + if filter_.sum() == 0: + break + x[filter_] += 360 + return x, y -def _add_legend( - self, handles=None, labels=None, *, ncol=None, ncols=None, - center=None, order='C', loc=None, label=None, title=None, - fontsize=None, fontweight=None, fontcolor=None, - color=None, marker=None, lw=None, linewidth=None, - dashes=None, linestyle=None, markersize=None, frameon=None, frame=None, - **kwargs -): +def _parse_2d(self, func, *args, order='C', globe=False, **kwargs): """ - Draw a legend with extra features. + Standardize the positional arguments for 2D data. """ - # Parse input args - # TODO: Legend entries for colormap or scatterplot objects! Idea is we - # pass a scatter plot or contourf or whatever, and legend is generated by - # drawing patch rectangles or markers using data values and their - # corresponding cmap colors! For scatterplots just test get_facecolor() - # to see if it contains more than one color. - # TODO: It is *also* often desirable to label a colormap object with - # one data value. Maybe add a legend option for the *number of samples* - # or the *sample points* when drawing legends for colormap objects. - # Look into "legend handlers", might just want to add own handlers by - # passing handler_map to legend() and get_legend_handles_labels(). - if order not in ('F', 'C'): + # Sanitize input + name = func.__name__ + autoformat = rc['autoformat'] + _load_objects() + if not args: + return func(self, *args, **kwargs) + elif len(args) > 5: raise ValueError( - f'Invalid order {order!r}. Choose from ' - '"C" (row-major, default) and "F" (column-major).' + f'{name}() takes up to 5 positional arguments but {len(args)} was given.' ) - ncol = _not_none(ncols=ncols, ncol=ncol) - title = _not_none(label=label, title=title) - frameon = _not_none( - frame=frame, frameon=frameon, default=rc['legend.frameon'] - ) - if handles is not None and not np.iterable(handles): # e.g. a mappable object - handles = [handles] - if labels is not None and (not np.iterable(labels) or isinstance(labels, str)): - labels = [labels] - if title is not None: - kwargs['title'] = title - if frameon is not None: - kwargs['frameon'] = frameon - if fontsize is not None: - kwargs['fontsize'] = rc._scale_font(fontsize) - - # Handle and text properties that are applied after-the-fact - # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds - # shading in legend entry. This change is not noticeable in other situations. - kw_text = {} - for key, value in ( - ('color', fontcolor), - ('weight', fontweight), - ): - if value is not None: - kw_text[key] = value - kw_handle = {'solid_capstyle': 'butt'} - for key, value in ( - ('color', color), - ('marker', marker), - ('linewidth', lw), - ('linewidth', linewidth), - ('markersize', markersize), - ('linestyle', linestyle), - ('dashes', dashes), - ): - if value is not None: - kw_handle[key] = value + x, y = None, None + if len(args) > 2: + x, y, *args = args - # Legend box properties - outline = rc.fill( - { - 'linewidth': 'axes.linewidth', - 'edgecolor': 'axes.edgecolor', - 'facecolor': 'axes.facecolor', - 'alpha': 'legend.framealpha', - } - ) - for key in (*outline,): - if key != 'linewidth': - if kwargs.get(key, None): - outline.pop(key, None) + # Ensure DataArray, DataFrame or ndarray + Zs = [] + for Z in args: + Z = _to_arraylike(Z) + if Z.ndim != 2: + raise ValueError(f'Z must be 2-dimensional, got shape {Z.shape}.') + Zs.append(Z) + if not all(Zs[0].shape == Z.shape for Z in Zs): + raise ValueError( + f'Zs must be same shape, got shapes {[Z.shape for Z in Zs]}.' + ) - # Get axes for legend handle detection - # TODO: Update this when no longer use "filled panels" for outer legends - axs = [self] - if self._panel_hidden: - if self._panel_parent: # axes panel - axs = list(self._panel_parent._iter_axes(hidden=False, children=True)) + # Retrieve coordinates + if x is None and y is None: + Z = Zs[0] + if order == 'C': + idx, idy = 1, 0 else: - axs = list(self.figure._iter_axes(hidden=False, children=True)) - - # Handle list of lists (centered row legends) - # NOTE: Avoid very common plot() error where users draw individual lines - # with plot() and add singleton tuples to a list of handles. If matplotlib - # gets a list like this but gets no 'labels' argument, it raises error. - list_of_lists = False - if handles is not None: - handles = [ - handle[0] if type(handle) is tuple and len(handle) == 1 else handle - for handle in handles - ] - list_of_lists = any(type(handle) in (list, np.ndarray) for handle in handles) - if handles is not None and labels is not None and len(handles) != len(labels): + idx, idy = 0, 1 + # x = np.arange(Z.shape[idx]) + # y = np.arange(Z.shape[idy]) + if isinstance(Z, ndarray): + x = np.arange(Z.shape[idx]) + y = np.arange(Z.shape[idy]) + elif isinstance(Z, DataArray): # DataArray + x = Z.coords[Z.dims[idx]] + y = Z.coords[Z.dims[idy]] + else: # DataFrame; never Series or Index because these are 1d + if order == 'C': + x = Z.columns + y = Z.index + else: + x = Z.index + y = Z.columns + + # Optionally re-order + # TODO: Double check this + if order == 'F': + x, y = x.T, y.T # in case they are 2-dimensional + Zs = tuple(Z.T for Z in Zs) + elif order != 'C': raise ValueError( - f'Got {len(handles)} handles and {len(labels)} labels.' + f'Invalid order {order!r}. Choose from ' + '"C" (row-major, default) and "F" (column-major).' ) - if list_of_lists: - if any(not np.iterable(_) for _ in handles): - raise ValueError(f'Invalid handles={handles!r}.') - if not labels: - labels = [None] * len(handles) - elif not all(np.iterable(_) and not isinstance(_, str) for _ in labels): - # e.g. handles=[obj1, [obj2, obj3]] requires labels=[lab1, [lab2, lab3]] - raise ValueError( - f'Invalid labels={labels!r} for handles={handles!r}.' - ) - # Parse handles and legends with native matplotlib parser - if not list_of_lists: - if isinstance(handles, np.ndarray): - handles = handles.tolist() - if isinstance(labels, np.ndarray): - labels = labels.tolist() - handles, labels, *_ = mlegend._parse_legend_args( - axs, handles=handles, labels=labels, + # Check coordinates + x, y = _to_arraylike(x), _to_arraylike(y) + if x.ndim != y.ndim: + raise ValueError( + f'x coordinates are {x.ndim}-dimensional, ' + f'but y coordinates are {y.ndim}-dimensional.' ) - pairs = list(zip(handles, labels)) - else: - pairs = [] - for ihandles, ilabels in zip(handles, labels): - if isinstance(ihandles, np.ndarray): - ihandles = ihandles.tolist() - if isinstance(ilabels, np.ndarray): - ilabels = ilabels.tolist() - ihandles, ilabels, *_ = mlegend._parse_legend_args( - axs, handles=ihandles, labels=ilabels, + for s, array in zip(('x', 'y'), (x, y)): + if array.ndim not in (1, 2): + raise ValueError( + f'{s} coordinates are {array.ndim}-dimensional, ' + f'but must be 1 or 2-dimensional.' ) - pairs.append(list(zip(handles, labels))) - # Manage pairs in context of 'center' option - center = _not_none(center, list_of_lists) - if not center and list_of_lists: # standardize format based on input - list_of_lists = False # no longer is list of lists - pairs = [pair for ipairs in pairs for pair in ipairs] - elif center and not list_of_lists: - list_of_lists = True - ncol = _not_none(ncol, 3) - pairs = [ - pairs[i * ncol:(i + 1) * ncol] for i in range(len(pairs)) - ] # to list of iterables - ncol = None - if list_of_lists: # remove empty lists, pops up in some examples - pairs = [ipairs for ipairs in pairs if ipairs] + # Auto axis labels + # TODO: Check whether isinstance(GeoAxes) instead of checking projection attribute + kw = {} + xi = yi = None + if not hasattr(self, 'projection'): + # First handle string-type x and y-coordinates + if _is_string(x): + xi = np.arange(len(x)) + kw['xlocator'] = mticker.FixedLocator(xi) + kw['xformatter'] = mticker.IndexFormatter(x) + kw['xminorlocator'] = mticker.NullLocator() + if _is_string(y): + yi = np.arange(len(y)) + kw['ylocator'] = mticker.FixedLocator(yi) + kw['yformatter'] = mticker.IndexFormatter(y) + kw['yminorlocator'] = mticker.NullLocator() - # Bail if no pairs - if not pairs: - return mlegend.Legend(self, [], [], ncol=ncol, loc=loc, **kwargs) + # Handle labels if 'autoformat' is on + # NOTE: Do not overwrite existing labels! + if autoformat: + for key, xy in zip(('xlabel', 'ylabel'), (x, y)): + # Axis label + _, label = _axis_labels_title(xy) + if label and not getattr(self, f'get_{key}')(): + kw[key] = label + # Reversed axis + if ( + len(xy) > 1 + and all(isinstance(xy, Number) for xy in xy[:2]) + and xy[1] < xy[0] + ): + kw[key[0] + 'reverse'] = True + if kw: + self.format(**kw) - # Individual legend - legs = [] - width, height = self.get_size_inches() - if not center: - # Optionally change order - # See: https://stackoverflow.com/q/10101141/4970632 - # Example: If 5 columns, but final row length 3, columns 0-2 have - # N rows but 3-4 have N-1 rows. - ncol = _not_none(ncol, 3) - if order == 'C': - split = [ # split into rows - pairs[i * ncol:(i + 1) * ncol] - for i in range(len(pairs) // ncol + 1) - ] - nrowsmax = len(split) # max possible row count - nfinalrow = len(split[-1]) # columns in final row - nrows = ( - [nrowsmax] * nfinalrow + [nrowsmax - 1] * (ncol - nfinalrow) - ) - fpairs = [] - for col, nrow in enumerate(nrows): # iterate through cols - fpairs.extend(split[row][col] for row in range(nrow)) - pairs = fpairs + # Use *index coordinates* from here on out if input was array of strings + if xi is not None: + x = xi + if yi is not None: + y = yi - # Draw legend - leg = mlegend.Legend(self, *zip(*pairs), ncol=ncol, loc=loc, **kwargs) - legs = [leg] + # Auto axes title and colorbar label + # NOTE: Do not overwrite existing title! + # NOTE: Must apply default colorbar label *here* rather than in + # cmap_args in case metadata is stripped by globe=True. + colorbar_kw = kwargs.pop('colorbar_kw', None) or {} + if autoformat: + _, colorbar_label = _axis_labels_title(Zs[0], units=True) + colorbar_kw.setdefault('label', colorbar_label) + kwargs['colorbar_kw'] = colorbar_kw - # Legend with centered rows, accomplished by drawing separate legends for - # each row. The label spacing/border spacing will be exactly replicated. - else: - # Message when overriding some properties - overridden = [] - kwargs.pop('frameon', None) # then add back later! - for override in ('bbox_transform', 'bbox_to_anchor'): - prop = kwargs.pop(override, None) - if prop is not None: - overridden.append(override) - if ncol is not None: - warnings._warn_proplot( - 'Detected list of *lists* of legend handles. ' - 'Ignoring user input property "ncol".' + # Enforce edges + if name in ('pcolor', 'pcolormesh', 'pcolorfast'): + Z = Zs[0] # already enforced that shapes must be identical (see above) + xlen, ylen = x.shape[-1], y.shape[0] + if Z.ndim != 2: + raise ValueError( + f'Input arrays must be 2D, instead got shape {Z.shape}.' ) - if overridden: - warnings._warn_proplot( - 'Ignoring user input properties ' - + ', '.join(map(repr, overridden)) - + ' for centered-row legend.' + elif Z.shape[1] == xlen and Z.shape[0] == ylen: + # Get edges given centers + if all(z.ndim == 1 and z.size > 1 and _is_number(z) for z in (x, y)): + x = edges(x) + y = edges(y) + else: + if ( + x.ndim == 2 and x.shape[0] > 1 and x.shape[1] > 1 + and _is_number(x) + ): + x = edges2d(x) + if ( + y.ndim == 2 and y.shape[0] > 1 and y.shape[1] > 1 + and _is_number(y) + ): + y = edges2d(y) + elif Z.shape[1] != xlen - 1 or Z.shape[0] != ylen - 1: + raise ValueError( + f'Input shapes x {x.shape} and y {y.shape} must match ' + f'Z centers {Z.shape} or ' + f'Z borders {tuple(i+1 for i in Z.shape)}.' ) - # Determine space we want sub-legend to occupy as fraction of height - # NOTE: Empirical testing shows spacing fudge factor necessary to - # exactly replicate the spacing of standard aligned legends. - fontsize = kwargs.get('fontsize', None) or rc['legend.fontsize'] - fontsize = rc._scale_font(fontsize) - spacing = kwargs.get('labelspacing', None) or rc['legend.labelspacing'] - if pairs: - interval = 1 / len(pairs) # split up axes - interval = (((1 + spacing * 0.85) * fontsize) / 72) / height - - # Iterate and draw - # NOTE: We confine possible bounding box in *y*-direction, but do not - # confine it in *x*-direction. Matplotlib will automatically move - # left-to-right if you request this. - ymin, ymax = None, None - if order == 'F': - raise NotImplementedError( - 'When center=True, ProPlot vertically stacks successive ' - 'single-row legends. Column-major (order="F") ordering ' - 'is un-supported.' - ) - loc = _not_none(loc, 'upper center') - if not isinstance(loc, str): + # Enforce centers + else: + Z = Zs[0] # already enforced that shapes must be identical (see above) + xlen, ylen = x.shape[-1], y.shape[0] + if Z.ndim != 2: raise ValueError( - f'Invalid location {loc!r} for legend with center=True. ' - 'Must be a location *string*.' - ) - elif loc == 'best': - warnings._warn_proplot( - 'For centered-row legends, cannot use "best" location. ' - 'Using "upper center" instead.' + f'Input arrays must be 2d, instead got shape {Z.shape}.' ) - - # Iterate through sublists - for i, ipairs in enumerate(pairs): - if i == 1: - kwargs.pop('title', None) - if i >= 1 and title is not None: - i += 1 # extra space! - - # Legend position - if 'upper' in loc: - y1 = 1 - (i + 1) * interval - y2 = 1 - i * interval - elif 'lower' in loc: - y1 = (len(pairs) + i - 2) * interval - y2 = (len(pairs) + i - 1) * interval - else: # center - y1 = 0.5 + interval * len(pairs) / 2 - (i + 1) * interval - y2 = 0.5 + interval * len(pairs) / 2 - i * interval - ymin = min(y1, _not_none(ymin, y1)) - ymax = max(y2, _not_none(ymax, y2)) - - # Draw legend - bbox = mtransforms.Bbox([[0, y1], [1, y2]]) - leg = mlegend.Legend( - self, *zip(*ipairs), loc=loc, ncol=len(ipairs), - bbox_transform=self.transAxes, bbox_to_anchor=bbox, - frameon=False, **kwargs + elif Z.shape[1] == xlen - 1 and Z.shape[0] == ylen - 1: + # Get centers given edges. + if all(z.ndim == 1 and z.size > 1 and _is_number(z) for z in (x, y)): + x = 0.5 * (x[1:] + x[:-1]) + y = 0.5 * (y[1:] + y[:-1]) + else: + if ( + x.ndim == 2 and x.shape[0] > 1 and x.shape[1] > 1 + and _is_number(x) + ): + x = 0.25 * (x[:-1, :-1] + x[:-1, 1:] + x[1:, :-1] + x[1:, 1:]) + if ( + y.ndim == 2 and y.shape[0] > 1 and y.shape[1] > 1 + and _is_number(y) + ): + y = 0.25 * (y[:-1, :-1] + y[:-1, 1:] + y[1:, :-1] + y[1:, 1:]) + elif Z.shape[1] != xlen or Z.shape[0] != ylen: + raise ValueError( + f'Input shapes x {x.shape} and y {y.shape} ' + f'must match Z centers {Z.shape} ' + f'or Z borders {tuple(i+1 for i in Z.shape)}.' ) - legs.append(leg) - # Add legends manually so matplotlib does not remove old ones - for leg in legs: - self.add_artist(leg) - leg.legendPatch.update(outline) # or get_frame() + # Cartopy projection axes + if ( + getattr(self, 'name', '') == 'cartopy' + and isinstance(kwargs.get('transform', None), PlateCarree) + ): + x, y = _parse_2d_monotonic_lons(x, y) + ix, iZs = x, [] + for Z in Zs: + if globe and x.ndim == 1 and y.ndim == 1: + # Fix holes over poles by *interpolating* there + y, Z = _parse_2d_interp_poles(y, Z) - # Apply *overrides* to legend elements - # WARNING: legendHandles only contains the *first* artist per legend because - # HandlerBase.legend_artist() called in Legend._init_legend_box() only - # returns the first artist. Instead we try to iterate through offset boxes. - # TODO: Remove this feature? Idea was this lets users create *categorical* - # legends in clunky way, e.g. entries denoting *colors* and entries denoting - # *markers*. But would be better to add capacity for categorical labels in a - # *single* legend like seaborn rather than multiple legends. - for leg in legs: - try: - children = leg._legend_handle_box._children - except AttributeError: # older versions maybe? - children = [] - for obj in _iter_legend_children(children): - # account for mixed legends, e.g. line on top of - # error bounds shading. - if isinstance(obj, mtext.Text): - leg.update(kw_text) - else: - for key, value in kw_handle.items(): - getattr(obj, f'set_{key}', lambda value: None)(value) + # Fix seams by ensuring circular coverage. Unlike basemap, + # cartopy can plot across map edges. + if x[0] % 360 != (x[-1] + 360) % 360: + ix = ma.concatenate((x, [x[0] + 360])) + Z = ma.concatenate((Z, Z[:, :1]), axis=1) + iZs.append(Z) + x, Zs = ix, iZs - # Draw manual fancy bounding box for un-aligned legend - # WARNING: The matplotlib legendPatch transform is the default transform, - # i.e. universal coordinates in points. Means we have to transform - # mutation scale into transAxes sizes. - # WARNING: Tempting to use legendPatch for everything but for some reason - # coordinates are messed up. In some tests all coordinates were just result - # of get window extent multiplied by 2 (???). Anyway actual box is found in - # _legend_box attribute, which is accessed by get_window_extent. - if center and frameon: - if len(legs) == 1: - # Use builtin frame - legs[0].set_frame_on(True) - else: - # Get coordinates - renderer = self.figure._get_renderer() - bboxs = [ - leg.get_window_extent(renderer).transformed(self.transAxes.inverted()) - for leg in legs - ] - xmin = min(bbox.xmin for bbox in bboxs) - xmax = max(bbox.xmax for bbox in bboxs) - ymin = min(bbox.ymin for bbox in bboxs) - ymax = max(bbox.ymax for bbox in bboxs) - fontsize = (fontsize / 72) / width # axes relative units - fontsize = renderer.points_to_pixels(fontsize) + # Basemap projection axes + elif getattr(self, 'name', '') == 'basemap' and kwargs.get('latlon', None): + # Fix grid + xmin, xmax = self.projection.lonmin, self.projection.lonmax + x, y = _parse_2d_monotonic_lons(x, y) + ix, iZs = x, [] + for Z in Zs: + # Ensure data is within map bounds + ix, Z = _parse_2d_enforce_bounds(x, Z, xmin, xmax) - # Draw and format patch - patch = mpatches.FancyBboxPatch( - (xmin, ymin), xmax - xmin, ymax - ymin, - snap=True, zorder=4.5, - mutation_scale=fontsize, - transform=self.transAxes - ) - if kwargs.get('fancybox', rc['legend.fancybox']): - patch.set_boxstyle('round', pad=0, rounding_size=0.2) - else: - patch.set_boxstyle('square', pad=0) - patch.set_clip_on(False) - patch.update(outline) - self.add_artist(patch) + # Globe coverage fixes + if globe and ix.ndim == 1 and y.ndim == 1: + # Fix holes over poles by interpolating there (equivalent to + # simple mean of highest/lowest latitude points) + y, Z = _parse_2d_interp_poles(y, Z) - # Add shadow - # TODO: This does not work, figure out - if kwargs.get('shadow', rc['legend.shadow']): - shadow = mpatches.Shadow(patch, 20, -20) - self.add_artist(shadow) + # Fix seams at map boundary; 3 scenarios here: + # Have edges (e.g. for pcolor), and they fit perfectly against + # basemap seams. Does not augment size. + if ix[0] == xmin and ix.size - 1 == Z.shape[1]: + pass # do nothing + # Have edges (e.g. for pcolor), and the projection edge is + # in-between grid cell boundaries. Augments size by 1. + elif ix.size - 1 == Z.shape[1]: # just add grid cell + ix = ma.append(xmin, ix) + ix[-1] = xmin + 360 + Z = ma.concatenate((Z[:, -1:], Z), axis=1) + # Have centers (e.g. for contourf), and we need to interpolate + # to left/right edges of the map boundary. Augments size by 2. + elif ix.size == Z.shape[1]: + xi = np.array([ix[-1], ix[0] + 360]) # x + if xi[0] != xi[1]: + Zq = ma.concatenate((Z[:, -1:], Z[:, :1]), axis=1) + xq = xmin + 360 + Zq = ( + Zq[:, :1] * (xi[1] - xq) + Zq[:, 1:] * (xq - xi[0]) + ) / (xi[1] - xi[0]) + ix = ma.concatenate(([xmin], ix, [xmin + 360])) + Z = ma.concatenate((Zq, Z, Zq), axis=1) + else: + raise ValueError( + 'Unexpected shape of longitude, latitude, and/or data array(s).' + ) + iZs.append(Z) + x, Zs = ix, iZs - # Add patch to list - legs = (patch, *legs) + # Convert to projection coordinates + if x.ndim == 1 and y.ndim == 1: + x, y = np.meshgrid(x, y) + x, y = self.projection(x, y) + kwargs['latlon'] = False - # Append attributes and return, and set clip property!!! This is critical - # for tight bounding box calcs! - for leg in legs: - leg.set_clip_on(False) - return legs[0] if len(legs) == 1 else tuple(legs) + # Finally return result + return func(self, x, y, *Zs, **kwargs) -def _add_colorbar( - self, mappable, values=None, - extend=None, extendsize=None, - title=None, label=None, - grid=None, tickminor=None, - reverse=False, tickloc=None, ticklocation=None, - locator=None, ticks=None, maxn=None, maxn_minor=None, - minorlocator=None, minorticks=None, - locator_kw=None, minorlocator_kw=None, - formatter=None, ticklabels=None, formatter_kw=None, rotation=None, - norm=None, norm_kw=None, # normalizer to use when passing colors/lines - orientation='horizontal', - edgecolor=None, linewidth=None, - labelsize=None, labelweight=None, labelcolor=None, - ticklabelsize=None, ticklabelweight=None, ticklabelcolor=None, +@warnings._rename_kwargs('0.6', centers='values') +def _parse_cmap( + self, func, *args, extend='neither', + cmap=None, cmap_kw=None, norm=None, norm_kw=None, + vmin=None, vmax=None, N=None, levels=None, values=None, + symmetric=False, locator=None, locator_kw=None, + edgefix=None, + colorbar=False, colorbar_kw=None, + lw=None, linewidth=None, linewidths=None, + ls=None, linestyle=None, linestyles=None, + color=None, colors=None, edgecolor=None, edgecolors=None, **kwargs ): """ - Draw a colorbar with extra features. + Interpret colormap keyword arguments. """ - # NOTE: There is a weird problem with colorbars when simultaneously - # passing levels and norm object to a mappable; fixed by passing vmin/vmax - # instead of levels. (see: https://stackoverflow.com/q/40116968/4970632). - # NOTE: Often want levels instead of vmin/vmax, while simultaneously - # using a Normalize (for example) to determine colors between the levels - # (see: https://stackoverflow.com/q/42723538/4970632). Workaround makes - # sure locators are in vmin/vmax range exclusively; cannot match values. - # NOTE: In legend_wrapper() we try to add to the objects accepted by - # legend() using handler_map. We can't really do anything similar for - # colorbars; input must just be insnace of mixin class cm.ScalarMappable - # Mutable args + name = func.__name__ + if not args: + return func(self, *args, **kwargs) + + # Mutable inputs + cmap_kw = cmap_kw or {} norm_kw = norm_kw or {} - formatter_kw = formatter_kw or {} locator_kw = locator_kw or {} - minorlocator_kw = minorlocator_kw or {} + colorbar_kw = colorbar_kw or {} - # Parse input args - label = _not_none(title=title, label=label) - locator = _not_none(ticks=ticks, locator=locator) - minorlocator = _not_none(minorticks=minorticks, minorlocator=minorlocator) - ticklocation = _not_none(tickloc=tickloc, ticklocation=ticklocation) - formatter = _not_none(ticklabels=ticklabels, formatter=formatter) + # Flexible user input + Z_sample = args[-1] + edgefix = _not_none(edgefix, rc['image.edgefix']) + linewidths = _not_none(lw=lw, linewidth=linewidth, linewidths=linewidths) + linestyles = _not_none(ls=ls, linestyle=linestyle, linestyles=linestyles) + colors = _not_none( + color=color, colors=colors, edgecolor=edgecolor, edgecolors=edgecolors, + ) - # Colorbar kwargs - # WARNING: PathCollection scatter objects have an extend method! - # WARNING: Matplotlib 3.3 deprecated 'extend' parameter passed to colorbar() - # but *also* fails to read 'extend' parameter when added to a pcolor mappable! - # Need to figure out workaround! - grid = _not_none(grid, rc['colorbar.grid']) - if extend is None: - if isinstance(getattr(mappable, 'extend', None), str): - extend = mappable.extend or 'neither' - else: + # Get colormap, but do not use cmap when 'colors' are passed to contour() + # or to contourf() -- the latter only when 'linewidths' and 'linestyles' + # are also *not* passed. This wrapper lets us add "edges" to contourf + # plots by calling contour() after contourf() if 'linewidths' or + # 'linestyles' are explicitly passed, but do not want to disable the + # native matplotlib feature for manually coloring filled contours. + # https://matplotlib.org/3.1.1/api/_as_gen/matplotlib.axes.Axes.contourf + add_contours = ( + name in ('contourf', 'tricontourf') + and (linewidths is not None or linestyles is not None) + ) + no_cmap = colors is not None and ( + name in ('contour', 'tricontour') + or name in ('contourf', 'tricontourf') and not add_contours + ) + if no_cmap: + if cmap is not None: + warnings._warn_proplot( + f'Ignoring input colormap cmap={cmap!r}, using input colors ' + f'colors={colors!r} instead.' + ) + cmap = None + if name in ('contourf', 'tricontourf'): + kwargs['colors'] = colors # this was not done above + colors = None + else: + if cmap is None: + if name == 'spy': + cmap = pcolors.ListedColormap(['w', 'k'], '_binary') + else: + cmap = rc['image.cmap'] + cmap = constructor.Colormap(cmap, **cmap_kw) + if getattr(cmap, '_cyclic', None) and extend != 'neither': + warnings._warn_proplot( + f'Cyclic colormap requires extend="neither". ' + f'Overriding user input extend={extend!r}.' + ) extend = 'neither' - kwargs.update({ - 'cax': self, - 'use_gridspec': True, - 'orientation': orientation, - 'spacing': 'uniform', - 'extend': extend, - }) - kwargs.setdefault('drawedges', grid) - # Text property keyword args - kw_label = {} - for key, value in ( - ('size', labelsize), - ('weight', labelweight), - ('color', labelcolor), - ): - if value is not None: - kw_label[key] = value - kw_ticklabels = {} + # Translate standardized keyword arguments back into the keyword args + # accepted by native matplotlib methods. Also disable edgefix if user want + # to customize the "edges". + style_kw = STYLE_ARGS_TRANSLATE.get(name, None) for key, value in ( - ('size', ticklabelsize), - ('weight', ticklabelweight), - ('color', ticklabelcolor), - ('rotation', rotation), - ): - if value is not None: - kw_ticklabels[key] = value - - # Special case where auto colorbar is generated from 1d methods, a list is - # always passed, but some 1d methods (scatter) do have colormaps. - if ( - np.iterable(mappable) - and len(mappable) == 1 - and hasattr(mappable[0], 'get_cmap') - ): - mappable = mappable[0] - - # For container objects, we just assume color is the same for every item. - # Works for ErrorbarContainer, StemContainer, BarContainer. - if ( - np.iterable(mappable) - and len(mappable) > 0 - and all(isinstance(obj, mcontainer.Container) for obj in mappable) + ('colors', colors), + ('linewidths', linewidths), + ('linestyles', linestyles) ): - mappable = [obj[0] for obj in mappable] - - # Test if we were given a mappable, or iterable of stuff; note Container - # and PolyCollection matplotlib classes are iterable. - cmap = None - if not isinstance(mappable, (martist.Artist, mcontour.ContourSet)): - # A colormap instance - # TODO: Pass remaining arguments through Colormap()? This is really - # niche usage so maybe not necessary. - if isinstance(mappable, mcolors.Colormap): - # NOTE: 'Values' makes no sense if this is just a colormap. Just - # use unique color for every segmentdata / colors color. - cmap = mappable - values = np.linspace(0, 1, cmap.N) - - # List of colors - elif np.iterable(mappable) and all( - isinstance(obj, str) or (np.iterable(obj) and len(obj) in (3, 4)) - for obj in mappable - ): - colors = list(mappable) - cmap = mcolors.ListedColormap(colors, '_no_name') - if values is None: - values = np.arange(len(colors)) - locator = _not_none(locator, values) # tick *all* values by default + if value is None or add_contours: + continue + if not style_kw or key not in style_kw: # no known conversion table + raise TypeError(f'{name}() got an unexpected keyword argument {key!r}') + edgefix = False # disable edgefix when specifying borders! + kwargs[style_kw[key]] = value - # List of artists - # NOTE: Do not check for isinstance(Artist) in case it is an mpl collection - elif np.iterable(mappable) and all( - hasattr(obj, 'get_color') or hasattr(obj, 'get_facecolor') - for obj in mappable - ): - # Generate colormap from colors and infer tick labels - colors = [] - for obj in mappable: - if hasattr(obj, 'get_color'): - color = obj.get_color() - else: - color = obj.get_facecolor() - if isinstance(color, np.ndarray): - color = color.squeeze() # e.g. scatter plot - if color.ndim != 1: - raise ValueError( - 'Cannot make colorbar from list of artists ' - f'with more than one color: {color!r}.' - ) - colors.append(to_rgb(color)) - cmap = mcolors.ListedColormap(colors, '_no_name') + # Build colormap normalizer and update keyword args + # NOTE: Standard algorithm for obtaining default levels does not work + # for hexbin, because it colors *counts*, not data values! + ticks = None + if cmap is not None and name not in ('hexbin',): + norm, cmap, levels, ticks = _build_discrete_norm( + Z_sample, # sample data for getting suitable levels + N=N, levels=levels, values=values, + norm=norm, norm_kw=norm_kw, + locator=locator, locator_kw=locator_kw, + cmap=cmap, vmin=vmin, vmax=vmax, extend=extend, + symmetric=symmetric, + minlength=(1 if name in ('contour', 'tricontour') else 2), + ) + if not no_cmap: + kwargs['cmap'] = cmap + if norm is not None: + kwargs['norm'] = norm + if name in ('contour', 'contourf', 'tricontour', 'tricontourf'): + kwargs['levels'] = levels + kwargs['extend'] = extend + if name in ('parametric',): + kwargs['values'] = values - # Try to infer tick values and tick labels from Artist labels - if values is None: - # Get object labels and values (avoid overwriting colorbar 'label') - labs = [] - values = [] - for obj in mappable: - lab = value = None - if hasattr(obj, 'get_label'): - lab = obj.get_label() or None - if lab and lab[:1] == '_': # intended to be ignored by legend - lab = None - if lab: - try: - value = float(lab) - except (TypeError, ValueError): - pass - labs.append(lab) - values.append(value) - # Use default values if labels are non-numeric (numeric labels are - # common when making on-the-fly colorbars). Try to use object labels - # for ticks with default vertical rotation, like datetime axes. - if any(value is None for value in values): - values = np.arange(len(mappable)) - if formatter is None and any(lab is not None for lab in labs): - formatter = labs # use these fixed values for ticks - if orientation == 'horizontal': - kw_ticklabels.setdefault('rotation', 90) - locator = _not_none(locator, values) # tick *all* values by default + # Call function, possibly twice to add 'edges' to contourf plot + obj = func(self, *args, **kwargs) + if not isinstance(obj, tuple): # hist2d + obj.extend = extend # normally 'extend' is just for contour/contourf + if ticks is not None: + obj.ticks = ticks # a Locator or ndarray used for controlling ticks + if add_contours: + colors = _not_none(colors, 'k') + self.contour( + *args, levels=levels, linewidths=linewidths, + linestyles=linestyles, colors=colors + ) - else: - raise ValueError( - 'Input mappable must be a matplotlib artist, ' - 'list of objects, list of colors, or colormap. ' - f'Got {mappable!r}.' - ) + return obj - # Build ad hoc ScalarMappable object from colors - if cmap is not None: - if np.iterable(mappable) and len(values) != len(mappable): - raise ValueError( - f'Passed {len(values)} values, but only {len(mappable)} ' - f'objects or colors.' - ) - norm, *_ = _build_discrete_norm( - values=values, extend='neither', - cmap=cmap, norm=norm, norm_kw=norm_kw, - ) - mappable = mcm.ScalarMappable(norm, cmap) - # Try to get tick locations from *levels* or from *values* rather than - # random points along the axis. - # NOTE: Do not necessarily want e.g. minor tick locations at logminor - # for LogNorm! In _build_discrete_norm we sometimes select evenly spaced - # levels in log-space *between* powers of 10, so logminor ticks would be - # misaligned with levels. - if locator is None: - locator = getattr(mappable, 'ticks', None) - if locator is None: - # This should only happen if user calls plotting method on native - # matplotlib axes. - if isinstance(norm, mcolors.LogNorm): - locator = 'log' - elif isinstance(norm, mcolors.SymLogNorm): - locator = 'symlog' - locator_kw.setdefault('linthresh', norm.linthresh) - else: - locator = 'auto' +def _parse_cycle( + self, func, *args, + cycle=None, cycle_kw=None, + label=None, labels=None, values=None, + errobjs=None, + **kwargs +): + """ + Parse the cycle argument. + """ + # Parse positional args + # NOTE: Requires standardize_1d wrapper before reaching this. Also note + # that the 'x' coordinates are sometimes ignored below. + name = func.__name__ + if not args: + return func(self, *args, **kwargs) + x, y, *args = args + ys = (y,) + if len(args) >= 1 and name in ('fill_between', 'fill_betweenx'): + ys, args = (y, args[0]), args[1:] - elif not isinstance(locator, mticker.Locator): - # Get default maxn, try to allot 2em squares per label maybe? - # NOTE: Cannot use Axes.get_size_inches because this is a - # 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) - fontsize = kw_ticklabels.get('size', rc['xtick.labelsize']) - else: - scale = 1 - length = height * abs(self.get_position().height) - fontsize = kw_ticklabels.get('size', rc['ytick.labelsize']) - fontsize = rc._scale_font(fontsize) - maxn = _not_none(maxn, int(length / (scale * fontsize / 72))) - maxn_minor = _not_none( - maxn_minor, int(length / (0.5 * fontsize / 72)) - ) + # Parse keyword args + autoformat = rc['autoformat'] # possibly manipulated by standardize_[12]d + barh = stacked = False + cycle_kw = cycle_kw or {} + if name in ('bar', 'fill_between', 'fill_betweenx'): + stacked = kwargs.pop('stacked', False) + if name in ('bar',): + barh = kwargs.get('orientation', None) == 'horizontal' + width = kwargs.pop('width', 0.8) # 'width' for bar *and* barh (see bar_wrapper) + bottom = 'x' if barh else 'bottom' + kwargs.setdefault(bottom, 0) # 'x' required even though 'y' isn't for bar plots - # Get locator - if tickminor and minorlocator is None: - step = 1 + len(locator) // max(1, maxn_minor) - minorlocator = locator[::step] - step = 1 + len(locator) // max(1, maxn) - locator = locator[::step] + # Determine and temporarily set cycler + # NOTE: Axes cycle has no getter, only set_prop_cycle, which sets a + # prop_cycler attribute on the hidden _get_lines and _get_patches_for_fill + # objects. This is the only way to query current axes cycler! Should not + # wrap set_prop_cycle because would get messy and fragile. + # NOTE: The _get_lines cycler is an *itertools cycler*. Has no length, so + # we must cycle over it with next(). We try calling next() the same number + # of times as the length of input cycle. If the input cycle *is* in fact + # the same, below does not reset the color position, cycles us to start! + if cycle is not None or cycle_kw: + # Get the new cycler + cycle_args = () if cycle is None else (cycle,) + if y.ndim > 1 and y.shape[1] > 1: # default samples count + cycle_kw.setdefault('N', y.shape[1]) + cycle = constructor.Cycle(*cycle_args, **cycle_kw) - # Get extend triangles in physical units - width, height = self.figure.get_size_inches() - if orientation == 'horizontal': - scale = width * abs(self.get_position().width) - else: - scale = height * abs(self.get_position().height) - extendsize = units(_not_none(extendsize, rc['colorbar.extend'])) - extendsize = extendsize / (scale - 2 * extendsize) + # Get the original property cycle + # NOTE: Matplotlib saves itertools.cycle(cycler), not the original + # cycler object, so we must build up the keys again. + i = 0 + by_key = {} + cycle_orig = self._get_lines.prop_cycler + for i in range(len(cycle)): # use the cycler object length as a guess + prop = next(cycle_orig) + for key, value in prop.items(): + if key not in by_key: + by_key[key] = set() + if isinstance(value, (list, np.ndarray)): + value = tuple(value) + by_key[key].add(value) - # Draw the colorbar - # NOTE: Set default formatter here because we optionally apply a FixedFormatter - # using *labels* from handle input. - locator = constructor.Locator(locator, **locator_kw) - formatter = constructor.Formatter(_not_none(formatter, 'auto'), **formatter_kw) - kwargs.update({ - 'ticks': locator, - 'format': formatter, - 'ticklocation': ticklocation, - 'extendfrac': extendsize - }) - mappable.extend = extend # matplotlib >=3.3 - cb = self.figure.colorbar(mappable, **kwargs) - axis = self.xaxis if orientation == 'horizontal' else self.yaxis + # Reset property cycler if it differs + reset = set(by_key) != set(cycle.by_key()) + if not reset: # test individual entries + for key, value in cycle.by_key().items(): + if by_key[key] != set(value): + reset = True + break + if reset: + self.set_prop_cycle(cycle) - # The minor locator - # TODO: Document the improved minor locator functionality! - # NOTE: Colorbar._use_auto_colorbar_locator() is never True because we use - # the custom DiscreteNorm normalizer. Colorbar._ticks() always called. - if minorlocator is None: - if tickminor: - cb.minorticks_on() - else: - cb.minorticks_off() - elif not hasattr(cb, '_ticker'): - warnings._warn_proplot( - 'Matplotlib colorbar API has changed. ' - f'Cannot use custom minor tick locator {minorlocator!r}.' - ) - cb.minorticks_on() # at least turn them on - else: - # Set the minor ticks just like matplotlib internally sets the - # major ticks. Private API is the only way! - minorlocator = constructor.Locator(minorlocator, **minorlocator_kw) - ticks, *_ = cb._ticker(minorlocator, mticker.NullFormatter()) - axis.set_ticks(ticks, minor=True) - axis.set_ticklabels([], minor=True) + # Custom property cycler additions + # NOTE: By default matplotlib uses _get_patches_for_fill.get_next_color + # for scatter next scatter color, but cannot get anything else! We simultaneously + # iterate through the _get_lines property cycler and apply relevant properties. + apply_from_cycler = set() # which keys to apply from property cycler + if name in ('scatter',): + # Figure out which props should be updated + prop_keys = set(self._get_lines._prop_keys) - {'color', 'linestyle', 'dashes'} + for key, prop in ( + ('markersize', 's'), + ('linewidth', 'linewidths'), + ('markeredgewidth', 'linewidths'), + ('markeredgecolor', 'edgecolors'), + ('alpha', 'alpha'), + ('marker', 'marker'), + ): + prop = kwargs.get(prop, None) + if key in prop_keys and prop is None: # if key in cycler and property unset + apply_from_cycler.add(key) - # Label and tick label settings - # WARNING: Must use colorbar set_label to set text, calling set_text on - # the axis will do nothing! - if label is not None: - cb.set_label(label) - axis.label.update(kw_label) - for obj in axis.get_ticklabels(): - obj.update(kw_ticklabels) + # Plot susccessive columns + y1 = ys[0] + objs = [] + for i in range(ncols): + # Prop cycle properties + kw = kwargs.copy() + if apply_from_cycler: + props = next(self._get_lines.prop_cycler) + for key in apply_from_cycler: + value = props[key] + if key in ('size', 'markersize'): + key = 's' + elif key in ('linewidth', 'markeredgewidth'): # translate + key = 'linewidths' + elif key == 'markeredgecolor': + key = 'edgecolors' + kw[key] = value - # Ticks - xy = axis.axis_name - for which in ('minor', 'major'): - kw = rc.category(xy + 'tick.' + which) - kw.pop('visible', None) - if edgecolor: - kw['color'] = edgecolor - if linewidth: - kw['width'] = linewidth - axis.set_tick_params(which=which, **kw) - axis.set_ticks_position(ticklocation) + # Get x coordinates for bar plot + ix = x # samples + if name in ('bar',): # adjust + if not stacked: + offset = width * (i - 0.5 * (ncols - 1)) + ix = x + offset + elif stacked and y1.ndim > 1: + key = 'x' if barh else 'bottom' + kw[key] = _to_indexer(y1)[:, :i].sum(axis=1) - # Fix alpha-blending issues. - # Cannot set edgecolor to 'face' if alpha non-zero because blending will - # occur, will get colored lines instead of white ones. Need manual blending - # NOTE: For some reason cb solids uses listed colormap with always 1.0 - # alpha, then alpha is applied after. - # See: https://stackoverflow.com/a/35672224/4970632 - cmap = cb.cmap - if not cmap._isinit: - cmap._init() - if any(cmap._lut[:-1, 3] < 1): - warnings._warn_proplot( - f'Using manual alpha-blending for {cmap.name!r} colorbar solids.' - ) - # Generate "secret" copy of the colormap! - lut = cmap._lut.copy() - cmap = mcolors.Colormap('_cbar_fix', N=cmap.N) - cmap._isinit = True - cmap._init = lambda: None - # Manually fill lookup table with alpha-blended RGB colors! - for i in range(lut.shape[0] - 1): - alpha = lut[i, 3] - lut[i, :3] = (1 - alpha) * 1 + alpha * lut[i, :3] # blend *white* - lut[i, 3] = 1 - cmap._lut = lut - # Update colorbar - cb.cmap = cmap - cb.draw_all() + # Get y coordinates and labels + if name in ('pie', 'boxplot', 'violinplot'): + # Only ever have one y value, cannot have legend labels + iys = (y1,) - # Fix colorbar outline - kw_outline = { - 'edgecolor': _not_none(edgecolor, rc['axes.edgecolor']), - 'linewidth': _not_none(linewidth, rc['axes.linewidth']), - } - if cb.outline is not None: - cb.outline.update(kw_outline) - if cb.dividers is not None: - cb.dividers.update(kw_outline) + else: + # The coordinates + # WARNING: If stacked=True then we always *ignore* second + # argument passed to fill_between. Warning should be issued + # by fill_between_wrapper in this case. + if stacked and name in ('fill_between', 'fill_betweenx'): + iys = tuple( + y1 if y1.ndim == 1 + else _to_indexer(y1)[:, :ii].sum(axis=1) + for ii in (i, i + 1) + ) + else: + iys = tuple( + y_i if y_i.ndim == 1 else _to_indexer(y_i)[:, i] + for y_i in ys + ) + kw['label'] = labels[i] or '' - # *Never* rasterize because it causes misalignment with border lines - if cb.solids: - cb.solids.set_rasterized(False) - cb.solids.set_linewidth(0.4) - cb.solids.set_edgecolor('face') + # Build coordinate arguments + ixy = () + if barh: # special case, use kwargs only! + kw.update({'bottom': ix, 'width': iys[0]}) + elif name in ('pie', 'hist', 'boxplot', 'violinplot'): + ixy = iys + else: # has x-coordinates, and maybe more than one y + ixy = (ix, *iys) + obj = func(self, *ixy, *args, **kw) + if type(obj) in (list, tuple) and len(obj) == 1: + obj = obj[0] + objs.append(obj) - # Invert the axis if descending DiscreteNorm - norm = mappable.norm - if getattr(norm, '_descending', None): - axis.set_inverted(True) - if reverse: # potentially double reverse, although that would be weird... - axis.set_inverted(True) - return cb + # Return + # WARNING: Make sure plot always returns tuple of objects, and bar always + # returns singleton unless we have bulk drawn bar plots! Other matplotlib + # methods call these internally and expect a certain output format! + if name == 'plot': + return tuple(objs) # always return tuple of objects + elif name in ('boxplot', 'violinplot'): + return objs[0] # always return singleton + else: + return objs[0] if len(objs) == 1 else tuple(objs) -def _add_autoformat(func): +def _with_autoformat(func): """ Decorator that adds an `autoformat` keyword and executed the rest of the block inside a `plot.config.RcConfiguroator.context`. @@ -2963,21 +2962,3 @@ def wrapper(*args, autoformat=None, **kwargs): autoformat = _not_none(autoformat, rc['autoformat']) with rc.context(autoformat=autoformat): return func(*args, **kwargs) - - -def _basemap_norecurse(func): - """ - Decorator to prevent recursion in basemap method overrides. - See `this post https://stackoverflow.com/a/37675810/4970632`__. - """ - name = func.__name__ - - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - if self._called_from_basemap: - result = getattr(maxes.Axes, name)(self, *args, **kwargs) - else: - with _state_context(self, _called_from_basemap=True): - result = func(self, *args, **kwargs) - return result - return wrapper