diff --git a/WHATSNEW.rst b/WHATSNEW.rst index bf528abc3..d387871ce 100644 --- a/WHATSNEW.rst +++ b/WHATSNEW.rst @@ -89,6 +89,34 @@ ProPlot v0.8.0 (2021-##-##) .. rubric:: Features +* Add ``pad`` keyword to ``legend``, ``colorbar``, and ``panel`` that controls + tight layout padding, analogous to ``space`` (:pr:`###`). +* Fix ``wequal`` and ``hequal`` so they only work between main subplot + rows and columns instead of panels. +* Allow variable tight layout padding between subplot panels using ``wpad`` and + ``hpad``, analogous to ``wspace`` and ``hspace`` (:pr:`###`). +* Support XDG directories for proplot configuration files, emit + warnings if multiple paths found (:issue:`###`). +* Add public `~proplot.config.RcConfigurator.user_file` and + `~proplot.config.RcConfigurator.user_folder` methods for displaying + folder locations (:commit:`b11d744a`). +* Add :rcraw:`colorbar.facecolor` and :rcraw:`colorbar.edgecolor` properties + analogous to legend properties for controlling frame (:pr:`264`). +* Allow list-of-list "centered row" ``legend`` specification with e.g. + ``[h, [h1, h2, h3]]`` (i.e., mixed list and non-list input) (:pr:`264`). +* Permit partial specification of labels with "centered rows", e.g. + ``labels=['label', None]`` can be combined with the above (:pr:`264`). +* Treat singleton lists and tuple ``legend`` input same as scalar + handle input, i.e. never triggers "centered row" specification (:pr:`264`). +* Silently ignore non-artist and non-container input -- e.g., ignore the bins + and values returned by ``hist`` (:pr:`264`). +* Support auto-detection of tuple-grouped legend handle labels when labels + not passed explicitly (:pr:`264`). +* Automatically pull out grouped tuples of artists if they have differing ``label``\ s + (:pr:`264`). This is convenient for passing error indications to ``legend``. +* Support more artist synonyms throughout plotting overrides, e.g. ``ec`` + for ``edgecolor``, ``lw`` for ``linewidth``, ``fc`` and ``fillcolor`` for + ``facecolor`` (:pr:`264`). This expands matplotlib synonyms. * Support list-of-strings parametric coordinate and format on-the-fly colorbar ticks with those string labels (:commit:`02fbda45`). * Add new :rcraw:`leftlabel.rotation`, :rcraw:`toplabel.rotation`, diff --git a/proplot/axes/__init__.py b/proplot/axes/__init__.py index 8701aa105..d83b257eb 100644 --- a/proplot/axes/__init__.py +++ b/proplot/axes/__init__.py @@ -4,12 +4,11 @@ """ import matplotlib.projections as mproj -from . import plot 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 .plot import PlotAxes # noqa: F401 from .polar import PolarAxes from .three import Axes3D # noqa: F401 @@ -22,6 +21,7 @@ # Prevent importing module names and set order of appearance for objects __all__ = [ 'Axes', + 'PlotAxes', 'CartesianAxes', 'PolarAxes', 'GeoAxes', @@ -29,4 +29,3 @@ 'BasemapAxes', 'Axes3D', ] -__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 10ec7fb97..01d0b47ed 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -1,16 +1,23 @@ #!/usr/bin/env python3 """ -The base axes class used for all ProPlot figures. +The first-level axes subclass used for all ProPlot figures. +Implements basic shared functionality. """ import copy import re from numbers import Integral, Number import matplotlib.axes as maxes -import matplotlib.collections as mcollections +import matplotlib.cm as mcm +import matplotlib.colors as mcolors +import matplotlib.container as mcontainer +import matplotlib.contour as mcontour import matplotlib.legend as mlegend import matplotlib.patches as mpatches +import matplotlib.patheffects as mpatheffects import matplotlib.projections as mprojections +import matplotlib.text as mtext +import matplotlib.ticker as mticker import matplotlib.transforms as mtransforms import numpy as np @@ -18,9 +25,8 @@ 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 . import plot as wrap +from ..internals import _not_none, _pop_kwargs, _pop_props, docstring, rcsetup, warnings +from ..utils import to_rgb, units __all__ = ['Axes'] @@ -28,6 +34,13 @@ KEYS_INNER = ( 'border', 'borderwidth', 'bbox', 'bboxpad', 'bboxcolor', 'bboxstyle', 'bboxalpha', ) +LOC_SIDES = { # translate 'filled' legends to location + None: 'center', + 'left': 'center right', + 'right': 'center left', + 'top': 'lower center', + 'bottom': 'upper center', +} LOC_TRANSLATE = { # for inset colorbars and legends TODO: also as text locations 'inset': 'best', 'i': 'best', @@ -57,7 +70,8 @@ } -docstring.snippets['axes.other'] = """ +# Format docstrings +_format_other_docstring = """ rc_kw : dict, optional Dictionary containing `~proplot.config.rc` settings applied to this axes using `~proplot.config.RcConfigurator.context`. @@ -66,24 +80,18 @@ and used to update axes `~proplot.config.rc` settings. For example, ``abcstyle='A.'`` modifies the :rcraw:`abc.style` setting. """ +docstring.snippets['axes.format_other'] = _format_other_docstring -docstring.snippets['axes.patch_kw'] = """ +_patch_kw_docstring = """ patch_kw : dict-like, optional Keyword arguments used to update the background patch. This can be used e.g. to apply background hatching with ``patch_kw={'hatch': 'xxx'}``. """ +docstring.snippets['axes.patch_kw'] = _patch_kw_docstring -docstring.snippets['axes.proj'] = """ -The map projection specification(s). If ``'cartesian'`` (the default), a -`~proplot.axes.CartesianAxes` is created. If ``'polar'``, a -`~proplot.axes.PolarAxes` is created. Otherwise, the argument is -interpreted by `~proplot.constructor.Proj`, and the result is used -to make a `~proplot.axes.GeoAxes` (in this case the argument can be -a `cartopy.crs.Projection` instance, a `~mpl_toolkits.basemap.Basemap` -instance, or a projection name listed in :ref:`this table `). -""" -docstring.snippets['axes.inset'] = """ +# Inset docstring +_inset_docstring = """ Return an inset `CartesianAxes`. This is similar to the builtin `~matplotlib.axes.Axes.inset_axes` but includes some extra options. @@ -129,9 +137,12 @@ ---------------- **kwargs Passed to `CartesianAxes`. -""" % docstring.snippets +""" +docstring.snippets['axes.inset'] = docstring.add_snippets(_inset_docstring) -docstring.snippets['axes.panel'] = """ + +# Panel docstring +_panel_docstring = """ Return a panel drawn along the edge of this axes. Parameters @@ -148,13 +159,17 @@ top ``'top'``, ``'t'`` ========== ===================== -width : float or str or list thereof, optional - The panel width. Units are interpreted by `~proplot.utils.units`. - Default is :rc:`subplots.panelwidth`. -space : float or str or list thereof, optional - Empty space between the main subplot and the panel. - When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. +width : float or str, optional + The panel width. Default is :rc:`subplots.panelwidth`. + %(units.in)s +space : float or str, optional + The fixed space between the main subplot and the panel. Units are + interpreted by `~proplot.utils.units`. When the tight layout algorithm + is active for the figure, this is adjusted automatically using `pad`. + Otherwise, a suitable default is selected. +pad : float or str, optional + The tight layout padding between the main subplot and the panel. Units are + interpreted by `~proplot.utils.units`. Default is :rc:`subplots.panelpad`. share : bool, optional Whether to enable axis sharing between the *x* and *y* axes of the main subplot and the panel long axes for each panel in the stack. @@ -166,12 +181,200 @@ `~proplot.axes.CartesianAxes` The panel axes. """ +docstring.snippets['axes.panel'] = docstring.add_snippets(_panel_docstring) + + +# Colorbar and legend space +_space_docstring = """ +space : float or str, optional + For outer {name}s only. The fixed space between the {name} and the main axes. + When the tight layout algorithm is active for the figure, this is adjusted + automatically using `pad`. Otherwise, a suitable default is selected. + %(units.em)s +pad : float or str, optional + The padding between the axes edge and the {name}. For outer {name}s, this is the + tight layout padding. Default is :rc:`subplots.panelpad`. For inset {name}s, this + is the fixed space between the axes edge and the {name}. Default is :rc:`{default}`. + %(units.em)s +queue : bool, optional + If ``True`` and `loc` is the same as an existing {name}, the input + arguments are added to a queue and this function returns ``None``. + This is used to "update" the same {name} with successive ``ax.{name}(...)`` + calls. If ``False`` (the default) and `loc` is the same as an existing + *inset* {name}, the old {name} is removed. If ``False`` and `loc` is an + *outer* {name}, the {name}s are stacked. +""" +docstring.snippets['axes.legend_space'] = docstring.add_snippets( + _space_docstring.format(name='legend', default='legend.borderaxespad') +) +docstring.snippets['axes.colorbar_space'] = docstring.add_snippets( + _space_docstring.format(name='colorbar', default='colorbar.insetpad') +) + + +# Colorbar docstrings +_colorbar_args_docstring = """ +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. +""" +_colorbar_kwargs_docstring = """ +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*. Default is + :rc:`colorbar.insetextend` for inset colorbars and :rc:`colorbar.extend` + for outer colorbars. %(units.em)s +frame, frameon : bool, optional + For inset colorbars only. Indicates whether to draw a "frame", just + like `~matplotlib.axes.Axes.legend`. Default is :rc:`colorbar.frameon`. +lw, linewidth, ec, edgecolor : optional + Controls the line width and edge color for the colorbar outline and + dividers. For inset colorbars, also controls frame properties. +a, alpha, framealpha, fc, facecolor, framecolor : optional + For inset colorbars only. Controls the transparency and color of the frame. + Defaults are :rc:`colorbar.framealpha` and :rc:`colorbar.framecolor`. +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`. +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. +tickdir, tickdirection : {'out', 'in', 'inout'}, optional + Direction that major and minor tick marks point. +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 `~matplotlib.axes.Axes.legend`. +locator, ticks : locator spec, optional + Used to determine the colorbar tick positions. Passed to the + `~proplot.constructor.Locator` constructor function. +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. +format, formatter, ticklabels : formatter spec, optional + The tick label format. Passed to the `~proplot.constructor.Formatter` + constructor function. +formatter_kw : dict-like, optional + The formatter settings. Passed to `~proplot.constructor.Formatter`. +rotation : float, optional + The tick label rotation. Default is ``0``. +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 : {{None, 'horizontal', 'vertical'}}, optional + The colorbar orientation. By default this depends on the "side" + of the subplot or figure where the colorbar is drawn. Inset + colorbars are always horizontal. +**kwargs + Passed to `~matplotlib.figure.Figure.colorbar`. +""" +docstring.snippets['axes.colorbar_args'] = _colorbar_args_docstring +docstring.snippets['axes.colorbar_kwargs'] = _colorbar_kwargs_docstring + + +# Legend docstrings +_legend_args_docstring = """ +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`. +""" +_legend_kwargs_docstring = """ +frame, frameon : bool, optional + Toggles the legend frame. For centered-row legends, a frame + independent from matplotlib's built-in legend frame is created. +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`. By default, + `center` is set to ``True`` if `handles` is a list of lists (each + sublist is used as a row in the legend). +title, label : str, optional + The legend title. The `label` keyword is also accepted, for consistency + with `~matplotlib.figure.Figure.colorbar`. +fontsize, fontweight, fontcolor : optional + The font size, weight, and color for the legend text. Font size is interpreted + by `~proplot.utils.units`. The default font size is :rcraw:`legend.fontsize`. +titlefontsize, titlefontweight, titlefontcolor : optional + The font size, weight, and color for the legend title. Font size is interpreted + by `~proplot.utils.units`. The default size is `fontsize`. +a, alpha, framealpha, fc, facecolor, framecolor, ec, edgecolor, ew, edgewidth : optional + The opacity, face color, edge color, and edge width for the legend frame. + Defaults are :rc:`legend.framealpha`, :rc:`legend.facecolor`, + :rc:`legend.edgecolor` and :rc:`axes.linewidth`. +color, lw, linewidth, m, marker, ls, linestyle, dashes, ms, markersize \ +: property-spec, optional + Properties used to override the legend handles. For example, for a + legend describing variations in line style ignoring variations in color, you + might want to use ``color='k'``. +borderpad, borderaxespad, handlelength, handleheight, handletextpad, \ +labelspacing, columnspacing : float or str, optional + Native `~matplotlib.axes.Axes.legend` spacing arguments interpreted with + `~proplot.utils.units`. The default units are still font size-relative. +**kwargs + Passed to `~matplotlib.axes.Axes.legend`. +""" +docstring.snippets['axes.legend_args'] = _legend_args_docstring +docstring.snippets['axes.legend_kwargs'] = _legend_kwargs_docstring class Axes(maxes.Axes): """ - Lowest-level axes subclass. Handles titles and axis - sharing. Adds several new methods and overrides existing ones. + Lowest-level axes subclass. """ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): """ @@ -192,6 +395,7 @@ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): See also -------- matplotlib.axes.Axes + proplot.axes.PlotAxes proplot.axes.CartesianAxes proplot.axes.PolarAxes proplot.axes.GeoAxes @@ -227,10 +431,6 @@ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): self._inset_zoom = False self._inset_zoom_data = None - # Axes colorbars and legends - self._colorbar_dict = {} - self._legend_dict = {} - # Axes panels d = self._panel_dict = {} d['left'] = [] # NOTE: panels will be sorted inside-to-outside @@ -270,6 +470,10 @@ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): d['bottom'] = rc['bottomlabel.pad'] d['top'] = rc['toplabel.pad'] + # Axes colorbars and legends + self._colorbar_dict = {} + self._legend_dict = {} + # Subplot spec # WARNING: For mpl>=3.4.0 subplotspec assigned *after* initialization using # set_subplotspec. Tried to defer to setter but really messes up both format() @@ -383,6 +587,31 @@ def _get_side_axes(self, side, panels=False): else: return axs + def _get_transform(self, transform): + """ + Translates user input transform. Also used in an axes method. + """ + cartopy = getattr(self, 'name', None) == 'proplot_cartopy' + if cartopy: + from cartopy.crs import CRS, PlateCarree + else: + CRS = PlateCarree = object + if ( + isinstance(transform, mtransforms.Transform) + or cartopy 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 transform == 'map' and cartopy: + 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 is used to @@ -715,7 +944,7 @@ def _update_super(self, suptitle, **kwargs): context=True, ) if labels or kw: - fig._update_super_labels(self, side, labels, **kw) + fig._update_super_labels(side, labels, **kw) def _update_title_all(self, title=None, **kwargs): """ @@ -982,8 +1211,7 @@ def format( leftlabelpad, toplabelpad, rightlabelpad, bottomlabelpad : float, optional The padding between the labels and the axes content in arbitrary units (default is points). Defaults are :rcraw:`leftlabel.pad`, - :rcraw:`toplabel.pad`, :rcraw:`rightlabel.pad`, and - :rcraw:`bottomlabel.pad` + :rcraw:`toplabel.pad`, :rcraw:`rightlabel.pad`, and :rcraw:`bottomlabel.pad` suptitle, figtitle : str, optional The figure "super" title, centered between the left edge of the lefmost column of subplots and the right edge of the rightmost @@ -996,7 +1224,7 @@ def format( Other parameters ---------------- - %(axes.other)s + %(axes.format_other)s Important --------- @@ -1061,48 +1289,6 @@ def format( suptitle, left=llabels, right=rlabels, top=tlabels, bottom=blabels ) - def area(self, *args, **kwargs): - """ - Shorthand for `~matplotlib.axes.Axes.fill_between`. - - See also - -------- - matplotlib.axes.Axes.fill_between - proplot.axes.fill_between_extras - proplot.axes.standardize_1d - proplot.axes.apply_cycle - """ - # 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) - - def areax(self, *args, **kwargs): - """ - Shorthand for `~matplotlib.axes.Axes.fill_betweenx`. - - See also - -------- - matplotlib.axes.Axes.fill_betweenx - proplot.axes.fill_betweenx_extras - proplot.axes.standardize_1d - proplot.axes.apply_cycle - """ - return self.fill_betweenx(*args, **kwargs) - - def boxes(self, *args, **kwargs): - """ - Shorthand for `~matplotlib.axes.Axes.boxplot`. - - See also - -------- - matplotlib.axes.Axes.boxplot - proplot.axes.boxplot_extras - proplot.axes.standardize_1d - proplot.axes.apply_cycle - """ - return self.boxplot(*args, **kwargs) - def draw(self, renderer=None, *args, **kwargs): # Perform extra post-processing steps # NOTE: Used to have _reassign_title here (maybe _reassign_label too?) @@ -1124,49 +1310,6 @@ def get_tightbbox(self, renderer, *args, **kwargs): self._tight_bbox = bbox return bbox - 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. - - Parameters - ---------- - 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 - will not be square. This parameter is a shortcut for explicitly calling - `~matplotlib.axes.set_aspect`. - - The default is :rc:`image.heatmap`. The options are: - - - ``'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. - """ - obj = self.pcolormesh(*args, **kwargs) - aspect = _not_none(aspect, rc['image.aspect']) - from .cartesian import CartesianAxes - if not isinstance(self, CartesianAxes): - warnings._warn_proplot( - 'Cannot adjust aspect ratio or ticks for non-Cartesian heatmap plot. ' - 'Consider using pcolormesh() or pcolor() instead.' - ) - else: - xlocator = ylocator = None - if hasattr(obj, '_coordinates'): - coords = obj._coordinates - coords = (coords[1:, ...] + coords[:-1, ...]) / 2 - coords = (coords[:, 1:, :] + coords[:, :-1, :]) / 2 - xlocator, ylocator = coords[0, :, 0], coords[:, 0, 1] - self.format( - aspect=aspect, - xgrid=False, ygrid=False, xtickminor=False, ytickminor=False, - xlocator=xlocator, ylocator=ylocator, - ) - return obj - @docstring.add_snippets def inset(self, *args, **kwargs): """ @@ -1188,7 +1331,7 @@ def inset_axes( if not transform: transform = self.transAxes else: - transform = wrap._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={}) @@ -1328,166 +1471,28 @@ def panel_axes(self, side, **kwargs): side = self._loc_translate(side, 'panel') return self.figure._add_axes_panel(self, side, **kwargs) - def plotx(self, *args, **kwargs): - """ - As with `~matplotlib.axes.Axes.plot` but interpret a single - positional argument as *x* and multiple positional arguments - as (*y*, *x*) pairs. - - Parameters - ---------- - *args, **kwargs - Passed to `~matplotlib.axes.Axes.plot`. - - See also - -------- - matplotlib.axes.Axes.plot - proplot.axes.standardize_1d - proplot.axes.indicate_error - proplot.axes.apply_cycle - """ - # NOTE: Arguments are standardized once we reach this block - x, y, *args = args - return super().plot(y, x, *args, **kwargs) - - @docstring.add_snippets - def parametric( - self, x, y, c=None, *, values=None, - cmap=None, norm=None, scalex=True, scaley=True, **kwargs - ): - """ - Draw a line whose color changes as a function of the parametric - coordinate ``values`` using the input colormap ``cmap``. - Invoked when you pass the `cmap` keyword argument to - `~matplotlib.axes.Axes.plot`. - - Parameters - ---------- - *args : (y,), (x, y), or (x, y, c) - The coordinates. If `x` is not provided, it is inferred from `y`. - The parametric coordinate can be indicated as a third positional - argument or with the `c`, `values`, or `labels` keywords. The - parametric coordinate can be numeric or an array of string labels. - c, values, labels : array-like, optional - The parametric coordinates passed as a keyword argument. They - can also be passed as a third positional argument. - %(axes.cmap_norm)s - interp : int, optional - If greater than ``0``, we interpolate to additional points - between the `values` coordinates. The number corresponds to the - number of additional color levels between the line joints - and the halfway points between line joints. - 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`. - - Other parameters - ---------------- - **kwargs - Valid `~matplotlib.collections.LineCollection` properties. - - Returns - ------- - `~matplotlib.collections.LineCollection` - The parametric line. See `this matplotlib example \ -`__. - - See also - -------- - matplotlib.axes.Axes.plot - proplot.axes.standardize_1d - proplot.axes.apply_cmap - """ - # Get coordinates and values for points to the 'left' and 'right' of joints - c = _not_none(c=c, values=values) - coords = [] - levels = edges(c) - for i in range(y.shape[0]): - icoords = np.empty((3, 2)) - for j, arr in enumerate((x, y)): - icoords[0, j] = arr[0] if i == 0 else 0.5 * (arr[i - 1] + arr[i]) - icoords[1, j] = arr[i] - icoords[2, j] = arr[-1] if i + 1 == y.shape[0] else 0.5 * (arr[i + 1] + arr[i]) # noqa: E501 - coords.append(icoords) - coords = np.array(coords) - - # Create LineCollection and update with values - # NOTE: Default capstyle is butt but this may look weird with vector graphics - hs = mcollections.LineCollection( - coords, cmap=cmap, norm=norm, - linestyles='-', capstyle='butt', joinstyle='miter', - ) - hs.set_array(c) - hs.update({ - key: value for key, value in kwargs.items() - if key not in ('color',) - }) - - # 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.autoscale_view(scalex=scalex, scaley=scaley) - hs.values = c - hs.levels = levels # needed for other functions - - return hs - - def scatterx(self, *args, **kwargs): - """ - As with `~matplotlib.axes.Axes.scatter` but interpret a single - positional argument as *x* and multiple positional arguments - as (*y*, *x*) pairs. - - Parameters - ---------- - *args, **kwargs - Passed to `~matplotlib.axes.Axes.scatter`. - - See also - -------- - matplotlib.axes.Axes.scatter - proplot.axes.scatter_extras - proplot.axes.scatterx_extras - proplot.axes.standardize_1d - proplot.axes.indicate_error - proplot.axes.apply_cycle - """ - # NOTE: Arguments are standardized once we reach this block - x, y, *args = args - return super().scatter(y, x, *args, **kwargs) - - def violins(self, *args, **kwargs): - """ - Shorthand for `~matplotlib.axes.Axes.violinplot`. - - See also - -------- - matplotlib.axes.Axes.violinplot - proplot.axes.violinplot_extras - proplot.axes.standardize_1d - proplot.axes.indicate_error - proplot.axes.apply_cycle - """ - return self.violinplot(*args, **kwargs) - - def _add_colorbar_legend(self, loc, obj, legend=False, **kwargs): + def _add_colorbar_legend(self, type_, loc, obj, legend=False, **kwargs): """ Queue up or replace objects for legends and list-of-artist style colorbars. """ + # Initial stuff + d = self._legend_dict if legend else self._colorbar_dict + if type_ not in ('legend', 'colorbar'): + raise TypeError(f'Invalid type {type_!r}.') + if loc == 'fill': # should have already been indexed in the *parent* + return + # Remove previous instances # NOTE: No good way to remove inset colorbars right now until the bounding # box and axes are merged into some colorbar subclass. Fine for now. - d = self._legend_dict if legend else self._colorbar_dict - if loc == 'fill': # will be index in *parent* instead - return if loc in d and not isinstance(d[loc], tuple): obj_prev = d.pop(loc) # possibly pop a queued object if hasattr(self, 'legend_') and self.legend_ is obj_prev: self.legend_ = None # was never added as artist elif legend: obj_prev.remove() # remove legends and inner colorbars - # Update queue or replace with instance + + # Replace with instance or update the queue if not isinstance(obj, tuple) or any(isinstance(_, mlegend.Legend) for _ in obj): # noqa: E501 d[loc] = obj else: @@ -1497,33 +1502,55 @@ def _add_colorbar_legend(self, loc, obj, legend=False, **kwargs): labels_full.extend(_not_none(labels, [])) kwargs_full.update(kwargs) + def _auto_colorbar_legend( + self, objs, colorbar=None, colorbar_kw=None, legend=None, legend_kw=None, + ): + """ + Add automatic legend. + """ + # Add colorbar + # NOTE: Colorbar will get the labels from the artists. Don't need to extract + # them because can't have multiple-artist entries like for legend() + if colorbar: + colorbar_kw = colorbar_kw or {} + self.colorbar(objs, loc=colorbar, queue=True, **colorbar_kw) + # Add legend + if legend: + legend_kw = legend_kw or {} + self.legend(objs, loc=legend, queue=True, **legend_kw) + def _draw_colorbars_legends(self): """ Draw the queued-up legends and colorbars. Wrapper funcs and legend func let user add handles to location lists with successive calls. """ - # WARNING: Passing empty list labels=[] to legend causes matplotlib - # _parse_legend_args to search for everything. Ensure None if empty. + # Draw colorbars for loc, colorbar in self._colorbar_dict.items(): if not isinstance(colorbar, tuple): continue handles, labels, kwargs = colorbar self.colorbar(handles, labels or None, loc=loc, **kwargs) + + # Draw legends + # WARNING: Passing empty list labels=[] to legend causes matplotlib + # _parse_legend_args to search for everything. Ensure None if empty. for loc, legend in self._legend_dict.items(): - if not isinstance(legend, tuple): - continue - elif any(isinstance(_, mlegend.Legend) for _ in legend): + if not isinstance(legend, tuple) or any(isinstance(_, mlegend.Legend) for _ in legend): # noqa: E501 continue handles, labels, kwargs = legend self.legend(handles, labels or None, loc=loc, **kwargs) - def _fill_colorbar_axes(self, length=None, **kwargs): + def _fill_colorbar_axes( + self, length=None, shrink=None, tickloc=None, ticklocation=None, + extendsize=None, orientation=None, **kwargs + ): """ Return the axes and adjusted keyword args for a panel-filling colorbar. """ # Get subplotspec for colorbar axes side = self._panel_side - length = _not_none(length, rc['colorbar.length']) + length = _not_none(length=length, shrink=shrink, default=rc['colorbar.length']) + ticklocation = _not_none(tickloc=tickloc, ticklocation=ticklocation) subplotspec = self.get_subplotspec() if length <= 0 or length > 1: raise ValueError( @@ -1552,35 +1579,25 @@ def _fill_colorbar_axes(self, length=None, **kwargs): self.add_child_axes(ax) # Location - if side is None: # manual - orientation = kwargs.pop('orientation', None) - if orientation == 'vertical': - side = 'left' - else: - side = 'bottom' + side = _not_none(side, 'left' if orientation == 'vertical' else 'bottom') if side in ('bottom', 'top'): outside, inside = 'bottom', 'top' if side == 'top': outside, inside = inside, outside - ticklocation = outside - orientation = 'horizontal' + ticklocation = _not_none(ticklocation, outside) + orientation = _not_none(orientation, 'horizontal') else: outside, inside = 'left', 'right' if side == 'right': outside, inside = inside, outside - ticklocation = outside - orientation = 'vertical' + ticklocation = _not_none(ticklocation, outside) + orientation = _not_none(orientation, 'vertical') # Update default keyword args - iorientation = kwargs.get('orientation', None) - if iorientation and iorientation != orientation: - warnings._warn_proplot(f'Overriding input orientation={iorientation!r}.') - ticklocation = _not_none( - ticklocation=kwargs.pop('ticklocation', None), - tickloc=kwargs.pop('tickloc', None), - default=ticklocation, - ) + extendsize = _not_none(extendsize, rc['colorbar.extend']) kwargs.update({ + 'cax': ax, + 'extendsize': extendsize, 'orientation': orientation, 'ticklocation': ticklocation }) @@ -1588,44 +1605,60 @@ def _fill_colorbar_axes(self, length=None, **kwargs): return ax, kwargs def _inset_colorbar_axes( - self, loc=None, width=None, length=None, pad=None, frame=None, frameon=None, - alpha=None, linewidth=None, edgecolor=None, facecolor=None, **kwargs + self, loc=None, width=None, length=None, shrink=None, pad=None, + frame=None, frameon=None, tickloc=None, ticklocation=None, + extendsize=None, orientation=None, **kwargs, ): """ Return the axes and adjusted keyword args for an inset colorbar. """ - # Default properties - cbwidth, cblength = width, length - width, height = self.get_size_inches() - frame = _not_none(frame=frame, frameon=frameon, default=rc['colorbar.frameon']) - cbwidth = units(_not_none(cbwidth, rc['colorbar.insetwidth'])) / height - cblength = units(_not_none(cblength, rc['colorbar.insetlength'])) / width - extend = units(_not_none(kwargs.get('extendsize', None), rc['colorbar.insetextend'])) # noqa: E501 - pad = units(_not_none(pad, rc['colorbar.insetpad'])) - xpad, ypad = pad / width, pad / height + # Frame properties + # NOTE: Compare to same block in legend() code. + kw_patch = _pop_kwargs( + kwargs, + alpha=('a', 'framealpha', 'facealpha'), + facecolor=('fc', 'framecolor'), + edgecolor=('ec',), + linewidth=('lw',), + ) + kw_patch['zorder'] = 4 + kw_patch.setdefault('alpha', rc['colorbar.framealpha']) + kw_patch.setdefault('edgecolor', rc['colorbar.edgecolor']) + kw_patch.setdefault('facecolor', rc['colorbar.facecolor']) + kw_patch.setdefault('linewidth', rc['axes.linewidth']) - # Get location in axes-relative coordinates - # Bounds are x0, y0, width, height in axes-relative coordinates + # Colorbar properties + frame = _not_none(frame=frame, frameon=frameon, default=rc['colorbar.frameon']) + length = _not_none(length=length, shrink=shrink, default=rc['colorbar.insetlength']) # noqa: E501 + length = units(length, 'em', 'ax', axes=self, width=True) # x direction + width = _not_none(width, rc['colorbar.insetwidth']) + width = units(width, 'em', 'ax', axes=self, width=False) # y direction + pad = _not_none(pad, rc['colorbar.insetpad']) + xpad = units(pad, 'em', 'ax', axes=self, width=True) + ypad = units(pad, 'em', 'ax', axes=self, width=False) xspace = rc['xtick.major.size'] / 72 if kwargs.get('label', None) or kwargs.get('title', None): xspace += 2.4 * rc['font.size'] / 72 else: xspace += 1.2 * rc['font.size'] / 72 - xspace /= height # space for labels + xspace /= self.get_size_inches()[1] # space for labels + + # Get location in axes-relative coordinates + # Bounds are x0, y0, width, height in axes-relative coordinates if loc == 'upper right': - ibounds = (1 - xpad - cblength, 1 - ypad - cbwidth) - fbounds = (1 - 2 * xpad - cblength, 1 - 2 * ypad - cbwidth - xspace) + ibounds = (1 - xpad - length, 1 - ypad - width) + fbounds = (1 - 2 * xpad - length, 1 - 2 * ypad - width - xspace) elif loc == 'upper left': - ibounds = (xpad, 1 - ypad - cbwidth) - fbounds = (0, 1 - 2 * ypad - cbwidth - xspace) + ibounds = (xpad, 1 - ypad - width) + fbounds = (0, 1 - 2 * ypad - width - xspace) elif loc == 'lower left': ibounds = (xpad, ypad + xspace) fbounds = (0, 0) else: - ibounds = (1 - xpad - cblength, ypad + xspace) - fbounds = (1 - 2 * xpad - cblength, 0) - ibounds = (*ibounds, cblength, cbwidth) # inset axes - fbounds = (*fbounds, 2 * xpad + cblength, 2 * ypad + cbwidth + xspace) # frame + ibounds = (1 - xpad - length, ypad + xspace) + fbounds = (1 - 2 * xpad - length, 0) + ibounds = (*ibounds, length, width) # inset axes + fbounds = (*fbounds, 2 * xpad + length, 2 * ypad + width + xspace) # frame # Make frame # NOTE: We do not allow shadow effects or fancy edges effect. @@ -1633,19 +1666,9 @@ def _inset_colorbar_axes( if frame: xmin, ymin, width, height = fbounds patch = mpatches.Rectangle( - (xmin, ymin), width, height, - snap=True, zorder=4, transform=self.transAxes + (xmin, ymin), width, height, snap=True, transform=self.transAxes ) - alpha = _not_none(alpha, rc['colorbar.framealpha']) - linewidth = _not_none(linewidth, rc['axes.linewidth']) - edgecolor = _not_none(edgecolor, rc['axes.edgecolor']) - facecolor = _not_none(facecolor, rc['axes.facecolor']) - patch.update({ - 'alpha': alpha, - 'linewidth': linewidth, - 'edgecolor': edgecolor, - 'facecolor': facecolor - }) + patch.update(kw_patch) self.add_artist(patch) # Make axes @@ -1657,33 +1680,231 @@ def _inset_colorbar_axes( self.add_child_axes(ax) # Default keyword args - orient = kwargs.pop('orientation', None) - if orient is not None and orient != 'horizontal': + if orientation is not None and orientation != 'horizontal': warnings._warn_proplot( f'Orientation for inset colorbars must be horizontal, ' - f'ignoring orient={orient!r}.' + f'ignoring orientation={orientation!r}.' ) - ticklocation = kwargs.pop('tickloc', None) - ticklocation = kwargs.pop('ticklocation', None) or ticklocation + ticklocation = _not_none(tickloc=tickloc, ticklocation=ticklocation) if ticklocation is not None and ticklocation != 'bottom': warnings._warn_proplot('Inset colorbars can only have ticks on the bottom.') - kwargs.update({'orientation': 'horizontal', 'ticklocation': 'bottom'}) - kwargs.setdefault('maxn', 5) - kwargs.setdefault('extendsize', extend) - kwargs.update({'edgecolor': edgecolor, 'linewidth': linewidth}) # cbar edge + extendsize = _not_none(extendsize, rc['colorbar.insetextend']) + kwargs.update({ + 'cax': ax, + 'extendsize': extendsize, + 'orientation': 'horizontal', + 'ticklocation': 'bottom', + }) + kwargs.setdefault('maxn', 5) # passed to _parse_colorbar_ticks return ax, kwargs + def _parse_colorbar_ticks( + self, mappable, ticks=None, locator=None, locator_kw=None, + format=None, formatter=None, ticklabels=None, formatter_kw=None, + minorticks=None, minorlocator=None, minorlocator_kw=None, + maxn=None, maxn_minor=None, tickminor=False, fontsize=None, **kwargs, + ): + """ + Get the default locator for colorbar ticks. + """ + locator_kw = locator_kw or {} + formatter_kw = formatter_kw or {} + minorlocator_kw = minorlocator_kw or {} + locator = _not_none(ticks=ticks, locator=locator) + minorlocator = _not_none(minorticks=minorticks, minorlocator=minorlocator) + locator = _not_none(locator, getattr(mappable, '_colorbar_ticks', None)) + formatter = _not_none(ticklabels=ticklabels, formatter=formatter, format=format) + + # Get colorbar locator + # NOTE: Do not necessarily want minor tick locations at logminor for LogNorm! + # In _auto_discrete_norm we sometimes select evenly spaced levels in log-space + # *between* powers of 10, so logminor ticks would be misaligned with levels. + if isinstance(locator, mticker.Locator): + pass + + elif locator is None: + # This should only happen if user calls plotting method on native mpl axes + if isinstance(mappable.norm, mcolors.LogNorm): + locator = 'log' + elif isinstance(mappable.norm, mcolors.SymLogNorm): + locator = 'symlog' + locator_kw.setdefault('linthresh', mappable.norm.linthresh) + else: + locator = 'auto' + + else: + # 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 kwargs.get('orientation', None) == 'vertical': + scale = 1 + length = height * abs(self.get_position().height) + fontsize = _not_none(fontsize, rc['ytick.labelsize']) + else: + scale = 3 # em squares alotted for labels + length = width * abs(self.get_position().width) + fontsize = _not_none(fontsize, rc['xtick.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] + + # Return tickers + locator = constructor.Locator(locator, **locator_kw) + if minorlocator is not None: + minorlocator = constructor.Locator(minorlocator, **minorlocator_kw) + formatter = _not_none(formatter, 'auto') + formatter = constructor.Formatter(formatter, **formatter_kw) + return locator, formatter, minorlocator, kwargs + + def _parse_colorbar_mappable( + self, mappable, values=None, *, norm=None, norm_kw=None, **kwargs, + ): + """ + Generate a mappable from flexible non-mappable input. Useful in bridging + the gap between legends and colorbars (e.g., creating colorbars from line + objects whose data values span a natural colormap range). + """ + # 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 isinstance(mappable[0], mcm.ScalarMappable) + ): + mappable = mappable[0] + if isinstance(mappable, mcm.ScalarMappable): + return mappable, 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] + + # A colormap instance + # TODO: Pass remaining arguments through Colormap()? This is really + # niche usage so maybe not necessary. + rotation = kwargs.pop('rotation', None) + locator = _not_none(kwargs.pop('ticks', None), kwargs.pop('locator', None)) + formatter = _not_none(kwargs.pop('ticklabels', None), kwargs.pop('formatter', None)) # noqa: E501 + 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 + ): + cmap = mcolors.ListedColormap(list(mappable), '_no_name') + if values is None: + values = np.arange(len(mappable)) + locator = _not_none(locator, values) # tick all values by default + + # 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)) + + # Try to infer tick values and tick labels from Artist labels + cmap = mcolors.ListedColormap(colors, '_no_name') + if values is None: + # Get object labels and values + labels = [] + values = [] + for obj in mappable: + label = self._get_label(obj) # could be None + try: + value = float(label) # could be float(None) + except (TypeError, ValueError): + value = None + labels.append(label) + 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 any(_ is not None for _ in labels): + formatter = _not_none(formatter, labels) + if kwargs.get('orientation', None) != 'vertical': + rotation = _not_none(rotation, 90) + # Tick all values by default + locator = _not_none(locator, values) + + else: + raise ValueError( + 'Input mappable must be a matplotlib artist, ' + 'list of objects, list of colors, or colormap. ' + f'Got {mappable!r}.' + ) + + # Build ad hoc ScalarMappable object from colors + 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, *_ = self._auto_discrete_norm( + cmap=cmap, + norm=norm, + norm_kw=norm_kw, + extend='neither', + values=values, + ) + mappable = mcm.ScalarMappable(norm, cmap) + + kwargs.update({'locator': locator, 'formatter': formatter, 'rotation': rotation}) # noqa: E501 + return mappable, kwargs + + @docstring.add_snippets def colorbar( - self, mappable, values=None, *, loc=None, length=None, shrink=None, width=None, - space=None, pad=None, queue=False, **kwargs + self, mappable, values=None, *, loc=None, space=None, pad=None, queue=False, + extend=None, reverse=False, tickdir=None, tickdirection=None, tickminor=None, + title=None, label=None, grid=None, norm=None, norm_kw=None, + ec=None, edgecolor=None, lw=None, linewidth=None, edgefix_linewidth=0.3, + labelsize=None, labelweight=None, labelcolor=None, + ticklabelsize=None, ticklabelweight=None, ticklabelcolor=None, + **kwargs ): """ Add an *inset* colorbar or *outer* colorbar along the outside edge of - the axes. See `~proplot.axes.colorbar_extras` for details. + the axes. Parameters ---------- + %(axes.colorbar_args)s loc : str, optional The colorbar location. Default is :rc:`colorbar.loc`. The following location keys are valid: @@ -1706,84 +1927,503 @@ def colorbar( ================== ======================================= length : float or str, optional - The colorbar length. For outer colorbars, units are relative to the - axes width or height. Default is :rc:`colorbar.length`. For inset - colorbars, units are interpreted by `~proplot.utils.units`. Default - is :rc:`colorbar.insetlength`. + The colorbar length. For outer colorbars, default is :rc:`colorbar.length` + and units are relative to the axes width or height. For inset default is + :rc:`colorbar.insetlength`. %(units.em)s shrink : float, optional Alias for `length`. This is included for consistency with `matplotlib.figure.Figure.colorbar`. width : float or str, optional - The colorbar width. Units are interpreted by `~proplot.utils.units`. - For outer colorbars, default is :rc:`colorbar.width`. For inset colorbars, - default is :rc:`colorbar.insetwidth`. - space : float or str, optional - For outer colorbars only. The space between the colorbar and the - main axes. Units are interpreted by `~proplot.utils.units`. - When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. - pad : float or str, optional - For inset colorbars only. The space between the axes edge and the colorbar. - Units are interpreted by `~proplot.utils.units`. - Default is :rc:`colorbar.insetpad`. - frame, frameon : bool, optional - For inset colorbars only. Indicates whether to draw a "frame", just - like `~matplotlib.axes.Axes.legend`. Default is :rc:`colorbar.frameon`. - alpha, linewidth, edgecolor, facecolor : optional - For inset colorbars only. Controls the transparency, edge width, edge color, - and face color of the frame. Defaults are :rc:`colorbar.framealpha`, - :rc:`axes.linewidth`, :rc:`axes.edgecolor`, and :rc:`axes.facecolor`. + The colorbar width. For outer colorbars, default is :rc:`colorbar.width`. + For inset colorbars, default is :rc:`colorbar.insetwidth`. + %(units.em)s + %(axes.colorbar_space)s Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.colorbar_extras`. + %(axes.colorbar_kwargs)s See also -------- proplot.figure.Figure.colorbar - proplot.axes.colorbar_extras + matplotlib.figure.Figure.colorbar """ # TODO: Add option to pad the frame away from the axes edge # TODO: Get the 'best' inset colorbar location using the legend algorithm. - length = _not_none(length=length, shrink=shrink) + # 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. + norm_kw = norm_kw or {} + grid = _not_none(grid, rc['colorbar.grid']) + label = _not_none(title=title, label=label) + linewidth = _not_none(lw=lw, linewidth=linewidth, default=rc['axes.linewidth']) + edgecolor = _not_none(ec=ec, edgecolor=edgecolor, default=rc['colorbar.edgecolor']) # noqa: E501 + tickdirection = _not_none(tickdir=tickdir, tickdirection=tickdirection) if loc != 'fill': loc = self._loc_translate(loc, 'colorbar') # Optionally add to queue if queue: obj = (mappable, values) - kwargs.update({'width': width, 'length': length, 'space': space, 'pad': pad}) # noqa: E501 - return self._add_colorbar_legend(loc, obj, legend=False, **kwargs) + kwargs.update({'space': space, 'pad': pad}) # noqa: E501 + self._add_colorbar_legend('colorbar', loc, obj, **kwargs) + return # Generate panel if loc in ('left', 'right', 'top', 'bottom'): - ax = self.panel_axes(loc, width=width, space=space, filled=True) - obj = ax.colorbar(mappable, values, loc='fill', length=length, **kwargs) - self._add_colorbar_legend(loc, obj, legend=False) + width = kwargs.pop('width', None) + ax = self.panel_axes(loc, width=width, space=space, pad=pad, filled=True) + obj = ax.colorbar(mappable, values, loc='fill', **kwargs) + self._add_colorbar_legend('colorbar', loc, obj) return obj # Generate colorbar axes + # NOTE: These add 'orientation' and 'ticklocation' to kwargs + # TODO: Seperate keywords for frame properties? if loc == 'fill': - ax, kwargs = self._fill_colorbar_axes(length=length, **kwargs) + kwargs.pop('width', None) + ax, kwargs = self._fill_colorbar_axes(**kwargs) else: - ax, kwargs = self._inset_colorbar_axes(loc=loc, width=width, length=length, pad=pad, **kwargs) # noqa: E501 + kwargs.update({'linewidth': linewidth, 'edgecolor': edgecolor}) + ax, kwargs = self._inset_colorbar_axes(loc=loc, pad=pad, **kwargs) # noqa: E501 + + # Test if we were given a mappable, or iterable of stuff. Note + # Container and PolyCollection matplotlib classes are iterable. + mappable, kwargs = self._parse_colorbar_mappable(mappable, values, **kwargs) + + # Try to get tick locations from *levels* or from *values* rather than + # random points along the axis. + locator, formatter, minorlocator, kwargs = self._parse_colorbar_ticks( + mappable, fontsize=ticklabelsize, tickminor=tickminor, **kwargs, + ) - # Generate colorbar - obj = wrap.colorbar_extras(ax, mappable, values, **kwargs) - self._add_colorbar_legend(loc, obj, legend=False) # possibly replace another + # Define 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', kwargs.pop('rotation', None)), + ): + if value is not None: + kw_ticklabels[key] = value + + # Get extend triangles in physical units + width, height = self.figure.get_size_inches() + orientation = kwargs.get('orientation', 'horizontal') # should be there + if orientation == 'vertical': + inches = height * abs(self.get_position().height) + else: + inches = width * abs(self.get_position().width) + extendsize = kwargs.pop('extendsize', rc['colorbar.extend']) # should be there + extendsize = units(extendsize, 'em', 'in') + extendfrac = extendsize / (inches - 2 * extendsize) + + # Draw the colorbar + # NOTE: Set default formatter here because we optionally apply a FixedFormatter + # using *labels* from handle input. + extend = _not_none(extend, getattr(mappable, '_colorbar_extend', 'neither')) + kwargs.update({ + 'ticks': locator, + 'format': formatter, + 'extendfrac': extendfrac, + 'use_gridspec': True, + 'spacing': 'uniform', + }) + kwargs.setdefault('drawedges', grid) + if isinstance(mappable, mcontour.ContourSet): + mappable.extend = extend # required in mpl >= 3.3, else optional + else: + kwargs['extend'] = extend + cb = self.figure.colorbar(mappable, **kwargs) + axis = self.yaxis if orientation == 'vertical' else self.xaxis + + # 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! + ticks, *_ = cb._ticker(minorlocator, mticker.NullFormatter()) + axis.set_ticks(ticks, minor=True) + axis.set_ticklabels([], minor=True) + + # 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 consistent with rc settings and overrides + # axis.set_ticks_position(ticklocation) + s = axis.axis_name + for which in ('minor', 'major'): + kw = rc.category(s + 'tick.' + which) + kw['width'] = linewidth + kw['color'] = edgecolor + kw['direction'] = tickdirection + kw.pop('visible', None) + axis.set_tick_params(which=which, **kw) + + # Fix alpha-blending issues. Cannot set edgecolor to 'face' because blending + # will occur, 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 + blend = 'pcolormesh.snap' not in rc or not rc['pcolormesh.snap'] + if not cmap._isinit: + cmap._init() + if blend and 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('_no_name', 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': edgecolor, 'linewidth': 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_linewidth(edgefix_linewidth) + cb.solids.set_edgecolor('face') + cb.solids.set_rasterized(False) + + # Invert the axis if norm is a 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 after registering location + self._add_colorbar_legend('colorbar', loc, cb) # possibly replace another return obj + @staticmethod + def _parse_handles_labels(axs, handles, labels, ncol=None, center=None): + """ + Parse input handles and labels. + """ + # NOTE: Pull out singleton lists of handles commonly returned by plot(). 99% + # of time users don't want auto-centered-legends here. + # 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 not isinstance(handles, (list, np.ndarray)): # e.g. a mappable object + handles = [handles] + if not isinstance(labels, (list, np.ndarray)): + labels = [labels] + list_of_lists = any(isinstance(h, (list, np.ndarray)) and len(h) > 1 for h in handles) # noqa: E501 + + # Iterate over sublists + axs = axs or () + ncol = _not_none(ncol, 3) + pairs = [] + if not list_of_lists: # temporary + handles, labels = [handles], [labels] + for ihandles, ilabels in zip(handles, labels): + # Sanitize sublists. Allow numpy array input and allow scalar input + if ihandles is None: + pass # auto detection + elif isinstance(ihandles, np.ndarray): + ihandles = ihandles.tolist() + elif not isinstance(ihandles, list): + ihandles = [ihandles] + if ilabels is None: + pass # auto detection + elif isinstance(ilabels, np.ndarray): + ilabels = ilabels.tolist() + elif not isinstance(ilabels, list): + ilabels = [ilabels] + + # Ignore e.g. extra hist() or hist2d() return values + if ihandles is not None: + ihandles = [ + tuple(obj for obj in objs if hasattr(obj, 'get_label')) + if type(objs) is tuple else objs for objs in ihandles + ] + # Auto-detect labels from tuple-grouped handles and auto-expand tuples + # containing different non-default labels. + if ihandles is not None and ilabels is None: + ihandles, ihandles_prev = [], ihandles + for objs in ihandles_prev: + if hasattr(objs, 'get_label'): + objs = (objs,) + labs = {obj.get_label() for obj in objs} + labs = {_ for _ in labs if _ is not None and str(_)[:1] != '_'} + if len(labs) > 1: + # Unfurl tuple of handles + # NOTE: This may also unfurl handles that get ignored + ilabels.extend(obj.get_label() for obj in objs) + ihandles.extend(objs) + else: + # Append this handle with some name + label = labs.pop() if labs else '_no_label' + ilabels.append(label) + ihandles.append(objs) + + # Run through native parser + ihandles, ilabels, *_ = mlegend._parse_legend_args( + axs, handles=ihandles, labels=ilabels, + ) + pairs.append(list(zip(ihandles, ilabels))) + + # Manage (handle, label) pairs in context of 'center' option + center = _not_none(center, list_of_lists) + if not list_of_lists: + pairs = pairs[0] + 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 + pairs = [pairs[i * ncol:(i + 1) * ncol] for i in range(len(pairs))] + if list_of_lists: # remove empty lists, pops up in some examples + pairs = [ipairs for ipairs in pairs if ipairs] + + return pairs + + def _iter_legend_objects(self, children): + """ + Iterate recursively through `_children` attributes of various `HPacker`, + `VPacker`, and `DrawingArea` classes. + """ + for obj in children: + if hasattr(obj, '_children'): + yield from self._iter_legend_objects(obj._children) + else: + yield obj + + def _single_legend(self, pairs, ncol=None, order=None, **kwargs): + """ + Draw an individual legend with support for changing legend-entries + between column-major and row-major. + """ + # 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': + nbase = len(pairs) // ncol + 1 + split = [pairs[i * ncol:(i + 1) * ncol] for i in range(nbase)] + pairs = [] + nrows_max = len(split) # max possible row count + ncols_final = len(split[-1]) # columns in final row + nrows = [nrows_max] * ncols_final + [nrows_max - 1] * (ncol - ncols_final) + for col, nrow in enumerate(nrows): # iterate through cols + pairs.extend(split[row][col] for row in range(nrow)) + + # Draw legend + return mlegend.Legend(self, *zip(*pairs), ncol=ncol, **kwargs) + + def _multiple_legend( + self, pairs, *, loc=None, ncol=None, order=None, fontsize=None, **kwargs + ): + """ + Draw "legend" with centered rows by creating separate legends for + each row. The label spacing/border spacing will be exactly replicated. + """ + # Message when overriding some properties + legs = [] + frameon = kwargs.pop('frameon', None) # we add our own frame + fontsize = _not_none(fontsize, rc['legend.fontsize']) + overridden = [] + 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".' + ) + 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. + width, height = self.get_size_inches() + 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 + 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'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.' + ) + + # Iterate through sublists + for i, ipairs in enumerate(pairs): + if i == 1: + title = kwargs.pop('title', None) + if i >= 1 and title is not None: + i += 1 # add 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 + ) + legs.append(leg) + + # Simple cases + if not frameon: + return legs + if len(legs) == 1: + legs[0].set_frame_on(True) + return legs + + # 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. + width, height = self.get_size_inches() + 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) + + # 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) + 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) + + # Add patch to list + return patch, *legs + + @docstring.add_snippets def legend( self, handles=None, labels=None, *, - loc=None, width=None, space=None, queue=False, **kwargs + loc=None, queue=False, width=None, pad=None, space=None, + frame=None, frameon=None, ncol=None, ncols=None, + center=None, order='C', label=None, title=None, + fontsize=None, fontweight=None, fontcolor=None, + titlefontsize=None, titlefontweight=None, titlefontcolor=None, + **kwargs ): """ Add an *inset* legend or *outer* legend along the edge of the axes. - See `~proplot.axes.legend_extras` for details. Parameters ---------- + %(axes.legend_args)s loc : int or str, optional The legend location. The following location keys are valid: @@ -1810,74 +2450,342 @@ def legend( ================== ======================================= 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`. - queue : bool, optional - If ``True`` and `loc` is the same as an existing legend, the `handles` - and `labels` are added to a queue and this function returns ``None``. - This is used to "update" the same legend with successive ``ax.legend(...)`` - calls. If ``False`` (the default) and `loc` is the same as an existing - legend, this function returns a `~matplotlib.legend.Legend` instance - and the old legend is removed from the axes. + For outer legends only. The space allocated for the legend box. This + does nothing if the tight layout algorithm is active for the figure. + %(units.em)s + %(axes.legend_space)s Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.legend_extras`. + %(axes.legend_kwargs)s See also -------- proplot.figure.Figure.legend - proplot.axes.legend_extras + matplotlib.axes.Axes.legend """ - if loc != 'fill': - loc = self._loc_translate(loc, 'legend') + ncol = _not_none(ncols=ncols, ncol=ncol) + frameon = _not_none(frame=frame, frameon=frameon, default=rc['legend.frameon']) if isinstance(loc, np.ndarray): loc = loc.tolist() + if loc != 'fill': + loc = self._loc_translate(loc, 'legend') # Optionally add to queue if queue: obj = (handles, labels) kwargs.update({'width': width, 'space': space}) - return self._add_colorbar_legend(loc, obj, legend=True, **kwargs) + self._add_colorbar_legend('legend', loc, obj, **kwargs) + return # Generate panel if loc in ('left', 'right', 'top', 'bottom'): - ax = self.panel_axes(loc, width=width, space=space, filled=True) + ax = self.panel_axes(loc, width=width, space=space, pad=pad, filled=True) obj = ax.legend(handles, labels, loc='fill', **kwargs) - self._add_colorbar_legend(loc, obj, legend=True) # add to *this* axes + self._add_colorbar_legend('legend', loc, obj) # add to *this* axes return obj - # Adjust settings + # Parse input args and properties + if order not in ('F', 'C'): + raise ValueError( + f'Invalid order {order!r}. Choose from ' + '"C" (row-major, default) and "F" (column-major).' + ) + + # Parse input argument units + fontsize = _not_none(kwargs.pop('fontsize', None), rc['legend.fontsize']) + titlefontsize = _not_none( + title_fontsize=kwargs.pop('title_fontsize', None), + titlefontsize=titlefontsize, + default=rc['legend.title_fontsize'] + ) + fontsize = rc._scale_font(fontsize) + titlefontsize = rc._scale_font(titlefontsize) + for key in ('borderpad', 'borderaxespad', 'handlelength', 'handleheight', 'handletextpad', 'labelspacing', 'columnspacing'): # noqa: E501 + value = kwargs.pop(key, None) + if isinstance(value, str): + value = units(kwargs[key], 'em', fontsize=fontsize) + if value is not None: + kwargs[key] = value + if pad is not None: + kwargs['borderaxespad'] = _not_none( + borderaxespad=kwargs.pop('borderaxespad', None), + pad=units(pad, 'em', fontsize=fontsize) + ) + + # Change default padding for "filled" axes + # NOTE: Important to remove None valued args above for these setdefault calls if loc == 'fill': - # Try to make handles and stuff flush against the axes edge self._hide_panel() kwargs.setdefault('borderaxespad', 0) - frameon = _not_none(kwargs.get('frame'), kwargs.get('frameon'), rc['legend.frameon']) # noqa: E501 if not frameon: kwargs.setdefault('borderpad', 0) - # Adjust 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' + loc = LOC_SIDES[self._panel_side] # note None redirects to 'center' + + # Legend bounding box properties + # NOTE: Here we permit only 'edgewidth' to avoid conflict with handle + # property overrides. + kw_patch = _pop_kwargs( + kwargs, + alpha=('a', 'framealpha', 'facealpha'), + facecolor=('fc', 'framecolor'), + edgecolor=('ec',), + edgewidth=('ew',), + ) + kw_outline_default = { + 'alpha': 'legend.framealpha', + 'facecolor': 'legend.facecolor', + 'edgecolor': 'legend.edgecolor', + 'edgewidth': 'axes.linewidth', + } + for key, name in kw_outline_default.items(): + kw_patch.setdefault(key, rc[name]) + kw_patch['linewidth'] = kw_patch.pop('edgewidth') + + # 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. + kw_handle = _pop_props(kwargs, 'collection') + kw_handle['solid_capstyle'] = 'butt' + kw_text = { + key: value for key, value in (('color', fontcolor), ('weight', fontweight)) + if value is not None + } + kw_title = { + key: value for key, value in (('size', titlefontsize), ('color', titlefontcolor), ('weight', titlefontweight)) # noqa: E501 + if value is not 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: # this is a "filled" legend + if self._panel_parent: # axes panel i..e axes-wide legend + axs = list(self._panel_parent._iter_axes(hidden=False, children=True)) + else: # figure panel i.e. figure-wide legend + axs = list(self.figure._iter_axes(hidden=False, children=True)) + + # Draw legend with input handles and labels + pairs = self._parse_handles_labels( + axs, handles, labels, ncol=ncol, center=center + ) + kwargs.update({'loc': loc, 'ncol': ncol, 'frameon': frameon}) + if not pairs: + # Bail if no pairs + objs = [mlegend.Legend(self, [], [], **kwargs)] + elif center: + # Multiple-legend pseudo-legend + objs = self._multiple_legend(pairs, order=order, **kwargs) + else: + # Individual legend + objs = [self._single_legend(pairs, order=order, **kwargs)] + + # Add legends manually so matplotlib does not remove old ones + for obj in objs: + if isinstance(obj, mpatches.FancyBboxPatch): + continue + if hasattr(self, 'legend_') and self.legend_ is None: + self.legend_ = obj # set *first* legend accessible with get_legend() + else: + self.add_artist(obj) + + # Apply *overrides* to legend elements + # 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. + # 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. + title = _not_none(label=label, title=title) + set_title = True + for obj in objs: + # Update patch + if not isinstance(obj, mpatches.FancyBboxPatch): + obj.legendPatch.update(kw_patch) # no-op if frame is off else: - raise ValueError(f'Invalid panel side {side!r}.') + obj.update(kw_patch) # the multiple-legend bounding box + continue + try: + children = obj._legend_handle_box._children + except AttributeError: # older versions maybe? + children = [] + # Update title text, handle text, and handle artist properties + if title and set_title: + obj.set_title(title, prop=kw_title) + set_title = False + for obj in self._iter_legend_objects(children): + if isinstance(obj, mtext.Text): + obj.update(kw_text) + continue + # NOTE: This silently other invalid properties + for key, value in kw_handle.items(): + getattr(obj, 'set_' + key, lambda value: None)(value) + + # Return after registering location + for obj in objs: + obj.set_clip_on(False) # critical for tight bounding box calcs + if isinstance(objs[0], mpatches.FancyBboxPatch): + objs = objs[1:] + obj = objs[0] if len(objs) == 1 else tuple(objs) + self._add_colorbar_legend('legend', loc, obj) # possibly replace another + return obj + + @staticmethod + def _text_update(text, props=None, **kwargs): + """ + Monkey patch that adds pseudo "border" and "bbox" properties to text + objects without wrapping the entire class. Overrides update to + facilitate updating inset titles. + """ + props = props or {} + props = props.copy() # shallow copy + props.update(kwargs) + + # Update border + border = props.pop('border', None) + bordercolor = props.pop('bordercolor', 'w') + borderinvert = props.pop('borderinvert', False) + borderwidth = props.pop('borderwidth', 2) + if border: + facecolor, bgcolor = text.get_color(), bordercolor + if borderinvert: + facecolor, bgcolor = bgcolor, facecolor + kwargs = { + 'linewidth': borderwidth, + 'foreground': bgcolor, + 'joinstyle': 'miter', + } + text.update({ + 'color': facecolor, + 'path_effects': [mpatheffects.Stroke(**kwargs), mpatheffects.Normal()], + }) + elif border is False: + text.update({ + 'path_effects': None, + }) + + # Update bounding box + # NOTE: We use '_title_pad' and '_title_above' for both titles and a-b-c labels + # because always want to keep them aligned. + # NOTE: For some reason using pad / 10 results in perfect alignment. Matplotlib + # docs are vague about bounding box units, maybe they are tens of points? + bbox = props.pop('bbox', None) + bboxcolor = props.pop('bboxcolor', 'w') + bboxstyle = props.pop('bboxstyle', 'round') + bboxalpha = props.pop('bboxalpha', 0.5) + bboxpad = _not_none(props.pop('bboxpad', None), text.axes._title_pad / 10) + if isinstance(bbox, dict): # *native* matplotlib usage + props['bbox'] = bbox + elif bbox: + text.set_bbox({ + 'edgecolor': 'black', + 'facecolor': bboxcolor, + 'boxstyle': bboxstyle, + 'alpha': bboxalpha, + 'pad': bboxpad, + }) + elif bbox is False: + text.set_bbox(None) # disables the bbox + + return type(text).update(text, props) + + @docstring.concatenate + def text( + self, x=0, y=0, s='', transform='data', *, + family=None, fontfamily=None, fontname=None, fontsize=None, size=None, + border=False, bordercolor='w', borderwidth=2, borderinvert=False, + bbox=False, bboxcolor='w', bboxstyle='round', bboxalpha=0.5, bboxpad=None, + **kwargs + ): + """ + Support specifying the coordinate `tranform` with a string name and + drawing white borders and bounding boxes around the text. + + Parameters + ---------- + x, y : float + The *x* and *y* coordinates for the text. + s : 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 corresponding to + `~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. + bbox : bool, optional + Whether to draw a bounding box around text. + bboxcolor : color-spec, optional + The color of the text bounding box. Default is ``'w'``. + bboxstyle : boxstyle, optional + The style of the bounding box. Default is ``'round'``. + bboxalpha : float, optional + The alpha for the bounding box. Default is ``'0.5'``. + bboxpad : float, optional + The padding for the bounding box. Default is :rc:`title.bboxpad`. + + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.axes.Axes.text`. - # Generate legend - obj = wrap.legend_extras(self, handles, labels, loc=loc, **kwargs) - self._add_colorbar_legend(loc, obj, legend=True) # possibly replace another + See also + -------- + 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: + kwargs['fontsize'] = rc._scale_font(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, s, transform=transform, **kwargs) + obj.update = self._text_update.__get__(obj) + obj.update({ + 'border': border, + 'bordercolor': bordercolor, + 'borderinvert': borderinvert, + 'borderwidth': borderwidth, + 'bbox': bbox, + 'bboxcolor': bboxcolor, + 'bboxstyle': bboxstyle, + 'bboxalpha': bboxalpha, + 'bboxpad': bboxpad, + }) return obj def _iter_axes(self, panels=None, hidden=False, children=False): @@ -1918,194 +2826,3 @@ def number(self, num): if num is not None and (not isinstance(num, Integral) or num < 1): raise ValueError(f'Invalid number {num!r}. Must be integer >=1.') self._number = num - - # Apply text wrapper - text = wrap._apply_wrappers( - maxes.Axes.text, - wrap.text_extras, - ) - - # Apply 1D plotting command wrappers - plot = wrap._apply_wrappers( - maxes.Axes.plot, - wrap.standardize_1d, - wrap._plot_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - plotx = wrap._apply_wrappers( - plotx, - wrap.standardize_1d, - wrap._plotx_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - step = wrap._apply_wrappers( - maxes.Axes.step, - wrap.standardize_1d, - wrap.apply_cycle, - ) - stem = wrap._apply_wrappers( - maxes.Axes.stem, - wrap.standardize_1d, - wrap._stem_extras, - ) - vlines = wrap._apply_wrappers( - maxes.Axes.vlines, - wrap.standardize_1d, - wrap.vlines_extras, - wrap.apply_cycle, - ) - hlines = wrap._apply_wrappers( - maxes.Axes.hlines, - wrap.standardize_1d, - wrap.hlines_extras, - wrap.apply_cycle, - ) - scatter = wrap._apply_wrappers( - maxes.Axes.scatter, - wrap.standardize_1d, - wrap.scatter_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - scatterx = wrap._apply_wrappers( - scatterx, - wrap.standardize_1d, - wrap.scatterx_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - bar = wrap._apply_wrappers( - maxes.Axes.bar, - wrap.standardize_1d, - wrap.bar_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - barh = wrap._apply_wrappers( - maxes.Axes.barh, - wrap.standardize_1d, - wrap.barh_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - hist = wrap._apply_wrappers( - maxes.Axes.hist, - wrap.standardize_1d, - wrap.apply_cycle, - ) - fill_between = wrap._apply_wrappers( - maxes.Axes.fill_between, - wrap.standardize_1d, - wrap.fill_between_extras, - wrap.apply_cycle, - ) - fill_betweenx = wrap._apply_wrappers( - maxes.Axes.fill_betweenx, - wrap.standardize_1d, - wrap.fill_betweenx_extras, - wrap.apply_cycle, - ) - boxplot = wrap._apply_wrappers( - maxes.Axes.boxplot, - wrap.standardize_1d, - wrap.boxplot_extras, - wrap.apply_cycle, - ) - violinplot = wrap._apply_wrappers( - maxes.Axes.violinplot, - wrap.standardize_1d, - wrap.violinplot_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - pie = wrap._apply_wrappers( - maxes.Axes.pie, - wrap.standardize_1d, - wrap.apply_cycle, - ) - parametric = wrap._apply_wrappers( - parametric, - wrap.standardize_1d, - wrap._parametric_extras, - wrap.apply_cmap, - ) - hexbin = wrap._apply_wrappers( - maxes.Axes.hexbin, - wrap.standardize_1d, - wrap.apply_cmap, - ) - hist2d = wrap._apply_wrappers( - maxes.Axes.hist2d, - wrap.standardize_1d, - wrap.apply_cmap, - ) - - # Apply 2D plotting command wrappers - contour = wrap._apply_wrappers( - maxes.Axes.contour, - wrap.standardize_2d, - wrap.apply_cmap, - ) - contourf = wrap._apply_wrappers( - maxes.Axes.contourf, - wrap.standardize_2d, - wrap.apply_cmap, - ) - pcolor = wrap._apply_wrappers( - maxes.Axes.pcolor, - wrap.standardize_2d, - wrap.apply_cmap, - ) - pcolormesh = wrap._apply_wrappers( - maxes.Axes.pcolormesh, - wrap.standardize_2d, - wrap.apply_cmap, - ) - pcolorfast = wrap._apply_wrappers( - maxes.Axes.pcolorfast, # WARNING: not available in cartopy and basemap - wrap.standardize_2d, - wrap.apply_cmap, - ) - streamplot = wrap._apply_wrappers( - maxes.Axes.streamplot, - wrap.standardize_2d, - wrap.apply_cmap, - ) - quiver = wrap._apply_wrappers( - maxes.Axes.quiver, - wrap.standardize_2d, - wrap.apply_cmap, - ) - barbs = wrap._apply_wrappers( - maxes.Axes.barbs, - wrap.standardize_2d, - wrap.apply_cmap, - ) - - # Unstandardized commands - tripcolor = wrap._apply_wrappers( - maxes.Axes.tripcolor, - wrap.apply_cmap, - ) - tricontour = wrap._apply_wrappers( - maxes.Axes.tricontour, - wrap.apply_cmap, - ) - tricontourf = wrap._apply_wrappers( - maxes.Axes.tricontourf, - wrap.apply_cmap, - ) - spy = wrap._apply_wrappers( - maxes.Axes.spy, - wrap.apply_cmap, - ) - imshow = wrap._apply_wrappers( - maxes.Axes.imshow, - wrap.apply_cmap, - ) - matshow = wrap._apply_wrappers( - maxes.Axes.matshow, - wrap.apply_cmap, - ) diff --git a/proplot/axes/cartesian.py b/proplot/axes/cartesian.py index e562eeac6..cafefef2a 100644 --- a/proplot/axes/cartesian.py +++ b/proplot/axes/cartesian.py @@ -13,7 +13,7 @@ from ..internals import ic # noqa: F401 from ..internals import _not_none, docstring, rcsetup, warnings from ..utils import units -from . import base +from . import plot __all__ = ['CartesianAxes'] @@ -196,7 +196,7 @@ def _parse_rcloc(x, string): # figures out string location return 'neither' -class CartesianAxes(base.Axes): +class CartesianAxes(plot.PlotAxes): """ Axes subclass for plotting in ordinary Cartesian coordinates. Adds the `~CartesianAxes.format` method and overrides several existing @@ -210,6 +210,8 @@ def __init__(self, *args, **kwargs): See also -------- proplot.ui.subplots + proplot.axes.Axes + proplot.axes.PlotAxes """ # Impose default formatter super().__init__(*args, **kwargs) @@ -1015,7 +1017,7 @@ def format( Other parameters ---------------- - %(axes.other)s + %(axes.format_other)s See also -------- diff --git a/proplot/axes/geo.py b/proplot/axes/geo.py index 92fd7b7c3..a92887b42 100644 --- a/proplot/axes/geo.py +++ b/proplot/axes/geo.py @@ -3,8 +3,8 @@ Axes filled with cartographic projections. """ import copy +import functools -import matplotlib.axes as maxes import matplotlib.axis as maxis import matplotlib.path as mpath import matplotlib.text as mtext @@ -16,17 +16,16 @@ from ..config import rc from ..internals import ic # noqa: F401 from ..internals import _not_none, _version, _version_cartopy, docstring, warnings -from . import base -from . import plot as wrap +from . import plot try: import cartopy.crs as ccrs import cartopy.feature as cfeature import cartopy.mpl.ticker as cticker - from cartopy.mpl.geoaxes import GeoAxes as GeoAxesBase + from cartopy.mpl.geoaxes import GeoAxes as _CartopyAxes except ModuleNotFoundError: cfeature = cticker = ccrs = None - GeoAxesBase = object + _CartopyAxes = object try: import mpl_toolkits.basemap as mbasemap except ModuleNotFoundError: @@ -245,7 +244,7 @@ def set_latmax(self, latmax): self._latmax = latmax -class GeoAxes(base.Axes): +class GeoAxes(plot.PlotAxes): """ Axes subclass for plotting on cartographic projections. Adds the `~GeoAxes.format` method and overrides several existing methods. @@ -258,6 +257,8 @@ def __init__(self, *args, **kwargs): See also -------- proplot.ui.subplots + proplot.axes.Axes + proplot.axes.PlotAxes proplot.axes.CartopyAxes proplot.axes.BasemapAxes """ @@ -417,7 +418,7 @@ def format( Other parameters ---------------- - %(axes.other)s + %(axes.format_other)s See also -------- @@ -675,7 +676,32 @@ def _to_label_array(labels, lon=True): return array -class CartopyAxes(GeoAxes, GeoAxesBase): +class _MetaCartopyAxes(plot._MetaPlotAxes): + """ + Impose default ``transform=cartopy.crs.PlateCarree()``. + """ + # NOTE: Not all of these appear to be wrapped directly in GeoAxes + # but they do accept transform argument. + def __new__(cls, name, bases, dct_orig): + dct = dct_orig.copy() + for attr in ( + 'barbs', 'contour', 'contourf', + 'fill', 'fill_between', 'fill_betweenx', # NOTE: not sure if these work + 'imshow', 'pcolor', 'pcolormesh', 'plot', + 'quiver', 'scatter', 'streamplot', 'step', + 'tricontour', 'tricontourf', 'tripcolor', # NOTE: not sure why these work + ): + func = dct_orig.get(attr, None) + if not callable(func): + continue + dct[attr] = functools.wraps(func)( + lambda self, *args, func_original=func, transform=None, **kwargs: + func_original(*args, transform=_not_none(transform, ccrs.PlateCarree()), **kwargs) # noqa: E501 + ) + return super().__new__(cls, name, bases, dct_orig) + + +class CartopyAxes(GeoAxes, _CartopyAxes, metaclass=_MetaCartopyAxes): """ Axes subclass for plotting `cartopy `__ projections. @@ -724,6 +750,9 @@ def __init__( See also -------- + proplot.ui.subplots + proplot.axes.Axes + proplot.axes.PlotAxes proplot.axes.GeoAxes proplot.constructor.Proj """ @@ -1169,107 +1198,36 @@ def projection(self, map_projection): raise ValueError('Projection must be a cartopy.crs.CRS instance.') self._map_projection = map_projection - if GeoAxesBase is object: - # Keep the base.Axes wrappers - pass - else: - # Apply text wrapper - text = wrap._apply_wrappers( - GeoAxesBase.text, - wrap.text_extras, - ) - - # Apply 1D plotting command wrappers - plot = wrap._apply_wrappers( - GeoAxesBase.plot, - wrap.default_transform, - wrap.standardize_1d, - wrap._plot_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - scatter = wrap._apply_wrappers( - GeoAxesBase.scatter, - wrap.default_transform, - wrap.standardize_1d, - wrap.scatter_extras, - wrap.indicate_error, - wrap.apply_cycle, - ) - fill = wrap._apply_wrappers( - GeoAxesBase.fill, - wrap.default_transform, - ) - - # Apply 1D plotting command wrappers - contour = wrap._apply_wrappers( - GeoAxesBase.contour, - wrap.default_transform, - wrap.standardize_2d, - wrap.apply_cmap, - ) - contourf = wrap._apply_wrappers( - GeoAxesBase.contourf, - wrap.default_transform, - wrap.standardize_2d, - wrap.apply_cmap, - ) - pcolor = wrap._apply_wrappers( - GeoAxesBase.pcolor, - wrap.default_transform, - wrap.standardize_2d, - wrap.apply_cmap, - ) - pcolormesh = wrap._apply_wrappers( - GeoAxesBase.pcolormesh, - wrap.default_transform, - wrap.standardize_2d, - wrap.apply_cmap, - ) - quiver = wrap._apply_wrappers( - GeoAxesBase.quiver, - wrap.default_transform, - wrap.standardize_2d, - wrap.apply_cmap, - ) - streamplot = wrap._apply_wrappers( - GeoAxesBase.streamplot, - wrap.default_transform, - wrap.standardize_2d, - wrap.apply_cmap, - ) - barbs = wrap._apply_wrappers( - GeoAxesBase.barbs, - wrap.default_transform, - wrap.standardize_2d, - wrap.apply_cmap, - ) - tripcolor = wrap._apply_wrappers( - GeoAxesBase.tripcolor, - wrap.default_transform, - wrap.apply_cmap, - ) - tricontour = wrap._apply_wrappers( - GeoAxesBase.tricontour, - wrap.default_transform, - wrap.apply_cmap, - ) - tricontourf = wrap._apply_wrappers( - GeoAxesBase.tricontourf, - wrap.default_transform, - wrap.apply_cmap, - ) +class _MetaBasemapAxes(plot._MetaPlotAxes): + """ + Avoid recursion inside function calls and impose default ``latlon=True``. + """ + def __new__(cls, name, bases, dct_orig): + dct = dct_orig.copy() + for attr in ( + 'barbs', 'contour', 'contourf', 'hexbin', + 'imshow', 'pcolor', 'pcolormesh', 'plot', + 'quiver', 'scatter', 'streamplot', 'step', + ): + func = dct_orig.get(attr, None) + if not callable(func): + continue + dct[attr] = functools.wraps(func)( + lambda self, *args, func_original=func, latlon=None, **kwargs: + func_original(*args, latlon=_not_none(latlon, True), **kwargs) + ) + return super().__new__(cls, name, bases, dct) -class BasemapAxes(GeoAxes): +class BasemapAxes(GeoAxes, metaclass=_MetaBasemapAxes): """ Axes subclass for plotting `~mpl_toolkits.basemap` projections. The `~mpl_toolkits.basemap.Basemap` instance is added as the `~BasemapAxes.projection` attribute, but you do not have to work with it directly -- plotting methods like `matplotlib.axes.Axes.plot` and `matplotlib.axes.Axes.contour` are redirected to the corresponding methods on - the `~mpl_toolkits.basemap.Basemap` instance. Also ``latlon=True`` is passed + the `~mpl_toolkits.basemap.Basemap` instance. Also ``latlon=None`` is passed to plotting methods by default. """ #: The registered projection name. @@ -1298,6 +1256,9 @@ def __init__(self, *args, map_projection=None, **kwargs): See also -------- + proplot.ui.subplots + proplot.axes.Axes + proplot.axes.PlotAxes proplot.axes.GeoAxes proplot.constructor.Proj """ @@ -1551,96 +1512,3 @@ def projection(self, map_projection): if not isinstance(map_projection, mbasemap.Basemap): raise ValueError('Projection must be a basemap.Basemap instance.') self._map_projection = map_projection - - # Apply 1D plotting command wrappers - plot = wrap._apply_wrappers( - maxes.Axes.plot, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_1d, - wrap._plot_extras, - wrap.indicate_error, - wrap.apply_cycle, - wrap._basemap_redirect, - ) - scatter = wrap._apply_wrappers( - maxes.Axes.scatter, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_1d, - wrap.scatter_extras, - wrap.indicate_error, - wrap.apply_cycle, - wrap._basemap_redirect, - ) - hexbin = wrap._apply_wrappers( - maxes.Axes.hexbin, - wrap._basemap_norecurse, - wrap.standardize_1d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - - # Apply 2D plotting command wrappers - contour = wrap._apply_wrappers( - maxes.Axes.contour, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_2d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - contourf = wrap._apply_wrappers( - maxes.Axes.contourf, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_2d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - pcolor = wrap._apply_wrappers( - maxes.Axes.pcolor, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_2d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - pcolormesh = wrap._apply_wrappers( - maxes.Axes.pcolormesh, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_2d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - quiver = wrap._apply_wrappers( - maxes.Axes.quiver, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_2d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - streamplot = wrap._apply_wrappers( - maxes.Axes.streamplot, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_2d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - barbs = wrap._apply_wrappers( - maxes.Axes.barbs, - wrap._basemap_norecurse, - wrap.default_latlon, - wrap.standardize_2d, - wrap.apply_cmap, - wrap._basemap_redirect, - ) - imshow = wrap._apply_wrappers( - maxes.Axes.imshow, - wrap._basemap_norecurse, - wrap.apply_cmap, - wrap._basemap_redirect, - ) diff --git a/proplot/axes/plot.py b/proplot/axes/plot.py index 34c43ec63..1edc736c5 100644 --- a/proplot/axes/plot.py +++ b/proplot/axes/plot.py @@ -1,38 +1,22 @@ #!/usr/bin/env python3 """ -The plotting wrappers that add functionality to various `~matplotlib.axes.Axes` -methods. "Wrapped" `~matplotlib.axes.Axes` methods accept the additional -arguments documented in the wrapper function. +The second-level axes subclass used for all ProPlot figures. +Implements plotting method overrides. """ -# NOTE: Two possible workflows are 1) make horizontal functions use wrapped -# vertical functions, then flip things around inside apply_cycle or by -# creating undocumented 'plot', 'scatter', etc. methods in Axes that flip -# arguments around by reading a 'orientation' key or 2) make separately -# wrapped chains of horizontal functions and vertical functions whose 'extra' -# wrappers jointly refer to a hidden helper function and create documented -# 'plotx', 'scatterx', etc. that flip arguments around before sending to -# superclass 'plot', 'scatter', etc. Opted for the latter approach. import functools import inspect import re import sys from numbers import Integral -import matplotlib.artist as martist import matplotlib.axes as maxes import matplotlib.cm as mcm import matplotlib.collections as mcollections import matplotlib.colors as mcolors -import matplotlib.container as mcontainer import matplotlib.contour as mcontour -import matplotlib.font_manager as mfonts -import matplotlib.legend as mlegend import matplotlib.lines as mlines import matplotlib.patches as mpatches -import matplotlib.patheffects as mpatheffects -import matplotlib.text as mtext import matplotlib.ticker as mticker -import matplotlib.transforms as mtransforms import numpy as np import numpy.ma as ma @@ -42,102 +26,286 @@ from ..config import rc from ..internals import ic # noqa: F401 from ..internals import ( - _dummy_context, _getattr_flexible, _not_none, + _pop_kwargs, _pop_props, + _process_props, _state_context, docstring, warnings, ) -from ..utils import edges, edges2d, to_rgb, to_xyz, units +from ..utils import edges, edges2d, to_xyz +from . import base try: from cartopy.crs import PlateCarree except ModuleNotFoundError: PlateCarree = object -__all__ = [ - 'default_latlon', - 'default_transform', - 'standardize_1d', - 'standardize_2d', - 'indicate_error', - 'apply_cmap', - 'apply_cycle', - 'colorbar_extras', - 'legend_extras', - 'text_extras', - 'vlines_extras', - 'hlines_extras', - 'scatter_extras', - 'scatterx_extras', - 'bar_extras', - 'barh_extras', - 'fill_between_extras', - 'fill_betweenx_extras', - 'boxplot_extras', - 'violinplot_extras', -] - - -# Positional args that can be passed as out-of-order keywords. Used by standardize_1d -# NOTE: The 'barh' interpretation represent a breaking change from default +__all__ = ['PlotAxes'] + + +# Positional args that can be passed as keywords. Should be a comprehensive list +# WARNING: The 'barh' interpretation represents a breaking change from default # (y, width, height, left) behavior. Want to have consistent interpretation # of vertical or horizontal bar 'width' with 'width' key or 3rd positional arg. -# Interal hist() func uses positional arguments when calling bar() so this is fine. -KEYWORD_TO_POSITIONAL_INSERT = { - 'fill_between': ('x', 'y1', 'y2'), - 'fill_betweenx': ('y', 'x1', 'x2'), - 'vlines': ('x', 'ymin', 'ymax'), - 'hlines': ('y', 'xmin', 'xmax'), - 'bar': ('x', 'height'), - 'barh': ('y', 'height'), - 'parametric': ('x', 'y', 'values'), - 'boxplot': ('positions',), # use as x-coordinates during wrapper processing - 'violinplot': ('positions',), -} -KEYWORD_TO_POSITIONAL_APPEND = { - 'bar': ('width', 'bottom'), - 'barh': ('width', 'left'), +# May cause errors if we let internal matplotlib commands use proplot wrappers. +KEYWORD_TO_POSITIONAL = { + 'plot': ('x', 'y'), + 'plotx': ('y', 'x'), + 'step': ('x', 'y'), + 'stepx': ('y', 'x'), + 'stem': ('x', 'y'), # TODO: add 'stemh' method? + 'parametric': ('x', 'y', 'c'), # TODO: ensure 'values' is synonym + 'vlines': ('x', 'ymin', 'ymax', 'c'), # TODO: native is 'colors', support synonyms + 'hlines': ('y', 'xmin', 'xmax', 'c'), + 'scatter': ('x', 'y', 's', 'c'), + 'scatterx': ('y', 'x', 's', 'c'), + 'fill_between': ('x', 'y1', 'y2', 'where'), + 'fill_betweenx': ('y', 'x1', 'x2', 'where'), + 'bar': ('x', 'height', 'width', 'bottom'), + 'barh': ('y', 'height', 'width', 'left'), + 'boxplot': ('positions', 'x'), # NOTE: used as x-coordinate only during wrapping + 'boxploth': ('positions', 'x'), + 'violinplot': ('positions', 'x'), # NOTE: native for 'x' is 'dataset' + 'violinploth': ('positions', 'x'), + 'pie': ('x',), # TODO: add 'explode', 'labels', 'colors' + 'hist': ('x',), # TODO: allow 'weights' + 'histh': ('y',), # TODO: allow 'weights' + 'hist2d': ('x', 'y'), # TODO: allow 'weights' + 'hexbin': ('x', 'y', 'c'), + 'contour': ('x', 'y', 'z'), # TODO: automatic application of legend elements! + 'contourf': ('x', 'y', 'z'), + 'pcolor': ('x', 'y', 'z'), # TODO: automatic application of legend elements! + 'pcolormesh': ('x', 'y', 'z'), + 'pcolorfast': ('x', 'y', 'z'), + 'streamplot': ('x', 'y', 'u', 'v'), + 'quiver': ('x', 'y', 'u', 'v', 'c'), # TODO: automatic quiver key! + 'barbs': ('x', 'y', 'u', 'v', 'c'), + 'tripcolor': ('x', 'y', 'z'), # NOTE: only parse cmap below this point + 'tricontour': ('x', 'y', 'z'), + 'tricontourf': ('x', 'y', 'z'), + 'imshow': ('z',), # NOTE: native arg is 'X' + 'matshow': ('z',), # NOTE: native arg is 'Z' + 'spy': ('z',), # NOTE: native arg is 'Z' } -# Consistent keywords for cmap plots. Used by apply_cmap to pass correct plural -# or singular form to matplotlib function. -STYLE_ARGS_TRANSLATE = { - 'contour': ('colors', 'linewidths', 'linestyles'), - 'tricontour': ('colors', 'linewidths', 'linestyles'), - 'pcolor': ('edgecolors', 'linewidth', 'linestyle'), - 'pcolormesh': ('edgecolors', 'linewidth', 'linestyle'), - 'pcolorfast': ('edgecolors', 'linewidth', 'linestyle'), - 'tripcolor': ('edgecolors', 'linewidth', 'linestyle'), - 'parametric': ('color', 'linewidth', 'linestyle'), - 'hexbin': ('edgecolors', 'linewidths', 'linestyles'), - 'hist2d': ('edgecolors', 'linewidths', 'linestyles'), - 'barbs': ('barbcolor', 'linewidth', 'linestyle'), - 'quiver': ('color', 'linewidth', 'linestyle'), # applied to arrow *outline* - 'streamplot': ('color', 'linewidth', 'linestyle'), - 'spy': ('color', 'linewidth', 'linestyle'), - 'matshow': ('color', 'linewidth', 'linestyle'), +# Standardized cycle keyword arguments translated to artist +# arguments to permit cycling over additional properties +CYCLE_ARGS_APPLY = { + 'scatter': { + 'color': 'c', + 'markersize': 's', + 'linewidth': 'linewidths', + 'markeredgewidth': 'linewidths', + 'markeredgecolor': 'edgecolors', + 'alpha': 'alpha', + 'marker': 'marker', + } } -docstring.snippets['axes.autoformat'] = """ +# Standardization docstrings +_shared_args_docstring = """ data : dict-like, optional A dict-like dataset container (e.g., `~pandas.DataFrame` or `~xarray.DataArray`). If passed, positional arguments must be valid `data` keys and the arrays used for plotting are retrieved with ``data[key]``. This is a native `matplotlib feature \ -`__ - previously restricted to just `~matplotlib.axes.Axes.plot` - and `~matplotlib.axes.Axes.scatter`. +`__. autoformat : bool, optional Whether *x* axis labels, *y* axis labels, axis formatters, axes titles, legend labels, and colorbar labels are automatically configured when a `~pandas.Series`, `~pandas.DataFrame` or `~xarray.DataArray` is passed to the plotting command. Default is :rc:`autoformat`. """ - -docstring.snippets['axes.cmap_norm'] = """ +_1d_args_docstring = """ +*args : ({y},) or ({x}, {y}) + The data passed as positional arguments. Interpreted as follows: + + * If only *{y}* coordinates are passed, try to infer the *{x}* coordinates + from the `~pandas.Series` or `~pandas.DataFrame` indices or the + `~xarray.DataArray` coordinate arrays. Otherwise, the *{x}* coordinates + are ``np.arange(0, {y}.shape[0])``. + * If the *{y}* coordinates are a 2D array, plot each column of data in succession + (except where each column of data represents a statistical distribution, as with + ``boxplot``, ``violinplot``, or when using ``means=True`` or ``medians=True``). + +%(plot.shared_args)s +""" +_1d_multi_args_docstring = """ +*args : ({y}2,), ({x}, {y}2), or ({x}, {y}1, {y}2) + The data passed as positional arguments. Interpreted as follows: + + * If only *{y}2* coordinates are passed, try to infer the *{x}* coordinates + from the `~pandas.Series` or `~pandas.DataFrame` indices or the + `~xarray.DataArray` coordinate arrays. Otherwise, the *{x}* coordinates + are ``np.arange(0, {y}2.shape[0])``. + * If only *{x}* and *{y}2* coordinates are passed, set the + *{y}1* coordinates to zero. This draws elements originating + from the zero line. + * If both `{y}1` and `{y}2` are provided, draw elements between + these points. If either are 2D, draw elements by iterating over + each column. +""" +_2d_args_docstring = """ +*args : (z1, ...) or (x, y, z1, ...) + The data passed as positional arguments. Interpreted as follows: + + * If only *z* coordinates are passed, try to infer the *x* and *y* coordinates + from the `~pandas.DataFrame` indices and columns or the `~xarray.DataArray` + coordinate arrays. Otherwise, the *y* coordinates are ``np.arange(0, y.shape[0])`` + and the *x* coordinates are ``np.arange(0, y.shape[1])``. + * For ``pcolor`` and ``pcolormesh``, calculate coordinate *edges* using + `~proplot.utils.edges` or `~proplot.utils.edges2d` if *centers* were + provided. For all other methods, calculate coordinate *centers* + if *edges* were provided. + +%(plot.shared_args)s +order : {{'C', 'F'}}, optional + If ``'C'``, *z* coordinates should be shaped ``(y, x)``. If ``'F'``, + *z* coordinates 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 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` only*. Cycles 1D longitude vectors 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}. +""" +docstring.snippets['plot.shared_args'] = _shared_args_docstring +docstring.snippets['plot.1d_args'] = docstring.add_snippets(_1d_args_docstring.format(x='x', y='y')) # noqa: E501 +docstring.snippets['plot.1d_argsx'] = docstring.add_snippets(_1d_args_docstring.format(x='y', y='x')) # noqa: E501 +docstring.snippets['plot.1d_multi_args'] = docstring.add_snippets(_1d_multi_args_docstring.format(x='x', y='y')) # noqa: E501 +docstring.snippets['plot.1d_multi_argsx'] = docstring.add_snippets(_1d_multi_args_docstring.format(x='y', y='x')) # noqa: E501 +docstring.snippets['plot.2d_args'] = docstring.add_snippets(_2d_args_docstring) + + +# Auto colorbar and legend docstring +_colorbar_legend_docstring = """ +colorbar, cbar : 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(s). If ``True``, the + default location is used. Valid locations are described in + `~proplot.axes.Axes.colorbar`. +colorbar_kw, cbar_kw : dict-like, optional + Ignored if `colorbar` is ``None``. Extra keyword args for the call + to `~proplot.axes.Axes.colorbar`. +legend, leg : bool, int, or str, optional + If not ``None``, this is a location specifying where to draw an *inset* + or *panel* legend from the resulting object(s). If ``True``, the + default location is used. Valid locations are described in + `~proplot.axes.Axes.legend`. +legend_kw, leg_kw : dict-like, optional + Ignored if `legend` is ``None``. Extra keyword args for the call + to `~proplot.axes.Axes.legend`. +""" +docstring.snippets['plot.colorbar_legend'] = _colorbar_legend_docstring + + +# Error indication docstrings +_error_means_docstring = """ +mean, means : bool, optional + Whether to plot the means of each column for 2D *{y}* coordinates. Means + are calculated with `numpy.nanmean`. If no other arguments are specified, + this also sets ``barstd=True`` (and ``boxstd=True`` for violin plots). +median, medians : bool, optional + Whether to plot the medians of each column for 2D *{y}* coordinates. Medians + are calculated with `numpy.nanmedian`. If no other arguments arguments are + specified, this also sets ``barstd=True`` (and ``boxstd=True`` for violin plots). +""" +_error_bars_docstring = """ +barstd, barstds : float, (float, float), or bool, optional + *Valid only if `mean` or `median` is ``True``*. Standard deviation multiples for + *thin error bars* with optional whiskers (i.e., caps). If scalar, then +/- that + multiple is used. If ``True``, the default standard deviation range of +/-3 is + used. Standard deviations are calculated with `numpy.nanstd` +barpctile, barpctiles : float, (float, float) or bool, optional + *Valid only if `mean` or `median` is ``True``*. As with `barstd`, but instead + using *percentiles* for the error bars. If scalar, that percentile range is + used (e.g., ``90`` shows the 5th to 95th percentiles). If ``True``, the default + percentile range of 0 to 100 is used. Percentiles are calculated with + `numpy.nanpercentile`. +bardata : 2 x N array or 1D array, optional + *Valid only if `mean` and `median` are ``False``*. If shape is 2 x N, these + are the lower and upper bounds for the thin error bars. If shape is N, these + are the absolute, symmetric deviations from the central points. +boxstd, boxstds, boxpctile, boxpctiles, boxdata : optional + As with `barstd`, `barpctile`, and `bardata`, but for *thicker error bars* + representing a smaller interval than the thin error bars. If `boxstd` is + ``True``, the default standard deviation range of +/-1 is used. If `boxpctiles` + is ``True``, the default percentile range of 25 to 75 is used (i.e., the + interquartile range). When "boxes" and "bars" are combined, this has the + effect of drawing miniature box-and-whisker plots. +capsize : float, optional + The cap size for thin error bars in points. Default is :rc:`errorbar.capsize`. +barz, barzorder, boxz, boxzorder : float, optional + The "zorder" for the thin and thick error bars. Default is ``2.5``. +barc, barcolor, boxc, boxcolor : color-spec, optional + Colors for the thin and thick error bars. Default is + :rc:`boxplot.whiskerprops.color`. +barlw, barlinewidth, boxlw, boxlinewidth : float, optional + Line widths for the thin and thick error bars, in points. The defaults + :rc:`boxplot.whiskerprops.linewidth` (bars) and four times that value (boxes). +boxm, boxmarker : bool or marker-spec, 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 ``'o'``. +boxms, boxmarkersize : size-spec, optional + The marker size for the `boxmarker` marker in points ** 2. Default size + is equal to ``(2 * boxlinewidth) ** 2``. +boxmc, boxmarkercolor, boxmec, boxmarkeredgecolor : color-spec, optional + Color and edge color for the `boxmarker` marker. Default color + and edge color are ``'w'``. +""" +_error_shading_docstring = """ +shadestd, shadestds, shadepctile, shadepctiles, shadedata : optional + As with `barstd`, `barpctile`, and `bardata`, but using *shading* to indicate + the error range. If `shadestds` is ``True``, the default standard deviation + range of +/-2 is used. If `shadepctiles` is ``True``, the default + percentile range of 10 to 90 is used. +fadestd, fadestds, fadepctile, fadepctiles, fadedata : optional + As with `shadestd`, `shadepctile`, and `shadedata`, but for an additional, + more faded, *secondary* shaded region. If `fadestds` is ``True``, the default + standard deviation range of +/-3 is used. If `fadepctiles` is ``True``, + the default percentile range of 0 to 100 is used. +shadec, shadecolor, fadec, fadecolor : color-spec, optional + Colors for the different shaded regions. Default is to inherit the parent color. +shadez, shadezorder, fadez, fadezorder : float, optional + The "zorder" for the different shaded regions. Default is ``1.5``. +shadea, shadealpha, fadea, fadealpha : float, optional + The opacity for the different shaded regions. Defaults are ``0.4`` and ``0.2``. +shadelw, shadelinewidth, fadelw, fadelinewidth : float, optional + The edge line width for the shading patches. Default is ``0``. +shdeec, shadeedgecolor, fadeec, fadeedgecolor : float, optional + The edge color for the shading patches. Default is ``'face'`` (i.e., inherited). +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. +""" +docstring.snippets['plot.error_means'] = _error_means_docstring.format(y='y') +docstring.snippets['plot.error_meansx'] = _error_means_docstring.format(y='x') +docstring.snippets['plot.error_bars'] = _error_bars_docstring +docstring.snippets['plot.error_shading'] = _error_shading_docstring + + +# Color docstrings +_cycle_docstring = """ +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`. +""" +_cmap_norm_docstring = """ cmap : colormap spec, optional The colormap specifer, passed to the `~proplot.constructor.Colormap` constructor. @@ -153,8 +321,21 @@ Whether to assign unique colors to out-of-bounds data and draw "extensions" (triangles, by default) on the colorbar. """ +docstring.snippets['plot.cycle'] = _cycle_docstring +docstring.snippets['plot.cmap_norm'] = _cmap_norm_docstring + -docstring.snippets['axes.levels_values'] = """ +# Levels docstrings +# NOTE: In some functions we only need some components +_vmin_vmax_docstring = """ +vmin, vmax : float, optional + Used to determine level locations if `levels` or `values` is an integer. + Actual levels may not fall exactly on `vmin` and `vmax`, but the minimum + level will be no smaller than `vmin` and the maximum level will be + no larger than `vmax`. If `vmin` or `vmax` are not provided, the + minimum and maximum data values are used. +""" +_levels_values_docstring = """ N Shorthand for `levels`. levels : int or list of float, optional @@ -178,19 +359,9 @@ `~matplotlib.axes.Axes.matshow`, `~matplotlib.axes.Axes.spy`, `~matplotlib.axes.Axes.hexbin`, and `~matplotlib.axes.Axes.hist2d` plots. """ - -docstring.snippets['axes.vmin_vmax'] = """ -vmin, vmax : float, optional - Used to determine level locations if `levels` or `values` is an integer. - Actual levels may not fall exactly on `vmin` and `vmax`, but the minimum - level will be no smaller than `vmin` and the maximum level will be - no larger than `vmax`. If `vmin` or `vmax` are not provided, the - minimum and maximum data values are used. -""" - -docstring.snippets['axes.auto_levels'] = """ +_auto_levels_docstring = """ inbounds : bool, optional - If ``True`` (the edefault), when automatically selecting levels in the presence + If ``True`` (the default), when automatically selecting levels in the presence of hard *x* and *y* axis limits (i.e., when `~matplotlib.axes.Axes.set_xlim` or `~matplotlib.axes.Axes.set_ylim` have been called previously), only the in-bounds data is sampled. Default is :rc:`image.inbounds`. @@ -214,25 +385,145 @@ If ``True``, ``0`` is removed from the level list. This is mainly useful for `~matplotlib.axes.Axes.contour` plots. """ +docstring.snippets['plot.levels_manual'] = _levels_values_docstring +docstring.snippets['plot.levels_auto'] = _auto_levels_docstring +docstring.snippets['plot.vmin_vmax'] = _vmin_vmax_docstring + + +# Labels docstrings +_1d_labels_docstring = """ +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. +""" +_2d_labels_docstring = """ +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 permits limiting the precision. +""" +docstring.snippets['plot.1d_labels'] = _1d_labels_docstring +docstring.snippets['plot.2d_labels'] = _2d_labels_docstring -_lines_docstring = """ -Support overlaying and stacking successive columns of data and support -different colors for "negative" and "positive" lines. -Important ---------- -This function wraps `~matplotlib.axes.Axes.{prefix}lines`. +# Composite docstrings +_1d_kwargs_docstring = """ +%(plot.cycle)s +%(plot.1d_labels)s +%(plot.colorbar_legend)s +""" +_2d_kwargs_docstring = """ +%(plot.cmap_norm)s +%(plot.levels_manual)s +%(plot.vmin_vmax)s +%(plot.levels_auto)s +%(plot.2d_labels)s +%(plot.colorbar_legend)s +""" +_levels_all_docstring = """ +%(plot.cmap_norm)s +%(plot.levels_manual)s +%(plot.vmin_vmax)s +%(plot.levels_auto)s +%(plot.colorbar_legend)s +""" # 2d kwargs minus 'labels' for e.g. 'imshow' +docstring.snippets['plot.1d_kwargs'] = docstring.add_snippets(_1d_kwargs_docstring) +docstring.snippets['plot.2d_kwargs'] = docstring.add_snippets(_2d_kwargs_docstring) +docstring.snippets['plot.levels_all'] = docstring.add_snippets(_levels_all_docstring) + + +# Plot docstring +_plot_docstring = """ +Plot lines. + +Parameters +---------- +%(plot.1d_args{suffix})s + +Other parameters +---------------- +%(plot.1d_kwargs)s +%(plot.error_means{suffix})s +%(plot.error_bars)s +%(plot.error_shading)s +**kwargs + Passed to `~matplotlib.axes.Axes.plot`. + +See also +-------- +PlotAxes.plot +PlotAxes.plotx +matplotlib.axes.Axes.plot +""" +docstring.snippets['plot.plot'] = docstring.add_snippets( + _plot_docstring.format(suffix='') +) +docstring.snippets['plot.plotx'] = docstring.add_snippets( + _plot_docstring.format(suffix='x') +) + + +# Step docstring +# NOTE: Internally matplotlib implements step with thin wrapper of plot +_step_docstring = """ +Plot steps. + +Parameters +---------- +%(plot.1d_args{suffix})s + +Other parameters +---------------- +%(plot.1d_kwargs)s +**kwargs + Passed to `~matplotlib.axes.Axes.step`. + +See also +-------- +PlotAxes.step +PlotAxes.stepx +matplotlib.axes.Axes.step +""" +docstring.snippets['plot.step'] = docstring.add_snippets( + _step_docstring.format(suffix='') +) +docstring.snippets['plot.stepx'] = docstring.add_snippets( + _step_docstring.format(suffix='x') +) + + +# Lines docstrings +_lines_docstring = """ +Plot {orientation} lines. 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 - draw lines between these points. If `{y}1` or `{y}2` are 2D, this - function is called with each column. The default value for `{y}2` is ``0``. +%(plot.1d_multi_args{suffix})s + +Other parameters +---------------- stack, 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. + 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 color lines greater than zero with `poscolor` and lines less than zero with `negcolor`. @@ -245,32 +536,32 @@ The line style(s). lw, linewidth, linewidths : linewidth-spec or list thereof, optional The line width(s). +%(plot.1d_kwargs)s +**kwargs + Passed to `~matplotlib.axes.Axes.{prefix}lines`. See also -------- -standardize_1d -apply_cycle +PlotAxes.vlines +PlotAxes.hlines +matplotlib.axes.Axes.vlines +matplotlib.axes.Axes.hlines """ -docstring.snippets['axes.vlines'] = _lines_docstring.format( - x='x', y='y', prefix='v', orientation='vertical', +docstring.snippets['plot.vlines'] = docstring.add_snippets( + _lines_docstring.format(y='y', prefix='v', suffix='', orientation='vertical') ) -docstring.snippets['axes.hlines'] = _lines_docstring.format( - x='y', y='x', prefix='h', orientation='horizontal', +docstring.snippets['plot.hlines'] = docstring.add_snippets( + _lines_docstring.format(y='x', prefix='h', suffix='x', orientation='horizontal') ) -_scatter_docstring = """ -Support `apply_cmap` features and support style keywords that are -consistent with `~{package}.axes.Axes.plot{suffix}` keywords. -Important ---------- -This function wraps `~{package}.axes.Axes.scatter{suffix}`. +# Scatter function docstring +_scatter_docstring = """ +Plot markers with flexible keyword arguments. Parameters ---------- -*args : {y} or {x}, {y} - The input *{x}* or *{x}* and *{y}* coordinates. If only *{y}* is provided, - *{x}* will be inferred from *{y}*. +%(plot.1d_args{suffix})s s, size, markersize : float or list of float, optional The marker size(s). The units are optionally scaled by `smin` and `smax`. @@ -280,52 +571,135 @@ c, color, markercolor : color-spec or list thereof, or array, optional The marker fill color(s). If this is an array of scalar values, colors will be generated using the colormap `cmap` and normalizer `norm`. -%(axes.vmin_vmax)s -%(axes.cmap_norm)s -%(axes.levels_values)s -%(axes.auto_levels)s +%(plot.vmin_vmax)s + +Other parameters +---------------- 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. +%(plot.cmap_norm)s +%(plot.levels_manual)s +%(plot.levels_auto)s +%(plot.1d_kwargs)s +%(plot.error_means{suffix})s +%(plot.error_bars)s +%(plot.error_shading)s +**kwargs + Passed to `~matplotlib.axes.Axes.scatter`. + +See also +-------- +PlotAxes.scatter +PlotAxes.scatterx +matplotlib.axes.Axes.scatter +""" +docstring.snippets['plot.scatter'] = docstring.add_snippets( + _scatter_docstring.format(suffix='') +) +docstring.snippets['plot.scatterx'] = docstring.add_snippets( + _scatter_docstring.format(suffix='x') +) + + +# Bar function docstring +_bar_docstring = """ +Plot individual, grouped, or stacked bars. + +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))``. The units for width + are *relative* by default. +%(plot.1d_args{suffix})s +width : float or array-like, optional + The width(s) of the bars relative to the {x} coordinate step size. + Can be passed as a third positional argument. +{bottom} : float or array-like, optional + The coordinate(s) of the {bottom} edge of the bars. Default is + ``0``. Can be passed as a fourth positinal argument. +absolute_width : bool, optional + Whether to make the `width` units *absolute*. If ``True``, this + restores the default matplotlib behavior. Default is ``False``. + +Other parameters +---------------- +stack, stacked : bool, optional + Whether to stack columns of the input array or plot the bars + side-by-side in groups. +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. +%(plot.1d_kwargs)s +%(plot.error_means{suffix})s +%(plot.error_bars)s +**kwargs + Passed to `~matplotlib.axes.Axes.bar{suffix2}`. + +See also +-------- +PlotAxes.bar +PlotAxes.barh +matplotlib.axes.Axes.bar +matplotlib.axes.Axes.barh +""" +docstring.snippets['plot.bar'] = docstring.add_snippets( + _bar_docstring.format(x='x', bottom='bottom', suffix='', suffix2='') +) +docstring.snippets['plot.barh'] = docstring.add_snippets( + _bar_docstring.format(x='y', bottom='left', suffix='x', suffix2='h') +) + + +# Histogram docstrings +_hist_docstring = """ +Plot {orientation} histograms. + +Parameters +---------- +%(plot.1d_args{suffix})s Other parameters ---------------- +%(plot.1d_kwargs)s **kwargs - Passed to `~{package}.axes.Axes.scatter{suffix}`. + Passed to `~matplotlib.axes.Axes.hist`. See also -------- -{package}.axes.Axes.bar{suffix} -standardize_1d -indicate_error -apply_cycle +PlotAxes.hist +PlotAxes.histh +matplotlib.axes.Axes.hist """ -docstring.snippets['axes.scatter'] = docstring.add_snippets( - _scatter_docstring.format(x='x', y='y', suffix='', package='matplotlib') +docstring.snippets['plot.hist'] = docstring.add_snippets( + _hist_docstring.format(suffix='', orientation='vertical') ) -docstring.snippets['axes.scatterx'] = docstring.add_snippets( - _scatter_docstring.format(x='y', y='x', suffix='', package='proplot') +docstring.snippets['plot.histh'] = docstring.add_snippets( + _hist_docstring.format(suffix='x', orientation='horizontal') ) -_fill_between_docstring = """ -Support overlaying and stacking successive columns of data support -different colors for "negative" and "positive" regions. -Important ---------- -This function wraps `~matplotlib.axes.Axes.fill_between{suffix}` and -`~proplot.axes.Axes.area{suffix}`. +# Area plot docstring +_fill_docstring = """ +Plot individual, grouped, or overlaid shading patches. 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 these points. If `{y}1` or `{y}2` are 2D, this function - is called with each column. The default value for `{y}2` is ``0``. +%(plot.1d_multi_args{suffix})s + +Other parameters +---------------- stack, 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. @@ -343,78 +717,204 @@ The edge width for the area patches. edgecolor : color-spec, optional The edge color for the area patches. +%(plot.1d_kwargs)s +**kwargs + Passed to `~matplotlib.axes.Axes.fill_between{suffix}`. + +See also +-------- +PlotAxes.area +PlotAxes.areax +PlotAxes.fill_between +PlotAxes.fill_betweenx +matplotlib.axes.Axes.fill_between +matplotlib.axes.Axes.fill_betweenx +""" +docstring.snippets['plot.fill_between'] = docstring.add_snippets( + _fill_docstring.format(x='x', y='y', suffix='') +) +docstring.snippets['plot.fill_betweenx'] = docstring.add_snippets( + _fill_docstring.format(x='y', y='x', suffix='x') +) + + +# Box plot docstrings +_boxplot_docstring = """ +Plot {orientation} boxes and whiskers with a nice default style. + +Parameters +---------- +%(plot.1d_args{suffix})s Other parameters ---------------- +mean, means : bool, optional + If ``True``, this passes ``showmeans=True`` and ``meanline=True`` to + `~matplotlib.axes.Axes.boxplot`. +fill : bool, optional + Whether to fill the box with a color. +fc, facecolor, fillcolor : color-spec, list, optional + The fill color for the boxes. Default is the next color cycler color. If + a list, it should be the same length as the number of objects. +a, alpha, fa, facealpha, fillalpha : float, optional + The opacity of the boxes. Default is ``0.7``. If a list, should be + the same length as the number of objects. +lw, linewidth : float, optional + The linewidth of all objects. Default is ``0.8``. +c, color, colors, ec, edgecolor, edgecolors : color-spec, list, optional + The color of all objects. Default is ``'black'``. If a list, it should + be the same length as the number of objects. +meanls, medianls, meanlinestyle, medianlinestyle, meanlinestyles, medianlinestyles \ +: line style-spec, optional + The line style for the mean and median lines drawn horizontally + across the box. +boxc, capc, whiskerc, flierc, meanc, medianc, \ +boxcolor, capcolor, whiskercolor, fliercolor, meancolor, mediancolor \ +boxcolors, capcolors, whiskercolors, fliercolors, meancolors, mediancolors \ +: color-spec, list, optional + The color of various boxplot components. If a list, it should be the + same length as the number of objects. These are shorthands so you don't + have to pass e.g. a ``boxprops`` dictionary. +boxlw, caplw, whiskerlw, flierlw, meanlw, medianlw, boxlinewidth, caplinewidth, \ +meanlinewidth, medianlinewidth, whiskerlinewidth, flierlinewidth, boxlinewidths, \ +caplinewidths, meanlinewidths, medianlinewidths, whiskerlinewidths, flierlinewidths \ +: float, optional + The line width of various boxplot components. These are shorthands so + you don't have to pass e.g. a ``boxprops`` dictionary. +m, marker : marker-spec, optional + Marker style for the 'fliers', i.e. outliers. +ms, markersize : float, optional + Marker size for the 'fliers', i.e. outliers. +%(plot.1d_kwargs)s **kwargs - Passed to `~matplotlib.axes.Axes.fill_between`. + Passed to `~matplotlib.axes.Axes.boxplot`. See also -------- -matplotlib.axes.Axes.fill_between{suffix} -proplot.axes.Axes.area{suffix} -standardize_1d -apply_cycle +PlotAxes.boxes +PlotAxes.boxesh +PlotAxes.boxplot +PlotAxes.boxploth +matplotlib.axes.Axes.boxplot """ -docstring.snippets['axes.fill_between'] = _fill_between_docstring.format( - x='x', y='y', suffix='', +docstring.snippets['plot.boxplot'] = docstring.add_snippets( + _boxplot_docstring.format(orientation='vertical', suffix='') ) -docstring.snippets['axes.fill_betweenx'] = _fill_between_docstring.format( - x='y', y='x', suffix='x', +docstring.snippets['plot.boxploth'] = docstring.add_snippets( + _boxplot_docstring.format(orientation='horizontal', suffix='x') ) -_bar_docstring = """ -Support grouping and stacking successive columns of data, specifying bar widths -relative to coordinate spacing, and using different colors for "negative" and -"positive" bar heights. -Important ---------- -This function wraps `~matplotlib.axes.Axes.bar{suffix}`. +# Violin plot docstrings +_violinplot_docstring = """ +Plot {orientation} violins with a nice default style matching `this matplotlib example \ +`__. 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))``. The units for width - are *relative* by default. -absolute_width : bool, optional - Whether to make the units for width *absolute*. This restores - the default matplotlib behavior. -stack, stacked : bool, optional - Whether to stack columns of the input array or plot the bars - side-by-side in groups. -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. +%(plot.1d_args{suffix})s Other parameters ---------------- +fillcolor : color-spec, list, optional + The violin plot fill color. Default is the next color cycler color. If + a list, it should be the same length as the number of objects. +fillalpha : float, optional + The opacity of the violins. Default is ``0.7``. If a list, it + should be the same length as the number of objects. +lw, linewidth : float, optional + The linewidth of the line objects. Default is ``0.8``. +color, edgecolor : color-spec, list, optional + The edge color for the violin patches. Default is ``'black'``. If a + list, it should be the same length as the number of objects. +%(plot.1d_kwargs)s +%(plot.error_bars)s **kwargs - Passed to `~matplotlib.axes.Axes.bar{suffix}`. + Passed to `~matplotlib.axes.Axes.violinplot`. + +Note +---- +It is 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. See also -------- -matplotlib.axes.Axes.bar{suffix} -standardize_1d -indicate_error -apply_cycle +PlotAxes.violins +PlotAxes.violinsh +PlotAxes.violinplot +PlotAxes.violinploth +matplotlib.axes.Axes.violinplot """ -docstring.snippets['axes.bar'] = _bar_docstring.format( - x='x', bottom='bottom', suffix='', +docstring.snippets['plot.violinplot'] = docstring.add_snippets( + _violinplot_docstring.format(orientation='vertical', suffix='') ) -docstring.snippets['axes.barh'] = _bar_docstring.format( - x='y', bottom='left', suffix='h', +docstring.snippets['plot.violinploth'] = docstring.add_snippets( + _violinplot_docstring.format(orientation='horizontal', suffix='x') ) +# Contour docstrings +_contour_docstring = """ +Parameters +---------- +%(plot.2d_args)s + +Other parameters +---------------- +lw, linewidth, linewidths + The width of the contour lines. For `contourf` plots, lines are added + between the filled contours. +ls, linestyle, linestyles + The style of the contour lines. For `contourf` plots, lines are added + between the filled contours. +c, color, colors, ec, edgecolor, edgecolors + The color for the contour lines. If not passed, the color is + determined by `cmap` and the `z` data. +edgefix : bool, optional + Whether to fix an issue where `white lines appear between filled contours \ +`__ in saved vector graphics. + This can slow down figure rendering. Default is :rc:`image.edgefix`. +""" +docstring.snippets['plot.contour'] = docstring.add_snippets(_contour_docstring) + + +# Pcolor docstring +_pcolor_docstring = """ +Parameters +---------- +%(plot.2d_args)s + +Other parameters +---------------- +%(plot.2d_kwargs)s +lw, linewidth, linewidths + The width of lines between grid boxes. +ls, linestyle, linestyles + The style of lines between grid boxes. +c, color, colors, ec, edgecolor, edgecolors + The color for lines between grid boxes. +edgefix : bool, optional + Whether to fix an issue where `white lines appear between grid boxes \ +`__ in saved vector graphics. + This can slow down figure rendering. Default is :rc:`image.edgefix`. +""" +docstring.snippets['plot.pcolor'] = docstring.add_snippets(_pcolor_docstring) + + +# Flow function docstring +_flow_docstring = """ +Parameters +---------- +%(plot.2d_args)s + +Other parameters +---------------- +%(plot.2d_kwargs)s +""" +docstring.snippets['plot.flow'] = docstring.add_snippets(_flow_docstring) + + def _load_objects(): """ Delay loading expensive modules. We just want to detect if *input @@ -437,17 +937,17 @@ def _is_number(data): """ Test whether input is numeric array rather than datetime or strings. """ - return len(data) and np.issubdtype(_to_ndarray(data).dtype, np.number) + return len(data) and np.issubdtype(_to_numpy_array(data).dtype, np.number) def _is_string(data): """ Test whether input is array of strings. """ - return len(data) and isinstance(_to_ndarray(data).flat[0], str) + return len(data) and isinstance(_to_numpy_array(data).flat[0], str) -def _to_arraylike(data): +def _to_array_like(data): """ Convert list of lists to array-like type. """ @@ -462,7 +962,7 @@ def _to_arraylike(data): return data -def _to_ndarray(data): +def _to_numpy_array(data): """ Convert arbitrary input to ndarray cleanly. Returns a masked array if input is a masked array. @@ -470,3025 +970,1798 @@ def _to_ndarray(data): return np.atleast_1d(getattr(data, 'values', data)) -def _mask_array(mask, *args): +class _MetaPlotAxes(type): """ - Apply the mask to the input arrays. Values matching ``False`` are - set to `np.nan`. + Redirect internal plotting calls to native matplotlib methods. """ - invalid = ~mask # True if invalid - args_masked = [] - for arg in args: - if arg.size > 1 and arg.shape != invalid.shape: - raise ValueError('Shape mismatch between mask and array.') - arg_masked = arg.astype(np.float64) - if arg.size == 1: - pass - elif invalid.size == 1: - arg_masked = np.nan if invalid.item() else arg_masked - elif arg.size > 1: - arg_masked[invalid] = np.nan - args_masked.append(arg_masked) - return args_masked[0] if len(args_masked) == 1 else args_masked + # NOTE: This obviates the need for basemap-specific redirection to mpl.axes.Axes. + # WARNING: This will fail if there is no native matplotlib method + def __new__(cls, name, bases, dct_orig): + dct = dct_orig.copy() + for attr in (*KEYWORD_TO_POSITIONAL, 'violin'): + func = dct_orig.get(attr, None) + if not callable(func): + continue + @functools.wraps(func) # noqa: E306 + def _redirect_matplotlib( + self, *args, func_name=attr, func_override=func, **kwargs + ): + func_native = getattr(super(PlotAxes, self), func_name) + internal_call = getattr(self, '_internal_call', None) + if internal_call: + return func_native(*args, **kwargs) # call bound meethod + else: + return func_override(self, *args, **kwargs) # call unbound method + dct[attr] = _redirect_matplotlib + return super().__new__(cls, name, bases, dct) + + +class PlotAxes(base.Axes, metaclass=_MetaPlotAxes): + """ + Second lowest-level axes subclass implementing plotting overrides. + """ + def __init__(self, *args, **kwargs): + """ + Parameters + ---------- + *args, **kwargs + Passed to `~proplot.axes.Axes`. + + See also + -------- + matplotlib.axes.Axes + proplot.axes.Axes + proplot.axes.CartesianAxes + proplot.axes.PolarAxes + proplot.axes.GeoAxes + """ + super().__init__(*args, **kwargs) + + def _call_method(self, name, *args, **kwargs): + """ + Call the plotting method and use context object to redirect internal calls to + native methods. Also implement automatic colorbars, legends, and labels. + """ + # Properties used for post-processing + edgefix = kwargs.pop('edgefix', rc['image.edgefix']) + contour_kw = kwargs.pop('contour_kw', None) or {} + + # Properties used for automatic colorbar, legend, and labels + # TODO: Ensure no conflicts with 1D plotting commands + # TODO: Implement these within individual plotting methods + kw_labels = _pop_kwargs(kwargs, 'labels', 'labels_kw', 'fmt', 'formatter', 'formatter_kw', 'precision') # noqa: E501 + kw_colorbar_legend = _pop_kwargs(kwargs, 'legend', 'legend_kw', 'colorbar', 'colorbar_kw') # noqa: E501 + + # Properties added for 'colorbar' function + ticks = kwargs.pop('ticks', None) + extend = kwargs.get('extend', None) if 'contour' in name else kwargs.pop('extend', None) # noqa: E501 + + # Safely plot stuff + # NOTE: Previously allowed internal matplotlib plotting function calls to run + # through proplot overrides then avoided awkward conflicts in piecemeal fashion + # (e.g. relative bar widths, sticky plot lines). Now just prevent internal calls + # from running through proplot overrides altogether. + with _state_context(self, _internal_call=True): + # Call method + if getattr(self, 'name', None) == 'proplot_basemap': + obj = getattr(self.projection, name)(*args, ax=self, **kwargs) + else: + obj = getattr(self, name)(*args, **kwargs) # NOTE: redirects + + # Automatic stuff + self._auto_labels(obj, **kw_labels) + self._auto_colorbar_legend(obj, **kw_colorbar_legend) + if edgefix: + self._fix_edges(obj) # valid for any ScalarMappable + if contour_kw: + self.contour(*args, **contour_kw, **kwargs) + if isinstance(obj, mcm.ScalarMappable): + obj._colorbar_ticks = ticks # used by proplot colorbar + obj._colorbar_extend = extend # used by proplot colorbar + # obj._colorbar_label = label # TODO: add this!!! + + return obj + + def _call_negpos( + self, name, x, *ys, negcolor=None, poscolor=None, + use_where=False, use_zero=False, **kwargs + ): + """ + Call the method with allotment for "negative" and "positive" regions. + """ + # Handle ignored arguments + if use_where: + kwargs.setdefault('interpolate', True) # see fill_between docs + for key in ('color', 'colors', 'stack', 'stacked', 'where'): + value = kwargs.pop(key, None) + if value is None: + continue + warnings._warn_proplot( + f'{name}() argument {key}={value!r} is incompatible with ' + 'negpos=True. Ignoring.' + ) + # Plot negative component + yneg = ys.copy() + if use_zero: # filter bar heights + yneg[0] = self._mask_negpos(ys[0] < 0, ys[0]) + elif use_where: # apply fill_between mask + kwargs['where'] = ys[1] < ys[0] + else: + yneg = self._mask_negpos(ys[1] < ys[0], *ys) + color = _not_none(negcolor, rc['negcolor']) + negobj = self._call_method(name, x, *yneg, color=color, **kwargs) + + # Plot positive component + ypos = ys.copy() + if use_zero: # filter bar heights + ypos[0] = self._mask_negpos(ys[0] >= 0, ys[0]) + elif use_where: # apply fill_between mask + kwargs['where'] = ys[1] >= ys[0] + else: + ypos = self._mask_negpos(ys[1] >= ys[0], *ys) + color = _not_none(poscolor, rc['poscolor']) + posobj = self._call_method(name, x, *ypos, color=color, **kwargs) -def default_latlon(self, *args, latlon=True, **kwargs): - """ - Make ``latlon=True`` the default for `~proplot.axes.BasemapAxes` plots. - This means you no longer have to pass ``latlon=True`` if your data - coordinates are longitude and latitude. + return (negobj, posobj) - Important - --------- - This function wraps {methods} for `~proplot.axes.BasemapAxes`. - """ - method = kwargs.pop('_method') - return method(self, *args, latlon=latlon, **kwargs) + @staticmethod + def _mask_negpos(mask, *args): + """ + Apply the mask to the input arrays. Values matching ``False`` are + set to `np.nan`. + """ + invalid = ~mask # True if invalid + args_masked = [] + for arg in args: + if arg.size > 1 and arg.shape != invalid.shape: + raise ValueError('Shape mismatch between mask and array.') + arg_masked = arg.astype(np.float64) + if arg.size == 1: + pass + elif invalid.size == 1: + arg_masked = np.nan if invalid.item() else arg_masked + elif arg.size > 1: + arg_masked[invalid] = np.nan + args_masked.append(arg_masked) + return args_masked[0] if len(args_masked) == 1 else args_masked + + @staticmethod + def _get_data(data, *args): + """ + Try to convert positional `key` arguments to `data[key]`. If argument is string + it could be a valid positional argument like `fmt` so do not raise error. + """ + args = list(args) + for i, arg in enumerate(args): + if isinstance(arg, str): + try: + array = data[arg] + except KeyError: + pass + else: + args[i] = array + return args + + @staticmethod + def _get_coords(*args, which='x', **kwargs): + """ + Convert string arrays and lists to index coordinates. + """ + # NOTE: Why FixedLocator and not IndexLocator? The latter requires plotting + # lines or else error is raised... very strange. + # NOTE: Why IndexFormatter and not FixedFormatter? The former ensures labels + # correspond to indices while the latter can mysteriously truncate labels. + res = [] + for arg in args: + arg = _to_array_like(arg) + if _is_string(arg) and arg.ndim > 1: + raise ValueError('Non-1D string coordinate input is unsupported.') + if not _is_string(arg): + res.append(arg) + continue + idx = np.arange(len(arg)) + kwargs.setdefault(which + 'locator', mticker.FixedLocator(idx)) + kwargs.setdefault(which + 'formatter', pticker._IndexFormatter(_to_numpy_array(arg))) # noqa: E501 + kwargs.setdefault(which + 'minorlocator', mticker.NullLocator()) + res.append(idx) + return *res, kwargs + + @staticmethod + def _get_title(data, include_units=True): + """ + Return the "title" associated with an array-like object with metadata. This + might be a pandas `DataFrame` `name` or a name constructed from `DataArray` + attributes. In the latter case we search for `long_name` and `standard_name`, + preferring the former, and append `(units)` if `units` is ``True``. If no + names are available but units are available we just use the units string. + """ + title = None + _load_objects() + if isinstance(data, ndarray): + pass + # Xarray object with possible long_name, standard_name, and units attributes. + # Output depends on if units is True + elif isinstance(data, DataArray): + title = getattr(data, 'name', None) + for key in ('standard_name', 'long_name'): + title = data.attrs.get(key, title) + if include_units: + suffix = data.attrs.get('units', None) + if title and suffix: + title = f'{title} ({suffix})' + elif suffix: + title = suffix + # Pandas object. DataFrame has no native name attribute but user can add one + # See: https://github.com/pandas-dev/pandas/issues/447 + elif isinstance(data, (DataFrame, Series, Index)): + title = getattr(data, 'name', None) or None + # Standardize result + if title is not None: + title = str(title).strip() + return title + + @staticmethod + def _get_labels(data, axis=0, always=True): + """ + Return the array-like "labels" along axis `axis` from an array-like + object. These might be an xarray `DataArray` or pandas `Index`. If + `always` is ``False`` we return ``None`` for simple ndarray input. + """ + # NOTE: Previously inferred 'axis 1' metadata of 1D variable using the + # data values metadata but that is incorrect. The paradigm for 1D plots + # is we have row coordinates representing x, data values representing y, + # and column coordinates representing individual series. + if axis not in (0, 1, 2): + raise ValueError(f'Invalid axis {axis}.') + labels = None + _load_objects() + if isinstance(data, ndarray): + if not always: + pass + elif axis < data.ndim: + labels = np.arange(data.shape[axis]) + else: # requesting 'axis 1' on a 1D array + labels = np.array([0]) + # Xarray object + # NOTE: Even if coords not present .coords[dim] auto-generates indices + elif isinstance(data, DataArray): + if axis < data.ndim: + labels = data.coords[data.dims[axis]] + elif not always: + pass + else: + labels = np.array([0]) + # Pandas object + elif isinstance(data, (DataFrame, Series, Index)): + if axis == 0 and isinstance(data, (DataFrame, Series)): + labels = data.index + elif axis == 1 and isinstance(data, (DataFrame,)): + labels = data.columns + elif not always: + pass + else: # beyond dimensionality + labels = np.array([0]) + # Everything else + # NOTE: Ensure data is at least 1D in _to_array_like so this covers everything + else: + raise ValueError(f'Unrecognized array type {type(data)}.') + return labels + + @staticmethod + def _require_centers(x, y, z): + """ + Enforce that coordinates are centers. Convert from edges if possible. + """ + xlen, ylen = x.shape[-1], y.shape[0] + if z.ndim == 2 and 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: + # Helpful error message + 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)}.' + ) + return x, y + @staticmethod + def _require_edges(x, y, z): + """ + Enforce that coordinates are edges. Convert from centers if possible. + """ + xlen, ylen = x.shape[-1], y.shape[0] + if z.ndim == 2 and 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: + # Helpful error message + raise ValueError( + f'Input shapes x {x.shape} and y {y.shape} must match ' + f'array centers {z.shape} or ' + f'array borders {tuple(i + 1 for i in z.shape)}.' + ) + return x, y -def default_transform(self, *args, transform=None, **kwargs): - """ - Make ``transform=cartopy.crs.PlateCarree()`` the default for - `~proplot.axes.CartopyAxes` plots. This means you no longer have to - pass ``transform=cartopy.crs.PlateCarree()`` if your data - coordinates are longitude and latitude. - - Important - --------- - 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 - method = kwargs.pop('_method') - if transform is None: - transform = PlateCarree() - return method(self, *args, transform=transform, **kwargs) + def _cartopy_2d(self, x, y, *zs, globe=False): + """ + Fix cartopy 2D geographic data arrays. + """ + # Fix coordinates + x, y = self._lon_monotonic(x, y) + + # Fix data + x_orig, y_orig, zs_orig = x, y, zs + zs = [] + for z_orig in zs_orig: + # Bail for 2D coordinates + if not globe or x_orig.ndim > 1 or y_orig.ndim > 1: + zs.append(z_orig) + continue + # Fix holes over poles by *interpolating* there + y, z = self._lat_global(y_orig, z_orig) + # Fix seams by ensuring circular coverage (cartopy can plot over map edges) + if x_orig[0] % 360 != (x_orig[-1] + 360) % 360: + x = ma.concatenate((x_orig, [x_orig[0] + 360])) + z = ma.concatenate((z, z[:, :1]), axis=1) + zs.append(z) + + return x, y, *zs + + def _basemap_1d(self, x, *ys): + """ + Fix basemap geographic 1D data arrays. + """ + xmin, xmax = self.projection.lonmin, self.projection.lonmax + x_orig, ys_orig = self._lon_monotonic(x), ys + ys = [] + for y_orig in ys_orig: + x, y = self._lon_global(x_orig, y_orig, xmin, xmax) + ys.append(y) + return x, *ys + + def _basemap_2d(self, x, y, *zs, globe=False): + """ + Fix basemap 2D geographic data arrays. + """ + # Fix coordinates + x = self._lon_monotonic(x) + + # Fix data + xmin, xmax = self.projection.lonmin, self.projection.lonmax + x_orig, y_orig, zs_orig = x, y, zs + zs = [] + for z_orig in zs_orig: + # Ensure data is within map bounds + x, z_orig = self._lon_global(x_orig, z_orig, xmin, xmax) + # Bail for 2D coordinates + if not globe or x_orig.ndim > 1 or y_orig.ndim > 1: + zs.append(z_orig) + continue + # Fix holes over poles by *interpolating* there + y, z = self._lat_global(y_orig, z_orig) + # Fix seams at map boundary + if x[0] == xmin and x.size - 1 == z.shape[1]: # scenario 1 + # Edges (e.g. pcolor) fit perfectly against seams. Size is unchanged. + pass + elif x.size - 1 == z.shape[1]: # scenario 2 + # Edges (e.g. pcolor) do not fit perfectly. Size augmented by 1. + x = ma.append(xmin, x) + x[-1] = xmin + 360 + z = ma.concatenate((z[:, -1:], z), axis=1) + elif x.size == z.shape[1]: # scenario 3 + # Centers (e.g. contour) interpolated to edge. Size augmented by 2. + xi = np.array([x[-1], x[0] + 360]) + if xi[0] == xi[1]: # impossible to interpolate + pass + else: + 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]) # noqa: E501 + x = ma.concatenate(([xmin], x, [xmin + 360])) + z = ma.concatenate((zq, z, zq), axis=1) + else: + raise ValueError('Unexpected shapes of coordinates or data arrays.') + zs.append(z) + + # Convert coordinates + if x.ndim == 1 and y.ndim == 1: + x, y = np.meshgrid(x, y) + x, y = self.projection(x, y) + + return x, y, *zs + + @staticmethod + def _lon_monotonic(x): + """ + Ensure longitudes are monotonic and make `~numpy.ndarray` copies so the + contents can be modified. Ignores 2D coordinate arrays. + """ + if x.ndim != 1 or all(x < x[0]): # skip 2D arrays and monotonic backwards data + return x + lon1 = x[0] + filter_ = x < lon1 + while filter_.sum(): + filter_ = x < lon1 + x[filter_] += 360 + return x + + @staticmethod + def _lon_global(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 + + # 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.astype(np.float64) + 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 _basemap_redirect(self, *args, **kwargs): - """ - Docorator that calls the basemap version of the function of the - same name. This must be applied as the innermost decorator. - """ - method = kwargs.pop('_method') - name = method.__name__ - if getattr(self, 'name', None) == 'proplot_basemap': - return getattr(self.projection, name)(*args, ax=self, **kwargs) - else: - return method(self, *args, **kwargs) + @staticmethod + def _lat_global(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 + + def _auto_format_1d( + self, x, *ys, name='plot', autoformat=False, + label=None, values=None, labels=None, **kwargs + ): + """ + Try to retrieve default coordinates from array-like objects and apply default + formatting. Also update the keyword arguments. + """ + # Parse input + parametric = name in ('parametric',) + scatter = name in ('scatter',) + hist = name in ('hist',) + box = name in ('boxplot', 'violinplot') + pie = name in ('pie',) + stem = name in ('stem',) + nocycle = name in ('stem', 'hexbin', 'hist2d', 'parametric') + labels = _not_none( + label=label, + values=values, + labels=labels, + colorbar_kw_values=kwargs.get('colorbar_kw', {}).pop('values', None), + legend_kw_labels=kwargs.get('legend_kw', {}).pop('labels', None), + ) + # Retrieve the x coords + # NOTE: Allow for "ragged array" input to boxplot and violinplot. + # NOTE: Where columns represent distributions, like for box and violinplot or + # where we use 'means' or 'medians', columns coords (axis 1) are 'x' coords. + # Otherwise, columns represent e.g. lines and row coords (axis 0) are 'x' coords + dists = box or any(kwargs.get(s) for s in ('mean', 'means', 'median', 'medians')) # noqa: E501 + ragged = any(getattr(y, 'dtype', None) == 'object' for y in ys) + xaxis = 1 if dists and not ragged else 0 + if x is None and not hist: + x = self._get_labels(ys[0], axis=xaxis) # infer from rows or columns + + # Default legend or colorbar labels and title. We want default legend + # labels if this is an object with 'title' metadata and/or coords are string + # WARNING: Confusing terminology differences here -- for box and violin plots + # 'labels' refer to indices along x axis. Get interpreted that way down the line + if autoformat and not stem: + # The inferred labels and title + title = None + if labels is not None: + title = self._get_title(labels) + else: + yaxis = xaxis if box or pie else xaxis + 1 + labels = self._get_labels(ys[0], axis=yaxis, always=False) + title = self._get_title(labels) # e.g. if labels is a Series + if labels is None: + pass + elif not title and not any(isinstance(_, str) for _ in labels): + labels = None + # Apply the title + if title: + kwargs.setdefault('colorbar_kw', {}).setdefault('title', title) + if not nocycle: + kwargs.setdefault('legend_kw', {}).setdefault('title', title) + # Apply the labels + if labels is not None: + if not nocycle: + kwargs['labels'] = _to_numpy_array(labels) + elif parametric: + values, colorbar_kw = self._get_coords(labels, which='') + kwargs['values'] = _to_numpy_array(values) + kwargs.setdefault('colorbar_kw', {}).update(colorbar_kw) + + # The basic x and y settings + # NOTE: We no longer test the function name + horiz = kwargs.get('vert') is False or kwargs.get('orientation') == 'horizontal' + sx, sy = 'yx' if horiz else 'xy' + sy = sx if hist else sy # histogram 'y' values end up along 'x' axis + if not hasattr(self, 'projection'): + # Apply label + # NOTE: Do not overwrite existing labels! + kw_format = {} + if autoformat: # 'y' axis + title = self._get_title(ys[0]) + if title and not getattr(self, f'get_{sy}label')(): + kw_format[sy + 'label'] = title + if autoformat and not hist: # 'x' axis + title = self._get_title(x) + if title and not getattr(self, f'get_{sx}label')(): + kw_format[sx + 'label'] = title + + # Handle string-type coordinates + if not pie and not hist: + x, kw_format = self._get_coords(x, which=sx, **kw_format) + if not hist and not box and not pie: + *ys, kw_format = self._get_coords(*ys, which=sy, **kw_format) + if not hist and not scatter and not parametric and x.ndim == 1 and x.size > 1 and x[1] < x[0]: # noqa: E501 + kw_format[sx + 'reverse'] = True # auto reverse + + # Appply + if kw_format: + self.format(**kw_format) + + # Finally strip metadata + # WARNING: Most methods that accept 2D arrays use columns of data, but when + # pandas DataFrame specifically is passed to hist, boxplot, or violinplot, rows + # of data assumed! Converting to ndarray necessary. + return _to_numpy_array(x), *map(_to_numpy_array, ys), kwargs + + @docstring.add_snippets + def _standardize_1d(self, name, *args, data=None, autoformat=None, **kwargs): + """ + Interpret positional arguments for all "1D" plotting commands + so the syntax is consistent. + """ + # TODO: Replace with keyword args + onecoord = name in ('hist',) + twocoords = name in ('vlines', 'hlines', 'fill_between', 'fill_betweenx') + allowempty = name in ('fill', 'plot', 'plotx',) + autoformat = _not_none(autoformat, rc['autoformat']) + + # Find and translate input args + args = list(args) + keys = KEYWORD_TO_POSITIONAL.get(name, {}) + for idx, key in enumerate(keys): + if key in kwargs: + args.insert(idx, kwargs.pop(key)) + if data is not None: + args = self._get_data(data, *args) + if not args: + if allowempty: + return [] # match matplotlib behavior + else: + raise TypeError('Positional arguments are required.') -def _basemap_norecurse(self, *args, called_from_basemap=False, **kwargs): - """ - Decorator to prevent recursion in basemap method overrides. - See `this post https://stackoverflow.com/a/37675810/4970632`__. - """ - method = kwargs.pop('_method') - name = method.__name__ - if called_from_basemap: - return getattr(maxes.Axes, name)(self, *args, **kwargs) - else: - return method(self, *args, called_from_basemap=True, **kwargs) + # Parse positional args + if onecoord or len(args) == 1: # allow hist() positional bins + x, ys, args = None, args[:1], args[1:] + elif twocoords: + x, ys, args = args[0], args[1:3], args[3:] + else: + x, ys, args = args[0], args[1:2], args[2:] + if x is not None: + x = _to_array_like(x) + ys = tuple(map(_to_array_like, ys)) + + # Automatic formatting and coordinates + # NOTE: For 'hist' the 'x' coordinate is None then is ignored in _apply_cycle. + x, *ys, kwargs = self._auto_format_1d( + x, *ys, name=name, autoformat=autoformat, **kwargs + ) + # Ensure data is monotonic and falls within map bounds + if getattr(self, 'name', None) == 'proplot_basemap' and kwargs.get('latlon', None): # noqa: E501 + x, *ys = self._basemap_1d(x, *ys) -def _get_data(data, *args): - """ - Try to convert positional `key` arguments to `data[key]`. If argument is string - it could be a valid positional argument like `fmt` so do not raise error. - """ - args = list(args) - for i, arg in enumerate(args): - if isinstance(arg, str): - try: - array = data[arg] - except KeyError: - pass - else: - args[i] = array - return args + # Call function + return x, *ys, *args, kwargs + def _auto_format_2d( + self, x, y, *zs, name=None, order='C', autoformat=False, **kwargs + ): + """ + Try to retrieve default coordinates from array-like objects and apply default + formatting. Also apply optional transpose and update the keyword arguments. + """ + # Retrieve coordinates + allow1d = name in ('barbs', 'quiver') # these also allow 1D data + if x is None and y is None: + z = zs[0] + if z.ndim == 1: + x = self._get_labels(z, axis=0) + y = np.zeros(z.shape) # default barb() and quiver() behavior in mpl + else: + x = self._get_labels(z, axis=1) + y = self._get_labels(z, axis=0) + if order == 'F': + x, y = y, x + + # Check coordinate and data shapes + shapes = tuple(z.shape for z in zs) + if any(len(_) != 2 and not (allow1d and len(_) == 1) for _ in shapes): + raise ValueError(f'Data arrays must be 2d, but got shapes {shapes}.') + shapes = set(shapes) + if len(shapes) > 1: + raise ValueError(f'Data arrays must have same shape, but got shapes {shapes}.') # noqa: E501 + if any(_.ndim not in (1, 2) for _ in (x, y)): + raise ValueError('x and y coordinates must be 1d or 2d.') + if x.ndim != y.ndim: + raise ValueError('x and y coordinates must have same dimensionality.') + if order == 'F': # TODO: double check this + x, y = x.T, y.T # in case they are 2-dimensional + zs = tuple(z.T for z in zs) + + # The labels and XY axis settings + if not hasattr(self, 'projection'): + # Apply labels + # NOTE: Do not overwrite existing labels! + kw_format = {} + if autoformat: + for s, d in zip('xy', (x, y)): + title = self._get_title(d) + if title and not getattr(self, f'get_{s}label')(): + kw_format[s + 'label'] = title + + # Handle string-type coordinates + x, kw_format = self._get_coords(x, which='x', **kw_format) + y, kw_format = self._get_coords(y, which='y', **kw_format) + for s, d in zip('xy', (x, y)): + if d.size > 1 and d.ndim == 1 and _to_numpy_array(d)[1] < _to_numpy_array(d)[0]: # noqa: E501 + kw_format[s + 'reverse'] = True -def _get_label(obj): - """ - Return a valid non-placeholder artist label from the artist or a tuple of - artists destined for a legend. Prefer final artist (drawn last and on top). - """ - # NOTE: BarContainer and StemContainer are instances of tuple - while not hasattr(obj, 'get_label') and isinstance(obj, tuple) and len(obj) > 1: - obj = obj[-1] - label = getattr(obj, 'get_label', lambda: None)() - return label if label and label[:1] != '_' else None + # Apply formatting + if kw_format: + self.format(**kw_format) + # Default colorbar label + # WARNING: This will fail for any funcs wrapped by _standardize_2d but not + # wrapped by _apply_cmap. So far there are none. + if autoformat: + kwargs.setdefault('colorbar_kw', {}) + title = self._get_title(zs[0]) + if title and True: + kwargs['colorbar_kw'].setdefault('label', title) -def _get_labels(data, axis=0, always=True): - """ - Return the array-like "labels" along axis `axis` from an array-like - object. These might be an xarray `DataArray` or pandas `Index`. If - `always` is ``False`` we return ``None`` for simple ndarray input. - """ - # NOTE: Previously inferred 'axis 1' metadata of 1D variable using the - # data values metadata but that is incorrect. The paradigm for 1D plots - # is we have row coordinates representing x, data values representing y, - # and column coordinates representing individual series. - if axis not in (0, 1, 2): - raise ValueError(f'Invalid axis {axis}.') - labels = None - _load_objects() - if isinstance(data, ndarray): - if not always: - pass - elif axis < data.ndim: - labels = np.arange(data.shape[axis]) - else: # requesting 'axis 1' on a 1D array - labels = np.array([0]) - # Xarray object - # NOTE: Even if coords not present .coords[dim] auto-generates indices - elif isinstance(data, DataArray): - if axis < data.ndim: - labels = data.coords[data.dims[axis]] - elif not always: - pass - else: - labels = np.array([0]) - # Pandas object - elif isinstance(data, (DataFrame, Series, Index)): - if axis == 0 and isinstance(data, (DataFrame, Series)): - labels = data.index - elif axis == 1 and isinstance(data, (DataFrame,)): - labels = data.columns - elif not always: - pass - else: # beyond dimensionality - labels = np.array([0]) - # Everything else - # NOTE: We ensure data is at least 1D in _to_arraylike so this covers everything - else: - raise ValueError(f'Unrecognized array type {type(data)}.') - return labels + # Finally strip metadata + return _to_numpy_array(x), _to_numpy_array(y), *map(_to_numpy_array, zs), kwargs + @docstring.add_snippets + def _standardize_2d( + self, name, *args, data=None, autoformat=None, order='C', globe=False, **kwargs + ): + """ + Interpret positional arguments for all "2D" plotting commands + so the syntax is consistent. + """ + # TODO: Replace name-dependent behavior with keyword args + method = kwargs.pop('_method') + name = method.__name__ + pcolor = name in ('pcolor', 'pcolormesh', 'pcolorfast') + allow1d = name in ('barbs', 'quiver') # these also allow 1D data + autoformat = _not_none(autoformat, rc['autoformat']) + + # Find and translate input args + if data is not None: + args = self._get_data(data, *args) + if not args: + raise TypeError('Positional arguments are required.') -def _get_title(data, units=True): - """ - Return the "title" associated with an array-like object with metadata. This - might be a pandas `DataFrame` `name` or a name constructed from xarray `DataArray` - attributes. In the latter case we search for `long_name` and `standard_name`, - preferring the former, and append `(units)` if `units` is ``True``. If no - names are available but units are available we just use the units string. - """ - title = None - _load_objects() - if isinstance(data, ndarray): - pass - # Xarray object with possible long_name, standard_name, and units attributes. - # Output depends on if units is True - elif isinstance(data, DataArray): - title = getattr(data, 'name', None) - for key in ('standard_name', 'long_name'): - title = data.attrs.get(key, title) - if units: - units = data.attrs.get('units', None) - if title and units: - title = f'{title} ({units})' - elif units: - title = units - # Pandas object. Note DataFrame has no native name attribute but user can add one - # See: https://github.com/pandas-dev/pandas/issues/447 - elif isinstance(data, (DataFrame, Series, Index)): - title = getattr(data, 'name', None) or None - # Standardize result - if title is not None: - title = str(title).strip() - return title - - -def _parse_string_coords(*args, which='x', **kwargs): - """ - Convert string arrays and lists to index coordinates. - """ - # NOTE: Why FixedLocator and not IndexLocator? The latter requires plotting - # lines or else error is raised... very strange. - # NOTE: Why IndexFormatter and not FixedFormatter? The former ensures labels - # correspond to indices while the latter can mysteriously truncate labels. - res = [] - for arg in args: - arg = _to_arraylike(arg) - if _is_string(arg) and arg.ndim > 1: - raise ValueError('Non-1D string coordinate input is unsupported.') - if not _is_string(arg): - res.append(arg) - continue - idx = np.arange(len(arg)) - kwargs.setdefault(which + 'locator', mticker.FixedLocator(idx)) - kwargs.setdefault(which + 'formatter', pticker._IndexFormatter(_to_ndarray(arg))) # noqa: E501 - kwargs.setdefault(which + 'minorlocator', mticker.NullLocator()) - res.append(idx) - return *res, kwargs - - -def _auto_format_1d( - self, x, *ys, name='plot', autoformat=False, - label=None, values=None, labels=None, **kwargs -): - """ - Try to retrieve default coordinates from array-like objects and apply default - formatting. Also update the keyword arguments. - """ - # Parse input - projection = hasattr(self, 'projection') - parametric = name in ('parametric',) - scatter = name in ('scatter',) - hist = name in ('hist',) - box = name in ('boxplot', 'violinplot') - pie = name in ('pie',) - vert = kwargs.get('vert', True) and kwargs.get('orientation', None) != 'horizontal' - vert = vert and name not in ('plotx', 'scatterx', 'fill_betweenx', 'barh') - stem = name in ('stem',) - nocycle = name in ('stem', 'hexbin', 'hist2d', 'parametric') - labels = _not_none( - label=label, - values=values, - labels=labels, - colorbar_kw_values=kwargs.get('colorbar_kw', {}).pop('values', None), - legend_kw_labels=kwargs.get('legend_kw', {}).pop('labels', None), - ) - - # Retrieve the x coords - # NOTE: Allow for "ragged array" input to boxplot and violinplot. - # NOTE: Where columns represent distributions, like for box and violin plots or - # where we use 'means' or 'medians', columns coords (axis 1) are 'x' coords. - # Otherwise, columns represent e.g. lines, and row coords (axis 0) are 'x' coords - dists = box or any(kwargs.get(s) for s in ('mean', 'means', 'median', 'medians')) - ragged = any(getattr(y, 'dtype', None) == 'object' for y in ys) - xaxis = 1 if dists and not ragged else 0 - if x is None and not hist: - x = _get_labels(ys[0], axis=xaxis) # infer from rows or columns - - # Default legend or colorbar labels and title. We want default legend - # labels if this is an object with 'title' metadata and/or coords are string - # WARNING: Confusing terminology differences here -- for box and violin plots - # 'labels' refer to indices along x axis. Get interpreted that way down the line. - if autoformat and not stem: - # The inferred labels and title - title = None - if labels is not None: - title = _get_title(labels) + # Parse input args + if len(args) > 2: + x, y, *args = args else: - yaxis = xaxis if box or pie else xaxis + 1 - labels = _get_labels(ys[0], axis=yaxis, always=False) - title = _get_title(labels) # e.g. if labels is a Series - if labels is None: - pass - elif not title and not any(isinstance(_, str) for _ in labels): - labels = None - # Apply the title - if title: - kwargs.setdefault('colorbar_kw', {}).setdefault('title', title) - if not nocycle: - kwargs.setdefault('legend_kw', {}).setdefault('title', title) - # Apply the labels - if labels is not None: - if not nocycle: - kwargs['labels'] = _to_ndarray(labels) - elif parametric: - values, colorbar_kw = _parse_string_coords(labels, which='') - kwargs['values'] = _to_ndarray(values) - kwargs.setdefault('colorbar_kw', {}).update(colorbar_kw) - - # The basic x and y settings - if not projection: - # Apply label - # NOTE: Do not overwrite existing labels! - sx, sy = 'xy' if vert else 'yx' - sy = sx if hist else sy # histogram 'y' values end up along 'x' axis - kw_format = {} - if autoformat: # 'y' axis - title = _get_title(ys[0]) - if title and not getattr(self, f'get_{sy}label')(): - kw_format[sy + 'label'] = title - if autoformat and not hist: # 'x' axis - title = _get_title(x) - if title and not getattr(self, f'get_{sx}label')(): - kw_format[sx + 'label'] = title - - # Handle string-type coordinates - if not pie and not hist: - x, kw_format = _parse_string_coords(x, which=sx, **kw_format) - if not hist and not box and not pie: - *ys, kw_format = _parse_string_coords(*ys, which=sy, **kw_format) - if not hist and not scatter and not parametric and x.ndim == 1 and x.size > 1 and x[1] < x[0]: # noqa: E501 - kw_format[sx + 'reverse'] = True # auto reverse - - # Appply - if kw_format: - self.format(**kw_format) - - # Finally strip metadata - # WARNING: Most methods that accept 2D arrays use columns of data, but when - # pandas DataFrame specifically is passed to hist, boxplot, or violinplot, rows - # of data assumed! Converting to ndarray necessary. - return _to_ndarray(x), *map(_to_ndarray, ys), kwargs - - -def _basemap_1d(x, *ys, projection=None): - """ - Fix basemap geographic 1D data arrays. - """ - xmin, xmax = projection.lonmin, projection.lonmax - x_orig, ys_orig = x, ys - ys = [] - for y_orig in ys_orig: - x, y = _fix_span(*_fix_coords(x_orig, y_orig), xmin, xmax) - ys.append(y) - return x, *ys - - -def _fix_coords(x, y): - """ - Ensure longitudes are monotonic and make `~numpy.ndarray` copies so the - contents can be modified. Ignores 2D coordinate arrays. - """ - if x.ndim != 1 or all(x < x[0]): # skip 2D arrays and monotonic backwards data - return x, y - lon1 = x[0] - filter_ = x < lon1 - while filter_.sum(): - filter_ = x < lon1 - x[filter_] += 360 - return x, y - - -def _fix_span(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 - - # 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 - - -@docstring.add_snippets -def standardize_1d(self, *args, data=None, autoformat=None, **kwargs): - """ - Interpret positional arguments for all "1D" plotting commands so the syntax - is consistent. The 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. - - Important - --------- - This function wraps {methods} - - Parameters - ---------- - %(axes.autoformat)s - - See also - -------- - apply_cycle - indicate_error - """ - method = kwargs.pop('_method') - name = method.__name__ - bar = name in ('bar', 'barh') - box = name in ('boxplot', 'violinplot') - hist = name in ('hist',) - parametric = name in ('parametric',) - onecoord = name in ('hist',) - twocoords = name in ('vlines', 'hlines', 'fill_between', 'fill_betweenx') - allowempty = name in ('fill', 'plot', 'plotx',) - autoformat = _not_none(autoformat, rc['autoformat']) - - # Find and translate input args - args = list(args) - keys = KEYWORD_TO_POSITIONAL_INSERT.get(name, {}) - for idx, key in enumerate(keys): - if key in kwargs: - args.insert(idx, kwargs.pop(key)) - if data is not None: - args = _get_data(data, *args) - if not args: - if allowempty: - return [] # match matplotlib behavior - else: - raise TypeError('Positional arguments are required.') - - # Translate between 'orientation' and 'vert' for flexibility - # NOTE: Users should only pass these to hist, boxplot, or violinplot. To change - # the bar plot orientation users should use 'bar' and 'barh'. Internally, - # matplotlib has a single central bar function whose behavior is configured - # by the 'orientation' key, so critical not to strip the argument here. - vert = kwargs.pop('vert', None) - orientation = kwargs.pop('orientation', None) - if orientation is not None: - vert = _not_none(vert=vert, orientation=(orientation == 'vertical')) - if orientation not in (None, 'horizontal', 'vertical'): - raise ValueError("Orientation must be either 'horizontal' or 'vertical'.") - if vert is None: - pass - elif box: - kwargs['vert'] = vert - elif bar or hist: - kwargs['orientation'] = 'vertical' if vert else 'horizontal' # used internally - else: - raise TypeError("Unexpected keyword argument(s) 'vert' and 'orientation'.") - - # Parse positional args - if parametric and len(args) == 3: # allow positional values - kwargs['values'] = args.pop(2) - if parametric and 'c' in kwargs: # handle aliases - kwargs['values'] = kwargs.pop('c') - if onecoord or len(args) == 1: # allow hist() positional bins - x, ys, args = None, args[:1], args[1:] - elif twocoords: - x, ys, args = args[0], args[1:3], args[3:] - else: - x, ys, args = args[0], args[1:2], args[2:] - if x is not None: - x = _to_arraylike(x) - ys = tuple(map(_to_arraylike, ys)) - - # Append remaining positional args - # NOTE: This is currently just used for bar and barh. More convenient to pass - # 'width' as positional so that matplotlib native 'barh' sees it as 'height'. - keys = KEYWORD_TO_POSITIONAL_APPEND.get(name, {}) - for key in keys: - if key in kwargs: - args.append(kwargs.pop(key)) - - # Automatic formatting and coordinates - # NOTE: For 'hist' the 'x' coordinate remains None then is ignored in apply_cycle. - x, *ys, kwargs = _auto_format_1d( - self, x, *ys, name=name, autoformat=autoformat, **kwargs - ) - - # Ensure data is monotonic and falls within map bounds - if getattr(self, 'name', None) == 'proplot_basemap' and kwargs.get('latlon', None): - x, *ys = _basemap_1d(x, *ys, projection=self.projection) - - # Call function - if box: - kwargs.setdefault('positions', x) # *this* is how 'x' is passed to boxplot - return method(self, x, *ys, *args, **kwargs) - - -def _auto_format_2d(self, x, y, *zs, name=None, order='C', autoformat=False, **kwargs): - """ - Try to retrieve default coordinates from array-like objects and apply default - formatting. Also apply optional transpose and update the keyword arguments. - """ - # Retrieve coordinates - allow1d = name in ('barbs', 'quiver') # these also allow 1D data - projection = hasattr(self, 'projection') - if x is None and y is None: - z = zs[0] - if z.ndim == 1: - x = _get_labels(z, axis=0) - y = np.zeros(z.shape) # default barb() and quiver() behavior in matplotlib - else: - x = _get_labels(z, axis=1) - y = _get_labels(z, axis=0) - if order == 'F': - x, y = y, x - - # Check coordinate and data shapes - shapes = tuple(z.shape for z in zs) - if any(len(_) != 2 and not (allow1d and len(_) == 1) for _ in shapes): - raise ValueError(f'Data arrays must be 2d, but got shapes {shapes}.') - shapes = set(shapes) - if len(shapes) > 1: - raise ValueError(f'Data arrays must have same shape, but got shapes {shapes}.') - if any(_.ndim not in (1, 2) for _ in (x, y)): - raise ValueError('x and y coordinates must be 1d or 2d.') - if x.ndim != y.ndim: - raise ValueError('x and y coordinates must have same dimensionality.') - if order == 'F': # TODO: double check this - x, y = x.T, y.T # in case they are 2-dimensional - zs = tuple(z.T for z in zs) - - # The labels and XY axis settings - if not projection: - # Apply labels - # NOTE: Do not overwrite existing labels! - kw_format = {} - if autoformat: - for s, d in zip('xy', (x, y)): - title = _get_title(d) - if title and not getattr(self, f'get_{s}label')(): - kw_format[s + 'label'] = title - - # Handle string-type coordinates - x, kw_format = _parse_string_coords(x, which='x', **kw_format) - y, kw_format = _parse_string_coords(y, which='y', **kw_format) - for s, d in zip('xy', (x, y)): - if d.size > 1 and d.ndim == 1 and _to_ndarray(d)[1] < _to_ndarray(d)[0]: - kw_format[s + 'reverse'] = True - - # Apply formatting - if kw_format: - self.format(**kw_format) - - # Default colorbar label - # WARNING: This will fail for any funcs wrapped by standardize_2d but not - # wrapped by apply_cmap. So far there are none. - if autoformat: - kwargs.setdefault('colorbar_kw', {}) - title = _get_title(zs[0]) - if title and True: - kwargs['colorbar_kw'].setdefault('label', title) - - # Finally strip metadata - return _to_ndarray(x), _to_ndarray(y), *map(_to_ndarray, zs), kwargs - - -def _add_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 - - -def _enforce_centers(x, y, z): - """ - Enforce that coordinates are centers. Convert from edges if possible. - """ - xlen, ylen = x.shape[-1], y.shape[0] - if z.ndim == 2 and 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: - # Helpful error message - 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)}.' - ) - return x, y - - -def _enforce_edges(x, y, z): - """ - Enforce that coordinates are edges. Convert from centers if possible. - """ - xlen, ylen = x.shape[-1], y.shape[0] - if z.ndim == 2 and 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: - # Helpful error message - raise ValueError( - f'Input shapes x {x.shape} and y {y.shape} must match ' - f'array centers {z.shape} or ' - f'array borders {tuple(i + 1 for i in z.shape)}.' + x = y = None + if x is not None: + x = _to_array_like(x) + if y is not None: + y = _to_array_like(y) + zs = tuple(map(_to_array_like, args)) + + # Automatic formatting + x, y, *zs, kwargs = self._auto_format_2d( + x, y, *zs, name=name, order=order, autoformat=autoformat, **kwargs ) - return x, y - -def _cartopy_2d(x, y, *zs, globe=False): - """ - Fix cartopy 2D geographic data arrays. - """ - # Fix coordinates - x, y = _fix_coords(x, y) - - # Fix data - x_orig, y_orig, zs_orig = x, y, zs - zs = [] - for z_orig in zs_orig: - # Bail for 2D coordinates - if not globe or x_orig.ndim > 1 or y_orig.ndim > 1: - zs.append(z_orig) - continue - # Fix holes over poles by *interpolating* there - y, z = _add_poles(y_orig, z_orig) - # Fix seams by ensuring circular coverage (cartopy can plot over map edges) - if x_orig[0] % 360 != (x_orig[-1] + 360) % 360: - x = ma.concatenate((x_orig, [x_orig[0] + 360])) - z = ma.concatenate((z, z[:, :1]), axis=1) - zs.append(z) - - return x, y, *zs - - -def _basemap_2d(x, y, *zs, globe=False, projection=None): - """ - Fix basemap 2D geographic data arrays. - """ - # Fix coordinates - x, y = _fix_coords(x, y) - - # Fix data - xmin, xmax = projection.lonmin, projection.lonmax - x_orig, y_orig, zs_orig = x, y, zs - zs = [] - for z_orig in zs_orig: - # Ensure data is within map bounds - x, z_orig = _fix_span(x_orig, z_orig, xmin, xmax) - # Bail for 2D coordinates - if not globe or x_orig.ndim > 1 or y_orig.ndim > 1: - zs.append(z_orig) - continue - # Fix holes over poles by *interpolating* there - y, z = _add_poles(y_orig, z_orig) - # Fix seams at map boundary - if x[0] == xmin and x.size - 1 == z.shape[1]: # scenario 1 - # Edges (e.g. pcolor) fit perfectly against seams. Size is unchanged. - pass - elif x.size - 1 == z.shape[1]: # scenario 2 - # Edges (e.g. pcolor) do not fit perfectly. Size augmented by 1. - x = ma.append(xmin, x) - x[-1] = xmin + 360 - z = ma.concatenate((z[:, -1:], z), axis=1) - elif x.size == z.shape[1]: # scenario 3 - # Centers (e.g. contour) must be interpolated to edge. Size augmented by 2. - xi = np.array([x[-1], x[0] + 360]) - if xi[0] == xi[1]: # impossible to interpolate - pass - else: - 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]) # noqa: E501 - x = ma.concatenate(([xmin], x, [xmin + 360])) - z = ma.concatenate((zq, z, zq), axis=1) + # Standardize coordinates + if pcolor: + x, y = self._require_edges(x, y, zs[0]) else: - raise ValueError('Unexpected shapes of coordinates or data arrays.') - zs.append(z) + x, y = self._require_centers(x, y, zs[0]) - # Convert coordinates - if x.ndim == 1 and y.ndim == 1: - x, y = np.meshgrid(x, y) - x, y = projection(x, y) + # Cartopy projection axes + if ( + not allow1d and getattr(self, 'name', None) == 'proplot_cartopy' + and isinstance(kwargs.get('transform', None), PlateCarree) + ): + x, y, *zs = self._cartopy_2d(x, y, *zs, globe=globe) - return x, y, *zs + # Basemap projection axes + elif ( + not allow1d and getattr(self, 'name', None) == 'proplot_basemap' + and kwargs.get('latlon', None) + ): + x, y, *zs = self._basemap_2d(x, y, *zs, globe=globe) + kwargs['latlon'] = False + # Call function + return x, y, *zs, kwargs -@docstring.add_snippets -def standardize_2d( - self, *args, data=None, autoformat=None, order='C', globe=False, **kwargs -): - """ - Interpret positional arguments for all "2D" plotting commands so the syntax is - consistent. The 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. This uses the `~proplot.utils.edges` and - `~propot.utils.edges2d` functions. For all other methods, coordinate - *centers* are calculated if *edges* were provided. - - Important - --------- - This function wraps {methods} - - Parameters - ---------- - %(axes.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 - -------- - apply_cmap - proplot.utils.edges - proplot.utils.edges2d - """ - method = kwargs.pop('_method') - name = method.__name__ - pcolor = name in ('pcolor', 'pcolormesh', 'pcolorfast') - allow1d = name in ('barbs', 'quiver') # these also allow 1D data - autoformat = _not_none(autoformat, rc['autoformat']) - - # Find and translate input args - if data is not None: - args = _get_data(data, *args) - if not args: - raise TypeError('Positional arguments are required.') - - # Parse input args - if len(args) > 2: - x, y, *args = args - else: - x = y = None - if x is not None: - x = _to_arraylike(x) - if y is not None: - y = _to_arraylike(y) - zs = tuple(map(_to_arraylike, args)) - - # Automatic formatting - x, y, *zs, kwargs = _auto_format_2d( - self, x, y, *zs, name=name, order=order, autoformat=autoformat, **kwargs - ) - - # Standardize coordinates - if pcolor: - x, y = _enforce_edges(x, y, zs[0]) - else: - x, y = _enforce_centers(x, y, zs[0]) - - # Cartopy projection axes - if ( - not allow1d and getattr(self, 'name', None) == 'proplot_cartopy' - and isinstance(kwargs.get('transform', None), PlateCarree) + def _error_distribution( + self, x, y, *args, + mean=None, means=None, median=None, medians=None, **kwargs ): - x, y, *zs = _cartopy_2d(x, y, *zs, globe=globe) - - # Basemap projection axes - elif ( - not allow1d and getattr(self, 'name', None) == 'proplot_basemap' - and kwargs.get('latlon', None) + """ + Take means or medians. + """ + # Get means or medians along columns + # TODO: Permit 3D array with error dimension coming first + # NOTE: Previously went to great pains to preserve metadata but now retrieval + # of default legend handles moved to _auto_format_1d so can strip. + x, y, *args = args + data = y + means = _not_none(mean=mean, means=means) + medians = _not_none(median=median, medians=medians) + if means and medians: + warnings._warn_proplot('Cannot have both means=True and medians=True. Using former.') # noqa: E501 + medians = None + if means or medians: + if data.ndim != 2: + raise ValueError(f'Expected 2D array for means=True. Got {data.ndim}D.') + if means: + y = np.nanmean(data, axis=0) + elif medians: + y = np.nanmedian(data, axis=0) + + # Save argument passed to _error_bars + kwargs['distribution'] = data + return x, y, *args, kwargs + + @staticmethod + def _error_data( + y, stds=None, pctiles=None, errdata=None, distribution=None, + stds_default=None, pctiles_default=None, absolute=False, label=False, ): - x, y, *zs = _basemap_2d(x, y, *zs, globe=globe, projection=self.projection) - kwargs['latlon'] = False - - # Call function - return method(self, x, y, *zs, **kwargs) - - -def _get_error_data( - data, y, errdata=None, stds=None, pctiles=None, - stds_default=None, pctiles_default=None, - reduced=True, absolute=False, label=False, -): - """ - Return values that can be passed to the `~matplotlib.axes.Axes.errorbar` - `xerr` and `yerr` keyword args. - """ - # Parse stds arguments - # NOTE: Have to guard against "truth value of an array is ambiguous" errors - if stds is True: - stds = stds_default - elif stds is False or stds is None: - stds = None - else: - stds = np.atleast_1d(stds) - if stds.size == 1: - stds = sorted((-stds.item(), stds.item())) - elif stds.size != 2: - raise ValueError('Expected scalar or length-2 stdev specification.') - - # Parse pctiles arguments - if pctiles is True: - pctiles = pctiles_default - elif pctiles is False or pctiles is None: - pctiles = None - else: - pctiles = np.atleast_1d(pctiles) - if pctiles.size == 1: - delta = (100 - pctiles.item()) / 2.0 - pctiles = sorted((delta, 100 - delta)) - elif pctiles.size != 2: - raise ValueError('Expected scalar or length-2 pctiles specification.') - - # 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 reduced 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 reduced 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.' - ) - - # Compute error data in format that can be passed to maxes.Axes.errorbar() - # NOTE: Include option to pass symmetric deviation from central points - if errdata is not None: - # Manual error data - if y.ndim != 1: + """ + Return values that can be passed to the `~matplotlib.axes.Axes.errorbar` + `xerr` and `yerr` keyword args. + """ + # Parse stds arguments + # NOTE: Have to guard against "truth value of an array is ambiguous" errors + if stds is True: + stds = stds_default + elif stds is False or stds is None: + stds = None + else: + stds = np.atleast_1d(stds) + if stds.size == 1: + stds = sorted((-stds.item(), stds.item())) + elif stds.size != 2: + raise ValueError('Expected scalar or length-2 stdev specification.') + + # Parse pctiles arguments + if pctiles is True: + pctiles = pctiles_default + elif pctiles is False or pctiles is None: + pctiles = None + else: + pctiles = np.atleast_1d(pctiles) + if pctiles.size == 1: + delta = (100 - pctiles.item()) / 2.0 + pctiles = sorted((delta, 100 - delta)) + elif pctiles.size != 2: + raise ValueError('Expected scalar or length-2 pctiles specification.') + + # Incompatible settings + if distribution is None and any(_ is not None for _ in (stds, pctiles)): raise ValueError( - 'errdata with 2D y coordinates is not yet supported.' + 'To automatically compute standard deviations or percentiles on ' + 'columns of data you must pass means=True or medians=True.' ) - label_default = 'uncertainty' - err = _to_ndarray(errdata) - if ( - err.ndim not in (1, 2) - or err.shape[-1] != y.size - 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 stds is not None and pctiles is not None: + warnings._warn_proplot( + 'You passed both a standard deviation range and a percentile range for ' + 'error indicators. Using the former.' ) - 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: - # Standard deviations - label_default = fr'{abs(stds[1])}$\sigma$ range' - err = y + np.nanstd(data, axis=0)[None, :] * _to_ndarray(stds)[:, None] - elif pctiles is not None: - # Percentiles - label_default = f'{pctiles[1] - pctiles[0]}% range' - err = np.nanpercentile(data, pctiles, axis=0) - else: - raise ValueError('You must provide error bounds.') - - # Return label possibly - if label is True: - label = label_default - elif not label: - label = None - - # Make relative data for maxes.Axes.errorbar() ingestion - if not absolute: - err = err - y - err[0, :] *= -1 # absolute deviations from central points - - # Return data with legend entry - return err, label - - -def indicate_error( - self, *args, - mean=None, means=None, median=None, medians=None, - barstd=None, barstds=None, barpctile=None, barpctiles=None, bardata=None, - boxstd=None, boxstds=None, boxpctile=None, boxpctiles=None, boxdata=None, - shadestd=None, shadestds=None, shadepctile=None, shadepctiles=None, shadedata=None, - fadestd=None, fadestds=None, fadepctile=None, fadepctiles=None, fadedata=None, - boxmarker=None, 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 -): - """ - Support on-the-fly error bars and error shading. Use the input error data or - optionally interpret columns of data as distributions, pass the column - means or medians to the relevant plotting command, and draw error - indications from the specified standard deviation or percentile range. - - Important - --------- - This function wraps {methods} - - Parameters - ---------- - *args - The input data. - mean, means : bool, optional - Whether to plot the means of each column in the input data. If no other - arguments specified, this also sets ``barstd=True`` (and ``boxstd=True`` - for violin plots). - median, medians : bool, optional - Whether to plot the medians of each column in the input data. If no other - arguments specified, this also sets ``barstd=True`` (and ``boxstd=True`` - for violin plots). - barstd, barstds : float, (float, float), or bool, optional - Standard deviation multiples for *thin error bars* with optional whiskers - (i.e. caps). If scalar, then +/- that number is used. If ``True``, the - default of +/-3 standard deviations is used. This argument is only valid - if `means` or `medians` is ``True``. - barpctile, barpctiles : float, (float, float) or bool, optional - As with `barstd`, but instead using *percentiles* for the error bars. The - percentiles are calculated with `numpy.percentile`. If scalar, that width - surrounding the 50th percentile is used (e.g. ``90`` shows the 5th to 95th - percentiles). If ``True``, the default percentile range of 0 to 100 is - used. This argument is only valid if `means` or `medians` is ``True``. - 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). - boxstd, boxstds, boxpctile, boxpctiles, boxdata : optional - As with `barstd`, `barpctile`, and `bardata`, but for *thicker error bars* - representing a smaller interval than the thin error bars. If `boxstds` is - ``True``, the default standard deviation range of +/-1 is used. If `boxpctiles` - is ``True``, the default percentile range of 25 to 75 is used (i.e. the - interquartile range). When "boxes" and "bars" are combined, this has the effect - of drawing miniature box-and-whisker plots. - shadestd, shadestds, shadepctile, shadepctiles, shadedata : optional - As with `barstd`, `barpctile`, and `bardata`, but using *shading* to indicate - the error range. If `shadestds` is ``True``, the default standard deviation - range of +/-2 is used. If `shadepctiles` is ``True``, the default - percentile range of 10 to 90 is used. Shading is generally useful for - `~matplotlib.axes.Axes.plot` plots. - fadestd, fadestds, fadepctile, fadepctiles, fadedata : optional - As with `shadestd`, `shadepctile`, and `shadedata`, but for an additional, - more faded, *secondary* shaded region. If `fadestds` is ``True``, the default - standard deviation range of +/-3 is used. If `fadepctiles` is ``True``, - the default percentile range of 0 to 100 is 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``. - 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. - """ - method = kwargs.pop('_method') - name = method.__name__ - bar = name in ('bar',) - flip = name in ('barh', 'plotx', 'scatterx') or kwargs.get('vert') is False - plot = name in ('plot', 'scatter') - violin = name in ('violinplot',) - means = _not_none(mean=mean, means=means) - medians = _not_none(median=median, medians=medians) - barstds = _not_none(barstd=barstd, barstds=barstds) - boxstds = _not_none(boxstd=boxstd, boxstds=boxstds) - shadestds = _not_none(shadestd=shadestd, shadestds=shadestds) - fadestds = _not_none(fadestd=fadestd, fadestds=fadestds) - barpctiles = _not_none(barpctile=barpctile, barpctiles=barpctiles) - boxpctiles = _not_none(boxpctile=boxpctile, boxpctiles=boxpctiles) - shadepctiles = _not_none(shadepctile=shadepctile, shadepctiles=shadepctiles) - fadepctiles = _not_none(fadepctile=fadepctile, fadepctiles=fadepctiles) - bars = any(_ is not None for _ in (bardata, barstds, barpctiles)) - boxes = any(_ is not None for _ in (boxdata, boxstds, boxpctiles)) - shade = any(_ is not None for _ in (shadedata, shadestds, shadepctiles)) - fade = any(_ is not None for _ in (fadedata, fadestds, fadepctiles)) - if means and medians: - warnings._warn_proplot('Cannot have both means=True and medians=True. Using former.') # noqa: E501 - - # Get means or medians while preserving metadata for autoformat - # TODO: Permit 3D array with error dimension coming first - # NOTE: Previously went to great pains to preserve metadata but now retrieval - # of default legend handles moved to _auto_format_1d so can strip. - x, y, *args = args - data = y - if means or medians: - if data.ndim != 2: - raise ValueError(f'Expected 2D array for means=True. Got {data.ndim}D.') - if not any((bars, boxes, shade, fade)): - bars = barstds = True - if violin: - boxes = boxstds = True - if means: - y = np.nanmean(data, axis=0) - elif medians: - y = np.nanpercentile(data, 50, axis=0) - - # Parse keyword args and apply defaults - # NOTE: Should not use plot() 'linewidth' for bar elements - # NOTE: violinplot_extras passes some invalid keyword args with expectation - # that indicate_error pops them and uses them for error bars. - getter = kwargs.pop if violin else kwargs.get if bar else lambda *args: None - boxmarker = _not_none(boxmarker, True if violin else False) - capsize = _not_none(capsize, 3.0) - linewidth = _not_none(getter('linewidth', None), getter('lw', None), 1.0) - barlinewidth = _not_none(barlinewidth=barlinewidth, barlw=barlw, default=linewidth) - boxlinewidth = _not_none(boxlinewidth=boxlinewidth, boxlw=boxlw, default=4 * barlinewidth) # noqa: E501 - edgecolor = _not_none(getter('edgecolor', None), 'k') - barcolor = _not_none(barcolor, edgecolor) - boxcolor = _not_none(boxcolor, barcolor) - shadecolor_infer = shadecolor is None - fadecolor_infer = fadecolor is None - shadecolor = _not_none(shadecolor, kwargs.get('color'), kwargs.get('facecolor'), edgecolor) # noqa: E501 - fadecolor = _not_none(fadecolor, shadecolor) - - # Draw dark and light shading - getter = kwargs.pop if plot else kwargs.get - eobjs = [] - fill = self.fill_betweenx if flip else self.fill_between - if fade: - edata, label = _get_error_data( - data, y, errdata=fadedata, stds=fadestds, pctiles=fadepctiles, - stds_default=(-3, 3), pctiles_default=(0, 100), absolute=True, - reduced=means or medians, label=fadelabel, - ) - eobj = fill( - x, *edata, linewidth=0, label=label, - color=fadecolor, alpha=fadealpha, zorder=fadezorder, - ) - eobjs.append(eobj) - if shade: - edata, label = _get_error_data( - data, y, errdata=shadedata, stds=shadestds, pctiles=shadepctiles, - stds_default=(-2, 2), pctiles_default=(10, 90), absolute=True, - reduced=means or medians, label=shadelabel, - ) - eobj = fill( - x, *edata, linewidth=0, label=label, - color=shadecolor, alpha=shadealpha, zorder=shadezorder, - ) - eobjs.append(eobj) - - # Draw thin error bars and thick error boxes - sy = 'x' if flip else 'y' # yerr - ex, ey = (y, x) if flip else (x, y) - if boxes: - edata, _ = _get_error_data( - data, y, errdata=boxdata, stds=boxstds, pctiles=boxpctiles, - stds_default=(-1, 1), pctiles_default=(25, 75), - reduced=means or medians, - ) - if boxmarker: - self.scatter( - ex, ey, s=boxlinewidth, marker='o', color=boxmarkercolor, zorder=5 + pctiles = None + if distribution is not None 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.' ) - eobj = self.errorbar( - ex, ey, color=boxcolor, linewidth=boxlinewidth, linestyle='none', - capsize=0, zorder=boxzorder, **{sy + 'err': edata} - ) - eobjs.append(eobj) - if bars: # now impossible to make thin bar width different from cap width! - edata, _ = _get_error_data( - data, y, errdata=bardata, stds=barstds, pctiles=barpctiles, - stds_default=(-3, 3), pctiles_default=(0, 100), - reduced=means or medians, - ) - eobj = self.errorbar( - ex, ey, color=barcolor, linewidth=barlinewidth, linestyle='none', - markeredgecolor=barcolor, markeredgewidth=barlinewidth, - capsize=capsize, zorder=barzorder, **{sy + 'err': edata} - ) - eobjs.append(eobj) - - # 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 violin else (x, y) - kwargs.setdefault('_errobjs', eobjs[:int(shade + fade)]) - res = obj = method(self, *xy, *args, **kwargs) - - # Apply inferrred colors to objects - i = 0 - if isinstance(res, tuple): # pull out patch from e.g. BarContainers - obj = res[0] - for b, infer in zip((fade, shade), (fadecolor_infer, shadecolor_infer)): - if not b or not infer: - continue - 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: - eobjs[i].set_facecolor(color) - i += 1 - - # Return objects - # NOTE: For now 'errobjs' can only be returned with 1D y coordinates - # NOTE: Avoid expanding matplolib collections that are list subclasses here - if not eobjs: - return res - elif isinstance(res, tuple) and not isinstance(res, mcontainer.Container): - return ((*res, *eobjs),) # for plot() - else: - return (res, *eobjs) - - -def _apply_plot(self, *args, cmap=None, values=None, **kwargs): - """ - Apply horizontal or vertical lines. - """ - # Deprecated functionality - 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 the next major release. Please use ' - 'ax.parametric(x, y, values, cmap=cmap) instead.' - ) - return self.parametric(*args, cmap=cmap, values=values, **kwargs) - - # Plot line(s) - method = kwargs.pop('_method') - name = method.__name__ - sx = 'y' if 'x' in name else 'x' # i.e. plotx - objs = [] - args = list(args) - while args: - # Support e.g. x1, y1, fmt, x2, y2, fmt2 input - # NOTE: Copied from _process_plot_var_args.__call__ to avoid relying - # on public API. ProPlot already supports passing extra positional - # arguments beyond x, y so can feed (x1, y1, fmt) through wrappers. - # Instead represent (x2, y2, fmt, ...) as successive calls to plot(). - iargs, args = args[:2], args[2:] - if args and isinstance(args[0], str): - iargs.append(args[0]) - args = args[1:] - - # Call function - iobjs = method(self, *iargs, values=values, **kwargs) - - # Add sticky edges - # NOTE: Skip edges when error bars present or caps are flush against axes edge - lines = all(isinstance(obj, mlines.Line2D) for obj in iobjs) - if lines and not getattr(self, '_no_sticky_edges', False): - for obj in iobjs: - data = getattr(obj, 'get_' + sx + 'data')() - if not data.size: - continue - convert = getattr(self, 'convert_' + sx + 'units') - edges = getattr(obj.sticky_edges, sx) - edges.append(convert(min(data))) - edges.append(convert(max(data))) - - objs.extend(iobjs) - - return tuple(objs) - - -def _plot_extras(self, *args, **kwargs): - """ - Pre-processing for `plot`. - """ - return _apply_plot(self, *args, **kwargs) - - -def _plotx_extras(self, *args, **kwargs): - """ - Pre-processing for `plotx`. - """ - # NOTE: The 'horizontal' orientation will be inferred by downstream - # wrappers using the function name. - return _apply_plot(self, *args, **kwargs) - - -def _stem_extras( - self, *args, linefmt=None, basefmt=None, markerfmt=None, **kwargs -): - """ - Make `use_line_collection` the default to suppress 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. - method = kwargs.pop('_method') - fmts = (linefmt, basefmt, markerfmt) - if not any(isinstance(_, str) and re.match(r'\AC[0-9]', _) for _ in fmts): - 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'] = 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 method(self, *args, **kwargs) - except TypeError: - del kwargs['use_line_collection'] # older version - return method(self, *args, **kwargs) - - -def _parametric_extras(self, x, y, c=None, *, values=None, interp=0, **kwargs): - """ - Interpolate the array. - """ - # Parse input - # NOTE: Critical to put this here instead of parametric() so that the - # interpolated 'values' are used to select colormap levels in apply_cmap. - method = kwargs.pop('_method') - c = _not_none(c=c, values=values) - if c is None: - raise ValueError('Values must be provided.') - c = _to_ndarray(c) - ndim = tuple(_.ndim for _ in (x, y, c)) - size = tuple(_.size for _ in (x, y, c)) - if any(_ != 1 for _ in ndim): - raise ValueError(f'Input coordinates must be 1D. Instead got dimensions {ndim}.') # noqa: E501 - if any(_ != size[0] for _ in size): - raise ValueError(f'Input coordinates must have identical size. Instead got sizes {size}.') # noqa: E501 - - # 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. - # NOTE: The 'extras' wrapper handles input before ingestion by other wrapper - # functions. *This* method is analogous to a native matplotlib method. - if interp > 0: - x_orig, y_orig, v_orig = x, y, c - x, y, c = [], [], [] - for j in range(x_orig.shape[0] - 1): - idx = slice(None) - if j + 1 < x_orig.shape[0] - 1: - idx = slice(None, -1) - x.extend(np.linspace(x_orig[j], x_orig[j + 1], interp + 2)[idx].flat) - y.extend(np.linspace(y_orig[j], y_orig[j + 1], interp + 2)[idx].flat) - c.extend(np.linspace(v_orig[j], v_orig[j + 1], interp + 2)[idx].flat) # noqa: E501 - x, y, c = np.array(x), np.array(y), np.array(c) - - return method(self, x, y, values=c, **kwargs) - - -def _check_negpos(name, **kwargs): - """ - Issue warnings if we are ignoring arguments for "negpos" pplt. - """ - for key, arg in kwargs.items(): - if arg is None: - continue - warnings._warn_proplot( - f'{name}() argument {key}={arg!r} is incompatible with ' - 'negpos=True. Ignoring.' - ) - -def _apply_lines( - self, *args, - stack=None, stacked=None, - negpos=False, negcolor=None, poscolor=None, - color=None, colors=None, - linestyle=None, linestyles=None, - lw=None, linewidth=None, linewidths=None, - **kwargs -): - """ - Apply hlines or vlines command. Support default "minima" at zero. - """ - # Parse input arguments - method = kwargs.pop('_method') - name = method.__name__ - stack = _not_none(stack=stack, stacked=stacked) - colors = _not_none(color=color, colors=colors) - linestyles = _not_none(linestyle=linestyle, linestyles=linestyles) - linewidths = _not_none(lw=lw, linewidth=linewidth, linewidths=linewidths) - args = list(args) - if len(args) > 3: - raise ValueError(f'Expected 1-3 positional args, got {len(args)}.') - if len(args) == 3 and stack: - warnings._warn_proplot( - f'{name}() cannot have three positional arguments with stack=True. ' - 'Ignoring second argument.' - ) - if len(args) == 2: # empty possible - args.insert(1, np.array([0.0])) # default base - - # Support "negative" and "positive" lines - x, y1, y2, *args = args # standardized - if not negpos: - # Plot basic lines - kwargs['stack'] = stack - if colors is not None: - kwargs['colors'] = colors - result = method(self, x, y1, y2, *args, **kwargs) - objs = (result,) - else: - # Plot negative and positive colors - _check_negpos(name, stack=stack, colors=colors) - y1neg, y2neg = _mask_array(y2 < y1, y1, y2) - color = _not_none(negcolor, rc['negcolor']) - negobj = method(self, x, y1neg, y2neg, color=color, **kwargs) - y1pos, y2pos = _mask_array(y2 >= y1, y1, y2) - color = _not_none(poscolor, rc['poscolor']) - posobj = method(self, x, y1pos, y2pos, color=color, **kwargs) - objs = result = (negobj, posobj) - - # Apply formatting unavailable in matplotlib - for obj in objs: - if linewidths is not None: - obj.set_linewidth(linewidths) # LineCollection setters - if linestyles is not None: - obj.set_linestyle(linestyles) - - return result - - -@docstring.add_snippets -def vlines_extras(self, *args, **kwargs): - """ - %(axes.vlines)s - """ - return _apply_lines(self, *args, **kwargs) - - -@docstring.add_snippets -def hlines_extras(self, *args, **kwargs): - """ - %(axes.hlines)s - """ - # NOTE: The 'horizontal' orientation will be inferred by downstream - # wrappers using the function name. - return _apply_lines(self, *args, **kwargs) - - -def _apply_scatter( - self, *args, - vmin=None, vmax=None, smin=None, smax=None, - cmap=None, cmap_kw=None, norm=None, norm_kw=None, - extend='neither', levels=None, N=None, values=None, - locator=None, locator_kw=None, discrete=None, - symmetric=False, positive=False, negative=False, nozero=False, inbounds=None, - **kwargs -): - """ - Apply scatter or scatterx markers. Permit up to 4 positional arguments - including `s` and `c`. - """ - # Manage input arguments - method = kwargs.pop('_method') - props = _pop_props(kwargs, 'lines') - c = props.pop('color', None) - s = props.pop('markersize', None) - args = list(args) - if len(args) > 4: - raise ValueError(f'Expected 1-4 positional arguments, got {len(args)}.') - if len(args) == 4: - c = _not_none(c_positional=args.pop(-1), c=c) - if len(args) == 3: - s = _not_none(s_positional=args.pop(-1), s=s) - - # Get colormap - cmap_kw = cmap_kw or {} - if cmap is not None: - cmap_kw.setdefault('luminance', 90) # matches to_listed behavior - cmap = constructor.Colormap(cmap, **cmap_kw) - - # Get normalizer and levels - ticks = None - carray = np.atleast_1d(c) - discrete = _not_none( - getattr(self, '_image_discrete', None), - discrete, - rc['image.discrete'], - True - ) - if ( - discrete and np.issubdtype(carray.dtype, np.number) - and not (carray.ndim == 2 and carray.shape[1] in (3, 4)) + # Compute error data in format that can be passed to maxes.Axes.errorbar() + # NOTE: Include option to pass symmetric deviation from central points + if errdata is not None: + # Manual error data + if y.ndim != 1: + raise ValueError( + 'errdata with 2D y coordinates is not yet supported.' + ) + label_default = 'uncertainty' + err = _to_numpy_array(errdata) + if ( + err.ndim not in (1, 2) + or err.shape[-1] != y.size + 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: + # Standard deviations + label_default = fr'{abs(stds[1])}$\sigma$ range' + stds = _to_numpy_array(stds)[:, None] + err = y + stds * np.nanstd(distribution, axis=0) + elif pctiles is not None: + # Percentiles + label_default = f'{pctiles[1] - pctiles[0]}% range' + err = np.nanpercentile(distribution, pctiles, axis=0) + else: + raise ValueError('You must provide error bounds.') + + # Return label possibly + if label is True: + label = label_default + elif not label: + label = None + + # Make relative data for maxes.Axes.errorbar() ingestion + if not absolute: + err = err - y + err[0, :] *= -1 # absolute deviations from central points + + # Return data with legend entry + return err, label + + def _error_bars( + self, x, y, *_, distribution=None, default_bars=True, default_boxes=False, + barstd=None, barstds=None, barpctile=None, barpctiles=None, bardata=None, + boxstd=None, boxstds=None, boxpctile=None, boxpctiles=None, boxdata=None, + capsize=None, **kwargs, ): - carray = carray.ravel() - levels = _not_none(levels=levels, N=N) - norm, cmap, _, ticks = _build_discrete_norm( - self, carray, # sample data for getting suitable levels - levels=levels, values=values, - cmap=cmap, norm=norm, norm_kw=norm_kw, extend=extend, - vmin=vmin, vmax=vmax, locator=locator, locator_kw=locator_kw, - symmetric=symmetric, positive=positive, negative=negative, - nozero=nozero, inbounds=inbounds, - ) - - # Fix 2D arguments but still support scatter(x_vector, y_2d) usage - # NOTE: numpy.ravel() preserves masked arrays - # NOTE: Since we are flattening vectors the coordinate metadata is meaningless, - # so converting to ndarray and stripping metadata is no problem. - if len(args) == 2 and all(_to_ndarray(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) - - # Call function - obj = objs = method(self, *args, c=c, s=s, cmap=cmap, norm=norm, **props, **kwargs) - if not isinstance(objs, tuple): - objs = (obj,) - for iobj in objs: - iobj._colorbar_extend = extend - iobj._colorbar_ticks = ticks - - return obj - - -@docstring.add_snippets -def scatter_extras(self, *args, **kwargs): - """ - %(axes.scatter)s - """ - return _apply_scatter(self, *args, **kwargs) - - -@docstring.add_snippets -def scatterx_extras(self, *args, **kwargs): - """ - %(axes.scatterx)s - """ - # NOTE: The 'horizontal' orientation will be inferred by downstream - # wrappers using the function name. - return _apply_scatter(self, *args, **kwargs) - - -def _apply_fill_between( - self, *args, where=None, negpos=None, negcolor=None, poscolor=None, - lw=None, linewidth=None, color=None, facecolor=None, - stack=None, stacked=None, **kwargs -): - """ - Apply `fill_between` or `fill_betweenx` shading. Permit up to 4 - positional arguments including `where`. - """ - # Parse input arguments - method = kwargs.pop('_method') - name = method.__name__ - sx = 'y' if 'x' in name else 'x' # i.e. fill_betweenx - sy = 'x' if sx == 'y' else 'y' - stack = _not_none(stack=stack, stacked=stacked) - color = _not_none(color=color, facecolor=facecolor) - linewidth = _not_none(lw=lw, linewidth=linewidth, default=0) - args = list(args) - if len(args) > 4: - raise ValueError(f'Expected 1-4 positional args, got {len(args)}.') - if len(args) == 4: - where = _not_none(where_positional=args.pop(3), where=where) - if len(args) == 3 and stack: - warnings._warn_proplot( - f'{name}() cannot have three positional arguments with stack=True. ' - 'Ignoring second argument.' - ) - if len(args) == 2: # empty possible - args.insert(1, np.array([0.0])) # default base - - # Draw patches with default edge width zero - x, y1, y2 = args - kwargs['linewidth'] = linewidth - if not negpos: - # Plot basic patches - kwargs.update({'where': where, 'stack': stack}) - if color is not None: - kwargs['color'] = color - result = method(self, x, y1, y2, **kwargs) - objs = (result,) - else: - # Plot negative and positive patches - if y1.ndim > 1 or y2.ndim > 1: - raise ValueError(f'{name}() arguments with negpos=True must be 1D.') - kwargs.setdefault('interpolate', True) - _check_negpos(name, where=where, stack=stack, color=color) - color = _not_none(negcolor, rc['negcolor']) - negobj = method(self, x, y1, y2, where=(y2 < y1), facecolor=color, **kwargs) - color = _not_none(poscolor, rc['poscolor']) - posobj = method(self, x, y1, y2, where=(y2 >= y1), facecolor=color, **kwargs) - result = objs = (posobj, negobj) # may be tuple of tuples due to apply_cycle - - # 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. - # NOTE: Could also retrieve data from PolyCollection but that's tricky. - # NOTE: Standardize function guarantees ndarray input by now - if not getattr(self, '_no_sticky_edges', False): - xsides = (np.min(x), np.max(x)) - ysides = [] - for y in (y1, y2): - if y.size == 1: - ysides.append(y.item()) - objs = tuple(obj for _ in objs for obj in (_ if isinstance(_, tuple) else (_,))) - for obj in objs: - for s, sides in zip((sx, sy), (xsides, ysides)): - convert = getattr(self, 'convert_' + s + 'units') - edges = getattr(obj.sticky_edges, s) - edges.extend(convert(sides)) - - return result - - -@docstring.add_snippets -def fill_between_extras(self, *args, **kwargs): - """ - %(axes.fill_between)s - """ - return _apply_fill_between(self, *args, **kwargs) - - -@docstring.add_snippets -def fill_betweenx_extras(self, *args, **kwargs): - """ - %(axes.fill_betweenx)s - """ - # NOTE: The 'horizontal' orientation will be inferred by downstream - # wrappers using the function name. - return _apply_fill_between(self, *args, **kwargs) - - -def _convert_bar_width(x, width=1, ncols=1): - """ - Convert bar plot widths from relative to coordinate spacing. Relative - widths are much more convenient for users. - """ - # WARNING: This will fail for non-numeric non-datetime64 singleton - # datatypes but this is good enough for vast majority of cases. - 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]') - return width * x_step / ncols - - -def _apply_bar( - self, *args, stack=None, stacked=None, - lw=None, linewidth=None, color=None, facecolor=None, edgecolor='black', - negpos=False, negcolor=None, poscolor=None, absolute_width=False, **kwargs -): - """ - Apply bar or barh command. Support default "minima" at zero. - """ - # Parse args - # TODO: Stacked feature is implemented in `apply_cycle`, but makes more - # sense do document here. Figure out way to move it here? - method = kwargs.pop('_method') - name = method.__name__ - stack = _not_none(stack=stack, stacked=stacked) - color = _not_none(color=color, facecolor=facecolor) - linewidth = _not_none(lw=lw, linewidth=linewidth, default=rc['patch.linewidth']) - kwargs.update({'linewidth': linewidth, 'edgecolor': edgecolor}) - args = list(args) - if len(args) > 4: - raise ValueError(f'Expected 1-4 positional args, got {len(args)}.') - if len(args) == 4 and stack: - warnings._warn_proplot( - f'{name}() cannot have four positional arguments with stack=True. ' - 'Ignoring fourth argument.' # i.e. ignore default 'bottom' - ) - if len(args) == 2: - args.append(np.array([0.8])) # default width - if len(args) == 3: - args.append(np.array([0.0])) # default base - - # Call func after converting bar width - x, h, w, b = args - reduce = any(kwargs.get(s) for s in ('mean', 'means', 'median', 'medians')) - absolute_width = absolute_width or getattr(self, '_bar_absolute_width', False) - if not stack and not absolute_width: - ncols = 1 if reduce or h.ndim == 1 else h.shape[1] - w = _convert_bar_width(x, w, ncols=ncols) - if not negpos: - # Draw simple bars - kwargs['stack'] = stack - if color is not None: - kwargs['color'] = color - return method(self, x, h, w, b, **kwargs) - else: - # Draw negative and positive bars - _check_negpos(name, stack=stack, color=color) - if x.ndim > 1 or h.ndim > 1: - raise ValueError(f'{name}() arguments with negpos=True must be 1D.') - hneg = _mask_array(h < b, h) - color = _not_none(negcolor, rc['negcolor']) - negobj = method(self, x, hneg, w, b, facecolor=color, **kwargs) - hpos = _mask_array(h >= b, h) - color = _not_none(poscolor, rc['poscolor']) - posobj = method(self, x, hpos, w, b, facecolor=color, **kwargs) - return (negobj, posobj) - - -@docstring.add_snippets -def bar_extras(self, *args, **kwargs): - """ - %(axes.bar)s - """ - return _apply_bar(self, *args, **kwargs) + """ + Add up to 2 error indicators: thick "boxes" and thin "bars". + """ + # TODO: Do not add auto error bars if user enabled shading! + vert = self._get_vert(**kwargs) + bars = any(_ is not None for _ in (bardata, barstds, barpctiles)) + boxes = any(_ is not None for _ in (boxdata, boxstds, boxpctiles)) + barstds = _not_none(barstd=barstd, barstds=barstds) + boxstds = _not_none(boxstd=boxstd, boxstds=boxstds) + barpctiles = _not_none(barpctile=barpctile, barpctiles=barpctiles) + boxpctiles = _not_none(boxpctile=boxpctile, boxpctiles=boxpctiles) + if distribution is not None and not bars and not boxes: + barstds = default_bars + boxstds = default_boxes + + # Error bar properties + edgecolor = kwargs.get('edgecolor', 'k') # NOTE: should already be standardized + barprops = _pop_props(kwargs, 'line', ignore='marker', prefix='bar') + barprops['capsize'] = _not_none(capsize, rc['errorbar.capsize']) + barprops['linestyle'] = 'none' + barprops.setdefault('zorder', 2.5) + barprops.setdefault('linewidth', rc['boxplot.whiskerprops.linewidth']) + barprops.setdefault('color', edgecolor, rc['boxplot.whiskerprops.color']) + + # Error box properties + boxprops = _pop_props(kwargs, 'line', prefix='box') + boxprops['capsize'] = 0 + boxprops['linestyle'] = 'none' + boxprops.setdefault('zorder', barprops['zorder']) + boxprops.setdefault('linewidth', 4 * barprops['linewidth']) + boxprops.setdefault('color', barprops['color']) + boxmarker = {key: boxprops.pop(key) for key in tuple(boxprops) if 'marker' in key} # noqa: E501 + boxmarker['zorder'] = boxprops['zorder'] + if boxmarker.get('marker', None) is True: + boxmarker['marker'] = 'o' + + # Draw thin or thick error bars from distributions or explicit errdata + sy = 'y' if vert else 'x' # yerr + ex, ey = (x, y) if vert else (y, x) + eobjs = [] + if boxes: + edata, _ = self._error_data( + y, distribution=distribution, + stds=boxstds, pctiles=boxpctiles, errdata=boxdata, + stds_default=(-1, 1), pctiles_default=(25, 75), + ) + obj = self.errorbar(ex, ey, **barprops, **{sy + 'err': edata}) + if boxmarker.get('marker', None): + self.scatter(ex, ey, **boxmarker) + eobjs.append(obj) + if bars: # now impossible to make thin bar width different from cap width! + edata, _ = self._error_data( + y, distribution=distribution, + stds=barstds, pctiles=barpctiles, errdata=bardata, + stds_default=(-3, 3), pctiles_default=(0, 100), + ) + obj = self.errorbar(ex, ey, **boxprops, **{sy + 'err': edata}) + eobjs.append(obj) + return *eobjs, kwargs -@docstring.add_snippets -def barh_extras(self, *args, **kwargs): - """ - %(axes.barh)s - """ - return _apply_bar(self, *args, **kwargs) - - -def boxplot_extras( - self, *args, - mean=None, means=None, - fill=True, fillcolor=None, fillalpha=None, - lw=None, linewidth=None, - color=None, edgecolor=None, - boxcolor=None, boxlw=None, boxlinewidth=None, - capcolor=None, caplw=None, caplinewidth=None, - whiskercolor=None, whiskerlw=None, whiskerlinewidth=None, - fliercolor=None, flierlw=None, flierlinewidth=None, # fliers have no line width - meancolor=None, meanlw=None, meanlinewidth=None, - mediancolor=None, medianlw=None, medianlinewidth=None, - meanls=None, meanlinestyle=None, medianls=None, medianlinestyle=None, - marker=None, markersize=None, - **kwargs -): - """ - Support convenient keyword arguments and change the default boxplot style. - - Important - --------- - This function wraps `~matplotlib.axes.Axes.boxplot`. - - Parameters - ---------- - *args : 1D or 2D ndarray - The data array. - vert : bool, optional - If ``False``, box plots are drawn horizontally. Otherwise, box plots - are drawn vertically. - orientation : {{None, 'vertical', 'horizontal'}}, optional - Alternative to the native `vert` keyword arg. Added for - consistency with the rest of matplotlib. - mean, means : bool, optional - If ``True``, this passes ``showmeans=True`` and ``meanline=True`` to - `~matplotlib.axes.Axes.boxplot`. - fill : bool, optional - Whether to fill the box with a color. - fillcolor : color-spec, list, optional - The fill color for the boxes. Default is the next color cycler color. If - a list, it should be the same length as the number of objects. - fillalpha : float, optional - The opacity of the boxes. Default is ``0.7``. If a list, should be - the same length as the number of objects. - lw, linewidth : float, optional - The linewidth of all objects. Default is ``0.8``. - color, edgecolor : color-spec, list, optional - The color of all objects. Defalut is ``'black'``. If a list, it should - be the same length as the number of objects. - meanls, medianls, meanlinestyle, medianlinestyle : line style-spec, optional - The line style for the mean and median lines drawn horizontally - across the box. - boxcolor, capcolor, whiskercolor, fliercolor, meancolor, mediancolor \ -: color-spec, list, optional - The color of various boxplot components. If a list, it should be the - same length as the number of objects. These are shorthands so you don't - have to pass e.g. a ``boxprops`` dictionary. - boxlw, caplw, whiskerlw, flierlw, meanlw, medianlw, boxlinewidth, caplinewidth, \ -meanlinewidth, medianlinewidth, whiskerlinewidth, flierlinewidth : float, optional - The line width of various boxplot components. These are shorthands so - you don't have to pass e.g. a ``boxprops`` dictionary. - marker : marker-spec, optional - Marker style for the 'fliers', i.e. outliers. - markersize : float, optional - Marker size for the 'fliers', i.e. outliers. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.boxplot`. - - See also - -------- - matplotlib.axes.Axes.boxplot - proplot.axes.Axes.boxes - standardize_1d - indicate_error - apply_cycle - """ - # Parse keyword args - # NOTE: For some reason native violinplot() uses _get_lines for - # property cycler. We dot he same here. - method = kwargs.pop('_method') - fill = fill is True or fillcolor is not None or fillalpha is not None - fillalpha = _not_none(fillalpha, default=0.7) - if fill and fillcolor is None: - cycler = next(self._get_lines.prop_cycler) - fillcolor = cycler.get('color', None) - color = _not_none(color=color, edgecolor=edgecolor, default='black') - linewidth = _not_none(lw=lw, linewidth=linewidth, default=0.8) - boxlinewidth = _not_none(boxlw=boxlw, boxlinewidth=boxlinewidth) - caplinewidth = _not_none(caplw=caplw, caplinewidth=caplinewidth) - whiskerlinewidth = _not_none(whiskerlw=whiskerlw, whiskerlinewidth=whiskerlinewidth) - flierlinewidth = _not_none(flierlw=flierlw, flierlinewidth=flierlinewidth) - meanlinewidth = _not_none(meanlw=meanlw, meanlinewidth=meanlinewidth) - medianlinewidth = _not_none(medianlw=medianlw, medianlinewidth=medianlinewidth) - meanlinestyle = _not_none(meanls=meanls, meanlinestyle=meanlinestyle) - medianlinestyle = _not_none(medianls=medianls, medianlinestyle=medianlinestyle) - means = _not_none(mean=mean, means=means, showmeans=kwargs.get('showmeans')) - if means: - kwargs['showmeans'] = kwargs['meanline'] = True - - # Call function - obj = method(self, *args, **kwargs) - - # Modify artist settings - # TODO: Pass props keyword args instead? Maybe does not matter. - for key, icolor, ilinewidth, ilinestyle in ( - ('boxes', boxcolor, boxlinewidth, None), - ('caps', capcolor, caplinewidth, None), - ('whiskers', whiskercolor, whiskerlinewidth, None), - ('fliers', fliercolor, flierlinewidth, None), - ('means', meancolor, meanlinewidth, meanlinestyle), - ('medians', mediancolor, medianlinewidth, medianlinestyle), + def _error_shading( + self, x, y, *_, distribution=None, infer_lines=False, + shadestd=None, shadestds=None, shadepctile=None, shadepctiles=None, shadedata=None, # noqa: E501 + fadestd=None, fadestds=None, fadepctile=None, fadepctiles=None, fadedata=None, + shadelabel=False, fadelabel=False, **kwargs ): - if key not in obj: # possible if not rendered - continue - artists = obj[key] - icolor = _not_none(icolor, color) - ilinewidth = _not_none(ilinewidth, linewidth) - if not isinstance(fillalpha, list): - fillalpha = [fillalpha] * len(artists) - if not isinstance(fillcolor, list): - fillcolor = [fillcolor] * len(artists) - for i, artist in enumerate(artists): - # Lines used for boxplot components - jcolor = icolor - if isinstance(icolor, list): - jcolor = icolor[i // 2 if key in ('caps', 'whiskers') else i] - if ilinestyle is not None: - artist.set_linestyle(ilinestyle) - if ilinewidth is not None: - artist.set_linewidth(ilinewidth) - artist.set_markeredgewidth(ilinewidth) - if jcolor is not None: - artist.set_color(jcolor) - artist.set_markeredgecolor(jcolor) - # "Filled" boxplot by adding patch beneath line path - if fill and key == 'boxes': - patch = mpatches.PathPatch( - artist.get_path(), linewidth=0, - facecolor=fillcolor[i], alpha=fillalpha[i] - ) - self.add_artist(patch) - # Outlier markers - 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_extras( - self, *args, fillcolor=None, fillalpha=None, - lw=None, linewidth=None, color=None, edgecolor=None, **kwargs -): - """ - Support convenient keyword arguments and change 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. - - Important - --------- - This function wraps `~matplotlib.axes.Axes.violinplot`. - - Parameters - ---------- - *args : 1D or 2D ndarray - The data array. - vert : bool, optional - If ``False``, violin plots are drawn horizontally. Otherwise, - violin plots are drawn vertically. - orientation : {{None, 'vertical', 'horizontal'}}, optional - Alternative to the native `vert` keyword arg. - Added for consistency with the rest of matplotlib. - fillcolor : color-spec, list, optional - The violin plot fill color. Default is the next color cycler color. If - a list, it should be the same length as the number of objects. - fillalpha : float, optional - The opacity of the violins. Default is ``0.7``. If a list, it - should be the same length as the number of objects. - lw, linewidth : float, optional - The linewidth of the line objects. Default is ``0.8``. - color, edgecolor : color-spec, list, optional - The edge color for the violin patches. Default is ``'black'``. If a - list, it should be the same length as the number of objects. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.violinplot`. - - See also - -------- - matplotlib.axes.Axes.violinplot - proplot.axes.Axes.violins - standardize_1d - indicate_error - apply_cycle - """ - # Parse keyword args - method = kwargs.pop('_method') - color = _not_none(color=color, edgecolor=edgecolor, default='black') - linewidth = _not_none(lw=lw, linewidth=linewidth, default=0.8) - fillalpha = _not_none(fillalpha, default=0.7) - kwargs.setdefault('capsize', 0) # caps are redundant for violin plots - kwargs.setdefault('means', kwargs.pop('showmeans', None)) # for indicate_error - kwargs.setdefault('medians', kwargs.pop('showmedians', None)) - if kwargs.pop('showextrema', None): - warnings._warn_proplot('Ignoring showextrema=True.') - - # Call function - kwargs.update({'showmeans': False, 'showmedians': False, 'showextrema': False}) - obj = result = method(self, *args, linewidth=linewidth, **kwargs) - if not args: - return result - - # Modify body settings - if isinstance(obj, (list, tuple)): - obj = obj[0] - artists = obj['bodies'] - if not isinstance(fillalpha, list): - fillalpha = [fillalpha] * len(artists) - if not isinstance(fillcolor, list): - fillcolor = [fillcolor] * len(artists) - if not isinstance(color, list): - color = [color] * len(artists) - for i, artist in enumerate(artists): - artist.set_linewidths(linewidth) - if fillalpha[i] is not None: - artist.set_alpha(fillalpha[i]) - if fillcolor[i] is not None: - artist.set_facecolor(fillcolor[i]) - if color[i] is not None: - artist.set_edgecolor(color[i]) - - 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', None) == 'proplot_cartopy' - if ( - isinstance(transform, mtransforms.Transform) - or CRS and isinstance(transform, CRS) + """ + Add up to 2 error indicators: more opaque "shading" and less opaque "fading". + """ + vert = self._get_vert(**kwargs) + shadestds = _not_none(shadestd=shadestd, shadestds=shadestds) + fadestds = _not_none(fadestd=fadestd, fadestds=fadestds) + shadepctiles = _not_none(shadepctile=shadepctile, shadepctiles=shadepctiles) + fadepctiles = _not_none(fadepctile=fadepctile, fadepctiles=fadepctiles) + shade = any(_ is not None for _ in (shadedata, shadestds, shadepctiles)) + fade = any(_ is not None for _ in (fadedata, fadestds, fadepctiles)) + + # Shading properties + shadeprops = _pop_props(kwargs, 'patch', prefix='shade') + shadeprops.setdefault('alpha', 0.4) + shadeprops.setdefault('zorder', 1.5) + shadeprops.setdefault('linewidth', 0) + shadeprops.setdefault('edgecolor', 'face') # infer from face + if shade and shadeprops.get('facecolor', None) is None: + # Retreive color then apply to outgoing keyword args so that + # plotting function will not advance to next cycle color. + key = 'color' if infer_lines else 'facecolor' + parser = self._get_lines if infer_lines else self._get_patches_for_fill + shadeprops['facecolor'] = kwargs[key] = parser.get_next_color() + # Fading properties + fadeprops = _pop_props(kwargs, 'patch', prefix='fade') + fadeprops.setdefault('linewidth', shadeprops['linewidth']) + fadeprops.setdefault('alpha', 0.5 * shadeprops['alpha']) + fadeprops.setdefault('zorder', shadeprops['zorder']) + fadeprops.setdefault('facecolor', fadeprops['facecolor']) + fadeprops.setdefault('edgecolor', 'face') + + # Draw dark and light shading from distributions or explicit errdata + eobjs = [] + fill = self.fill_between if vert else self.fill_betweenx + if fade: + edata, label = self._error_data( + y, distribution=distribution, + stds=fadestds, pctiles=fadepctiles, errdata=fadedata, + stds_default=(-3, 3), pctiles_default=(0, 100), + label=fadelabel, absolute=True, + ) + eobj = fill(x, *edata, label=label, **shadeprops) + eobjs.append(eobj) + if shade: + edata, label = self._error_data( + y, distribution=distribution, + stds=shadestds, pctiles=shadepctiles, errdata=shadedata, + stds_default=(-2, 2), pctiles_default=(10, 90), + label=shadelabel, absolute=True, + ) + eobj = fill(x, *edata, label=label, **fadeprops) + eobjs.append(eobj) + + return *eobjs, kwargs + + def _get_vert(vert=None, orientation=None, **kwargs): # noqa: U100 + """ + Get the orientation specified as either `vert` or `orientation`. + """ + if vert is not None: + return vert + elif orientation is not None: + return orientation != 'horizontal' # should already be sanitized + + @staticmethod + def _parse_vert( + vert=None, orientation=None, + default_vert=None, default_orientation=None, + **kwargs ): - 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" and "bbox" properties to text objects without - wrapping the entire class. Overrides update to facilitate updating inset titles. - """ - props = props.copy() # shallow copy - - # Update border - 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()], - }) - elif border is False: - self.update({ - 'path_effects': None, - }) - - # Update bounding box - # NOTE: We use '_title_pad' and '_title_above' for both titles and a-b-c labels - # because always want to keep them aligned. - # NOTE: For some reason using pad / 10 results in perfect alignment. Matplotlib - # docs are vague about bounding box units, maybe they are tens of points? - bbox = props.pop('bbox', None) - bboxcolor = props.pop('bboxcolor', 'w') - bboxstyle = props.pop('bboxstyle', 'round') - bboxalpha = props.pop('bboxalpha', 0.5) - bboxpad = _not_none(props.pop('bboxpad', None), self.axes._title_pad / 10) - if isinstance(bbox, dict): # *native* matplotlib usage - props['bbox'] = bbox - elif bbox: - self.set_bbox({ - 'edgecolor': 'black', - 'facecolor': bboxcolor, - 'boxstyle': bboxstyle, - 'alpha': bboxalpha, - 'pad': bboxpad, - }) - elif bbox is False: - self.set_bbox(None) # disables the bbox - - return type(self).update(self, props) - - -def text_extras( - self, x=0, y=0, s='', transform='data', *, - family=None, fontfamily=None, fontname=None, fontsize=None, size=None, - border=False, bordercolor='w', borderwidth=2, borderinvert=False, - bbox=False, bboxcolor='w', bboxstyle='round', bboxalpha=0.5, bboxpad=None, **kwargs -): - """ - Support specifying the coordinate `tranform` with a string name and - drawing white borders and bounding boxes around the text. - - Important - --------- - This function wraps {methods} - - Parameters - ---------- - x, y : float - The *x* and *y* coordinates for the text. - s : 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 corresponding to - `~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. - bbox : bool, optional - Whether to draw a bounding box around text. - bboxcolor : color-spec, optional - The color of the text bounding box. Default is ``'w'``. - bboxstyle : boxstyle, optional - The style of the bounding box. Default is ``'round'``. - bboxalpha : float, optional - The alpha for the bounding box. Default is ``'0.5'``. - bboxpad : float, optional - The padding for the bounding box. Default is :rc:`title.bboxpad`. - - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.axes.Axes.text`. - - See also - -------- - 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. - method = kwargs.pop('_method') - fontsize = _not_none(fontsize, size) - fontfamily = _not_none(fontname, fontfamily, family) - if fontsize is not None: - if fontsize in mfonts.font_scalings: - fontsize = rc._scale_font(fontsize) - else: - 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 = method(self, x, y, s, transform=transform, **kwargs) - obj.update = _update_text.__get__(obj) - obj.update({ - 'border': border, - 'bordercolor': bordercolor, - 'borderinvert': borderinvert, - 'borderwidth': borderwidth, - 'bbox': bbox, - 'bboxcolor': bboxcolor, - 'bboxstyle': bboxstyle, - 'bboxalpha': bboxalpha, - 'bboxpad': bboxpad, - }) - return obj - - -def _iter_objs_labels(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. - label = _get_label(objs) - if label: - yield (objs, label) - elif isinstance(objs, list): - for obj in objs: - yield from _iter_objs_labels(obj) - - -def _update_cycle(self, cycle, scatter=False, **kwargs): - """ - Try to update the `~cycler.Cycler` without resetting it if it has not changed. - Also return keys that should be explicitly iterated over for commands that - otherwise don't use the property cycler (currently just scatter). - """ - # Get the original property cycle - # NOTE: Matplotlib saves itertools.cycle(cycler), not the original - # cycler object, so we must build up the keys again. - # 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! - 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) - - # 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) # updates both _get_lines and _get_patches_for_fill - - # Psuedo-expansion of matplotlib's property cycling for scatter(). Return dict - # of cycle keys and translated scatter() keywords for those not specified by user - # NOTE: This is similar to _process_plot_var_args._getdefaults but want to rely - # minimally on private API. - # NOTE: By default matplotlib uses the property cycler in _get_patches_for_fill - # for scatter() plots. It also only inherits color from that cycler. We instead - # use _get_lines with scatter() to help overarching goal of unifying plot() and - # scatter(). Now shading/bars loop over one cycle, plot/scatter along another. - apply_manually = {} # which keys to apply from property cycler - if scatter: + """ + Interpret both 'vert' and 'orientation' and add to keyword args + if a default is provided. + """ + # NOTE: Users should only pass these to hist, boxplot, or violinplot. To change + # the plot, scatter, area, or bar orientation users should use the differently + # named functions. Internally, however, they use these keyword args. + if default_vert is not None: + kwargs['vert'] = _not_none( + vert=vert, + orientation=None if orientation is None else orientation == 'vertical', + default=default_vert, + ) + if default_orientation is not None: + kwargs['orientation'] = _not_none( + orientation=orientation, + vert=None if vert is None else 'vertical' if vert else 'horizontal', + default=default_orientation, + ) + if kwargs.get('orientation', None) not in (None, 'horizontal', 'vertical'): + raise ValueError("Orientation must be either 'horizontal' or 'vertical'.") + return kwargs + + def _from_cycle(self, name, **kwargs): + """ + Return the properties that should be used by cycler. + """ + # Return dict of cycle props and function keys for those unspecified by user + # NOTE: Matplotlib uses the property cycler in _get_patches_for_fill for + # scatter() plots. It only ever inherits color from that cycler. We instead + # use _get_lines to help overarching goal of unifying plot() and scatter(). + # Now shading/bars loop over one cycle, plot/scatter along another. parser = self._get_lines # the _process_plot_var_args instance prop_keys = set(parser._prop_keys) - {'color', 'linestyle', 'dashes'} - for prop, key in ( - ('markersize', 's'), - ('linewidth', 'linewidths'), - ('markeredgewidth', 'linewidths'), - ('markeredgecolor', 'edgecolors'), - ('alpha', 'alpha'), - ('marker', 'marker'), - ): - value = kwargs.get(key, None) # a apply_cycle argument - if prop in prop_keys and value is None: # if key in cycler and prop unset - apply_manually[prop] = key - - return apply_manually # set indicating additional keys we cycle through - - -def apply_cycle( - self, *args, - cycle=None, cycle_kw=None, - label=None, labels=None, values=None, - legend=None, legend_kw=None, - colorbar=None, colorbar_kw=None, - **kwargs -): - """ - Support on-the-fly creation and application of property cycles, and support - on-the-fly legends and colorbars. - - Important - --------- - This function wraps {methods} - - 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 the 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 the call - to `~proplot.axes.Axes.colorbar`. - - Other parameters - ---------------- - *args, **kwargs - Passed to the matplotlib plotting method. - - See also - -------- - standardize_1d - indicate_error - proplot.constructor.Cycle - proplot.constructor.Colors - """ - # Parse input arguments - # NOTE: Requires standardize_1d wrapper before reaching this. - method = kwargs.pop('_method') - errobjs = kwargs.pop('_errobjs', None) - name = method.__name__ - plot = name in ('plot',) - scatter = name in ('scatter',) - fill = name in ('fill_between', 'fill_betweenx') - bar = name in ('bar', 'barh') - lines = name in ('vlines', 'hlines') - box = name in ('boxplot', 'violinplot') - violin = name in ('violinplot',) - hist = name in ('hist',) - pie = name in ('pie',) - cycle_kw = cycle_kw or {} - legend_kw = legend_kw or {} - colorbar_kw = colorbar_kw or {} - labels = _not_none(label=label, values=values, labels=labels) - - # Special cases - # TODO: Support stacking vlines/hlines because it would be really easy - x, y, *args = args - stack = False - if lines or fill or bar: - stack = kwargs.pop('stack', False) - elif stack in kwargs: - raise TypeError(f"{name}() got unexpected keyword argument 'stack'.") - if fill or lines: - ncols = max(1 if iy.ndim == 1 else iy.shape[1] for iy in (y, args[0])) - else: - ncols = 1 if pie or box or y.ndim == 1 else y.shape[1] - - # Update the property cycler - apply_manually = {} - if cycle is not None or cycle_kw: - if y.ndim > 1 and y.shape[1] > 1: # default samples count - cycle_kw.setdefault('N', y.shape[1]) + get_from_cycle = {} # which keys to apply from property cycler + for prop, key in CYCLE_ARGS_APPLY.get(name, {}).items(): + value = kwargs.get(key, None) # an _update_cycle argument + if prop in prop_keys and value is None: # key in cycler and prop unset + get_from_cycle[prop] = key + if get_from_cycle: + kwargs['get_from_cycle'] = get_from_cycle + return kwargs + + def _parse_cycle(self, N=None, cycle=None, cycle_kw=None, **kwargs): + """ + Update the property cycle. + """ + # Update the property cycler + # TODO: Automatically use appropriate 'N' + if cycle is None and not cycle_kw: + return kwargs + cycle_kw = cycle_kw or {} + cycle_kw.setdefault('N', _not_none(N, 10)) cycle_args = () if cycle is None else (cycle,) cycle = constructor.Cycle(*cycle_args, **cycle_kw) - apply_manually = _update_cycle(self, cycle, scatter=scatter, **kwargs) - - # Handle legend labels - if pie or box: - # Functions handle multiple labels on their own - # NOTE: Using boxplot() without this will overwrite labels previously - # set along the x-axis by _auto_format_1d. - if not violin and labels is not None: - kwargs['labels'] = _to_ndarray(labels) - else: - # Check and standardize labels + + # Get the original property cycle + # NOTE: Matplotlib saves an itertools.cycle(cycler) object under the _get_lines + # and _get_patches_for_fill parsers. We must build up the original input cycler + # by iterating over prop_cycler with next() then comparing those keys and values + # to the input cycler keys and values. Only reset if the keys and values differ + 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) # returns to original pos if they were identical + 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) + + # 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 key in by_key and by_key[key] != set(value): + reset = True + break + if reset: + self.set_prop_cycle(cycle) # update _get_lines and _get_patches_for_fill + + return kwargs + + def _iter_columns( + self, *args, label=None, labels=None, values=None, get_from_cycle=None, **kwargs + ): + """ + Retrieve the positional and keyword arguments for successively + calling the plotting function. + """ + # Handle legend labels # NOTE: Must convert to ndarray or can get singleton DataArrays + ncols = max(1 if _.ndim == 1 else _ for _ in args if isinstance(_, np.ndarray)) + labels = _not_none(label=label, values=values, labels=labels) if not np.iterable(labels) or isinstance(labels, str): labels = [labels] * ncols if len(labels) != ncols: - raise ValueError(f'Array has {ncols} columns but got {len(labels)} labels.') + raise ValueError(f'Array has {ncols} columns but got {len(labels)} labels.') # noqa: E501 if labels is not None: - labels = [str(_not_none(label, '')) for label in _to_ndarray(labels)] + labels = [str(_not_none(label, '')) for label in _to_numpy_array(labels)] # noqa: E501 else: labels = [None] * ncols - # Plot successive columns - objs = [] - for i in range(ncols): - # Property cycling for scatter plots - # NOTE: See comments in _update_cycle - ikwargs = kwargs.copy() - if apply_manually: - props = next(self._get_lines.prop_cycler) - for prop, key in apply_manually.items(): - ikwargs[key] = props[prop] - - # The x coordinates for bar plots - ix, iy, iargs = x, y, args.copy() - offset = 0 - if bar and not stack: - offset = iargs[0] * (i - 0.5 * (ncols - 1)) # 3rd positional arg is 'width' - ix = x + offset - - # The y coordinates for stacked plots - # NOTE: If stack=True then we always *ignore* second argument passed - # to area or lines. Warning should be issued by 'extras' wrappers. - if stack and ncols > 1: - if bar: - iargs[1] = iy[:, :i].sum(axis=1) # the new 'bottom' - else: - iy = iargs[0] # for vlines, hlines, area, arex - ys = (iy if iy.ndim == 1 else iy[:, :j].sum(axis=1) for j in (i, i + 1)) - iy, iargs[0] = ys - - # The y coordinates and labels - # NOTE: Support 1D x, 2D y1, and 2D y2 input - if not pie and not box: # only ever have one y value - iy, *iargs = ( - arg if not isinstance(arg, np.ndarray) or arg.ndim == 1 - else arg[:, i] for arg in (iy, *iargs) + # Plot successive columns + get_from_cycle = get_from_cycle or {} + for i in range(ncols): + # Keyword args for column + kw = kwargs.copy() + if get_from_cycle: + props = next(self._get_lines.prop_cycler) + for prop, key in get_from_cycle.items(): + kw[key] = props[prop] + kw['label'] = labels[i] or None + # Positional args for column + a = tuple( + a if not isinstance(a, np.ndaray) or a.ndim == 1 else a[:, i] + for a in args ) - ikwargs['label'] = labels[i] or None - - # Call function for relevant column - # NOTE: Should have already applied 'x' coordinates with keywords - # or as labels by this point for funcs where we omit them. Also note - # hist() does not pass kwargs to bar() so need this funky state context. - with _state_context(self, _bar_absolute_width=True, _no_sticky_edges=True): - if pie or box or hist: - obj = method(self, iy, *iargs, **ikwargs) - else: - obj = method(self, ix, iy, *iargs, **ikwargs) - if isinstance(obj, (list, tuple)) and len(obj) == 1: - obj = obj[0] - objs.append(obj) - - # Add colorbar - # NOTE: Colorbar will get the labels from the artists. Don't need to extract - # them because can't have multiple-artist entries like for legend() - if colorbar: - self.colorbar(objs, loc=colorbar, queue=True, **colorbar_kw) - - # Add legend - # NOTE: Put error bounds objects *before* line objects in the tuple - # so that line gets drawn on top of bounds. - # NOTE: If error objects have separate label, allocate separate legend entry. - # If they do not, try to combine with current legend entry. - if legend: - if not isinstance(errobjs, (list, tuple)): - errobjs = (errobjs,) - eobjs = [obj for obj in errobjs if obj and not _get_label(obj)] - hobjs = [(*eobjs[::-1], *objs)] if eobjs else objs.copy() - hobjs.extend(obj for obj in errobjs if obj and _get_label(obj)) - try: - hobjs, labels = list(zip(*_iter_objs_labels(hobjs))) - except ValueError: - hobjs = labels = () - self.legend(hobjs, labels, loc=legend, queue=True, **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 plot: - return tuple(objs) # always return tuple of objects - else: - return objs[0] if len(objs) == 1 else tuple(objs) - - -def _adjust_inbounds(self, x, y, z, *, centers=False): - """ - Adjust the smaple based on the axis limits. - """ - # Get masks - # TODO: Expand this to selecting x-limits giving scales y-limits, vice versa? - # NOTE: X and Y coordinates were sanitized by standardize_2d when passed here. - xmask = ymask = None - if any(_ is None or _.ndim not in (1, 2) for _ in (x, y, z)): - return z - if centers and z.ndim == 2: - x, y = _enforce_centers(x, y, z) - if not self.get_autoscalex_on(): - xlim = self.get_xlim() - xmask = (x >= xlim[0]) & (x <= xlim[1]) - if not self.get_autoscaley_on(): - ylim = self.get_ylim() - ymask = (y >= ylim[0]) & (y <= ylim[1]) - - # Subsample - if xmask is not None and ymask is not None: - z = z[np.ix_(ymask, xmask)] if z.ndim == 2 and xmask.ndim == 1 else z[ymask & xmask] # noqa: E501 - elif xmask is not None: - z = z[:, xmask] if z.ndim == 2 and xmask.ndim == 1 else z[xmask] - elif ymask is not None: - z = z[ymask, :] if z.ndim == 2 and ymask.ndim == 1 else z[ymask] - return z - - -def _auto_levels_locator( - self, *args, N=None, norm=None, norm_kw=None, extend='neither', - vmin=None, vmax=None, locator=None, locator_kw=None, - symmetric=False, positive=False, negative=False, nozero=False, - inbounds=None, centers=False, counts=False, -): - """ - Automatically generate level locations based on the input data, the - input locator, and the input normalizer. - - Parameters - ---------- - *args - The x, y, z, data. - N : int, optional - The (approximate) number of levels to create. - norm, norm_kw - Passed to `~proplot.constructor.Norm`. Used to determine suitable - level locations if `locator` is not passed. - extend : str, optional - The extend setting. - vmin, vmax : float, optional - The data limits. - locator, locator_kw - Passed to `~proplot.constructor.Locator`. Used to determine suitable - level locations. - symmetric, positive, negative : bool, optional - Whether the automatic levels should be symmetric, should be all positive - with a minimum at zero, or should be all negative with a maximum at zero. - nozero : bool, optional - Whether zero should be excluded from the levels. - inbounds : bool, optional - Whether to filter to in-bounds data. - centers : bool, optional - Whether to convert coordinates to 'centers'. - counts : bool, optional - Whether to guesstimate histogram counts rather than use data. - - Returns - ------- - levels : ndarray - The levels. - locator : ndarray or `matplotlib.ticker.Locator` - The locator used for colorbar tick locations. - """ - # Input args - norm_kw = norm_kw or {} - locator_kw = locator_kw or {} - inbounds = _not_none(inbounds, rc['image.inbounds']) - N = _not_none(N, rc['image.levels']) - - if np.iterable(N): - # Included so we can use this to apply positive, negative, nozero - levels = tick_locator = N - else: - # Get default locator based on input norm - norm = constructor.Norm(norm or 'linear', **norm_kw) - if positive and negative: - raise ValueError('Incompatible options: positive=True and negative=True.') - if locator is not None: - level_locator = tick_locator = constructor.Locator(locator, **locator_kw) - elif isinstance(norm, mcolors.LogNorm): - level_locator = tick_locator = mticker.LogLocator(**locator_kw) - elif isinstance(norm, mcolors.SymLogNorm): - locator_kw.setdefault('base', _getattr_flexible(norm, 'base', 10)) - locator_kw.setdefault('linthresh', _getattr_flexible(norm, 'linthresh', 1)) - level_locator = tick_locator = mticker.SymmetricalLogLocator(**locator_kw) + # Yield column + yield *a, kw + + def _auto_labels( + self, obj, *args, labels=False, labels_kw=None, + fmt=None, formatter=None, formatter_kw, precision=None, + ): + """ + Add automatic labels. + """ + # Apply labels + # TODO: Add quiverkey to this! + if not labels: + return + labels_kw = labels_kw or {} + formatter_kw = formatter_kw or {} + formatter = _not_none( + fmt_labels_kw=labels_kw.pop('fmt', None), + formatter_labels_kw=labels_kw.pop('formatter', None), + fmt=fmt, + formatter=formatter, + default='simple' + ) + formatter_kw.setdefault('precision', precision) + formatter = constructor.Formattter(formatter, **formatter_kw) + if isinstance(obj, mcontour.ContourSet): + self._label_contours(obj, *args, fmt=formatter, **labels_kw) + elif isinstance(obj, (mcollections.PolyCollection, mcollections.QuadMesh)): + self._label_gridboxes(obj, fmt=formatter, **labels_kw) + else: + raise RuntimeError('Not possible to add labels to object {obj!r}.') + + def _label_contours(self, obj, *args, fmt=None, **kwargs): + """ + Add labels to contours with support for shade-dependent filled contour labels + flexible keyword arguments. Requires original arguments passed to contour func. + """ + # Parse input args + text_kw = {} + for key in (*kwargs,): # allow dict to change size + if key not in ( + 'levels', 'colors', 'fontsize', 'inline', 'inline_spacing', + 'manual', 'rightside_up', 'use_clabeltext', + ): + text_kw[key] = kwargs.pop(key) + kwargs.setdefault('inline_spacing', 3) + kwargs.setdefault('fontsize', rc['text.labelsize']) + fmt = _not_none(fmt, pticker.SimpleFormatter()) + + # Draw hidden additional contour for filled contour labels + cobj = obj + colors = None + if _getattr_flexible(obj, 'filled'): # guard against changes? + cobj = self.contour(*args, levels=obj.levels, linewidths=0) + lums = (to_xyz(obj.cmap(obj.norm(level)), 'hcl')[2] for level in obj.levels) + colors = ['w' if lum < 50 else 'k' for lum in lums] + kwargs.setdefault('colors', colors) + + # Draw labels + labs = cobj.clabel(fmt=fmt, **kwargs) + if labs is not None: # returns None if no contours + for lab in labs: + lab.update(text_kw) + + return labs + + def _label_gridboxes(self, obj, fmt=None, **kwargs): + """ + Add labels to pcolor boxes with support for shade-dependent text colors. + """ + # Parse input args and populate _facecolors, which is initially unfilled + # See: https://stackoverflow.com/a/20998634/4970632 + fmt = _not_none(fmt, pticker.SimpleFormatter()) + labels_kw = {'size': rc['text.labelsize'], 'ha': 'center', 'va': 'center'} + labels_kw.update(kwargs) + obj.update_scalarmappable() # populate _facecolors + + # Get positions and contour colors + array = obj.get_array() + paths = obj.get_paths() + colors = _to_numpy_array(obj.get_facecolors()) + edgecolors = _to_numpy_array(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) + + # Apply colors + labs = [] + 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 kwargs: + _, _, lum = to_xyz(color, 'hcl') + if lum < 50: + color = 'w' + else: + color = 'k' + labels_kw['color'] = color + lab = self.text(x, y, fmt(num), **labels_kw) + labs.append(lab) + obj.set_edgecolors(edgecolors) + + return labs + + @staticmethod + def _fix_edges(obj, linewidth=0.3): + """ + Fix white lines between between filled contours and mesh and fix issues with + colormaps that are transparent. + """ + # See: https://github.com/jklymak/contourfIssues + # See: https://stackoverflow.com/q/15003353/4970632 + # 0.3pt is thick enough to hide lines but thin enough to not add "dots" + # in corner of pcolor plots so good compromise. + if not isinstance(obj, mcm.ScalarMappable): + return + cmap = obj.cmap + if not cmap._isinit: + cmap._init() + if all(cmap._lut[:-1, 3] == 1): # skip for cmaps with transparency + edgecolor = 'face' else: - nbins = N * 2 if positive or negative else N - locator_kw.setdefault('symmetric', symmetric or positive or negative) - level_locator = mticker.MaxNLocator(nbins, min_n_ticks=1, **locator_kw) - tick_locator = None + edgecolor = 'none' + # Contour fixes + # NOTE: This also covers TriContourSet returned by tricontour + if isinstance(obj, mcontour.ContourSet): + for contour in obj.collections: + contour.set_edgecolor(edgecolor) + contour.set_linewidth(linewidth) + contour.set_linestyle('-') + # Pcolor fixes + # NOTE: This ignores AxesImage and PcolorImage sometimes returned by pcolorfast + elif isinstance(obj, (mcollections.PolyCollection, mcollections.QuadMesh)): + if hasattr(obj, 'set_linewidth'): # not always true for pcolorfast + obj.set_linewidth(linewidth) + if hasattr(obj, 'set_edgecolor'): # not always true for pcolorfast + obj.set_edgecolor(edgecolor) + + @staticmethod + def _pop_unused_args(kwargs): + """ + Remove args specific to `_auto_vmin_vmax` and `_auto_levels_locator` + in case user passed their own levels and did not reach these blocks. + """ + # NOTE: 'nozero' only makes sense when we are creating levels without + # a colormap i.e. only in _auto_levels_locator. + for key in ( + 'vmin', 'vmax', 'locator', 'locator_kw', + 'symmetric', 'positive', 'negative', 'nozero', + 'counts', 'centers', 'inbounds', 'cmap_kw', 'norm_kw', + ): + value = kwargs.pop(key, None) + if value is not None: + warnings._warn_proplot(f'Ignoring unused argument {key}={value!r}.') + + def _adjust_inbounds(self, x, y, z, *, centers=False): + """ + Adjust the smaple based on the axis limits. + """ + # Get masks + # TODO: Expand this to selecting x-limits giving scales y-limits, vice versa? + # NOTE: X and Y coordinates were sanitized by _standardize_2d when passed here. + xmask = ymask = None + if any(_ is None or _.ndim not in (1, 2) for _ in (x, y, z)): + return z + if centers and z.ndim == 2: + x, y = self._require_centers(x, y, z) + if not self.get_autoscalex_on(): + xlim = self.get_xlim() + xmask = (x >= xlim[0]) & (x <= xlim[1]) + if not self.get_autoscaley_on(): + ylim = self.get_ylim() + ymask = (y >= ylim[0]) & (y <= ylim[1]) + + # Subsample + if xmask is not None and ymask is not None: + z = z[np.ix_(ymask, xmask)] if z.ndim == 2 and xmask.ndim == 1 else z[ymask & xmask] # noqa: E501 + elif xmask is not None: + z = z[:, xmask] if z.ndim == 2 and xmask.ndim == 1 else z[xmask] + elif ymask is not None: + z = z[ymask, :] if z.ndim == 2 and ymask.ndim == 1 else z[ymask] + return z - # Get level locations - # NOTE: Critical to use _to_arraylike here because some commands - # are unstandardized. - # NOTE: Try to get reasonable *count* levels for hexbin/hist2d, but in - # general have no way to select nice ones a priori which is why discrete - # is disabled by default. + def _auto_vmin_vmax( + self, *args, vmin=None, vmax=None, counts=False, centers=False, inbounds=None, + **kwargs, + ): + """ + Return a suitable colormap range based on the input data. + + Parameters + ---------- + *args + The x, y, z, data. + vmin, vmax : float, optional + The user input minimum and maximum. + inbounds : bool, optional + Whether to filter to in-bounds data. + centers : bool, optional + Whether to convert coordinates to 'centers'. + counts : bool, optional + Whether to guesstimate histogram counts rather than use data. + + Returns + ------- + vmin, vmax : float + The minimum and maximum. + """ + # Which bounds we need to get automin = vmin is None automax = vmax is None - if automin or automax: - # Get sample data - x = y = None - if len(args) < 3: - zs = map(_to_arraylike, args) + inbounds = _not_none(inbounds, rc['image.inbounds']) + if not automin and not automax: + return vmin, vmax, kwargs + + # Get sample data + # NOTE: Critical to use _to_array_like here because some commands + # are unstandardized. + # NOTE: Try to get reasonable *count* levels for hexbin/hist2d, but in general + # have no way to select nice ones a priori (why we disable discretenorm). + x = y = None + if len(args) < 3: + zs = map(_to_array_like, args) + else: + x, y, *zs = map(_to_array_like, args) + vmins, vmaxs = [], [] + for z in zs: + # Restrict to in-bounds data + # Use catch-all exception because it really isn't mission-critical. + if z.ndim > 2: + continue # 3D imshow plots will ignore the cmap we give it + if counts: + z = np.array([0, z.size]) // 10 + elif inbounds: + # WARNING: Experimental, seems robust but this is not + # mission-critical so keep this try-except clause for now. + try: + z = self._adjust_inbounds(x, y, z, centers=centers) + except Exception: + warnings._warn_proplot( + 'Failed to adjust bounds for automatic colormap normalization.' # noqa: E501 + ) + # Mask invalid data + z = ma.masked_invalid(z, copy=False) + if automin: + vmin = float(z.min()) + if automax: + vmax = float(z.max()) + if vmin == vmax or ma.is_masked(vmin) or ma.is_masked(vmax): + vmin, vmax = 0, 1 + vmins.append(vmin) + vmaxs.append(vmax) + + # Return + if vmins: + return min(vmins), max(vmaxs), kwargs + else: + return 0, 1, kwargs # fallback + + def _auto_levels_locator( + self, *args, levels=None, norm=None, norm_kw=None, extend='neither', + vmin=None, vmax=None, locator=None, locator_kw=None, + symmetric=False, positive=False, negative=False, nozero=False, **kwargs + ): + """ + Automatically generate level locations based on the input data, the + input locator, and the input normalizer. + + Parameters + ---------- + *args + The x, y, z, data. + levels : int, optional + The (approximate) number of levels to create. + norm, norm_kw + Passed to `~proplot.constructor.Norm`. Used to determine suitable + level locations if `locator` is not passed. + extend : str, optional + The extend setting. + vmin, vmax : float, optional + The data limits. + locator, locator_kw + Passed to `~proplot.constructor.Locator`. Used to determine suitable + level locations. + symmetric, positive, negative, nozero : bool, optional + Whether the automatic levels should be symmetric, should be all positive + with a minimum at zero, should be all negative with a maximum at zero, + or should exclude the value zero. + + Returns + ------- + levels : ndarray + The levels. + locator : ndarray or `matplotlib.ticker.Locator` + The locator used for colorbar tick locations. + """ + # Input args + # NOTE: Some of this is adapted from the hidden contour.ContourSet._autolev + norm_kw = norm_kw or {} + locator_kw = locator_kw or {} + levels = _not_none(levels, rc['image.levels']) + + if np.iterable(levels): + # Included so we can use this to apply positive, negative, nozero + levels = ticks = levels + else: + # Get default locator from input norm + norm = constructor.Norm(norm or 'linear', **norm_kw) + if positive and negative: + raise ValueError('Incompatible options positive=True and negative=True') + if locator is not None: + locator = ticks = constructor.Locator(locator, **locator_kw) + elif isinstance(norm, mcolors.LogNorm): + locator = ticks = mticker.LogLocator(**locator_kw) + elif isinstance(norm, mcolors.SymLogNorm): + locator_kw.setdefault('base', _getattr_flexible(norm, 'base', 10)) + locator_kw.setdefault('linthresh', _getattr_flexible(norm, 'linthresh', 1)) # noqa: E501 + locator = ticks = mticker.SymmetricalLogLocator(**locator_kw) else: - x, y, *zs = map(_to_arraylike, args) - vmins, vmaxs = [], [] - for z in zs: - # Restrict to in-bounds data - # Use catch-all exception because it really isn't mission-critical. - if z.ndim > 2: - continue # 3D imshow plots will ignore the cmap we give it - if counts: - z = np.array([0, z.size]) // 10 - elif inbounds: - # WARNING: Experimental, seems robust but this is not - # mission-critical so keep this try-except clause for now. - try: - z = _adjust_inbounds(self, x, y, z, centers=centers) - except Exception: - warnings._warn_proplot( - 'Failed to adjust bounds for automatic colormap normalization.' # noqa: E501 - ) - # Mask invalid data - z = ma.masked_invalid(z, copy=False) - if automin: - vmin = float(z.min()) - if automax: - vmax = float(z.max()) - if vmin == vmax or ma.is_masked(vmin) or ma.is_masked(vmax): - vmin, vmax = 0, 1 - vmins.append(vmin) - vmaxs.append(vmax) - if vmins: - vmin, vmax = min(vmins), max(vmaxs) + nbins = levels * 2 if positive or negative else levels + locator_kw.setdefault('symmetric', symmetric or positive or negative) + locator = mticker.MaxNLocator(nbins, min_n_ticks=1, **locator_kw) + ticks = None # do not necessarily prefer ticks at these levels' + + # Get default level locations + automin = vmin is None + automax = vmax is None + vmin, vmax, kwargs = self._auto_vmin_vmax( + *args, vmin=vmin, vmax=vmax, **kwargs + ) + levels_orig = levels + try: + levels = locator.tick_values(vmin, vmax) + except RuntimeError: # too-many-ticks error + levels = np.linspace(vmin, vmax, levels) # TODO: _autolev used N+1 + + # Trim excess levels the locator may have supplied + # NOTE: This part is mostly copied from matplotlib _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] + + # 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 = levels_orig // 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) + + # Filter the remaining contours + if nozero and 0 in levels: + levels = levels[levels != 0] + if positive: + levels = levels[levels >= 0] + if negative: + levels = levels[levels <= 0] + + # Use auto-generated levels for ticks if still None + ticks = _not_none(ticks, levels) + return levels, ticks, kwargs + + def _auto_discrete_norm( + self, *args, levels=None, values=None, cmap=None, norm=None, norm_kw=None, + extend='neither', vmin=None, vmax=None, minlength=2, **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 + ---------- + *args + The data. + cmap : `matplotlib.colors.Colormap`, optional + The colormap. Passed to `DiscreteNorm`. + norm, norm_kw + Passed to `~proplot.constructor.Norm` and then to `DiscreteNorm`. + extend : str, optional + The extend setting. + levels, N, values : ndarray, optional + The explicit boundaries. + vmin, vmax : float, optional + The minimum and maximum values for the normalizer. + minlength : int, optional + The minimum length for level lists. + **kwargs + Passed to `_auto_levels_locator`. + + 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 {} + levels = _not_none( + 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' + + # NOTE: Matplotlib colorbar algorithm *cannot* handle descending levels + # so this function reverses them and adds special attribute to the + # normalizer. Then colorbar_extras 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}.' + ) + + # Get level edges from level centers + locator = None + if isinstance(values, Integral): + levels = values + 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: - vmin, vmax = 0, 1 # simple default - try: - levels = level_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 matplotlib _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] - - # 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) - - # Filter the remaining contours - if nozero and 0 in levels: - levels = levels[levels != 0] - if positive: - levels = levels[levels >= 0] - if negative: - levels = levels[levels <= 0] - - # Use auto-generated levels for ticks if still None - return levels, _not_none(tick_locator, levels) - - -def _build_discrete_norm( - self, *args, levels=None, values=None, cmap=None, norm=None, norm_kw=None, - extend='neither', vmin=None, vmax=None, minlength=2, **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 - ---------- - *args - The data. - cmap : `matplotlib.colors.Colormap`, optional - The colormap. Passed to `DiscreteNorm`. - norm, norm_kw - Passed to `~proplot.constructor.Norm` and then to `DiscreteNorm`. - extend : str, optional - The extend setting. - levels, N, values : ndarray, optional - The explicit boundaries. - vmin, vmax : float, optional - The minimum and maximum values for the normalizer. - minlength : int, optional - The minimum length for level lists. - **kwargs - Passed to `_auto_levels_locator`. - - 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 {} - levels = _not_none( - 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' - - # NOTE: Matplotlib colorbar algorithm *cannot* handle descending levels - # so this function reverses them and adds special attribute to the - # normalizer. Then colorbar_extras 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]) - ): + inorm = constructor.Norm(norm, **norm_kw) + levels = inorm.inverse(edges(inorm(values))) + elif values is not None: raise ValueError( - f'{key!r} must be monotonically increasing or decreasing ' - f'and at least length {minlength}, got {val}.' + f'Unexpected input values={values!r}. ' + 'Must be integer or list of numbers.' ) - # Get level edges from level centers - locator = None - if isinstance(values, Integral): - levels = values + 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.' - ) + # Get default normalizer + # Only use LinearSegmentedNorm if necessary, because it is slow + descending = False + if np.iterable(levels): + if len(levels) == 1: + if norm is None: + norm = mcolors.Normalize(vmin=levels[0] - 1, vmax=levels[0] + 1) + else: + levels, descending = pcolors._check_levels(levels) + if norm is None 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' # same result with improved speed + else: + norm_kw['levels'] = levels + norm = constructor.Norm(norm or 'linear', **norm_kw) - # Get default normalizer - # Only use LinearSegmentedNorm if necessary, because it is slow - descending = False - if np.iterable(levels): - if len(levels) == 1: - if norm is None: - norm = mcolors.Normalize(vmin=levels[0] - 1, vmax=levels[0] + 1) - else: - levels, descending = pcolors._check_levels(levels) - if norm is None 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' # same result with improved speed + # Figure out correct levels + # 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 + # NOTE: No warning because we get here internally? + levels = norm.boundaries + elif np.iterable(values): + # Prefer ticks in center, but subsample if necessary + locator = _to_numpy_array(values) + elif np.iterable(levels): + # Prefer ticks on level edges, but subsample if necessary + locator = _to_numpy_array(levels) else: - norm_kw['levels'] = levels - norm = constructor.Norm(norm or 'linear', **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 - # NOTE: No warning because we get here internally? - levels = norm.boundaries - elif np.iterable(values): - # Prefer ticks in center, but subsample if necessary - locator = _to_ndarray(values) - elif np.iterable(levels): - # Prefer ticks on level edges, but subsample if necessary - locator = _to_ndarray(levels) - else: - # Determine levels automatically - levels, locator = _auto_levels_locator( - self, *args, N=levels, norm=norm, vmin=vmin, vmax=vmax, extend=extend, **kwargs # noqa: E501 - ) + # Determine levels automatically + levels, locator, kwargs = self._auto_levels_locator( + *args, levels=levels, norm=norm, vmin=vmin, vmax=vmax, extend=extend, **kwargs # noqa: E501 + ) - # 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, + # 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, unique=extend, + ) + if descending: + cmap = cmap.reversed() + return norm, cmap, levels, locator, kwargs + + @staticmethod + def _parse_labels( + labels=False, labels_kw=None, + fmt=None, formatter=None, precision=2, **kwargs + ): + """ + Parse arguments for grid box and contour labels. + """ + # Keyword args used with labels + kw_labels = { + 'labels': labels, + 'labels_kw': labels_kw or {}, + 'precision': precision, + 'formatter': formatter, + 'fmt': fmt, + } + return kw_labels, kwargs + + def _parse_edges(name, **kwargs): + """ + Parse arguments related to pcolor or contour 'edges'. + """ + add_contours = name in ('contourf', 'tricontourf') and ( + kwargs.get('linewidths') is not None or kwargs.get('linestyles') is not None + ) + no_cmap = kwargs.get('colors', None) is not None and ( + name in ('contour', 'triccontour') + or not add_contours and name in ('contourf', 'tricontourf') + ) + kwargs['no_cmap'] = no_cmap + if add_contours: + kwargs['contour_kw'] = { + 'colors': _not_none(kwargs.pop('colors', None), 'k'), + 'linewidths': kwargs.pop('linewidths', None), + 'linestyles': kwargs.pop('linestyles', None), + } + kwargs['edgefix'] = False + + return kwargs + + @warnings._rename_kwargs('0.6', centers='values') + @docstring.add_snippets + def _parse_cmap( + self, *args, + cmap=None, cmap_kw=None, norm=None, norm_kw=None, extend='neither', + N=None, levels=None, values=None, vmin=None, vmax=None, discrete=None, + no_cmap=False, keep_levels=False, keep_values=False, + default_cmap=None, default_discrete=True, **kwargs + ): + """ + Parse colormap-related arguments. + """ + # Parse keyword args + # NOTE: For now when drawing contour or contourf plots with no colormap, + # cannot use 'values' to specify level centers or level center count. + # NOTE: For now need to duplicate 'levels' parsing here and in + # _auto_discrete_norm so that it works with contour plots with no cmap. + cmap_kw = cmap_kw or {} + norm_kw = norm_kw or {} + norm_kw = norm_kw or {} + discrete = _not_none(discrete, rc['image.discrete'], default_discrete) + levels = _not_none( + N=N, + levels=levels, + norm_kw_levels=norm_kw.pop('levels', None), + default=rc['image.levels'] if discrete else None ) - if descending: - cmap = cmap.reversed() - return norm, cmap, levels, locator - -def _fix_white_lines(obj, linewidth=0.3): - """ - Fix white lines between between filled contours and mesh and fix issues with - colormaps that are transparent. - """ - # See: https://github.com/jklymak/contourfIssues - # See: https://stackoverflow.com/q/15003353/4970632 - # 0.3pt is thick enough to hide lines but thin enough to not add "dots" - # in corner of pcolor plots so good compromise. - if not hasattr(obj, 'cmap'): - return - cmap = obj.cmap - if not cmap._isinit: - cmap._init() - if all(cmap._lut[:-1, 3] == 1): # skip for cmaps with transparency - edgecolor = 'face' - else: - edgecolor = 'none' - # Contour fixes - # NOTE: This also covers TriContourSet returned by tricontour - if isinstance(obj, mcontour.ContourSet): - for contour in obj.collections: - contour.set_edgecolor(edgecolor) - contour.set_linewidth(linewidth) - contour.set_linestyle('-') - # Pcolor fixes - # NOTE: This ignores AxesImage and PcolorImage sometimes returned by pcolorfast - elif isinstance(obj, (mcollections.PolyCollection, mcollections.QuadMesh)): - if hasattr(obj, 'set_linewidth'): # not always true for pcolorfast - obj.set_linewidth(linewidth) - if hasattr(obj, 'set_edgecolor'): # not always true for pcolorfast - obj.set_edgecolor(edgecolor) - - -def _labels_contour(self, obj, *args, fmt=None, **kwargs): - """ - Add labels to contours with support for shade-dependent filled contour labels - flexible keyword arguments. Requires original arguments passed to contour function. - """ - # Parse input args - text_kw = {} - for key in (*kwargs,): # allow dict to change size - if key not in ( - 'levels', 'colors', 'fontsize', 'inline', 'inline_spacing', - 'manual', 'rightside_up', 'use_clabeltext', - ): - text_kw[key] = kwargs.pop(key) - kwargs.setdefault('inline_spacing', 3) - kwargs.setdefault('fontsize', rc['text.labelsize']) - fmt = _not_none(fmt, pticker.SimpleFormatter()) - - # Draw hidden additional contour for filled contour labels - cobj = obj - colors = None - if _getattr_flexible(obj, 'filled'): # guard against changes? - cobj = self.contour(*args, levels=obj.levels, linewidths=0) - lums = (to_xyz(obj.cmap(obj.norm(level)), 'hcl')[2] for level in obj.levels) - colors = ['w' if lum < 50 else 'k' for lum in lums] - kwargs.setdefault('colors', colors) - - # Draw labels - labs = cobj.clabel(fmt=fmt, **kwargs) - if labs is not None: # returns None if no contours - for lab in labs: - lab.update(text_kw) - - return labs - - -def _labels_pcolor(self, obj, fmt=None, **kwargs): - """ - Add labels to pcolor boxes with support for shade-dependent text colors. - """ - # Parse input args and populate _facecolors, which is initially unfilled - # See: https://stackoverflow.com/a/20998634/4970632 - fmt = _not_none(fmt, pticker.SimpleFormatter()) - labels_kw = {'size': rc['text.labelsize'], 'ha': 'center', 'va': 'center'} - labels_kw.update(kwargs) - obj.update_scalarmappable() # populate _facecolors - - # Get positions and contour colors - array = obj.get_array() - paths = obj.get_paths() - colors = _to_ndarray(obj.get_facecolors()) - edgecolors = _to_ndarray(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) - - # Apply colors - labs = [] - 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 kwargs: - _, _, lum = to_xyz(color, 'hcl') - if lum < 50: - color = 'w' - else: - color = 'k' - labels_kw['color'] = color - lab = self.text(x, y, fmt(num), **labels_kw) - labs.append(lab) - obj.set_edgecolors(edgecolors) - - return labs - - -@warnings._rename_kwargs('0.6', centers='values') -@docstring.add_snippets -def apply_cmap( - self, *args, - cmap=None, cmap_kw=None, norm=None, norm_kw=None, - extend='neither', levels=None, N=None, values=None, - vmin=None, vmax=None, locator=None, locator_kw=None, - symmetric=False, positive=False, negative=False, nozero=False, - discrete=None, edgefix=None, labels=False, labels_kw=None, fmt=None, precision=2, - inbounds=True, colorbar=False, colorbar_kw=None, **kwargs -): - """ - Support on-the-fly creation of colormaps and normalizers, universal discrete - colormap levels, on-the-fly text labels, and on-the-fly colorbars. - - Important - --------- - This function wraps {methods} - - Parameters - ---------- - %(axes.cmap_norm)s - %(axes.levels_values)s - %(axes.vmin_vmax)s - %(axes.auto_levels)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 the 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. - c, color, colors, ec, 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 - proplot.utils.edges - """ - method = kwargs.pop('_method') - name = method.__name__ - contour = name in ('contour', 'tricontour') - contourf = name in ('contourf', 'tricontourf') - pcolor = name in ('pcolor', 'pcolormesh', 'pcolorfast') - hexbin = name in ('hexbin',) - hist2d = name in ('hist2d',) - imshow = name in ('imshow', 'matshow', 'spy') - parametric = name in ('parametric',) - discrete = _not_none( - getattr(self, '_image_discrete', None), - discrete, - rc['image.discrete'], - not hexbin and not hist2d and not imshow - ) - - # Parse keyword args - # NOTE: For now when drawing contour or contourf plots with no colormap, - # cannot use 'values' to specify level centers or level center count. - # NOTE: For now need to duplicate 'levels' parsing here and in - # _build_discrete_norm so that it works with contour plots with no cmap. - 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 {} - norm_kw = norm_kw or {} - edgefix = _not_none(edgefix, rc['image.edgefix']) - props = _pop_props(kwargs, 'fills') - linewidths = props.get('linewidths', None) - linestyles = props.get('linestyles', None) - colors = props.get('colors', None) - levels = _not_none( - N=N, - levels=levels, - norm_kw_levels=norm_kw.pop('levels', None), - default=rc['image.levels'] if discrete else None - ) - - # 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/stable/api/_as_gen/matplotlib.axes.Axes.contourf - add_contours = contourf and (linewidths is not None or linestyles is not None) - use_cmap = colors is None or (not contour and (not contourf or add_contours)) - if not use_cmap: - if cmap is not None: - warnings._warn_proplot( - f'Ignoring input colormap cmap={cmap!r}, using input colors ' - f'colors={colors!r} instead.' - ) + # 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/stable/api/_as_gen/matplotlib.axes.Axes.contourf + if no_cmap: + if cmap is not None: + warnings._warn_proplot(f'Ignoring colormap cmap={cmap!r}.') cmap = None - if contourf: - 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) + else: + if cmap is None: + cmap = _not_none(default_cmap, rc['image.cmap']) + cmap = constructor.Colormap(cmap, **cmap_kw) if getattr(cmap, '_cyclic', None) and extend != 'neither': warnings._warn_proplot( 'Cyclic colormap requires extend="neither". ' @@ -3496,1101 +2769,1279 @@ def apply_cmap( ) extend = 'neither' - # Translate standardized keyword arguments back into the keyword args - # accepted by native matplotlib methods. Also disable edgefix if user want - # to customize the "edges". - styles = STYLE_ARGS_TRANSLATE.get(name, None) - for idx, (key, value) in enumerate(( - ('colors', colors), ('linewidths', linewidths), ('linestyles', linestyles) - )): - if value is None or add_contours: - continue - if not styles: # no known conversion table, e.g. for imshow() plots - raise TypeError(f'{name}() got an unexpected keyword argument {key!r}') - edgefix = False # disable edgefix when specifying borders! - kwargs[styles[idx]] = value - - # Build colormap normalizer - # NOTE: This ensures contour() and tricontour() use the same default levels - # whether or not colormap is active. - kw = dict( - norm=norm, norm_kw=norm_kw, - extend=extend, vmin=vmin, vmax=vmax, locator=locator, locator_kw=locator_kw, - symmetric=symmetric, positive=positive, negative=negative, nozero=nozero, - inbounds=inbounds, centers=pcolor, counts=hexbin or hist2d, - ) - ticks = None - if levels is None: - if norm is not None: - norm = constructor.Norm(norm, **norm_kw) - elif not use_cmap: - levels, _ = _auto_levels_locator(self, *args, N=levels, **kw) - else: - kw.update(levels=levels, values=values, cmap=cmap, minlength=2 - int(contour)) - norm, cmap, levels, ticks = _build_discrete_norm(self, *args, **kw) - - # Call function with correct keyword args - if cmap is not None: - kwargs['cmap'] = cmap - if norm is not None: - kwargs['norm'] = norm - if parametric: - kwargs['values'] = values - if levels is None: # i.e. no DiscreteNorm was used - kwargs['vmin'] = vmin - kwargs['vmax'] = vmax - if contour or contourf: - kwargs['levels'] = levels - kwargs['extend'] = extend - with _state_context(self, _image_discrete=False): - obj = method(self, *args, **kwargs) - if not isinstance(obj, tuple): # hist2d - obj._colorbar_extend = extend # used by proplot colorbar - obj._colorbar_ticks = ticks # used by proplot colorbar - - # Possibly add solid contours between filled ones or fix common "white lines" - # issues with vector graphic output - if add_contours: - colors = _not_none(colors, 'k') - self.contour( - *args, levels=levels, linewidths=linewidths, - linestyles=linestyles, colors=colors - ) - if edgefix: - _fix_white_lines(obj) - - # Apply labels - # TODO: Add quiverkey to this! - if labels: - fmt = _not_none(labels_kw.pop('fmt', None), fmt, 'simple') - fmt = constructor.Formatter(fmt, precision=precision) - if contour or contourf: - _labels_contour(self, obj, *args, fmt=fmt, **labels_kw) - elif pcolor: - _labels_pcolor(self, obj, fmt=fmt, **labels_kw) + # Build colormap normalizer + # NOTE: This ensures contour() and tricontour() use the same default + # levels whether or not colormap is active. + kwargs.update({ + 'norm': norm, 'norm_kw': norm_kw, 'extend': extend, + 'vmin': vmin, 'vmax': vmax, + }) + ticks = None + if levels is None: + if norm is not None: + norm = constructor.Norm(norm, **norm_kw) + elif no_cmap: + if not np.iterable(levels): + levels, ticks, kwargs = self._auto_levels_locator( + *args, levels=levels, **kwargs + ) else: - raise RuntimeError(f'Not possible to add labels to {name!r} pplt.') + norm, cmap, levels, ticks, kwargs = self._auto_discrete_norm( + *args, cmap=cmap, levels=levels, values=values, **kwargs # noqa: E501 + ) + self._pop_unused_args(kwargs) - # Optionally add colorbar - if colorbar: - m = obj - if hist2d: - m = obj[-1] - if parametric and values is not None: - colorbar_kw.setdefault('values', values) - self.colorbar(m, loc=colorbar, **colorbar_kw) + # Keyword args passed to function + kwargs['ticks'] = ticks # handled by _call_method + kwargs['extend'] = extend # handled by _call_method + if cmap is not None: + kwargs['cmap'] = cmap + if norm is not None: + kwargs['norm'] = norm + if levels is None: # i.e. no DiscreteNorm was used + kwargs['vmin'] = vmin + kwargs['vmax'] = vmax + if keep_levels: + kwargs['levels'] = levels + if keep_values: + kwargs['values'] = values + + return kwargs + + def _plot_pairs(self, *args): + """ + Support ``x1, y1, fmt1, x2, y2, fmt2, ...`` input. + """ + # NOTE: Copied from _process_plot_var_args.__call__ to avoid relying + # on public API. ProPlot already supports passing extra positional + # arguments beyond x, y so can feed (x1, y1, fmt) through wrappers. + # Instead represent (x2, y2, fmt, ...) as successive calls to plot(). + args = list(args) + while args: + iargs, args = args[:2], args[2:] + if args and isinstance(args[0], str): + iargs.append(args[0]) + args = args[1:] + yield iargs + + def _apply_plot(self, *args, vert=True, **kwargs): + """ + Plot the lines. + """ + # Plot the lines + _process_props(kwargs, 'line') + objs = [] + for args in self._plot_pairs(*args): + *a, kw = self._standardize_1d('plot', *args, **kwargs) # TODO: add 'N' key + *a, kw = self._error_distribution(*a, **kw) + kw = self._parse_cycle(**kw) + for *a, kw in self._iter_columns(*a, **kw): + *eb, kw = self._error_bars(*a, vert=vert, **kw) + *es, kw = self._error_shading(*a, vert=vert, infer_lines=True, **kw) + if vert: + x, y, *a = a + else: + y, x, *a = a + obj = self._call_method('plot', x, y, *a, **kw) + if eb or es: + objs.append((*eb, *es, *obj)) + else: + objs.extend(obj) - return obj + # Add sticky edges + axis = 'x' if vert else 'y' + for obj in objs: + if not isinstance(obj, mlines.Line2D): + continue # TODO: still looks good with error caps??? + data = getattr(obj, 'get_' + axis + 'data')() + if not data.size: + continue + convert = getattr(self, 'convert_' + axis + 'units') + edges = getattr(obj.sticky_edges, axis) + edges.append(convert(min(data))) + edges.append(convert(max(data))) + + return objs # always return list to match matplotlib behavior + + @docstring.concatenate + @docstring.add_snippets + def plot(self, *args, **kwargs): + """ + %(plot.plot)s + """ + kwargs = self._parse_vert(default_vert=True, **kwargs) + return self._apply_plot(*args, **kwargs) + + @docstring.add_snippets + def plotx(self, *args, **kwargs): + """ + %(plot.plotx)s + """ + kwargs = self._parse_vert(default_vert=False, **kwargs) + return self._apply_plot(*args, **kwargs) + + def _apply_step(self, *args, vert=True, where='pre', **kwargs): + """ + Plot the steps. + """ + # Plot the steps + # NOTE: Internally matplotlib plot() calls step() so we could rely on + # that but better to disable 'error bar' feature where it makes no sense. + _process_props(kwargs, 'line') + if where not in ('pre', 'post', 'mid'): + raise ValueError(f"Invalid where={where!r}. Options are 'pre', 'post', 'mid'.") # noqa: E501 + kwargs.setdefault('drawstyle', 'steps-' + where) + objs = [] + for args in self._plot_pairs(*args): + *a, kw = self._standardize_1d('step', *args, **kwargs) # TODO: add 'N' key + kw = self._parse_cycle(**kw) + for x, y, *a, kw in self._iter_columns(*a, **kw): + x, y = (x, y) if vert else (y, x) + obj = self._call_method('step', x, y, *a, **kw) + objs.extend(obj) + + return objs + + @docstring.concatenate + @docstring.add_snippets + def step(self, *args, **kwargs): + """ + %(plot.step)s + """ + kwargs = self._parse_vert(default_vert=True, **kwargs) + return self._apply_step(*args, **kwargs) + + @docstring.add_snippets + def stepx(self, *args, **kwargs): + """ + %(plot.stepx)s + """ + kwargs = self._parse_vert(default_vert=False, **kwargs) + return self._apply_step(*args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, **kwargs): + """ + Plot stem lines. + + Parameters + ---------- + %(plot.1d_args)s + + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.axes.Axes.stem`. + """ + # 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. + fmts = (linefmt, basefmt, markerfmt) + if not any(isinstance(_, str) and re.match(r'\AC[0-9]', _) for _ in fmts): + cycle = constructor.Cycle((rc['negcolor'], rc['poscolor']), name='_no_name') + kwargs.setdefault('cycle', cycle) + + # Stem lines with bluish stem color and reddish base color + # NOTE: Here we are careful to only apply cycle *temporarily* + kwargs['basefmt'] = _not_none(basefmt, 'C1-') # red base + kwargs['linefmt'] = linefmt = _not_none(linefmt, 'C0-') # blue stems + kwargs['markerfmt'] = _not_none(markerfmt, linefmt[:-1] + 'o') # blue marker + sig = inspect.signature(maxes.Axes.stem) + if 'use_line_collection' in sig.parameters: + kwargs.setdefault('use_line_collection', True) + + # Call function then restore property cycle + cycle = rc['axes.prop_cycle'] # always return to default + *args, kwargs = self._standardize_1d(*args, **kwargs) + kwargs = self._parse_cycle(**kwargs) # allow re-application + obj = self._call_method('stem', *args, **kwargs) + self.set_prop_cycle(cycle) + return obj + + @docstring.add_snippets + def parametric( + self, x, y, c=None, *, values=None, interp=0, cmap=None, norm=None, + scalex=True, scaley=True, **kwargs + ): + """ + Plot a parametric line. + + Parameters + ---------- + *args : (y,), (x, y), or (x, y, c) + The coordinates. If `x` is not provided, it is inferred from `y`. + The parametric coordinate can be indicated as a third positional + argument or with the `c`, `values`, or `labels` keywords. The + parametric coordinate can be numeric or an array of string labels. + c, values, labels : array-like, optional + The parametric coordinates passed as a keyword argument. They + can also be passed as a third positional argument. + + Other parameters + ---------------- + %(plot.cmap_norm)s + %(plot.vmin_vmax)s + %(plot.colorbar_legend)s + 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`. + interp : int, optional + If greater than ``0``, we interpolate to additional points + between the `values` coordinates. The number corresponds to the + number of additional color levels between the line joints + and the halfway points between line joints. + **kwargs + Valid `~matplotlib.collections.LineCollection` properties. + + Returns + ------- + `~matplotlib.collections.LineCollection` + The parametric line. See `this matplotlib example \ +`__. + + See also + -------- + PlotAxes.plot + PlotAxes.plotx + matplotlib.collections.LineCollection + """ + # Standardize arguments + _process_props(kwargs, 'collection') + x, y, kwargs = self._standardize_1d(x, y, c, values=values, **kwargs) + kwargs = self._parse_cmap(c, keep_values=True, **kwargs) + c = kwargs.pop('values', None) + + # Parse input + # NOTE: Critical to put this here instead of parametric() so that the + # interpolated 'values' are used to select colormap levels in _apply_cmap. + c = _not_none(c=c, values=values) + if c is None: + raise ValueError('Values must be provided.') + c = _to_numpy_array(c) + ndim = tuple(_.ndim for _ in (x, y, c)) + size = tuple(_.size for _ in (x, y, c)) + if any(_ != 1 for _ in ndim): + raise ValueError(f'Input coordinates must be 1D. Instead got dimensions {ndim}.') # noqa: E501 + if any(_ != size[0] for _ in size): + raise ValueError(f'Input coordinates must have identical size. Instead got sizes {size}.') # noqa: E501 + + # 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. + # NOTE: The 'extras' wrapper handles input before ingestion by other wrapper + # functions. *This* method is analogous to a native matplotlib method. + if interp > 0: + x_orig, y_orig, v_orig = x, y, c + x, y, c = [], [], [] + for j in range(x_orig.shape[0] - 1): + idx = slice(None) + if j + 1 < x_orig.shape[0] - 1: + idx = slice(None, -1) + x.extend(np.linspace(x_orig[j], x_orig[j + 1], interp + 2)[idx].flat) + y.extend(np.linspace(y_orig[j], y_orig[j + 1], interp + 2)[idx].flat) + c.extend(np.linspace(v_orig[j], v_orig[j + 1], interp + 2)[idx].flat) # noqa: E501 + x, y, c = np.array(x), np.array(y), np.array(c) + + # Get coordinates and values for points to the 'left' and 'right' of joints + c = _not_none(c=c, values=values) + coords = [] + levels = edges(c) + for i in range(y.shape[0]): + icoords = np.empty((3, 2)) + for j, arr in enumerate((x, y)): + icoords[0, j] = arr[0] if i == 0 else 0.5 * (arr[i - 1] + arr[i]) + icoords[1, j] = arr[i] + icoords[2, j] = arr[-1] if i + 1 == y.shape[0] else 0.5 * (arr[i + 1] + arr[i]) # noqa: E501 + coords.append(icoords) + coords = np.array(coords) + + # Create 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', + ) + obj.set_array(c) # the ScalarMappable method + obj.update({ + key: value for key, value in kwargs.items() + if key not in ('color',) + }) + # 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 = c + obj.levels = levels # needed for other functions -def _generate_mappable( - self, mappable, values=None, *, orientation=None, - locator=None, formatter=None, norm=None, norm_kw=None, rotation=None, -): - """ - Generate a mappable from flexible non-mappable input. Useful in bridging - the gap between legends and colorbars (e.g., creating colorbars from line - objects whose data values span a natural colormap range). - """ - # A colormap instance - # TODO: Pass remaining arguments through Colormap()? This is really - # niche usage so maybe not necessary. - orientation = _not_none(orientation, 'horizontal') - 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 - ): - cmap = mcolors.ListedColormap(list(mappable), '_no_name') - if values is None: - values = np.arange(len(mappable)) - locator = _not_none(locator, values) # tick *all* values by default - - # 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 + return obj + + def _apply_lines( + self, *args, vert=True, + stack=None, stacked=None, negpos=False, **kwargs ): - # Generate colormap from colors and infer tick labels - colors = [] - for obj in mappable: - if hasattr(obj, 'get_color'): - color = obj.get_color() + """ + Apply hlines or vlines command. Support default "minima" at zero. + """ + # Parse input arguments + name = 'vlines' if vert else 'hlines' + stack = _not_none(stack=stack, stacked=stacked) + _process_props(kwargs, 'collection') + *args, kwargs = self._standardize_1d(*args, vert=vert, **kwargs) + args = list(args) + if len(args) > 3: + raise ValueError(f'Expected 1-3 positional args, got {len(args)}.') + if len(args) == 3 and stack: + warnings._warn_proplot( + f'{name}() cannot have three positional arguments with stack=True. ' + 'Ignoring second argument.' + ) + if len(args) == 2: # empty possible + args.insert(1, np.array([0.0])) # default base + + # Support "negative" and "positive" lines + y0 = 0 + objs = [] + kwargs['stack'] = stack + for x, y1, y2, *args, kwargs in self._iter_columns(*args, **kwargs): + # Apply stacking + if stack: + y1 += y0 + y2 += y0 + y0 += y2 - y1 # irrelevant that we added y0 to both + # Plot basic lines or negative-positive lines + # TODO: Ensure 'linewidths' etc. are applied! For some reason + # previously thought they had to be manually applied. + if negpos: + obj = self._call_negpos(name, x, y1, y2, *args, **kwargs) 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)) - - # Try to infer tick values and tick labels from Artist labels - cmap = mcolors.ListedColormap(colors, '_no_name') - if values is None: - # Get object labels and values - labels = [] - values = [] - for obj in mappable: - label = _get_label(obj) # could be None - try: - value = float(label) # could be float(None) - except (TypeError, ValueError): - value = None - labels.append(label) - 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(label is not None for label in labels): - formatter = labels # use these fixed values for ticks - if orientation == 'horizontal': - rotation = _not_none(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}.' + obj = self._call_method(name, x, y1, y2, *args, **kwargs) + objs.append(obj) + + return objs[0] if len(objs) == 1 else objs + + @docstring.add_snippets + def vlines(self, *args, **kwargs): + """ + %(plot.vlines)s + """ + kwargs = self._parse_vert(default_vert=True, **kwargs) + return self._apply_lines(*args, **kwargs) + + @docstring.add_snippets + def hlines(self, *args, **kwargs): + """ + %(plot.hlines)s + """ + kwargs = self._parse_vert(default_vert=False, **kwargs) + return self._apply_lines(*args, **kwargs) + + def _apply_scatter(self, *args, smin=None, smax=None, vert=True, **kwargs): + """ + Apply scatter or scatterx markers. Permit up to 4 positional arguments + including `s` and `c`. + """ + # Manage input arguments + # TODO: Carefully manage props + args = list(args) + _process_props(kwargs, 'line') + kwargs = self._parse_cycle(**kwargs) + if len(args) > 4: + raise ValueError(f'Expected 1-4 positional arguments, got {len(args)}.') + c = _not_none( + color_positional=args.pop(-1) if len(args) == 4 else None, + color=kwargs.pop('color', None) ) - - # Build ad hoc ScalarMappable object from colors - if np.iterable(mappable) and len(values) != len(mappable): - raise ValueError( - f'Passed {len(values)} values, but only {len(mappable)} ' - f'objects or colors.' + s = _not_none( + markersize_positional=args.pop(-1) if len(args) == 3 else None, + markersize=kwargs.pop('markersize', None) ) - norm, *_ = _build_discrete_norm( - self, - cmap=cmap, - norm=norm, - norm_kw=norm_kw, - extend='neither', - values=values, - ) - mappable = mcm.ScalarMappable(norm, cmap) - - return mappable, rotation - - -def colorbar_extras( - self, mappable, values=None, *, # analogous to handles and labels - extend=None, extendsize=None, - title=None, label=None, - grid=None, tickminor=None, - reverse=False, tickloc=None, ticklocation=None, tickdir=None, tickdirection=None, - locator=None, ticks=None, maxn=None, maxn_minor=None, - minorlocator=None, minorticks=None, - locator_kw=None, minorlocator_kw=None, - format=None, formatter=None, ticklabels=None, formatter_kw=None, rotation=None, - norm=None, norm_kw=None, # normalizer to use when passing colors/lines - orientation=None, - edgecolor=None, linewidth=None, - labelsize=None, labelweight=None, labelcolor=None, - ticklabelsize=None, ticklabelweight=None, ticklabelcolor=None, - **kwargs -): - """ - Support non-mappable input arguments and support flexible arguments controlling - ticks and styling. - - Important - --------- - 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. - tickdir, tickdirection : {'out', 'in', 'inout'}, optional - Direction that major and minor tick marks point. - 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 `~matplotlib.axes.Axes.legend`. - locator, ticks : locator spec, optional - Used to determine the colorbar tick positions. Passed to the - `~proplot.constructor.Locator` constructor function. - 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. - format, formatter, ticklabels : formatter spec, optional - The tick label format. Passed to the `~proplot.constructor.Formatter` - constructor function. - 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 : {{None, 'horizontal', 'vertical'}}, optional - The colorbar orientation. By default this depends on the "side" of the subplot - or figure where the colorbar is drawn. Inset colorbars are always horizontal. - - Other parameters - ---------------- - **kwargs - Passed to `matplotlib.figure.Figure.colorbar`. - - See also - -------- - matplotlib.figure.Figure.colorbar - proplot.figure.Figure.colorbar - proplot.axes.Axes.colorbar - """ - # 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_extras() 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 {} - - # 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) - tickdirection = _not_none(tickdir=tickdir, tickdirection=tickdirection) - formatter = _not_none(ticklabels=ticklabels, formatter=formatter, format=format) - - # Colorbar kwargs - grid = _not_none(grid, rc['colorbar.grid']) - orientation = _not_none(orientation, 'horizontal') - kwargs.update({ - 'cax': self, - 'use_gridspec': True, - 'orientation': orientation, - 'spacing': 'uniform', - }) - kwargs.setdefault('drawedges', grid) - - # 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) + *a, s, c, kw = self._standardize_1d(*args, c, s, vert=vert, **kwargs) + if all(_.squeeze().ndim > 1 for _ in (*a, s, c) if isinstance(_, np.ndarray)): + *a, s, c = (np.ravel(_) if isinstance(_, np.ndarray) else _ for _ in (*a, s, c)) # noqa: E501 + + # Scale s array + if np.iterable(s) and (smin is not None or smax is not None): + s = _to_numpy_array(s) + smin_true, smax_true = np.min(s), np.max(s) + if smin is None: + smin = smin_true + if smax is None: + smax = smax_true + factor = (s - smin_true) / (smax_true - smin_true) + s = smin + (smax - smin) * factor + + # Scale c array + if ( + isinstance(c, np.ndaray) and np.issubdtype(c.dtype, np.number) + and not (c.ndim == 2 and c.shape[1] in (3, 4)) + ): + kwargs.setdefault('cmap_kw', {}).setdefault('luminance', 90) + norm, cmap, levels, ticks, kwargs = self._auto_discrete_norm(c.ravel(), **kwargs) # noqa: E501 + kwargs.update({'cmap': cmap, 'norm': norm, 'ticks': ticks}) + self._pop_unused_args(kwargs) + + # Iterate using keys + objs = [] + kwargs = self._from_cycle('scatter', **kwargs) + *a, kw = self._error_distribution(*a, **kw) + for *a, kw in self._iter_columns(*a, **kw): + *eb, kw = self._error_bars(*a, vert=vert, **kw) + *es, kw = self._error_shading(*a, vert=vert, infer_lines=True, **kw) + if vert: + x, y, *a = a + else: + y, x, *a = a + obj = self._call_method('scatter', x, y, *a, c=c, s=s, **kw) + if eb or es: + objs.append((*eb, *es, obj)) + else: + objs.append(obj) + objs.append(obj if not eb and not es else (*eb, *es, obj)) + + return objs[0] if len(objs) == 1 else objs + + @docstring.concatenate + @docstring.add_snippets + def scatter(self, *args, **kwargs): + """ + %(plot.scatter)s + """ + kwargs = self._parse_vert(default_vert=True, **kwargs) + return self._apply_scatter(*args, **kwargs) + + @docstring.add_snippets + def scatterx(self, *args, **kwargs): + """ + %(plot.scatterx)s + """ + kwargs = self._parse_vert(default_vert=False, **kwargs) + return self._apply_scatter(*args, **kwargs) + + def _apply_fill_between( + self, *args, where=None, negpos=None, + stack=None, stacked=None, vert=True, **kwargs ): - 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. - if not isinstance(mappable, (martist.Artist, mcontour.ContourSet)): - mappable, rotation = _generate_mappable( - self, mappable, values, locator=locator, formatter=formatter, - norm=norm, norm_kw=norm_kw, orientation=orientation, rotation=rotation - ) + """ + Apply area shading. + """ + # Parse input arguments + _process_props(kwargs, 'patch') + name = 'fill_between' if vert else 'fill_betweenx' + stack = _not_none(stack=stack, stacked=stacked) + *args, kwargs = self._standardize_1d(*args, vert=vert, **kwargs) + kwargs = self._parse_cycle(**kwargs) + if len(args) > 4: + raise ValueError(f'Expected 1-4 positional args, got {len(args)}.') + if len(args) == 4: + where = _not_none(where_positional=args.pop(3), where=where) + if len(args) == 3 and stack: + warnings._warn_proplot( + f'{name}() cannot have three positional arguments with stack=True. ' + 'Ignoring second argument.' + ) + if len(args) == 2: # empty possible + args.insert(1, np.array([0.0])) # default base + + # Draw patches with default edge width zero + y0 = 0 + objs, xsides, ysides = [], [], [] + for x, y1, y2, kw in self._iter_columns(*args, **kwargs): + # Apply stacking + if stack: + y1 += y0 + y2 += y0 + y0 += y2 - y1 # irrelevant that we added y0 to both + # Call basic patches or negative-positive patches + kw.update({'where': where, 'stack': stack}) + if negpos: + obj = self._call_negpos(name, x, y1, y2, use_where=True, **kw) + else: + obj = self._call_method(name, x, y1, y2, **kw) + # Document sticky sides + xsides.extend((np.min(x), np.max(x))) + for y in (y1, y2): + if y.size == 1: + ysides.append(y.item()) + objs.append(obj) + + # 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. + # NOTE: Could also retrieve data from PolyCollection but that's tricky. + # NOTE: Standardize function guarantees ndarray input by now + iter_ = (obj for _ in objs for obj in (_ if isinstance(_, tuple) else (_,))) + for obj in iter_: + for s, sides in zip('xy' if vert else 'yx', (xsides, ysides)): + convert = getattr(self, 'convert_' + s + 'units') + edges = getattr(obj.sticky_edges, s) + edges.extend(convert(sides)) - # Define 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), + return objs[0] if len(objs) == 1 else objs + + @docstring.add_snippets + def area(self, *args, **kwargs): + """ + %(plot.fill_between)s + """ + return self.fill_between(*args, **kwargs) + + @docstring.add_snippets + def areax(self, *args, **kwargs): + """ + %(plot.fill_betweenx)s + """ + return self.fill_betweenx(*args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def fill_between(self, *args, **kwargs): + """ + %(plot.fill_between)s + """ + kwargs = self._parse_vert(default_vert=True, **kwargs) + return self._apply_fill_between(*args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def fill_betweenx(self, *args, **kwargs): + """ + %(plot.fill_betweenx)s + """ + # NOTE: The 'horizontal' orientation will be inferred by downstream + # wrappers using the function name. + kwargs = self._parse_vert(default_vert=False, **kwargs) + return self._apply_fill_between(*args, **kwargs) + + @staticmethod + def _convert_bar_width(x, width=1): + """ + Convert bar plot widths from relative to coordinate spacing. Relative + widths are much more convenient for users. + """ + # WARNING: This will fail for non-numeric non-datetime64 singleton + # datatypes but this is good enough for vast majority of cases. + x_test = np.atleast_1d(_to_numpy_array(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]') + return width * x_step + + def _apply_bar( + self, *args, stack=None, stacked=None, negpos=False, absolute_width=False, + **kwargs ): - if value is not None: - kw_ticklabels[key] = value - - # 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, '_colorbar_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) + """ + Apply bar or barh command. Support default "minima" at zero. + """ + # Parse args + # TODO: Stacked feature is implemented in `_update_cycle`, but makes more + # sense do document here. Figure out way to move it here? + name = 'barh' if kwargs.get('orientation') == 'horizontal' else 'bar' + *args, kwargs = self._standardize_1d(*args, **kwargs) + stack = _not_none(stack=stack, stacked=stacked) + if len(args) > 4: + raise ValueError(f'Expected 1-4 positional args, got {len(args)}.') + if len(args) == 4 and stack: + warnings._warn_proplot( + f'{name}() cannot have four positional arguments with stack=True. ' + 'Ignoring fourth argument.' # i.e. ignore default 'bottom' + ) + if len(args) == 2: + args.append(np.array([0.8])) # default width + if len(args) == 3: + args.append(np.array([0.0])) # default base + + # Call func after converting bar width + _process_props(kwargs, 'patch') + kwargs.setdefault('edgecolor', 'black') + *a, kw = self._error_distribution(*args, **kwargs) + kw = self._parse_cycle(**kw) + b0 = 0 + objs = [] + iter_ = tuple(self._iter_columns(*a, stack=stack, **kw)) + ncols = len(iter_) + for i, (x, h, w, b, kw) in enumerate(iter_): + # Adjust x or y coordinates for grouped and stacked bars + if not absolute_width: + w = self._convert_bar_width(x, w) + if not stack: + o = 0.5 * (ncols - 1) + x += w * (i - o) / ncols else: - locator = 'auto' - - 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']) + b += b0 + b0 += h + # Draw simple bars + *eb, kw = self._error_bars(x, b + h, *a, **kw) + if negpos: + obj = self._call_negpos(name, x, h, w, b, use_zero=True, **kw) 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] - - # 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) - - # Draw the colorbar - # NOTE: Set default formatter here because we optionally apply a FixedFormatter - # using *labels* from handle input. - extend = _not_none(extend, getattr(mappable, '_colorbar_extend', 'neither')) - 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, - }) - if isinstance(mappable, mcontour.ContourSet): - mappable.extend = extend # required in mpl >= 3.3, else optional - else: - kwargs['extend'] = extend - with _state_context(self, _image_discrete=False): - cb = self.figure.colorbar(mappable, **kwargs) - axis = self.xaxis if orientation == 'horizontal' else self.yaxis - - # 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) - - # 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 consistent with rc settings and overrides - s = axis.axis_name - for which in ('minor', 'major'): - kw = rc.category(s + 'tick.' + which) - kw.pop('visible', None) - if tickdirection: - kw['direction'] = tickdirection - 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' because blending will - # occur, end up with 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 - blend = 'pcolormesh.snap' not in rc or not rc['pcolormesh.snap'] - if not cmap._isinit: - cmap._init() - if blend and 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 _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 _multiple_legend( - self, pairs, *, fontsize, loc=None, ncol=None, order=None, **kwargs -): - """ - Draw "legend" with centered rows by creating separate legends for - each row. The label spacing/border spacing will be exactly replicated. - """ - # Message when overriding some properties - legs = [] - overridden = [] - frameon = 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".' - ) - if overridden: - warnings._warn_proplot( - 'Ignoring user input properties ' - + ', '.join(map(repr, overridden)) - + ' for centered-row legend.' - ) + obj = self._call_method(name, x, h, w, b, **kw) + objs.append(obj) + + return obj + + @docstring.concatenate + @docstring.add_snippets + def bar(self, *args, **kwargs): + """ + %(plot.bar)s + """ + kwargs = self._parse_vert(default_orientation='vertical', **kwargs) + return self._apply_bar('bar', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def barh(self, *args, **kwargs): + """ + %(plot.barh)s + """ + kwargs = self._parse_vert(default_orientation='horizontal', **kwargs) + return self._apply_bar('barh', *args, **kwargs) + + @docstring.concatenate + def pie(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + + Other parameters + ---------------- + %(plot.1d_kwargs)s + + See also + -------- + matplotlib.axes.Axes.pie + """ + *args, kwargs = self._standardize_1d(*args, **kwargs) + kwargs = self._parse_cycle(**kwargs) + self._call_method('pie', *args, **kwargs) + + def _apply_boxplot( + self, *args, mean=None, means=None, fill=None, + fillcolor=None, fillalpha=None, marker=None, markersize=None, **kwargs + ): + """ + Apply the box plot. + """ + # Global and fill properties + _process_props(kwargs, 'patch') + edgecolor = kwargs.pop('edgecolor', 'black') + linewidth = kwargs.pop('linewidth', 0.8) + fillcolor = kwargs.pop('facecolor', None) + fillalpha = kwargs.pop('alpha', None) + fill = fill is True or fillcolor is not None or fillalpha is not None + if fill and fillcolor is None: # TODO: support e.g. 'facecolor' cycle? + parser = self._get_patches_for_fill + fillcolor = parser.get_next_color() + fillalpha = _not_none(fillalpha, default=0.7) + + # Arist-specific properties + props = {} + for key in ('box', 'cap', 'whisker', 'flier', 'mean', 'median'): + props[key] = iprops = _pop_props(kwargs, 'line', prefix=key) + iprops.setdefault('color', edgecolor) + iprops.setdefault('linewidth', linewidth) + iprops.setdefault('markeredgecolor', edgecolor) + means = _not_none(mean=mean, means=means, showmeans=kwargs.get('showmeans')) + if means: + kwargs['showmeans'] = kwargs['meanline'] = True - # 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. - width, height = self.get_size_inches() - 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 - 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'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.' - ) + # Call function + x, y, *a, kw = self._standardize_1d('boxplot', *args, **kwargs) + kw.setdefault('positions', x) + obj = self._call_method('boxplot', y, *a, **kw) + + # Modify artist settings + for key, aprops in props.items(): + if key not in obj: # possible if not rendered + continue + artists = obj[key] + if not isinstance(fillalpha, list): + fillalpha = [fillalpha] * len(artists) + if not isinstance(fillcolor, list): + fillcolor = [fillcolor] * len(artists) + for i, artist in enumerate(artists): + # Update lines used for boxplot components + # TODO: Test this thoroughly! + iprops = { + key: ( + value[i // 2 if key in ('caps', 'whiskers') else i] + if isinstance(value, (list, np.ndarray)) + else value + ) for key, value in aprops.items() + } + artist.update(iprops) + # "Filled" boxplot by adding patch beneath line path + ifillcolor = fillcolor[i] + ifillalpha = fillalpha[i] + if key == 'boxes': + if ifillcolor is not None or ifillalpha is not None: + patch = mpatches.PathPatch( + artist.get_path(), + linewidth=0, facecolor=ifillcolor, alpha=ifillalpha, + ) + self.add_artist(patch) + # Outlier markers + if key == 'fliers': + if marker is not None: + artist.set_marker(marker) + if markersize is not None: + artist.set_markersize(markersize) + + return obj + + @docstring.add_snippets + def box(self, *args, **kwargs): + """ + %(plot.boxplot)s + """ + return self.boxplot(*args, **kwargs) + + @docstring.add_snippets + def boxh(self, *args, **kwargs): + """ + %(plot.boxploth)s + """ + return self.boxploth(*args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def boxplot(self, *args, **kwargs): + """ + %(plot.boxplot)s + """ + kwargs = self._parse_vert(default_vert=True, **kwargs) + return self._apply_boxplot(*args, **kwargs) + + @docstring.add_snippets + def boxploth(self, *args, **kwargs): + """ + %(plot.boxploth)s + """ + kwargs = self._parse_vert(default_vert=False, **kwargs) + return self._apply_boxplot(*args, **kwargs) + + def _apply_violinplot(self, *args, vert=True, **kwargs): + """ + Apply the violinplot. + """ + # Parse keyword args + _process_props(kwargs, 'patch') + linewidth = kwargs.setdefault('linewidth', 0.8) + edgecolor = kwargs.pop('edgecolor', 'black') + fillalpha = kwargs.pop('alpha', 0.7) + fillcolor = kwargs.pop('facecolor', None) + kwargs.setdefault('capsize', 0) # caps are redundant for violin plots + kwargs.setdefault('means', kwargs.pop('showmeans', None)) # for _indicate_error + kwargs.setdefault('medians', kwargs.pop('showmedians', None)) + if kwargs.pop('showextrema', None): + warnings._warn_proplot('Ignoring showextrema=True.') + + # Parse and control error bars + x, y, *a, kw = self._standardize_1d('violinplot', *args, **kwargs) + x, y, *a, kw = self._error_distribution(x, y, **kw) + *eb, kw = self._error_bars(x, y, *a, vert=vert, default_boxes=True, pop_lineprops=True, **kw) # noqa: E501 + kw = self._parse_cycle(**kw) - # Iterate through sublists - for i, ipairs in enumerate(pairs): - if i == 1: - title = kwargs.pop('title', None) - if i >= 1 and title is not None: - i += 1 # add 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 - ) - legs.append(leg) - - # Simple cases - if not frameon: - return legs - if len(legs) == 1: - legs[0].set_frame_on(True) - return legs - - # 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. - width, height = self.get_size_inches() - 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) - - # 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) - 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) - - # Add patch to list - return patch, *legs - - -def _single_legend(self, pairs, ncol=None, order=None, **kwargs): - """ - Draw an individual legend with support for changing legend-entries - between column-major and row-major. - """ - # 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 = [pairs[i * ncol:(i + 1) * ncol] for i in range(len(pairs) // ncol + 1)] - pairs = [] - nrows_max = len(split) # max possible row count - ncols_final = len(split[-1]) # columns in final row - nrows = [nrows_max] * ncols_final + [nrows_max - 1] * (ncol - ncols_final) - for col, nrow in enumerate(nrows): # iterate through cols - pairs.extend(split[row][col] for row in range(nrow)) - - # Draw legend - return mlegend.Legend(self, *zip(*pairs), ncol=ncol, **kwargs) - - -def legend_extras( - self, handles=None, labels=None, *, loc=None, - frame=None, frameon=None, ncol=None, ncols=None, - center=None, order='C', label=None, title=None, - fontsize=None, fontweight=None, fontcolor=None, - **kwargs -): - """ - Support centered-row legends and more flexible arguments controlling - legend styling. - - Important - --------- - 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`. - 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'`` - ================== ================================================ - - frame, frameon : bool, optional - Toggles the legend frame. For centered-row legends, a frame - independent from matplotlib's built-in legend frame is created. - 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`. By default, `center` is set to ``True`` - if `handles` is a list of lists (each sublist is used as a row in the legend). - title, label : str, optional - The legend title. The `label` keyword is also accepted, for consistency - with `~matplotlib.figure.Figure.colorbar`. - fontsize, fontweight, fontcolor : optional - The font size, weight, and color for the legend text. The default - font size is :rcraw:`legend.fontsize`. - color, lw, linewidth, m, marker, ls, linestyle, dashes, ms, markersize \ -: property-spec, optional - Properties used to override the legend handles. For example, for a - legend describing 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`. - - See also - -------- - matplotlib.figure.Figure.legend - matplotlib.axes.Axes.legend - proplot.figure.Figure.legend - proplot.axes.Axes.legend - """ - # 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 - fontsize = kwargs.get('fontsize', None) or rc['legend.fontsize'] - if fontsize is None: - pass - elif fontsize in mfonts.font_scalings: - kwargs['fontsize'] = rc._scale_font(fontsize) - else: - kwargs['fontsize'] = units(fontsize, 'pt') - - # 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. - kw_text = {} - for key, value in (('color', fontcolor), ('weight', fontweight)): - if value is not None: - kw_text[key] = value - kw_handle = _pop_props(kwargs, 'lines') - kw_handle['solid_capstyle'] = 'butt' - - # 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 = [h[0] if isinstance(h, tuple) and len(h) == 1 else h for h in handles] - list_of_lists = any(isinstance(h, (list, np.ndarray)) for h in handles) - 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, - ) - 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, + # Call function + kw.pop('labels', None) # already applied in _standardize_1d + kw.update({'showmeans': False, 'showmedians': False, 'showextrema': False}) + kw.setdefault('positions', x) + y = kw.pop('distribution', None) # 'y' was changes in _error_distribution + obj = self._call_method('violinplot', y, *a, **kw) + + # Modify body settings + artists = (obj or {}).get('bodies', ()) + if not isinstance(fillalpha, list): + fillalpha = [fillalpha] * len(artists) + if not isinstance(fillcolor, list): + fillcolor = [fillcolor] * len(artists) + if not isinstance(edgecolor, list): + edgecolor = [edgecolor] * len(artists) + for i, artist in enumerate(artists): + artist.set_linewidths(linewidth) + if fillalpha[i] is not None: + artist.set_alpha(fillalpha[i]) + if fillcolor[i] is not None: + artist.set_facecolor(fillcolor[i]) + if edgecolor[i] is not None: + artist.set_edgecolor(edgecolor[i]) + + return obj + + @docstring.add_snippets + def violin(self, *args, **kwargs): + """ + %(plot.violinplot)s + """ + # WARNING: This disables use of 'violin' by users but + # probably very few people use this anyway. + return self.violinplot(*args, **kwargs) + + @docstring.add_snippets + def violinh(self, *args, **kwargs): + """ + %(plot.violinploth)s + """ + return self.violinploth(*args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def violinplot(self, *args, **kwargs): + """ + %(plot.violinplot)s + """ + kwargs = self._parse_vert(default_vert=True, **kwargs) + return self._apply_violinplot(*args, **kwargs) + + @docstring.add_snippets + def violinploth(self, *args, **kwargs): + """ + %(plot.violinploth)s + """ + kwargs = self._parse_vert(default_vert=False, **kwargs) + return self._apply_violinplot(*args, **kwargs) + + def _apply_hist(self, *args, **kwargs): + """ + Apply the histogram. + """ + *args, kwargs = self._standardize_1d(*args, **kwargs) + kwargs = self._parse_cycle(**kwargs) + for *a, kw in self._iter_columns(*args, **kwargs): + self._call_method('hist', *a, **kw) + + @docstring.concatenate + @docstring.add_snippets + def hist(self, *args, **kwargs): + """ + %(plot.hist)s + """ + kwargs = self._parse_vert(default_orientation='vertical', **kwargs) + self._apply_hist(*args, **kwargs) + + @docstring.add_snippets + def histh(self, *args, **kwargs): + """ + %(plot.histh)s + """ + kwargs = self._parse_vert(default_orientation='horizontal', **kwargs) + self._apply_hist(*args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def hexbin(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + + Other parameters + ---------------- + %(plot.2d_kwargs)s + **kwargs + Passed to `~matplotlib.axes.Axes.hexbin`. + + See also + -------- + PlotAxes.hist2d + matplotlib.axes.Axes.hexbin + """ + _process_props(kwargs, 'collection') # takes LineCollection props + if 'colors' in kwargs: + kwargs['edgecolors'] = kwargs.pop('colors') + *args, kwargs = self._standardize_1d(*args, **kwargs) + kwargs = self._parse_cmap(*args, counts=True, default_discrete=False, **kwargs) + return self._call_method('hexbin', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def hist2d(self, *args, **kwargs): + """ + Parameters + ---------- + %(plot.1d_args)s + + Other parameters + ---------------- + %(plot.2d_kwargs)s + **kwargs + Passed to `~matplotlib.axes.Axes.hexbin`. + + See also + -------- + PlotAxes.hist2d + matplotlib.axes.Axes.hexbin + """ + _process_props(kwargs, 'collection') # takes LineCollection props + if 'colors' in kwargs: + kwargs['edgecolors'] = kwargs.pop('colors') + *args, kwargs = self._standardize_1d(*args, **kwargs) + kwargs = self._parse_cmap(*args, counts=True, default_discrete=False, **kwargs) + return self._call_method('hist2d', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def contour(self, *args, **kwargs): + """ + Plot contour lines. + + %(plot.contour)s + **kwargs + Passed to `~matplotlib.axes.Axes.contour`. + + See also + -------- + PlotAxes.contourf + matplotlib.axes.Axes.contour + """ + _process_props(kwargs, 'collection') + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, minlength=1, keep_levels=True, **kwargs) + return self._call_method('pcolor', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def contourf(self, *args, **kwargs): + """ + Plot filled contour lines. + + %(plot.contour)s + **kwargs + Passed to `~matplotlib.axes.Axes.contourf`. + + See also + -------- + PlotAxes.contour + matplotlib.axes.Axes.contourf + """ + _process_props(kwargs, 'collection') + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, keep_levels=True, **kwargs) + return self._call_method('pcolor', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def pcolor(self, *args, **kwargs): + """ + Plot irregular grid boxes. + + %(plot.pcolor)s + **kwargs + Passed to `~matplotlib.axes.Axes.pcolor`. + + See also + -------- + PlotAxes.pcolormesh + PlotAxes.pcolorfast + matplotlib.axes.Axes.pcolor + """ + _process_props(kwargs, 'line') + if 'color' in kwargs: + kwargs['edgecolors'] = kwargs.pop('color') + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, centers=True, **kwargs) + return self._call_method('pcolor', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def pcolormesh(self, *args, **kwargs): + """ + Plot regular grid boxes. + + %(plot.pcolor)s + **kwargs + Passed to `~matplotlib.axes.Axes.pcolormesh`. + + See also + -------- + PlotAxes.pcolor + PlotAxes.pcolorfast + PlotAxes.heatmap + matplotlib.axes.Axes.pcolormesh + """ + _process_props(kwargs, 'line') + if 'color' in kwargs: + kwargs['edgecolors'] = kwargs.pop('color') + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, centers=True, **kwargs) + return self._call_method('pcolormesh', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def pcolorfast(self, *args, **kwargs): + """ + Plot grid boxes quickly. + + %(plot.pcolor)s + **kwargs + Passed to `~matplotlib.axes.Axes.pcolorfast`. + + See also + -------- + PlotAxes.pcolor + PlotAxes.pcolormesh + PlotAxes.heatmap + matplotlib.axes.Axes.pcolorfast + """ + _process_props(kwargs, 'line') + if 'color' in kwargs: + kwargs['edgecolors'] = kwargs.pop('color') + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, centers=True, **kwargs) + return self._call_method('pcolorfast', *args, **kwargs) + + @docstring.add_snippets + def heatmap(self, *args, aspect=None, **kwargs): + """ + Plot grid boxes with formatting suitable for heatmaps. Ensures square + grid boxes, adds major ticks to the center of each grid box, and + disables minor ticks and gridlines. + + %(plot.pcolor)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 + will not be square. This parameter is a shortcut for explicitly calling + `~matplotlib.axes.set_aspect`. + + The default is :rc:`image.heatmap`. The options are: + + * ``'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 boxes. + + **kwargs + Passed to `~matplotlib.axes.Axes.pcolormesh`. + + See also + -------- + PlotAxes.pcolor + PlotAxes.pcolormesh + PlotAxes.pcolorfast + matplotlib.axes.Axes.pcolormesh + """ + obj = self.pcolormesh(*args, **kwargs) + aspect = _not_none(aspect, rc['image.aspect']) + from .cartesian import CartesianAxes + if not isinstance(self, CartesianAxes): + warnings._warn_proplot( + 'Cannot adjust aspect ratio or ticks for non-Cartesian heatmap plot. ' + 'Consider using pcolormesh() or pcolor() instead.' ) - pairs.append(list(zip(ihandles, ilabels))) - - # 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))] - ncol = None - if list_of_lists: # remove empty lists, pops up in some examples - pairs = [ipairs for ipairs in pairs if ipairs] - - # Bail if no pairs - if not pairs: - return mlegend.Legend(self, [], [], loc=loc, ncol=ncol, **kwargs) - # Multiple-legend pseudo-legend - elif center: - objs = _multiple_legend(self, pairs, loc=loc, ncol=ncol, order=order, **kwargs) - # Individual legend - else: - objs = [_single_legend(self, pairs, loc=loc, ncol=ncol, order=order, **kwargs)] - - # Add legends manually so matplotlib does not remove old ones - for obj in objs: - if isinstance(obj, mpatches.FancyBboxPatch): - continue - if hasattr(self, 'legend_') and self.legend_ is None: - self.legend_ = obj # set *first* legend accessible with get_legend() - else: - self.add_artist(obj) - - # Apply 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) - for obj in objs: - if isinstance(obj, mpatches.FancyBboxPatch): - obj.update(outline) # the multiple-legend bounding box else: - obj.legendPatch.update(outline) # no-op if frame is off - - # 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 obj in objs: - try: - children = obj._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): - obj.update(kw_text) - else: - for key, value in kw_handle.items(): - getattr(obj, 'set_' + key, lambda value: None)(value) - - # Append attributes and return, and set clip property!!! This is critical - # for tight bounding box calcs! - for obj in objs: - obj.set_clip_on(False) - if isinstance(objs[0], mpatches.FancyBboxPatch): - objs = objs[1:] - return objs[0] if len(objs) == 1 else tuple(objs) - - -def _apply_wrappers(method, *args): - """ - Return the axes method `method` wrapped by input wrappers `args`. The order - of input should match the order you would apply the wrappers as decorator - functions (first argument is 'outermost' and last argument is 'innermost'). - """ - # Local functions - name = method.__name__ - local = name in ('area', 'areax', 'plotx', 'parametric', 'heatmap', 'scatterx') - - for func in args[::-1]: - # Apply wrapper - # NOTE: Must assign fucn and method as keywords to avoid overwriting - # by loop scope and associated recursion errors. - method = functools.wraps(method)( - lambda self, *args, _func=func, _method=method, **kwargs: - _func(self, *args, _method=_method, **kwargs) - ) - - # List wrapped methods in the driver function docstring - if not hasattr(func, '_methods_wrapped'): - func._methods_wrapped = [] - if not hasattr(func, '_docstring_orig'): - func._docstring_orig = func.__doc__ or '' - docstring = func._docstring_orig - if '{methods}' not in docstring: - continue - pkg = 'proplot' if local else 'matplotlib' - link = f'`~{pkg}.axes.Axes.{name}`' - methods = func._methods_wrapped - if link not in methods: - methods.append(link) - prefix = ', '.join(methods[:-1]) - modifier = ', and ' if len(methods) > 2 else ' and ' if len(methods) > 1 else '' - suffix = methods[-1] + '.' - func.__doc__ = docstring.format(methods=prefix + modifier + suffix) - - # Remove documentation - # NOTE: Without this step matplotlib method documentation appears on proplot - # website! This doesn't affect user experience because help() will search for - # documentation on superclass matplotlib.axes.Axes above proplot.axes.Axes. - if not local: - method.__doc__ = None # let help() function seek the superclass docstring - - return method - - -def _concatenate_docstrings(func): - """ - Concatenate docstrings from a matplotlib axes method with a ProPlot axes - method and obfuscate the call signature to avoid misleading users. - - Warning - ------- - This is not yet used but will be in the future. - """ - # 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. - # Get matplotlib axes func - name = func.__name__ - orig = getattr(maxes.Axes, name) - odoc = inspect.getdoc(orig) - if not odoc: # 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) - if regex: - fdoc = odoc[:regex.start() + 1] + '\n\n' + fdoc - if rc['docstring.hardcopy']: # True when running sphinx - func.__doc__ = fdoc - 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) - func.__signature__ = fsig.replace(parameters=tuple(dsig.parameters.values())) - - # Concatenate docstrings and copy summary - # Make sure different sections are very visible - pad = '=' * len(name) - doc = f""" - ================================{pad} - proplot.axes.Axes.{name} documentation - ================================{pad} - {fdoc} - ==================================={pad} - matplotlib.axes.Axes.{name} documentation - ==================================={pad} - {odoc} - """ - func.__doc__ = inspect.cleandoc(doc) # dedents and trims whitespace - - # Return - return func + xlocator = ylocator = None + if hasattr(obj, '_coordinates'): + coords = obj._coordinates + coords = 0.5 * (coords[1:, ...] + coords[:-1, ...]) + coords = 0.5 * (coords[:, 1:, :] + coords[:, :-1, :]) + xlocator, ylocator = coords[0, :, 0], coords[:, 0, 1] + self.format( + aspect=aspect, xlocator=xlocator, ylocator=ylocator, + xgrid=False, ygrid=False, xtickminor=False, ytickminor=False, + ) + return obj + + @docstring.concatenate + @docstring.add_snippets + def barbs(self, *args, **kwargs): + """ + Plot wind barbs. + + %(plot.flow)s + **kwargs + Passed to `~matplotlib.axes.Axes.barbs`. + + See also + -------- + PlotAxes.quiver + PlotAxes.streamplot + matplotlib.axes.Axes.barbs + """ + _process_props(kwargs, 'line') # applied to barbs + if 'color' in kwargs: # more intuitive for 'color' to apply to barbs + kwargs['barbcolor'] = kwargs.pop('color') + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, default_discrete=False, **kwargs) + return self._call_method('barbs', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def quiver(self, *args, **kwargs): + """ + Plot quiver arrows. + + %(plot.flow)s + **kwargs + Passed to `~matplotlib.axes.Axes.quiver`. + + See also + -------- + PlotAxes.barbs + PlotAxes.streamplot + matplotlib.axes.Axes.quiver + """ + _process_props(kwargs, 'line') # applied to outline + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, default_discrete=False, **kwargs) + return self._call_method('quiver', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def streamplot(self, *args, **kwargs): + """ + Plot streamlines. + + %(plot.flow)s + **kwargs + Passed to `~matplotlib.axes.Axes.streamplot`. + + See also + -------- + PlotAxes.barbs + PlotAxes.quiver + matplotlib.axes.Axes.streamplot + """ + _process_props(kwargs, 'line') # applied to lines + *args, kwargs = self._standardize_2d(*args, **kwargs) + kwargs = self._parse_cmap(*args, default_discrete=False, **kwargs) + return self._call_method('streamplot', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def tricontour(self, *args, **kwargs): + """ + Plot contour lines from irregular points. + + Other parameters + ---------------- + %(plot.levels_all)s + **kwargs + Passed to `~matplotlib.axes.Axes.tricontour`. + + See also + -------- + matplotlib.axes.Axes.tricontour + """ + _process_props(kwargs, 'collection') + kwargs = self._parse_cmap(minlength=1, keep_levels=True, **kwargs) + self._call_method('tricontour', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def tricontourf(self, *args, **kwargs): + """ + Plot filled contour lines from irregular points. + + Other parameters + ---------------- + %(plot.levels_all)s + **kwargs + Passed to `~matplotlib.axes.Axes.tricontourf`. + + See also + -------- + matplotlib.axes.Axes.tricontourf + """ + _process_props(kwargs, 'collection') + kwargs = self._parse_cmap(keep_levels=True, **kwargs) + self._call_method('tricontourf', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def tripcolor(self, *args, **kwargs): + """ + Plot grid boxes from irregular points. + + Other parameters + ---------------- + %(plot.levels_all)s + **kwargs + Passed to `~matplotlib.axes.Axes.tripcolor`. + + See also + -------- + matplotlib.axes.Axes.tripcolor + """ + _process_props(kwargs, 'line') + if 'color' in kwargs: + kwargs['edgecolors'] = kwargs.pop('color') + kwargs = self._parse_cmap(**kwargs) + self._call_method('tripcolor', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def imshow(self, *args, **kwargs): + """ + Plot an image. + + Other parameters + ---------------- + %(plot.levels_all)s + **kwargs + Passed to `~matplotlib.axes.Axes.imshow`. + + See also + -------- + matplotlib.axes.Axes.imshow + """ + kwargs = self._parse_cmap(default_discrete=False, **kwargs) + self._call_method('imshow', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def matshow(self, *args, **kwargs): + """ + Plot a matrix. + + Other parameters + ---------------- + %(plot.levels_all)s + **kwargs + Passed to `~matplotlib.axes.Axes.matshow`. + + See also + -------- + matplotlib.axes.Axes.matshow + """ + kwargs = self._parse_cmap(**kwargs) + self._call_method('imshow', *args, **kwargs) + + @docstring.concatenate + @docstring.add_snippets + def spy(self, *args, **kwargs): + """ + Plot a sparcity pattern. + + Other parameters + ---------------- + %(plot.levels_all)s + **kwargs + Passed to `~matplotlib.axes.Axes.spy`. + + See also + -------- + matplotlib.axes.Axes.spy + """ + _process_props(kwargs, 'line') # takes valid Line2D properties + default_cmap = pcolors.ListedColormap(['w', 'k'], '_no_name') + kwargs = self._parse_cmap(default_cmap=default_cmap, **kwargs) + self._call_method('spy', *args, **kwargs) + + # Rename the shorthands + boxes = warnings._rename_objs('0.8', boxes=box) + violins = warnings._rename_objs('0.8', violins=violin) diff --git a/proplot/axes/polar.py b/proplot/axes/polar.py index 078d9da08..0495a8a63 100644 --- a/proplot/axes/polar.py +++ b/proplot/axes/polar.py @@ -11,12 +11,12 @@ from ..config import rc from ..internals import ic # noqa: F401 from ..internals import _not_none, docstring, warnings -from . import base +from . import plot __all__ = ['PolarAxes'] -class PolarAxes(base.Axes, mproj.PolarAxes): +class PolarAxes(plot.PlotAxes, mproj.PolarAxes): """ Axes subclass for plotting in polar coordinates. Adds the `~PolarAxes.format` method and overrides several existing methods. @@ -29,6 +29,9 @@ def __init__(self, *args, **kwargs): See also -------- proplot.ui.subplots + proplot.axes.Axes + proplot.axes.PlotAxes + matplotlib.projections.PolarAxes """ # Set tick length to zero so azimuthal labels are not too offset # Change default radial axis formatter but keep default theta one @@ -109,7 +112,7 @@ def format( Other parameters ---------------- - %(axes.other)s + %(axes.format_other)s See also -------- diff --git a/proplot/axes/three.py b/proplot/axes/three.py index f850b545c..ecfd26796 100644 --- a/proplot/axes/three.py +++ b/proplot/axes/three.py @@ -3,7 +3,7 @@ The "3D" axes class. """ from ..config import rc -from . import base +from . import plot try: from mpl_toolkits.mplot3d import Axes3D as Axes3DBase @@ -11,9 +11,9 @@ Axes3DBase = object -class Axes3D(base.Axes, Axes3DBase): +class Axes3D(plot.PlotAxes, Axes3DBase): """ - Simple mix-in of `proplot.axes.Axes` with `~mpl_toolkits.mplot3d.Axes3D`. + Simple mix-in of `proplot.axes.PlotAxes` with `~mpl_toolkits.mplot3d.Axes3D`. """ #: The registered projection name. name = 'proplot_3d' diff --git a/proplot/colors.py b/proplot/colors.py index 092bacf3f..3d18c1c17 100644 --- a/proplot/colors.py +++ b/proplot/colors.py @@ -2,10 +2,10 @@ """ New colormap classes and colormap normalization classes. """ -# NOTE: Avoid colormap/color name conflicts by checking -# set(pplt.colors._cmap_database) & set(pplt.colors.mcolors._colors_full_map) -# whenever new default colormaps are added. Currently result is -# {'gray', 'marine', 'ocean', 'pink'} which correspond to MATLAB and GNUplot maps. +# NOTE: Avoid colormap/color name conflicts by checking set(pplt.colors._cmap_database) +# & set(pplt.colors.mcolors._colors_full_map) whenever new default colormaps are added. +# Currently result is {'gray', 'marine', 'ocean', 'pink'} which correspond to +# MATLAB and GNUplot colormaps. import json import os import re @@ -884,8 +884,8 @@ def reversed(self, name=None, **kwargs): matplotlib.colors.LinearSegmentedColormap.reversed """ segmentdata = { - key: (lambda x: data(1.0 - x)) if callable(data) - else [(1.0 - x, y1, y0) for x, y0, y1 in reversed(data)] + key: lambda x, func=data: func(x) if callable(data) else + [(1.0 - x, y1, y0) for x, y0, y1 in reversed(data)] for key, data in self._segmentdata.items() } for key in ('gamma1', 'gamma2'): diff --git a/proplot/config.py b/proplot/config.py index 84a15005a..30d99af74 100644 --- a/proplot/config.py +++ b/proplot/config.py @@ -999,13 +999,15 @@ def _get_synced_params(self, key, value): # Props matching the below strings use the units 'points'. # See: https://matplotlib.org/users/customizing.html # TODO: Incorporate into more sophisticated validation system + # NOTE: Ignores all proplot categories since we translate them before + # application wherever they are used. if REGEX_POINTS.match(key): if key in FONT_KEYS and value in mfonts.font_scalings: pass elif key.startswith('legend') and not key.endswith('fontsize'): value = units(value, 'em') # scaled by font size else: - value = units(value, 'pt') # untis points fontsize='10px' + value = units(value, 'pt') # point units e.g. fontsize='10px' # Special key: configure inline backend if key == 'inlinefmt': @@ -1187,20 +1189,19 @@ def _scale_font(size): """ # NOTE: Critical this remains KeyError so except clause # in _get_synced_params works. + scalings = mfonts.font_scalings if not isinstance(size, str): return size + if size in mfonts.font_scalings: + return rc_matplotlib['font.size'] * scalings[size] try: - scale = mfonts.font_scalings[size] - except KeyError: + return units(size, 'pt') + except (TypeError, ValueError): raise KeyError( f'Invalid font scaling {size!r}. Options are: ' - + ', '.join( - f'{key!r} ({value})' - for key, value in mfonts.font_scalings.items() - ) + '.' + + ', '.join(f'{key!r} ({value})' for key, value in scalings.items()) + + '.' ) - else: - return rc_matplotlib['font.size'] * scale @staticmethod def local_files(): diff --git a/proplot/demos.py b/proplot/demos.py index 16ad1c60c..62adc7ef9 100644 --- a/proplot/demos.py +++ b/proplot/demos.py @@ -13,7 +13,7 @@ from . import constructor, ui from .config import COLORS_BASE, COLORS_OPEN, COLORS_XKCD, _get_data_folders, rc from .internals import ic # noqa: F401 -from .internals import _not_none, docstring +from .internals import _not_none, docstring, warnings from .utils import to_rgb, to_xyz __all__ = [ @@ -51,6 +51,9 @@ 'Seaborn diverging': ( 'IceFire', 'Vlag', ), + 'Other sequential': ( + 'cubehelix', + ), 'ProPlot sequential': ( 'Fire', 'Stellar', @@ -138,7 +141,7 @@ ), 'Other': ( 'binary', 'bwr', 'brg', # appear to be custom matplotlib - 'cubehelix', 'Wistia', 'CMRmap', # individually released + 'Wistia', 'CMRmap', # individually released 'seismic', 'terrain', 'nipy_spectral', # origin ambiguous 'tab10', 'tab20', 'tab20b', 'tab20c', # merged colormap cycles ) @@ -238,7 +241,7 @@ def show_channels( labels += ('Red', 'Green', 'Blue') fig, axs = ui.subplots( array=array, refwidth=refwidth, wratios=(1.5, 1, 1, 1, 1, 1.5), - share=1, span=False, innerpad='1em', + share=1, span=False, innerpad=1, ) # Iterate through colormaps mc = ms = mp = 0 @@ -384,7 +387,7 @@ def show_colorspaces(*, luminance=None, saturation=None, hue=None, refwidth=2): # Make figure, with black indicating invalid values # Note we invert the x-y ordering for imshow - fig, axs = ui.subplots(ncols=3, share=0, refwidth=refwidth, innerpad=0.05) + fig, axs = ui.subplots(ncols=3, share=0, refwidth=refwidth, innerpad=0.5) for ax, space in zip(axs, ('hcl', 'hsl', 'hpl')): rgba = np.ones((*hsl.shape[:2][::-1], 4)) # RGBA for j in range(hsl.shape[0]): @@ -404,8 +407,9 @@ def show_colorspaces(*, luminance=None, saturation=None, hue=None, refwidth=2): return fig, axs +@warnings._rename_kwargs('0.8', categories='include') def _draw_bars( - cmaps, *, source, unknown='User', categories=None, + cmaps, *, source, unknown='User', include=None, ignore=None, length=4.0, width=0.2, N=None ): """ @@ -453,17 +457,22 @@ def _draw_bars( cmapdict[cat] = names_cat # Filter out certain categories - if categories is None: - categories = source.keys() - {'MATLAB', 'GNUplot', 'GIST', 'Other'} - if isinstance(categories, str): - categories = (categories,) - if any(cat not in source and cat != unknown for cat in categories): + options = set(map(str.lower, source)) + if ignore is None: + ignore = ('matlab', 'gnuplot', 'gist', 'other') + if isinstance(include, str): + include = (include.lower(),) + if isinstance(ignore, str): + ignore = (ignore.lower(),) + if include is None: + include = options - set(map(str.lower, ignore)) + if any(cat not in options and cat != unknown for cat in include): raise ValueError( - f'Invalid categories {categories!r}. Options are: ' + f'Invalid categories {include!r}. Options are: ' + ', '.join(map(repr, source)) + '.' ) - for cat in (*cmapdict,): - if cat not in categories and cat != unknown: + for cat in tuple(cmapdict): + if cat.lower() not in include and cat != unknown: cmapdict.pop(cat) # Draw figure @@ -499,6 +508,7 @@ def _draw_bars( if imap == 0: ax.set_title(cat, weight='bold') nbars += len(names) + return fig, axs @@ -520,14 +530,14 @@ def show_cmaps(*args, **kwargs): Category name for colormaps that are unknown to ProPlot. The default is ``'User'``. Set this to ``False`` to hide unknown colormaps. - categories : str or list of str, optional - Category names to be shown in the table. By default, all categories - are shown except for ``'MATLAB'``, ``'GNUplot'``, ``'GIST'``, and - ``'Other'``. Use of these colormaps is discouraged, because they - contain a variety of non-uniform colormaps (see - :ref:`perceptually uniform colormaps ` for details). - - Valid categories are %(cmap.categories)s. + include : str or list of str, optional + Category names to be shown in the table. Use this to limit the table + to a subset of categories. Valid categories are %(cmap.categories)s. + ignore : str or list of str, optional + Used only if `include` was not passed. Category names to be removed from the + table. Default is ``'MATLAB'``, ``'GNUplot'``, ``'GIST'``, and ``'Other'``. + Use of these colormaps is discouraged, because they contain non-uniform color + transitions (see the :ref:`user guide `). %(show.colorbars)s Returns @@ -544,17 +554,20 @@ def show_cmaps(*args, **kwargs): show_fonts """ # Get the list of colormaps + # TODO: Filter out colormaps ending with '_copy' and '_r'? if args: cmaps = [constructor.Colormap(cmap) for cmap in args] + ignore = () else: cmaps = [ name for name, cmap in pcolors._cmap_database.items() if isinstance(cmap, pcolors.LinearSegmentedColormap) - # and not name.endswith('_copy') and not name.endswith('_r') ] + ignore = None # Return figure of colorbars kwargs.setdefault('source', CMAPS_TABLE) + kwargs.setdefault('ignore', ignore) return _draw_bars(cmaps, **kwargs) @@ -570,14 +583,14 @@ def show_cycles(*args, **kwargs): *args : colormap-spec, optional Cycle names or objects. unknown : str, optional - Category name for cycles that are unknown to ProPlot. The - default is ``'User'``. Set this to ``False`` to hide - unknown colormaps. - categories : str or list of str, optional - Category names to be shown in the table. - By default, all categories are shown. - - Valid categories are %(cycle.categories)s. + Category name for cycles that are unknown to ProPlot. The default + is ``'User'``. Set this to ``False`` to hide unknown colormaps. + include : str or list of str, optional + Category names to be shown in the table. Use this to limit the table + to a subset of categories. Valid categories are %(cycle.categories)s. + ignore : str or list of str, optional + Used only if `include` was not passed. Category names to be removed from + the table. Default is ``'MATLAB'``, ``'GNUplot'``, ``'GIST'``, and ``'Other'``. %(show.colorbars)s Returns @@ -594,14 +607,17 @@ def show_cycles(*args, **kwargs): # Get the list of cycles if args: cycles = [constructor.Cycle(cmap, to_listed=True) for cmap in args] + ignore = () else: cycles = [ name for name, cmap in pcolors._cmap_database.items() if isinstance(cmap, pcolors.ListedColormap) ] + ignore = None # Return figure of colorbars kwargs.setdefault('source', CYCLES_TABLE) + kwargs.setdefault('ignore', ignore) return _draw_bars(cycles, **kwargs) @@ -631,7 +647,9 @@ def _filter_colors(hcl, ihue, nhues, minsat): @docstring.add_snippets -def show_colors(*, nhues=17, minsat=10, categories=None, unknown='User'): +def show_colors( + *, nhues=17, minsat=10, unknown='User', include=None, ignore=None +): """ Generate tables of the registered color names. Adapted from `this example `__. @@ -644,15 +662,15 @@ def show_colors(*, nhues=17, minsat=10, categories=None, unknown='User'): minsat : float, optional The threshold saturation, between ``0`` and ``100``, for designating "gray colors" in the color table. - categories : str or list of str, optional - Category names to be shown in the table. - By default, every category is shown except for CSS colors. - - Valid categories are %(color.categories)s. unknown : str, optional - Category name for color names that are unknown to ProPlot. The - default is ``'User'``. Set this to ``False`` to hide - unknown color names. + Category name for color names that are unknown to ProPlot. The default + is ``'User'``. Set this to ``False`` to hide unknown color names. + include : str or list of str, optional + Category names to be shown in the table. Use this to limti the table + to a subset of categories. Valid categories are %(color.categories)s. + ignore : str or list of str, optional + Used only if `include` was not passed. Category names to be removed + from the colormap table. Default is ``'CSS4'``. Returns ------- @@ -661,14 +679,19 @@ def show_colors(*, nhues=17, minsat=10, categories=None, unknown='User'): """ # Tables of known colors to be plotted colordict = {} - if isinstance(categories, str): - categories = (categories,) - if categories is None: - categories = ('base', 'opencolor', 'xkcd') # preserve order - for cat in categories: + if ignore is None: + ignore = 'css4' + if isinstance(include, str): + include = (include.lower(),) + if isinstance(ignore, str): + ignore = (ignore.lower(),) + if include is None: + include = COLORS_TABLE.keys() + include -= set(map(str.lower, ignore)) + for cat in include: if cat not in COLORS_TABLE: raise ValueError( - f'Invalid categories {categories!r}. Options are: ' + f'Invalid categories {include!r}. Options are: ' + ', '.join(map(repr, COLORS_TABLE)) + '.' ) colordict[cat] = COLORS_TABLE[cat] @@ -688,7 +711,7 @@ def show_colors(*, nhues=17, minsat=10, categories=None, unknown='User'): # colors, so just reshape them into grids. For other colors, we group # them by hue in descending order of luminance. namess = {} - for cat in categories: + for cat in include: if cat == 'base': names = np.asarray(colordict[cat]).reshape((2, 8)).T elif cat == 'opencolor': @@ -726,7 +749,7 @@ def show_colors(*, nhues=17, minsat=10, categories=None, unknown='User'): maxcols = max(names.shape[0] for names in namess.values()) hratios = tuple(names.shape[1] for names in namess.values()) fig, axs = ui.subplots( - nrows=len(categories), + nrows=len(include), hratios=hratios, figwidth=figwidth, refaspect=refaspect, diff --git a/proplot/figure.py b/proplot/figure.py index 36a435a95..ce37eb0e6 100644 --- a/proplot/figure.py +++ b/proplot/figure.py @@ -14,11 +14,12 @@ from .config import rc from .internals import ic # noqa: F401 from .internals import ( - _dummy_context, + _empty_context, _not_none, _state_context, _version, _version_mpl, + docstring, warnings, ) from .utils import units @@ -26,33 +27,106 @@ __all__ = ['Figure'] -def _parse_panel_args( - side, share=None, width=None, space=None, - filled=False, figure=False -): - """ - Return default properties for new axes and figure panels. - """ - if side not in ('left', 'right', 'bottom', 'top'): - raise ValueError(f'Invalid panel location {side!r}.') - - space = space_user = units(space) - if share is None: - share = not filled - - if width is None: - if filled: - width = rc['colorbar.width'] - else: - width = rc['subplots.panelwidth'] - width = units(width) - - if space is None: - key = 'wspace' if side in ('left', 'right') else 'hspace' - pad = rc['subplots.innerpad'] if figure else rc['subplots.panelpad'] - space = pgridspec._default_space(key, share, pad=pad) - - return share, width, space, space_user +# Figure docstring +_figure_docstring = """ +tight : bool, optional + Toggles automatic tight layout adjustments. Default is :rc:`subplots.tight`. + If you manually specified a spacing in the call to `~proplot.ui.subplots`, + it will be used to override the tight layout spacing. For example, with + ``left=0.1``, the left margin is set to 0.1 inches wide, while the + remaining margin widths are calculated automatically. +outerpad : float or str, optional + Padding around edge of figure. Units are interpreted by + `~proplot.utils.units`. Default is :rc:`subplots.outerpad`. +innerpad, pad : float or str, optional + Padding between subplots in adjacent columns and rows. The shorthand + ``pad`` is also valid. Units are interpreted by `~proplot.utils.units`. + Default is :rc:`subplots.innerpad`. +panelpad : float or str, optional + Padding between subplots and axes panels, and between "stacked" + panels. Units are interpreted by `~proplot.utils.units`. Default is + :rc:`subplots.panelpad`. +sharex, sharey, share : {3, 2, 1, 0}, optional + The "axis sharing level" for the *x* axis, *y* axis, or both axes. + Default is :rc:`subplots.share`. Options are as follows: + + 0. No axis sharing. Also sets the default `spanx` and `spany` + values to ``False``. + 1. Only draw *axis label* on the leftmost column (*y*) or bottommost row + (*x*) of subplots. Axis tick labels still appear on every subplot. + 2. As in 1, but forces the axis limits to be identical. Axis + tick labels still appear on every subplot. + 3. As in 2, but only show the *axis tick labels* on the + leftmost column (*y*) or bottommost row (*x*) of subplots. + +spanx, spany, span : bool or {0, 1}, optional + Toggles "spanning" axis labels for the *x* axis, *y* axis, or both + axes. Default is ``False`` if `sharex`, `sharey`, or `share` are + ``0``, :rc:`subplots.span` otherwise. When ``True``, a single, centered + axis label is used for all axes with bottom and left edges in the same + row or column. This can considerably redundancy in your figure. + + "Spanning" labels integrate with "shared" axes. For example, + for a 3-row, 3-column figure, with ``sharey > 1`` and ``spany=1``, + your figure will have 1 ylabel instead of 9. +alignx, aligny, align : bool or {0, 1}, optional + Whether to `"align" axis labels \ +`__ + for the *x* axis, *y* axis, or both axes. Aligned labels always appear in the + same row or column. This Only has an effect when `spanx`, `spany`, or `span` + are ``False``. Default is :rc:`subplots.align`. +includepanels : bool, optional + Whether to include panels when centering *x* axis labels, + *y* axis labels, and figure "super titles" along the edge of the + subplot grid. Default is ``False``. +mathtext_fallback : bool or str, optional + Figure-specific application of the :rc:`mathtext.fallback` property. + If ``True`` or string, unavailable glyphs are replaced with a glyph from a + fallback font (Computer Modern by default). Otherwise, they are replaced + with the "¤" dummy character. See this `mathtext tutorial \ +`__ + for details. +""" +docstring.snippets['figure.figure'] = _figure_docstring + + +# Colorbar or legend panel docstring +_space_docstring = """ +loc : str, optional + The {name} location. Valid location keys are as follows. + + =========== ===================== + Location Valid keys + =========== ===================== + left edge ``'l'``, ``'left'`` + right edge ``'r'``, ``'right'`` + bottom edge ``'b'``, ``'bottom'`` + top edge ``'t'``, ``'top'`` + =========== ===================== + +row, rows : optional + Aliases for `span` for panels on the left or right side. +col, cols : optional + Aliases for `span` for panels on the top or bottom side. +span : int or (int, int), optional + Describes how the {name} spans rows and columns of subplots. + For example, ``fig.{name}(loc='b', col=1)`` draws a {name} + beneath the leftmost column of subplots, and + ``fig.{name}(loc='b', cols=(1,2))`` draws a {name} beneath the + left two columns of subplots. By default, the {name} will span + all rows and columns. +space : float or str, optional + The fixed space between the {name} and the subplot grid. Units are + interpreted by `~proplot.utils.units`. When the tight layout algorithm + is active for the figure, this is adjusted automatically using `pad`. + Otherwise, a suitable default is selected. +pad : float or str, optional + The tight layout padding between the subplot grid and the {name}. + Default is :rc:`subplots.innerpad` for the first {name} and + :rc:`subplots.panelpad` for subsequently stacked {name}. +""" +docstring.snippets['figure.colorbar_space'] = _space_docstring.format(name='colorbar') +docstring.snippets['figure.legend_space'] = _space_docstring.format(name='legend') def _canvas_preprocessor(canvas, method): @@ -142,12 +216,13 @@ class Figure(mfigure.Figure): """ # NOTE: If _rename_kwargs argument is an invalid identifier, it is # simply used in the warning message. - @warnings._rename_kwargs('0.7', pad='outerpad', axpad='innerpad') + @warnings._rename_kwargs('0.7', axpad='innerpad') @warnings._rename_kwargs('0.6.4', autoformat='pplt.rc.autoformat = {}') + @docstring.add_snippets def __init__( - self, tight=None, - ref=1, outerpad=None, innerpad=None, panelpad=None, includepanels=False, - span=None, spanx=None, spany=None, + self, ref=1, tight=None, + pad=None, outerpad=None, innerpad=None, panelpad=None, + span=None, spanx=None, spany=None, includepanels=False, align=None, alignx=None, aligny=None, share=None, sharex=None, sharey=None, gridspec_kw=None, subplots_kw=None, subplots_orig_kw=None, @@ -157,69 +232,14 @@ def __init__( """ Parameters ---------- - tight : bool, optional - Toggles automatic tight layout adjustments. Default is :rc:`subplots.tight`. - If you manually specified a spacing in the call to `~proplot.ui.subplots`, - it will be used to override the tight layout spacing. For example, with - ``left=0.1``, the left margin is set to 0.1 inches wide, while the - remaining margin widths are calculated automatically. ref : int, optional The reference subplot number. See `~proplot.ui.subplots` for details. Default is ``1``. - outerpad : float or str, optional - Padding around edge of figure. Units are interpreted by - `~proplot.utils.units`. Default is :rc:`subplots.outerpad`. - innerpad : float or str, optional - Padding between subplots in adjacent columns and rows. Units are - interpreted by `~proplot.utils.units`. Default is - :rc:`subplots.innerpad`. - panelpad : float or str, optional - Padding between subplots and axes panels, and between "stacked" - panels. Units are interpreted by `~proplot.utils.units`. Default is - :rc:`subplots.panelpad`. - includepanels : bool, optional - Whether to include panels when centering *x* axis labels, - *y* axis labels, and figure "super titles" along the edge of the - subplot grid. Default is ``False``. - sharex, sharey, share : {3, 2, 1, 0}, optional - The "axis sharing level" for the *x* axis, *y* axis, or both axes. - Default is :rc:`subplots.share`. Options are as follows: - - 0. No axis sharing. Also sets the default `spanx` and `spany` - values to ``False``. - 1. Only draw *axis label* on the leftmost column (*y*) or bottommost row - (*x*) of subplots. Axis tick labels still appear on every subplot. - 2. As in 1, but forces the axis limits to be identical. Axis - tick labels still appear on every subplot. - 3. As in 2, but only show the *axis tick labels* on the - leftmost column (*y*) or bottommost row (*x*) of subplots. - - spanx, spany, span : bool or {0, 1}, optional - Toggles "spanning" axis labels for the *x* axis, *y* axis, or both - axes. Default is ``False`` if `sharex`, `sharey`, or `share` are - ``0``, :rc:`subplots.span` otherwise. When ``True``, a single, centered - axis label is used for all axes with bottom and left edges in the same - row or column. This can considerably redundancy in your figure. - - "Spanning" labels integrate with "shared" axes. For example, - for a 3-row, 3-column figure, with ``sharey > 1`` and ``spany=1``, - your figure will have 1 ylabel instead of 9. - alignx, aligny, align : bool or {0, 1}, optional - Whether to `align axis labels \ -`__ - for the *x* axis, *y* axis, or both axes. Only has an effect when `spanx`, - `spany`, or `span` are ``False``. Default is :rc:`subplots.align`. - mathtext_fallback : bool or str, optional - Figure-specific application of the :rc:`mathtext.fallback` property. - If ``True`` or string, unavailable glyphs are replaced with a glyph from a - fallback font (Computer Modern by default). Otherwise, they are replaced - with the "¤" dummy character. See this `mathtext tutorial \ -`__ - for details. + %(figure.figure)s gridspec_kw, subplots_kw, subplots_orig_kw - Keywords used for initializing the main gridspec, for initializing - the figure, and original spacing keyword args used for initializing - the figure that override tight layout spacing. + Keywords used for initializing the main gridspec, the default + `~proplot.ui.subplots` layout, and the `~proplot.ui.subplots` + layout with user overrides. Other parameters ---------------- @@ -286,10 +306,13 @@ def __init__( gridspec_kw = gridspec_kw or {} gridspec = pgridspec.GridSpec(self, **gridspec_kw) nrows, ncols = gridspec.get_active_geometry() + outerpad = _not_none(outerpad, rc['subplots.outerpad']) + innerpad = _not_none(innerpad, pad, rc['subplots.innerpad']) + panelpad = _not_none(panelpad, rc['subplots.panelpad']) self._auto_tight = _not_none(tight, rc['subplots.tight']) - self._outer_pad = units(_not_none(outerpad, rc['subplots.outerpad'])) - self._inner_pad = units(_not_none(innerpad, rc['subplots.innerpad'])) - self._panel_pad = units(_not_none(panelpad, rc['subplots.panelpad'])) + self._outer_pad = units(outerpad, 'em', 'in') + self._inner_pad = units(innerpad, 'em', 'in') + self._panel_pad = units(panelpad, 'em', 'in') self._include_panels = includepanels self._ref_num = ref self._gridspec_main = gridspec @@ -312,9 +335,166 @@ def __init__( d['bottom'] = np.empty((0, ncols), dtype=bool) d['top'] = np.empty((0, ncols), dtype=bool) + def _context_authorize_add_subplot(self): + """ + Prevent warning message when adding subplots one-by-one. Used + internally. + """ + return _state_context(self, _authorized_add_subplot=True) + + def _context_autoresizing(self): + """ + Ensure backend calls to `~matplotlib.figure.Figure.set_size_inches` + during pre-processing are not interpreted as *manual* resizing. + """ + return _state_context(self, _is_autoresizing=True) + + def _context_preprocessing(self, cache=True): + """ + Prevent re-running pre-processing steps due to draws triggered + by figure resizes during pre-processing. `cache` controls whether the + renderer passed to draw should be cached. + """ + kwargs = {} + if not cache: + kwargs['_cachedRenderer'] = None # __exit__ will restore previous value + return _state_context(self, _is_preprocessing=True, **kwargs) + + def _draw_colorbars_legends(self): + """ + Draw legends and colorbars requested via plotting commands. Drawing is + deferred so that successive calls to the plotting commands can successively + add entries to legends and colorbars in particular locations. + """ + for ax in self._iter_axes(hidden=False, children=True): + if isinstance(ax, paxes.Axes): + ax._draw_colorbars_legends() # may insert panels + + def _get_align_coord(self, side, axs): + """ + Return the figure coordinate for spanning labels or super titles. + The `x` can be ``'x'`` or ``'y'``. + """ + # Get position in figure relative coordinates + if side in ('left', 'right'): + s = 'y' + panels = ('top', 'bottom') + else: + s = 'x' + panels = ('left', 'right') + if self._include_panels: + axs = [ + iax for ax in axs + for iax in ax._iter_axes(panels=panels, children=False) + ] + + # Get coordinates + ranges = np.array([ax._range_gridspec(s) for ax in axs]) + min_, max_ = ranges[:, 0].min(), ranges[:, 1].max() + ax_lo = axs[np.where(ranges[:, 0] == min_)[0][0]] + ax_hi = axs[np.where(ranges[:, 1] == max_)[0][0]] + box_lo = ax_lo.get_subplotspec().get_position(self) + box_hi = ax_hi.get_subplotspec().get_position(self) + if s == 'x': + pos = 0.5 * (box_lo.x0 + box_hi.x1) + else: + pos = 0.5 * (box_lo.y1 + box_hi.y0) # 'lo' is actually on top of figure + + # Return axis suitable for spanning position + ax_span = axs[(np.argmin(ranges[:, 0]) + np.argmax(ranges[:, 1])) // 2] + ax_span = ax_span._panel_parent or ax_span + return pos, ax_span + + def _get_align_axes(self, side): + """ + Return the main axes along the left, right, bottom, or top sides + of the figure. + """ + # Initial stuff + idx = 0 if side in ('left', 'top') else 1 + if side in ('left', 'right'): + x, y = 'x', 'y' + else: + x, y = 'y', 'x' + + # Get edge index + axs = self._subplots_main + if not axs: + return [] + ranges = np.array([ax._range_gridspec(x) for ax in axs]) + min_, max_ = ranges[:, 0].min(), ranges[:, 1].max() + edge = min_ if side in ('left', 'top') else max_ + + # Return axes on edge sorted by order of appearance + axs = [ax for ax in self._subplots_main if ax._range_gridspec(x)[idx] == edge] + ranges = [ax._range_gridspec(y)[0] for ax in axs] + return [ax for _, ax in sorted(zip(ranges, axs)) if ax.get_visible()] + + def _get_renderer(self): + """ + Get a renderer at all costs, even if it means generating a new one. Used for + updating the figure bounding box when it is accessed and calculating centered + row legend bounding boxes. This is copied from tight_layout.py in matplotlib. + """ + if self._cachedRenderer: + renderer = self._cachedRenderer + else: + canvas = self.canvas + if canvas and hasattr(canvas, 'get_renderer'): + renderer = canvas.get_renderer() + else: + from matplotlib.backends.backend_agg import FigureCanvasAgg + canvas = FigureCanvasAgg(self) + renderer = canvas.get_renderer() + return renderer + + def _parse_panel_args( + self, side, share=None, width=None, space=None, pad=None, filled=False, figure=False # noqa: E501 + ): + """ + Return default properties for new axes and figure panels. + """ + if side not in ('left', 'right', 'bottom', 'top'): + raise ValueError(f'Invalid panel location {side!r}.') + if share is None: + share = not filled + + # Panel width + # NOTE: Colorbar width is em to match legend while panel width + # is in to match subplot refwidth. + if filled: + width = units(_not_none(width, rc['colorbar.width']), 'em', 'in') + else: + width = units(_not_none(width, rc['subplots.panelwidth']), 'in') + + # Panel space + space = space_user = units(space, 'em', 'in') + if space is None: + key = 'wspace' if side in ('left', 'right') else 'hspace' + space = pgridspec._default_space(key, share, pad=pad) + + # Panel padding + wpanels = self._subplots_kw['wpanels'] + hpanels = self._subplots_kw['hpanels'] + if pad is not None: + pass + elif ( + not figure + or side == 'left' and wpanels[0] == 'f' + or side == 'right' and wpanels[-1] == 'f' + or side == 'top' and hpanels[0] == 'f' + or side == 'bottom' and hpanels[-1] == 'f' + ): + pad = self._panel_pad # already converted with units + else: + pad = self._inner_pad + pad = units(pad, 'em', 'in') + + return share, width, space, space_user, pad + def _add_axes_panel(self, ax, side, filled=False, **kwargs): """ - Hidden method that powers `~proplot.axes.panel_axes`. + Add an axes panel. """ # Interpret args # NOTE: Axis sharing not implemented for figure panels, 99% of the @@ -323,7 +503,7 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): if side not in ('left', 'right', 'bottom', 'top'): raise ValueError(f'Invalid side {side!r}.') ax = ax._panel_parent or ax # redirect to main axes - share, width, space, space_orig = _parse_panel_args( + share, width, space, space_orig, pad = self._parse_panel_args( side, filled=filled, figure=False, **kwargs ) @@ -342,7 +522,7 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): idx2 = slice(col1, col2 + 1) gridspec_prev = self._gridspec_main gridspec = self._insert_row_column( - side, iratio, width, space, space_orig, figure=False + side, iratio, width, space=space, space_orig=space_orig, pad=pad, figure=0 ) if gridspec is not gridspec_prev: if side == 'top': @@ -373,30 +553,23 @@ def _add_figure_panel( **kwargs ): """ - Add a figure panel. Also modifies the panel attribute stored - on the figure to include these panels. + Add a figure panel. """ # Interpret args and enforce sensible keyword args if side not in ('left', 'right', 'bottom', 'top'): raise ValueError(f'Invalid side {side!r}.') - _, width, space, space_orig = _parse_panel_args( + _, width, space, space_orig, pad = self._parse_panel_args( side, filled=True, figure=True, **kwargs ) if side in ('left', 'right'): for key, value in (('col', col), ('cols', cols)): if value is not None: - raise ValueError( - f'Invalid keyword arg {key!r} for figure panel ' - f'on side {side!r}.' - ) + raise ValueError(f'Invalid keyword {key!r} for {side!r} panel.') span = _not_none(span=span, row=row, rows=rows) else: for key, value in (('row', row), ('rows', rows)): if value is not None: - raise ValueError( - f'Invalid keyword arg {key!r} for figure panel ' - f'on side {side!r}.' - ) + raise ValueError(f'Invalid keyword {key!r} for {side!r} panel.') span = _not_none(span=span, col=col, cols=cols) # Get props @@ -457,7 +630,7 @@ def _add_figure_panel( idx1 = max(iratio, 0) idx2 = slice(idxs[start], idxs[stop - 1] + 1) gridspec = self._insert_row_column( - side, iratio, width, space, space_orig, figure=True + side, iratio, width, space=space, space_orig=space_orig, pad=pad, figure=1 ) # Draw and setup panel @@ -470,6 +643,117 @@ def _add_figure_panel( pax._panel_parent = None return pax + def _insert_row_column( + self, side, idx, ratio, space=None, space_orig=None, pad=None, figure=False, + ): + """ + "Overwrite" the main figure gridspec to make room for a panel. The + `side` is the panel side, the `idx` is the slot you want the panel + to occupy, and the remaining args are the panel widths and spacings. + """ + # Constants and stuff + # Insert spaces to the left of right panels or to the right of + # left panels. And note that since .insert() pushes everything in + # that column to the right, actually must insert 1 slot farther to + # the right when inserting left panels/spaces + if side not in ('left', 'right', 'bottom', 'top'): + raise ValueError(f'Invalid side {side}.') + idx_space = idx - 1 * bool(side in ('bottom', 'right')) + idx_offset = 1 * bool(side in ('top', 'left')) + if side in ('left', 'right'): + w, ncols = 'w', 'ncols' + else: + w, ncols = 'h', 'nrows' + + # Load arrays and test if we need to insert + subplots_kw = self._subplots_kw + subplots_orig_kw = self._subplots_orig_kw + panels = subplots_kw[w + 'panels'] + ratios = subplots_kw[w + 'ratios'] + spaces = subplots_kw[w + 'space'] + spaces_orig = subplots_orig_kw[w + 'space'] + pads = subplots_kw[w + 'pad'] + + # Adjust space, ratio, and panel indicator arrays + slot_type = 'f' if figure else side[0] + slot_exists = idx not in (-1, len(panels)) and panels[idx] == slot_type + if slot_exists: + # Slot already exists + if spaces_orig[idx_space] is None: + spaces_orig[idx_space] = units(space_orig, 'em', 'in') + spaces[idx_space] = _not_none(spaces_orig[idx_space], space) + pads[idx_space] = pad + else: + # Modify basic geometry and insert new slot + idx += idx_offset + idx_space += idx_offset + subplots_kw[ncols] += 1 + ratios.insert(idx, ratio) + panels.insert(idx, slot_type) + spaces.insert(idx_space, space) + spaces_orig.insert(idx_space, space_orig) + pads.insert(idx_space, pad) + + # Update figure + figsize, gridspec_kw, _ = pgridspec._calc_geometry(**subplots_kw) + if slot_exists: + # Update gridspec + gridspec = self._gridspec_main + gridspec.update(**gridspec_kw) + else: + # Make new gridspec + gridspec = pgridspec.GridSpec(self, **gridspec_kw) + self._gridspec_main.figure = None + self._gridspec_main = gridspec + self._replace_subplotspecs(idx, side) + + # Adjust figure size *after* gridspecs are fixed + self.set_size_inches(figsize, internal=True) + + return gridspec + + def _replace_subplotspecs(self, idx, side): + """ + Replace subplot specs after inserting a row or column + at index `idx` to make room for a side-`side` panel. + """ + gridspec = self._gridspec_main + if side not in ('left', 'right', 'bottom', 'top'): + raise ValueError(f'Invalid side {side}.') + for ax in self._iter_axes(hidden=True, children=True): + # Get old index + # NOTE: Endpoints are inclusive, not exclusive! + if not hasattr(ax, 'get_subplotspec'): + continue + if side in ('left', 'right'): + inserts = (None, None, idx, idx) + else: + inserts = (idx, idx, None, None) + subplotspec = ax.get_subplotspec() + subplotspec_top = subplotspec.get_topmost_subplotspec() + subplotspec_gridspec = subplotspec.get_gridspec() + + # Apply new subplotspec + # NOTE: Should only have one possible level of GridSpecFromSubplotSpec + # nesting: when making side colorbars with length less than 1. + _, _, *coords = subplotspec_top.get_active_rows_columns() + for i in range(4): + if inserts[i] is not None and coords[i] >= inserts[i]: + coords[i] += 1 + row1, row2, col1, col2 = coords + subplotspec_new = gridspec[row1:row2 + 1, col1:col2 + 1] + if subplotspec_top is subplotspec: + ax.set_subplotspec(subplotspec_new) + elif subplotspec_top is subplotspec_gridspec._subplot_spec: + subplotspec_gridspec._subplot_spec = subplotspec_new + else: + raise RuntimeError('Unexpected GridSpecFromSubplotSpec nesting.') + if _version_mpl >= _version('3.4'): + ax.set_position(ax.get_subplotspec().get_position(ax.figure)) + else: + ax.update_params() + ax.set_position(ax.figbox) # equivalent to above + def _align_axis_labels(self, b=True): """ Align spanning *x* and *y* axis labels in the perpendicular @@ -629,220 +913,6 @@ def _align_super_labels(self, renderer): } suptitle.update(kw) - def _context_authorize_add_subplot(self): - """ - Prevent warning message when adding subplots one-by-one. Used - internally. - """ - return _state_context(self, _authorized_add_subplot=True) - - def _context_autoresizing(self): - """ - Ensure backend calls to `~matplotlib.figure.Figure.set_size_inches` - during pre-processing are not interpreted as *manual* resizing. - """ - return _state_context(self, _is_autoresizing=True) - - def _context_preprocessing(self, cache=True): - """ - Prevent re-running pre-processing steps due to draws triggered - by figure resizes during pre-processing. `cache` controls whether the - renderer passed to draw should be cached. - """ - kwargs = {} - if not cache: - kwargs['_cachedRenderer'] = None # __exit__ will restore previous value - return _state_context(self, _is_preprocessing=True, **kwargs) - - def _draw_colorbars_legends(self): - """ - Draw legends and colorbars requested via plotting commands. Drawing is - deferred so that successive calls to the plotting commands can successively - add entries to legends and colorbars in particular locations. - """ - for ax in self._iter_axes(hidden=False, children=True): - if isinstance(ax, paxes.Axes): - ax._draw_colorbars_legends() # may insert panels - - def _get_align_coord(self, side, axs): - """ - Return the figure coordinate for spanning labels or super titles. - The `x` can be ``'x'`` or ``'y'``. - """ - # Get position in figure relative coordinates - if side in ('left', 'right'): - s = 'y' - panels = ('top', 'bottom') - else: - s = 'x' - panels = ('left', 'right') - if self._include_panels: - axs = [ - iax for ax in axs - for iax in ax._iter_axes(panels=panels, children=False) - ] - - # Get coordinates - ranges = np.array([ax._range_gridspec(s) for ax in axs]) - min_, max_ = ranges[:, 0].min(), ranges[:, 1].max() - ax_lo = axs[np.where(ranges[:, 0] == min_)[0][0]] - ax_hi = axs[np.where(ranges[:, 1] == max_)[0][0]] - box_lo = ax_lo.get_subplotspec().get_position(self) - box_hi = ax_hi.get_subplotspec().get_position(self) - if s == 'x': - pos = 0.5 * (box_lo.x0 + box_hi.x1) - else: - pos = 0.5 * (box_lo.y1 + box_hi.y0) # 'lo' is actually on top of figure - - # Return axis suitable for spanning position - ax_span = axs[(np.argmin(ranges[:, 0]) + np.argmax(ranges[:, 1])) // 2] - ax_span = ax_span._panel_parent or ax_span - return pos, ax_span - - def _get_align_axes(self, side): - """ - Return the main axes along the left, right, bottom, or top sides - of the figure. - """ - # Initial stuff - idx = 0 if side in ('left', 'top') else 1 - if side in ('left', 'right'): - x, y = 'x', 'y' - else: - x, y = 'y', 'x' - - # Get edge index - axs = self._subplots_main - if not axs: - return [] - ranges = np.array([ax._range_gridspec(x) for ax in axs]) - min_, max_ = ranges[:, 0].min(), ranges[:, 1].max() - edge = min_ if side in ('left', 'top') else max_ - - # Return axes on edge sorted by order of appearance - axs = [ax for ax in self._subplots_main if ax._range_gridspec(x)[idx] == edge] - ranges = [ax._range_gridspec(y)[0] for ax in axs] - return [ax for _, ax in sorted(zip(ranges, axs)) if ax.get_visible()] - - def _get_renderer(self): - """ - Get a renderer at all costs, even if it means generating a brand new one! - Used for updating the figure bounding box when it is accessed and calculating - centered-row legend bounding boxes. This is copied from tight_layout.py in - matplotlib. - """ - if self._cachedRenderer: - renderer = self._cachedRenderer - else: - canvas = self.canvas - if canvas and hasattr(canvas, 'get_renderer'): - renderer = canvas.get_renderer() - else: - from matplotlib.backends.backend_agg import FigureCanvasAgg - canvas = FigureCanvasAgg(self) - renderer = canvas.get_renderer() - return renderer - - def _insert_row_column( - self, side, idx, - ratio, space, space_orig, figure=False, - ): - """ - "Overwrite" the main figure gridspec to make room for a panel. The - `side` is the panel side, the `idx` is the slot you want the panel - to occupy, and the remaining args are the panel widths and spacings. - """ - # Constants and stuff - # Insert spaces to the left of right panels or to the right of - # left panels. And note that since .insert() pushes everything in - # that column to the right, actually must insert 1 slot farther to - # the right when inserting left panels/spaces - if side not in ('left', 'right', 'bottom', 'top'): - raise ValueError(f'Invalid side {side}.') - idx_space = idx - 1 * bool(side in ('bottom', 'right')) - idx_offset = 1 * bool(side in ('top', 'left')) - if side in ('left', 'right'): - w, ncols = 'w', 'ncols' - else: - w, ncols = 'h', 'nrows' - - # Load arrays and test if we need to insert - subplots_kw = self._subplots_kw - subplots_orig_kw = self._subplots_orig_kw - panels = subplots_kw[w + 'panels'] - ratios = subplots_kw[w + 'ratios'] - spaces = subplots_kw[w + 'space'] - spaces_orig = subplots_orig_kw[w + 'space'] - - # Adjust space, ratio, and panel indicator arrays - slot_type = 'f' if figure else side[0] - slot_exists = idx not in (-1, len(panels)) and panels[idx] == slot_type - if slot_exists: - # Slot already exists - if spaces_orig[idx_space] is None: - spaces_orig[idx_space] = units(space_orig) - spaces[idx_space] = _not_none(spaces_orig[idx_space], space) - - else: - # Modify basic geometry and insert new slot - idx += idx_offset - idx_space += idx_offset - subplots_kw[ncols] += 1 - spaces_orig.insert(idx_space, space_orig) - spaces.insert(idx_space, space) - ratios.insert(idx, ratio) - panels.insert(idx, slot_type) - - # Update figure - figsize, gridspec_kw, _ = pgridspec._calc_geometry(**subplots_kw) - if slot_exists: - gridspec = self._gridspec_main - gridspec.update(**gridspec_kw) - - else: - # Make new gridspec - gridspec = pgridspec.GridSpec(self, **gridspec_kw) - self._gridspec_main.figure = None - self._gridspec_main = gridspec - - # Reassign subplotspecs to all axes and update positions - for ax in self._iter_axes(hidden=True, children=True): - # Get old index - # NOTE: Endpoints are inclusive, not exclusive! - if not hasattr(ax, 'get_subplotspec'): - continue - if side in ('left', 'right'): - inserts = (None, None, idx, idx) - else: - inserts = (idx, idx, None, None) - subplotspec = ax.get_subplotspec() - gridspec_ss = subplotspec.get_gridspec() - subplotspec_top = subplotspec.get_topmost_subplotspec() - - # Apply new subplotspec - _, _, *coords = subplotspec_top.get_active_rows_columns() - for i in range(4): - if inserts[i] is not None and coords[i] >= inserts[i]: - coords[i] += 1 - row1, row2, col1, col2 = coords - subplotspec_new = gridspec[row1:row2 + 1, col1:col2 + 1] - if subplotspec_top is subplotspec: - ax.set_subplotspec(subplotspec_new) - elif subplotspec_top is gridspec_ss._subplot_spec: - gridspec_ss._subplot_spec = subplotspec_new - else: - raise ValueError('Unexpected GridSpecFromSubplotSpec nesting.') - if _version_mpl >= _version('3.4'): - ax.set_position(ax.get_subplotspec().get_position(ax.figure)) - else: - ax.update_params() - ax.set_position(ax.figbox) # equivalent to above - - # Adjust figure size *after* gridspecs are fixed - self.set_size_inches(figsize, internal=True) - - return gridspec - def _update_super_title(self, title, **kwargs): """ Assign the figure "super title" and update settings. @@ -852,14 +922,13 @@ def _update_super_title(self, title, **kwargs): if kwargs: self._suptitle.update(kwargs) - def _update_super_labels(self, ax, side, labels, **kwargs): + def _update_super_labels(self, side, labels, **kwargs): """ Assign the side labels and update settings. """ - if side not in ('left', 'right', 'bottom', 'top'): - raise ValueError(f'Invalid label side {side!r}.') - # Get main axes on the edge + if side not in ('left', 'right', 'bottom', 'top'): + raise ValueError(f'Invalid side {side!r}.') axs = self._get_align_axes(side) if not axs: return # occurs if called while adding axes @@ -971,39 +1040,27 @@ def _update_geometry_from_spacing(self, renderer, resize=True): subplots_kw[key] = _not_none(previous, current - offset + pad) # Get arrays storing gridspec spacing args - innerpad = self._inner_pad - panelpad = self._panel_pad + # NOTE: The self._inner_pad and self._panel_pad are added to wpad and hpad + # arrays on figure instantiation and when panels are created gridspec = self._gridspec_main nrows, ncols = gridspec.get_active_geometry() wspace = subplots_kw['wspace'] hspace = subplots_kw['hspace'] wspace_orig = subplots_orig_kw['wspace'] hspace_orig = subplots_orig_kw['hspace'] + wpad = subplots_kw['wpad'] + hpad = subplots_kw['hpad'] # Get new subplot spacings, axes panel spacing, figure panel spacing - spaces = [] - for (w, x, y, nacross, ispace, ispace_orig) in zip( - 'wh', 'xy', 'yx', (nrows, ncols), - (wspace, hspace), (wspace_orig, hspace_orig), + spaces = {} + for (w, x, y, nacross, ispace, ispace_orig, ipad) in zip( + 'wh', 'xy', 'yx', (nrows, ncols), (wspace, hspace), (wspace_orig, hspace_orig), (wpad, hpad), # noqa: E501 ): # Determine which rows and columns correspond to panels - panels = subplots_kw[w + 'panels'] jspace = list(ispace) # a copy ralong = np.array([ax._range_gridspec(x) for ax in axs]) racross = np.array([ax._range_gridspec(y) for ax in axs]) - for i, (space, space_orig) in enumerate(zip(ispace, ispace_orig)): - # Figure out whether this is a normal space, or a - # panel stack space/axes panel space - pad = innerpad - if ( - panels[i] in ('l', 't') - and panels[i + 1] in ('l', 't', '') - or panels[i] in ('r', 'b', '') - and panels[i + 1] in ('r', 'b') - or panels[i] == 'f' and panels[i + 1] == 'f' - ): - pad = panelpad - + for i, (space, space_orig, pad) in enumerate(zip(ispace, ispace_orig, ipad)): # noqa: E501 # Find axes that abutt aginst this space on each row or column groups = [] filt1 = ralong[:, 1] == i # i.e. right/bottom edge abutts against this @@ -1052,10 +1109,10 @@ def _update_geometry_from_spacing(self, renderer, resize=True): jspace[i] = space # Add row or column space - spaces.append(jspace) + spaces[w] = jspace # Update with new spaces - subplots_kw.update({'wspace': spaces[0], 'hspace': spaces[1]}) + subplots_kw.update({'wspace': spaces['w'], 'hspace': spaces['h']}) self._update_geometry(resize=resize) def add_subplot(self, *args, **kwargs): @@ -1128,46 +1185,21 @@ def auto_layout(self, renderer=None, resize=None, aspect=None, tight=None): self._align_axis_labels(True) self._align_super_labels(renderer) + @docstring.add_snippets def colorbar( - self, mappable, values=None, *, loc='r', width=None, space=None, + self, mappable, values=None, *, loc='r', row=None, col=None, rows=None, cols=None, span=None, - **kwargs + space=None, pad=None, width=None, **kwargs ): """ - Draw a colorbar along the left, right, bottom, or top side - of the figure, centered between the leftmost and rightmost (or - topmost and bottommost) main axes. + Draw a colorbar along the left, right, bottom, or top side of the + figure, centered between the leftmost and rightmost (or topmost + and bottommost) main subplots. Parameters ---------- - loc : str, optional - The colorbar location. Valid location keys are as follows. - - =========== ===================== - Location Valid keys - =========== ===================== - left edge ``'l'``, ``'left'`` - right edge ``'r'``, ``'right'`` - bottom edge ``'b'``, ``'bottom'`` - top edge ``'t'``, ``'top'`` - =========== ===================== - - row, rows : optional - Aliases for `span` for panels on the left or right side. - col, cols : optional - Aliases for `span` for panels on the top or bottom side. - span : int or (int, int), optional - Describes how the colorbar spans rows and columns of subplots. - For example, ``fig.colorbar(loc='b', col=1)`` draws a colorbar - beneath the leftmost column of subplots, and - ``fig.colorbar(loc='b', cols=(1,2))`` draws a colorbar beneath the - left two columns of subplots. By default, the colorbar will span - all rows and columns. - space : float or str, optional - The space between the main subplot grid and the colorbar, or the space - between successively stacked colorbars. Units are interpreted by - `~proplot.utils.units`. By default, this is determined by the "tight layout" - algorithm, or is :rc:`subplots.panelpad` if "tight layout" is off. + %(axes.colorbar_args)s + %(figure.colorbar_space)s length : float or str, optional The colorbar length. Units are relative to the span of the rows and columns of subplots. Default is :rc:`colorbar.length`. @@ -1180,86 +1212,77 @@ def colorbar( Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.colorbar_extras`. + %(axes.colorbar_kwargs)s + + See also + -------- + proplot.axes.Axes.colorbar + matplotlib.figure.Figure.colorbar """ ax = kwargs.pop('ax', None) cax = kwargs.pop('cax', None) # Fill this axes if cax is not None: - return super().colorbar(mappable, cax=cax, **kwargs) + with _state_context(cax, _internal_call=True): # avoid wrapping pcolor + return super().colorbar(mappable, cax=cax, **kwargs) # Generate axes panel if ax is not None: - return ax.colorbar(mappable, values, space=space, width=width, **kwargs) + return ax.colorbar( + mappable, values, space=space, pad=pad, width=width, **kwargs + ) # Generate figure panel loc = self._subplots_main[0]._loc_translate(loc, 'panel') ax = self._add_figure_panel( - loc, space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols + loc, row=row, col=col, rows=rows, cols=cols, span=span, + space=space, pad=pad, width=width, ) return ax.colorbar(mappable, values, loc='fill', **kwargs) + @docstring.add_snippets def legend( - self, handles=None, labels=None, *, loc='r', width=None, space=None, + self, handles=None, labels=None, *, loc='r', row=None, col=None, rows=None, cols=None, span=None, - **kwargs + space=None, pad=None, width=None, **kwargs ): """ Draw a legend along the left, right, bottom, or top side of the - figure, centered between the leftmost and rightmost (or - topmost and bottommost) main axes. + figure, centered between the leftmost and rightmost (or topmost + and bottommost) main subplots. Parameters ---------- - loc : str, optional - The legend location. Valid location keys are as follows. - - =========== ===================== - Location Valid keys - =========== ===================== - left edge ``'l'``, ``'left'`` - right edge ``'r'``, ``'right'`` - bottom edge ``'b'``, ``'bottom'`` - top edge ``'t'``, ``'top'`` - =========== ===================== - - row, rows : optional - Aliases for `span` for panels on the left or right side. - col, cols : optional - Aliases for `span` for panels on the top or bottom side. - span : int or (int, int), optional - Describes how the legend spans rows and columns of subplots. - For example, ``fig.legend(loc='b', col=1)`` draws a legend - beneath the leftmost column of subplots, and - ``fig.legend(loc='b', cols=(1,2))`` draws a legend beneath the - left two columns of subplots. By default, the legend will span - all rows and columns. - space : float or str, optional - The space between the main subplot grid and the legend, or the - space between successively stacked colorbars. Units are interpreted - 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. + %(axes.legend_args)s + %(figure.legend_space)s + width : float or str, optional + The space allocated for the legend box. This does nothing if the + tight layout algorithm is active for the figure. Units are + interpreted by `~proplot.utils.units`. Other parameters ---------------- - *args, **kwargs - Passed to `~proplot.axes.legend_extras`. + %(axes.legend_kwargs)s + + See also + -------- + proplot.axes.Axes.legend + matplotlib.axes.Axes.legend """ ax = kwargs.pop('ax', None) # Generate axes panel if ax is not None: - return ax.legend(handles, labels, space=space, width=width, **kwargs) + return ax.legend( + handles, labels, space=space, pad=pad, width=width, **kwargs + ) # Generate figure panel loc = self._subplots_main[0]._loc_translate(loc, 'panel') ax = self._add_figure_panel( - loc, space=space, width=width, span=span, - row=row, col=col, rows=rows, cols=cols + loc, row=row, col=col, rows=rows, cols=cols, span=span, + space=space, pad=pad, width=width, ) return ax.legend(handles, labels, loc='fill', **kwargs) @@ -1332,7 +1355,7 @@ def set_size_inches(self, w, h=None, forward=True, internal=False): ) if user: self._subplots_kw.update(width=width, height=height) - context = self._context_autoresizing if internal or not user else _dummy_context + context = self._context_autoresizing if internal or not user else _empty_context with context(): super().set_size_inches(width, height, forward=forward) diff --git a/proplot/gridspec.py b/proplot/gridspec.py index ad5ab5230..35e367358 100644 --- a/proplot/gridspec.py +++ b/proplot/gridspec.py @@ -19,8 +19,8 @@ def _default_space(key, share=0, pad=None): Return suitable default spacing given a shared axes setting. """ # Pull out sizes - outerpad = _not_none(pad, rc['subplots.outerpad']) - innerpad = _not_none(pad, rc['subplots.innerpad']) + outerpad = units(_not_none(pad, rc['subplots.outerpad']), 'em', 'in') + innerpad = units(_not_none(pad, rc['subplots.innerpad']), 'em', 'in') xtick = rc['xtick.major.size'] ytick = rc['ytick.major.size'] xtickpad = rc['xtick.major.pad'] @@ -33,21 +33,21 @@ def _default_space(key, share=0, pad=None): # Get suitable size for various spaces if key == 'left': - space = units(outerpad) + (ytick + yticklabel + ytickpad + label) / 72 + space = outerpad + (ytick + yticklabel + ytickpad + label) / 72 elif key == 'right': - space = units(outerpad) + space = outerpad elif key == 'bottom': - space = units(outerpad) + (xtick + xticklabel + xtickpad + label) / 72 + space = outerpad + (xtick + xticklabel + xtickpad + label) / 72 elif key == 'top': - space = units(outerpad) + (title + titlepad) / 72 + space = outerpad + (title + titlepad) / 72 elif key == 'wspace': - space = units(innerpad) + ytick / 72 + space = innerpad + ytick / 72 if share < 3: space += (yticklabel + ytickpad) / 72 if share < 1: space += label / 72 elif key == 'hspace': - space = units(innerpad) + (title + titlepad + xtick) / 72 + space = innerpad + (title + titlepad + xtick) / 72 if share < 3: space += (xticklabel + xtickpad) / 72 if share < 0: @@ -60,9 +60,9 @@ def _default_space(key, share=0, pad=None): def _calc_geometry(**kwargs): """ - Save arguments passed to `~proplot.ui.subplots`, calculates - gridspec settings and figure size necessary for requested geometry, and - returns keyword args necessary to reconstruct and modify this + Save arguments passed to `~proplot.ui.subplots`, calculate gridspec + settings and figure size necessary for requested geometry, and + return keyword args necessary to reconstruct and modify this configuration. Note that `wspace`, `hspace`, `left`, `right`, `top`, and `bottom` always have fixed physical units, then we scale figure width, figure height, and width and height ratios to accommodate spaces. @@ -77,76 +77,65 @@ def _calc_geometry(**kwargs): nrows, ncols = kwargs['nrows'], kwargs['ncols'] # Gridspec settings + wpad, hpad = kwargs['wpad'], kwargs['hpad'] wspace, hspace = kwargs['wspace'], kwargs['hspace'] wratios, hratios = kwargs['wratios'], kwargs['hratios'] left, bottom = kwargs['left'], kwargs['bottom'] right, top = kwargs['right'], kwargs['top'] - wequal, hequal, equal = kwargs['wequal'], kwargs['hequal'], kwargs['equal'] + wequal, hequal = kwargs['wequal'], kwargs['hequal'] - # Panel string toggles, lists containing empty strings '' (indicating a - # main axes), or one of 'l', 'r', 'b', 't' (indicating axes panels) or - # 'f' (indicating figure panels) + # Panel string toggles, lists containing empty strings '' (indicating main axes), + # or one of 'l', 'r', 'b', 't' (indicating axes panels) or 'f' (figure panels) wpanels, hpanels = kwargs['wpanels'], kwargs['hpanels'] - # Checks, important now that we modify gridspec geometry - if len(hratios) != nrows: - raise ValueError( - f'Expected {nrows} width ratios for {nrows} rows, ' - f'got {len(hratios)}.' - ) - if len(wratios) != ncols: - raise ValueError( - f'Expected {ncols} width ratios for {ncols} columns, ' - f'got {len(wratios)}.' - ) - if len(hspace) != nrows - 1: - raise ValueError( - f'Expected {nrows - 1} hspaces for {nrows} rows, ' - f'got {len(hspace)}.' - ) - if len(wspace) != ncols - 1: - raise ValueError( - f'Expected {ncols - 1} wspaces for {ncols} columns, ' - f'got {len(wspace)}.' - ) - if len(hpanels) != nrows: - raise ValueError( - f'Expected {nrows} hpanel toggles for {nrows} rows, ' - f'got {len(hpanels)}.' - ) - if len(wpanels) != ncols: - raise ValueError( - f'Expected {ncols} wpanel toggles for {ncols} columns, ' - f'got {len(wpanels)}.' - ) + # Check lengths, important now that we modify gridspec geometry + # NOTE: We do not need to touch 'pad' here + def _check_length(value, *, n, descrip): + if len(value) != n: + raise ValueError(f'Expected {n} {descrip}, got {len(value)}.') + _check_length(wpad, n=ncols - 1, descrip='width pads') + _check_length(hpad, n=nrows - 1, descrip='height pads') + _check_length(wspace, n=ncols - 1, descrip='width spaces') + _check_length(hspace, n=nrows - 1, descrip='height spaces') + _check_length(wratios, n=ncols, descrip='width ratios') + _check_length(hratios, n=nrows, descrip='height ratios') + _check_length(wpanels, n=ncols, descrip='column panel indicators') + _check_length(hpanels, n=nrows, descrip='row panel indicators') # Get indices corresponding to main axes or main axes space slots - idxs_ratios, idxs_space = [], [] - for panels in (hpanels, wpanels): + idx_ratios, idx_spaces = {}, {} + for key, panels in zip('wh', (wpanels, hpanels)): # Ratio indices - mask = np.array([bool(s) for s in panels]) - ratio_idxs, = np.where(~mask) - idxs_ratios.append(ratio_idxs) + idx_ratios[key] = [i for i, s in enumerate(panels) if not s] # Space indices - space_idxs = [] - for idx in ratio_idxs[:-1]: # exclude last axes slot - offset = 1 - while panels[idx + offset] not in 'rbf': # main space next to this - offset += 1 - space_idxs.append(idx + offset - 1) - idxs_space.append(space_idxs) + idx_spaces[key] = [ + i for i in range(len(panels) - 1) + if not (panels[i] == 'f' and panels[i + 1] == 'f') + and not (panels[i] in ('l', 't') and panels[i + 1] in ('l', 't', '')) + and not (panels[i] in ('r', 'b', '') and panels[i + 1] in ('r', 'b')) + ] # Separate the panel and axes ratios - hratios_main = [hratios[idx] for idx in idxs_ratios[0]] - wratios_main = [wratios[idx] for idx in idxs_ratios[1]] - hratios_panels = [r for idx, r in enumerate(hratios) if idx not in idxs_ratios[0]] - wratios_panels = [r for idx, r in enumerate(wratios) if idx not in idxs_ratios[1]] - hspace_main = [hspace[idx] for idx in idxs_space[0]] - wspace_main = [wspace[idx] for idx in idxs_space[1]] - - # Reduced geometry - nrows_main = len(hratios_main) + wspace_main = [wspace[i] for i in idx_spaces['w']] + hspace_main = [hspace[i] for i in idx_spaces['h']] + wratios_main = [wratios[i] for i in idx_ratios['w']] + hratios_main = [hratios[i] for i in idx_ratios['h']] + wratios_panels = [r for i, r in enumerate(wratios) if i not in idx_ratios['w']] + hratios_panels = [r for i, r in enumerate(hratios) if i not in idx_ratios['h']] + + # Geometry and space corrections ncols_main = len(wratios_main) + nrows_main = len(hratios_main) + if wequal and wspace_main: + space = max(wspace_main) + for i in idx_spaces['w']: + wspace[i] = space + wspace_main = [space] * len(wspace_main) + if hequal and hspace_main: + space = max(hspace_main) + for i in idx_spaces['h']: + hspace[i] = space + hspace_main = [space] * len(hspace_main) # Get reference properties, account for panel slots in space and ratios # TODO: Shouldn't panel space be included in these calculations? @@ -171,7 +160,7 @@ def _calc_geometry(**kwargs): auto_height = figheight is None and figwidth is not None if figwidth is None and figheight is None: # get stuff directly from axes if refwidth is None and refheight is None: - refwidth = units(rc['subplots.refwidth']) + refwidth = units(rc['subplots.refwidth'], 'in') if refheight is not None: auto_width = True refheight_all = (nrows_main * (refheight - rhspace)) / (dy * rhratio) @@ -200,13 +189,13 @@ def _calc_geometry(**kwargs): refwidth_all = (ncols_main * (refwidth - rwspace)) / (dx * rwratio) figwidth = refwidth_all + left + right + sum(wspace) + sum(wratios_panels) if refwidth_all < 0: - raise ValueError( + raise RuntimeError( f'Not enough room for axes (would have width {refwidth_all}). ' 'Try using tight=False, increasing figure width, or decreasing ' "'left', 'right', or 'wspace' spaces." ) if refheight_all < 0: - raise ValueError( + raise RuntimeError( f'Not enough room for axes (would have height {refheight_all}). ' 'Try using tight=False, increasing figure height, or decreasing ' "'top', 'bottom', or 'hspace' spaces." @@ -216,10 +205,10 @@ def _calc_geometry(**kwargs): # The panel slots are unchanged because panels have fixed widths wratios_main = refwidth_all * np.array(wratios_main) / sum(wratios_main) hratios_main = refheight_all * np.array(hratios_main) / sum(hratios_main) - for idx, ratio in zip(idxs_ratios[0], hratios_main): - hratios[idx] = ratio - for idx, ratio in zip(idxs_ratios[1], wratios_main): + for idx, ratio in zip(idx_ratios['w'], wratios_main): wratios[idx] = ratio + for idx, ratio in zip(idx_ratios['h'], hratios_main): + hratios[idx] = ratio # Convert margins to figure-relative coordinates left = left / figwidth @@ -227,14 +216,6 @@ def _calc_geometry(**kwargs): right = 1 - right / figwidth top = 1 - top / figheight - # Constant spacing corrections - if equal: # do both - wequal = hequal = True - if wequal: - wspace = [max(wspace) for _ in wspace] - if hequal: - hspace = [max(hspace) for _ in hspace] - # Return gridspec keyword args gridspec_kw = { 'ncols': ncols, 'nrows': nrows, diff --git a/proplot/internals/__init__.py b/proplot/internals/__init__.py index 81932b0af..c24a22587 100644 --- a/proplot/internals/__init__.py +++ b/proplot/internals/__init__.py @@ -20,20 +20,21 @@ # Aliases. This package only works with a subset of available artists # and keywords so we simply create our own system rather than working # with matplotlib's normalize_kwargs and _alias_maps. +# NOTE: Add 'edgewidth' for patch edges and 'fillcolor' for patch face color ALIASES = { - 'hsla': { - 'hue': ('h',), - 'saturation': ('s', 'c', 'chroma'), - 'luminance': ('l',), - 'alpha': ('a',), - }, 'rgba': { 'red': ('r',), 'green': ('g',), 'blue': ('b',), 'alpha': ('a',), }, - 'lines': { # copied from lines.py but expanded to include plurals + 'hsla': { + 'hue': ('h',), + 'saturation': ('s', 'c', 'chroma'), + 'luminance': ('l',), + 'alpha': ('a',), + }, + 'line': { # copied from lines.py but expanded to include plurals 'antialiased': ('aa',), 'alpha': ('a', 'alphas'), 'color': ('c', 'colors'), @@ -46,48 +47,38 @@ 'markeredgecolor': ('mec', 'markeredgecolors'), 'markeredgewidth': ('mew', 'markeredgewidths'), 'markerfacecolor': ('mfc', 'markerfacecolors'), + 'zorder': ('z', 'zorders'), }, - 'fills': { - 'linewidths': ('lw', 'linewidth'), - 'linestyles': ('ls', 'linestyle'), + 'collection': { # WARNING: face color gets ignored for line collections + 'alphas': ('a', 'alphas'), 'colors': ('c', 'color', 'ec', 'edgecolor', 'edgecolors'), - } + 'facecolors': ('fc', 'facecolors', 'fillcolor', 'fillcolors'), + 'linewidths': ('lw', 'linewidth', 'ew', 'edgewidth', 'edgewidths'), + 'linestyles': ('ls', 'linestyle'), + 'zorder': ('z', 'zorders'), + }, + 'patch': { # TODO: remove breaking change where 'color' refers to 'edge' + 'alpha': ('a', 'alphas', 'facealpha', 'facealphas', 'fillalpha', 'fillalphas'), + 'facecolor': ('fc', 'facecolors', 'fillcolor', 'fillcolors'), + 'edgecolor': ('ec', 'edgecolors', 'c', 'color', 'colors'), + 'linewidth': ('lw', 'linewidths', 'ew', 'edgewidth', 'edgewidths'), + 'linestyle': ('ls', 'linestyles'), + 'zorder': ('z', 'zorders'), + }, + 'text': { + 'color': ('c', 'fontcolor'), # NOTE: see text.py source code + 'fontfamily': ('family',), + 'fontname': ('name',), + 'fontsize': ('size',), + 'fontstretch': ('stretch',), + 'fontstyle': ('style',), + 'fontvariant': ('variant',), + 'fontweight': ('weight',), + 'fontproperties': ('fp', 'font', 'font_properties'), + }, } -def _getattr_flexible(obj, attr, default=None): - """ - Search for attribute ``attr`` and ``_attr``. This crudely guards against - upstream matplotlib changes. - """ - if hasattr(obj, attr) and hasattr(obj, '_' + attr): - warnings._warn_proplot( - f"Object {obj!r} has both {attr!r} and {'_' + attr!r} attributes." - 'Using former.' - ) - return getattr(obj, attr, getattr(obj, '_' + attr, default)) - - -def _pop_props(kwargs, *categories): - """ - Pop out properties from category `category` after accounting for - aliases. Return a dictionary of the non-None property values. This - modifies the input `kwargs` dictionary in-place. - """ - props = {} - for category in categories: - if category not in ALIASES: - raise ValueError(f'Invalid alias category {category!r}.') - for key, aliases in ALIASES[category].items(): - if isinstance(aliases, str): - aliases = (aliases,) - opts = {alias: kwargs.pop(alias, None) for alias in (key, *aliases)} - prop = _not_none(**opts) - if prop is not None: - props[key] = prop - return props - - def _not_none(*args, default=None, **kwargs): """ Return the first non-``None`` value. This is used with keyword arg aliases and @@ -116,7 +107,89 @@ def _not_none(*args, default=None, **kwargs): return first -class _dummy_context(object): +def _translate_kwargs(input, output, *keys, **aliases): + """ + Driver function. + """ + aliases.update({key: () for key in keys}) + for key, aliases in aliases.items(): + aliases = (aliases,) if isinstance(aliases, str) else aliases + opts = {key: input.pop(key, None) for key in (key, *aliases)} + value = _not_none(**opts) + if value is not None: + output[key] = value + return output + + +def _translate_props(input, output, *categories, prefix=None, ignore=None): + """ + Driver function. + """ + # Get properties + prefix = prefix or '' # e.g. 'box' for boxlw, boxlinewidth, etc. + for category in categories: + if category not in ALIASES: + raise ValueError(f'Invalid alias category {category!r}.') + for key, aliases in ALIASES[category].items(): + if isinstance(aliases, str): + aliases = (aliases,) + opts = {prefix + alias: input.pop(prefix + alias, None) for alias in (key, *aliases)} # noqa: E501 + prop = _not_none(**opts) + if prop is not None: + output[key] = prop + # Ignore properties (e.g., ignore 'marker' properties) + ignore = ignore or () + if isinstance(ignore, str): + ignore = (ignore,) + for string in ignore: + for key in tuple(output): + if string in key: + value = output.pop(key) + warnings._warn_proplot(f'Ignoring property {key}={value!r}.') + return output + + +def _pop_kwargs(src, *keys, **aliases): + """ + Pop out input properties and return them in a new dictionary. + """ + return _translate_kwargs(src, {}, *keys, **aliases) + + +def _process_kwargs(src, *keys, **aliases): + """ + Translate input properties and add translated names to the original dictionary. + """ + return _translate_kwargs(src, src, *keys, **aliases) + + +def _pop_props(src, *categories, **kwargs): + """ + Pop out registered properties and return them in a new dictionary. + """ + return _translate_props(src, {}, *categories, **kwargs) + + +def _process_props(src, *categories, **kwargs): + """ + Translate registered properties and add translated names to the original dictionary. + """ + return _translate_props(src, src, *categories, **kwargs) + + +def _getattr_flexible(obj, attr, default=None): + """ + Search for attribute ``attr`` and ``_attr``. This crudely guards against + upstream matplotlib changes. + """ + if hasattr(obj, attr) and hasattr(obj, '_' + attr): + warnings._warn_proplot( + f"{obj!r} has both {attr!r} and {'_' + attr!r} attributes. Using former." + ) + return getattr(obj, attr, getattr(obj, '_' + attr, default)) + + +class _empty_context(object): """ Dummy context manager. """ @@ -167,7 +240,7 @@ def __init__(self, version): major, minor = int(major), int(minor) except (ValueError, AttributeError): warnings._warn_proplot( - f"Invalid version {version!r}. Defaulting to '0.0'." + f"Invalid version {version!r}. Setting to '0.0'." ) major = minor = 0 self._version = version diff --git a/proplot/internals/docstring.py b/proplot/internals/docstring.py index 4230846e6..1c0fc55fd 100644 --- a/proplot/internals/docstring.py +++ b/proplot/internals/docstring.py @@ -3,6 +3,10 @@ Utilities for modifying proplot docstrings. """ import inspect +import re + +import matplotlib.axes as maxes +from matplotlib import rcParams #: Dictionary of docstring snippets. snippets = {} @@ -23,3 +27,64 @@ def add_snippets(obj): if obj.__doc__: obj.__doc__ %= parts return obj + + +def concatenate(func, prepend_summary=False): + """ + Concatenate docstrings from a matplotlib axes method with a ProPlot axes + method and obfuscate the call signature to avoid misleading users. + + Warning + ------- + This is not yet used but will be in the future. + """ + # 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. + # Get matplotlib axes func + name = func.__name__ + func_orig = getattr(maxes.Axes, name, None) + doc = inspect.getdoc(func) or '' # also dedents + doc_orig = inspect.getdoc(func_orig) + if not doc_orig: # should never happen + return func + + # Prepend summary + if prepend_summary: + regex = re.search(r'\.( | *\n|\Z)', doc_orig) + if regex: + doc = doc_orig[:regex.start() + 1] + '\n\n' + doc + + # Exit if this is documentation generated for website + if rcParams['docstring.hardcopy']: + 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 + sig = inspect.signature(func) + sig_obfuscated = inspect.signature(lambda *args, **kwargs: None) + func.__signature__ = sig.replace( + parameters=tuple(sig_obfuscated.parameters.values()) + ) + + # Concatenate docstrings and copy summary + # Make sure different sections are very visible + pad = '=' * len(name) + doc = f""" +===================================={pad} +proplot.axes.PlotAxes.{name} documentation +===================================={pad} +{doc} + +==================================={pad} +matplotlib.axes.Axes.{name} documentation +==================================={pad} +{doc_orig} +""" + func.__doc__ = inspect.cleandoc(doc) # dedents and trims whitespace + + # Return + return func diff --git a/proplot/internals/rcsetup.py b/proplot/internals/rcsetup.py index a13c5719d..b6c01cce3 100644 --- a/proplot/internals/rcsetup.py +++ b/proplot/internals/rcsetup.py @@ -24,6 +24,7 @@ GRIDALPHA = 0.11 GRIDBELOW = 'line' GRIDCOLOR = 'black' +GRIDPAD = 5.0 GRIDRATIO = 0.5 # differentiated from major by half size reduction GRIDSTYLE = '-' LABELPAD = 4.0 # default is 4.0, previously was 3.0 @@ -70,7 +71,6 @@ 'large': ('text.titlesize', '0.6'), 'span': ('subplots.span', '0.6'), 'tight': ('subplots.tight', '0.6'), - 'tick.labelpad': ('tick.pad', '0.6'), 'axes.formatter.timerotation': ('formatter.timerotation', '0.6'), 'axes.formatter.zerotrim': ('formatter.zerotrim', '0.6'), 'abovetop': ('title.above', '0.7'), @@ -84,12 +84,14 @@ # plots, 0.05 is good for line plot in y direction but not x direction. # WARNING: Critical to include every parameter here that can be changed by a # "meta" setting so that _get_default_param returns the value imposed by *proplot* -# and so that "changed" settings detectd by RcConfigurator.save are correct. +# and so that "changed" settings detected by RcConfigurator.save are correct. _rc_matplotlib_default = { 'axes.axisbelow': GRIDBELOW, 'axes.formatter.use_mathtext': MATHTEXT, 'axes.grid': True, # enable lightweight transparent grid by default 'axes.grid.which': 'major', + 'axes.edgecolor': COLOR, + 'axes.labelcolor': COLOR, 'axes.labelpad': LABELPAD, # more compact 'axes.labelsize': LABELSIZE, 'axes.labelweight': 'normal', @@ -99,6 +101,7 @@ 'axes.titleweight': 'normal', 'axes.xmargin': MARGIN, 'axes.ymargin': MARGIN, + 'errorbar.capsize': 3.0, 'figure.autolayout': False, 'figure.dpi': 100, 'figure.facecolor': '#f2f2f2', # similar to MATLAB interface @@ -200,6 +203,8 @@ 'legend.borderaxespad': 0, # looks sleeker flush against edge 'legend.borderpad': 0.5, # a bit more space 'legend.columnspacing': 1.5, # more compact + 'legend.edgecolor': COLOR, + 'legend.facecolor': 'white', 'legend.fancybox': False, # looks modern without curvy box 'legend.fontsize': LABELSIZE, 'legend.framealpha': FRAMEALPHA, @@ -213,6 +218,7 @@ 'savefig.facecolor': 'white', # different from figure.facecolor 'savefig.format': 'pdf', # most users use bitmap, but vector graphics are better 'savefig.transparent': False, + 'xtick.color': COLOR, 'xtick.direction': TICKDIR, 'xtick.labelsize': LABELSIZE, 'xtick.major.pad': TICKPAD, @@ -222,6 +228,7 @@ 'xtick.minor.size': TICKLEN * TICKLENRATIO, 'xtick.minor.visible': TICKMINOR, 'xtick.minor.width': LINEWIDTH * TICKRATIO, + 'ytick.color': COLOR, 'ytick.direction': TICKDIR, 'ytick.labelsize': LABELSIZE, 'ytick.major.pad': TICKPAD, @@ -241,7 +248,9 @@ # RcParams and adding validators. Quick settings should be implemented in __getitem__. # NOTE: Cannot have different a-b-c and title paddings because they are both controlled # by matplotlib's _title_offset_trans transform and want to keep them aligned anyway. -_addendum_units = ' Interpreted by `~proplot.utils.units`.' +_addendum_em = ' Interpreted by `~proplot.utils.units`. Numeric units are font size-relative.' # noqa: E501 +_addendum_in = ' Interpreted by `~proplot.utils.units`. Numeric units are inches.' +_addendum_pt = ' Interpreted by `~proplot.utils.units`. Numeric units are points.' _addendum_fonts = ( ' (see `this list of valid font sizes ' '`__).' @@ -290,7 +299,7 @@ 'abc.bboxpad': ( None, 'Padding for the a-b-c label bounding box. By default this is scaled ' - 'to make the box flush against the subplot edge. ' + _addendum_units + 'to make the box flush against the subplot edge. ' + _addendum_pt ), 'abc.color': ( 'black', @@ -405,7 +414,7 @@ 'bottomlabel.pad': ( '0.3em', 'Padding between axes content and column labels on the bottom of the figure. ' - + _addendum_units + + _addendum_pt ), 'bottomlabel.rotation': ( 0, @@ -449,14 +458,22 @@ # Colorbars 'colorbar.extend': ( - '1.3em', + 1.3, 'Length of rectangular or triangular "extensions" for panel colorbars. ' - + _addendum_units + + _addendum_em + ), + 'colorbar.edgecolor': ( + COLOR, + 'Color for colorbar dividers, outline, and inset frame edge.' ), 'colorbar.framealpha': ( FRAMEALPHA, 'Opacity for inset colorbar frames.' ), + 'colorbar.facecolor': ( + 'white', + 'Color for the inset colorbar frame.' + ), 'colorbar.frameon': ( True, 'Boolean, indicates whether to draw a frame behind inset colorbars.' @@ -466,21 +483,21 @@ 'Boolean, indicates whether to draw borders between each level of the colorbar.' ), 'colorbar.insetextend': ( - '1em', + 1, 'Length of rectangular or triangular "extensions" for inset colorbars. ' - + _addendum_units + + _addendum_em ), 'colorbar.insetlength': ( - '8em', - 'Length of inset colorbars. ' + _addendum_units + 8, + 'Length of inset colorbars. ' + _addendum_em ), 'colorbar.insetpad': ( - '0.7em', - 'Padding between axes edge and inset colorbars. ' + _addendum_units + 0.7, + 'Padding between axes edge and inset colorbars. ' + _addendum_em ), 'colorbar.insetwidth': ( - '1.2em', - 'Width of inset colorbars. ' + _addendum_units + 1.2, + 'Width of inset colorbars. ' + _addendum_em ), 'colorbar.length': ( 1, @@ -492,8 +509,8 @@ '`.' ), 'colorbar.width': ( - '1.5em', - 'Width of outer colorbars. ' + _addendum_units + 1.5, + 'Width of outer colorbars. ' + _addendum_em ), # Style shorthands @@ -536,16 +553,21 @@ 'Boolean, indicates whether to use degrees-minutes-seconds rather than ' 'decimals for gridline labels on `~proplot.axes.CartopyAxes`.' ), - 'grid.pad': ( - 5, - 'Padding between map boundary edge and longitude and ' - 'latitude labels for `~proplot.axes.GeoAxes`. ' + _addendum_units - ), 'grid.labels': ( False, 'Boolean, indicates whether to label the longitude and latitude gridlines ' 'in `~proplot.axes.GeoAxes`.' ), + 'grid.labelcolor': ( + COLOR, + 'Font color for longitude and latitude gridline labels in ' + '`~proplot.axes.GeoAxes`.' + ), + 'grid.labelpad': ( + GRIDPAD, + 'Padding between map boundary edge and longitude and ' + 'latitude labels for `~proplot.axes.GeoAxes`. ' + _addendum_pt + ), 'grid.labelsize': ( LABELSIZE, 'Font size for longitude and latitude gridline labels in ' @@ -556,11 +578,6 @@ 'Font weight for longitude and latitude gridline labels in ' '`~proplot.axes.GeoAxes`.' ), - 'grid.labelcolor': ( - COLOR, - 'Font color for longitude and latitude gridline labels in ' - '`~proplot.axes.GeoAxes`.' - ), 'grid.latinline': ( False, 'Whether to use inline labels for `~proplot.axes.CartopyAxes` ' @@ -575,6 +592,10 @@ 250, 'Number of interpolation steps used to draw cartopy gridlines.' ), + 'grid.pad': ( + GRIDPAD, + 'Alias for :rcraw:`grid.labelpad`.' + ), 'grid.ratio': ( GRIDRATIO, 'Ratio of minor gridline width to major gridline width.' @@ -703,7 +724,7 @@ 'leftlabel.pad': ( '0.6em', 'Padding between axes content and row labels on the left-hand side. ' - + _addendum_units + + _addendum_pt ), 'leftlabel.rotation': ( 90, @@ -777,7 +798,7 @@ 'rightlabel.pad': ( '0.6em', 'Padding between axes content and row labels on the right-hand side. ' - + _addendum_units + + _addendum_pt ), 'rightlabel.rotation': ( 90, @@ -818,25 +839,25 @@ '`__.' # noqa: E501 ), 'subplots.innerpad': ( - '1em', - 'Padding between adjacent subplots. ' + _addendum_units + 1, + 'Padding between adjacent subplots. ' + _addendum_em ), 'subplots.outerpad': ( - '0.5em', - 'Padding around figure edge. ' + _addendum_units + 0.5, + 'Padding around figure edge. ' + _addendum_em ), 'subplots.panelpad': ( - '0.5em', + 0.5, 'Padding between subplots and panels, and between stacked panels. ' - + _addendum_units + + _addendum_em ), 'subplots.panelwidth': ( '4em', - 'Width of side panels. ' + _addendum_units + 'Width of side panels. ' + _addendum_in ), 'subplots.refwidth': ( '20em', # about 3 inches wide - 'Default width of the reference subplot. ' + _addendum_units + 'Default width of the reference subplot. ' + _addendum_in ), 'subplots.share': ( 3, @@ -868,7 +889,7 @@ 'suptitle.pad': ( '0.5em', 'Padding between axes content and the figure super title. ' - + _addendum_units + + _addendum_pt ), # Text settings @@ -903,6 +924,10 @@ 'Axis tick label color. Mirrors the *axis* label ' ':rcraw:`axes.labelcolor` setting.' ), + 'tick.labelpad': ( + TICKPAD, + 'Padding between ticks and tick labels. ' + _addendum_pt + ), 'tick.labelsize': ( LABELSIZE, 'Axis tick label font size. Mirrors the *axis* label ' @@ -927,7 +952,7 @@ ), 'tick.pad': ( TICKPAD, - 'Padding between ticks and tick labels. ' + _addendum_units + 'Alias for :rcraw:`tick.labelpad`.' ), 'tick.ratio': ( TICKRATIO, @@ -943,7 +968,7 @@ 'title.pad': ( TITLEPAD, 'Padding between the axes edge and the inner and outer titles and ' - 'a-b-c labels. Alias for :rcraw:`axes.titlepad`. ' + _addendum_units + 'a-b-c labels. Alias for :rcraw:`axes.titlepad`. ' + _addendum_pt ), 'title.border': ( True, @@ -974,7 +999,7 @@ 'title.bboxpad': ( None, 'Padding for the title bounding box. By default this is scaled ' - 'to make the box flush against the axes edge. ' + _addendum_units + 'to make the box flush against the axes edge. ' + _addendum_pt ), 'title.color': ( 'black', @@ -1002,7 +1027,7 @@ 'toplabel.pad': ( '0.3em', 'Padding between axes content and column labels on the top of the figure. ' - + _addendum_units + + _addendum_pt ), 'toplabel.rotation': ( 0, @@ -1026,7 +1051,7 @@ # and because setting was only introduced in version 3.2. _rc_children = { 'color': ( # change the 'color' of an axes - 'axes.edgecolor', 'axes.labelcolor', + 'axes.edgecolor', 'axes.labelcolor', 'legend.edgecolor', 'colorbar.edgecolor', 'tick.labelcolor', 'hatch.color', 'xtick.color', 'ytick.color' ), 'text.labelsize': ( # the 'small' fonts @@ -1105,6 +1130,8 @@ 'font.name': 'font.family', 'grid.below': 'axes.axisbelow', 'title.pad': 'axes.titlepad', + 'grid.labelpad': 'grid.pad', + 'tick.labelpad': 'tick.pad', } for key, value in _rc_aliases.items(): _rc_children[key] = (value,) diff --git a/proplot/scale.py b/proplot/scale.py index 86ade427f..62e73973a 100644 --- a/proplot/scale.py +++ b/proplot/scale.py @@ -73,10 +73,6 @@ class _Scale(object): """ def __init__(self, *args, **kwargs): # Pass a dummy axis to the superclass - # WARNING: Smart bounds is deprecated. Figure out workaround by matplotlib - # 3.4: https://github.com/matplotlib/matplotlib/pull/11004 - # Without smart bounds, inverse scale ticks disappear and Mercator ticks - # have weird issues. axis = type('Axis', (object,), {'axis_name': 'x'})() super().__init__(axis, *args, **kwargs) self._default_major_locator = None @@ -94,11 +90,11 @@ def set_default_locators_and_formatters(self, axis, only_if_default=False): axis : `~matplotlib.axis.Axis` The axis. only_if_default : bool, optional - Whether to refrain from updating the locators and formatters if - the axis is currently using non-default versions. Useful if we - want to avoid overwriting user customization when the scale - is changed. + Whether to refrain from updating the locators and formatters if the + axis is currently using non-default versions. Useful if we want to + avoid overwriting user customization when the scale is changed. """ + # TODO: Always use only_if_default=True? Currently is only for dual axes. # Apply isDefault because matplotlib does this in axis._set_scale # but sometimes we need to bypass this method! Minor locator can be # "non default" even when user has not changed it, due to "turning @@ -151,8 +147,8 @@ def __init__(self, **kwargs): class LogitScale(_Scale, mscale.LogitScale): """ - As with `~matplotlib.scale.LogitScale` but with - `~proplot.ticker.AutoFormatter` as the default major formatter. + As with `~matplotlib.scale.LogitScale` but with `~proplot.ticker.AutoFormatter` + as the default major formatter. """ #: The registered scale name name = 'logit' @@ -173,10 +169,9 @@ def __init__(self, **kwargs): class LogScale(_Scale, mscale.LogScale): """ - As with `~matplotlib.scale.LogScale` but with - `~proplot.ticker.AutoFormatter` as the default major formatter. - ``x`` and ``y`` versions of each keyword argument are no longer - required. + As with `~matplotlib.scale.LogScale` but with `~proplot.ticker.AutoFormatter` + as the default major formatter. ``x`` and ``y`` versions of each keyword + argument are no longer required. """ #: The registered scale name name = 'log' @@ -249,7 +244,7 @@ def __init__(self, **kwargs): class FuncScale(_Scale, mscale.ScaleBase): """ - An axis scale comprised of arbitrary forward and inverse transformations. + Axis scale composed of arbitrary forward and inverse transformations. """ #: The registered scale name name = 'function' @@ -273,19 +268,19 @@ def __init__( or `involutory \ `__. For example, to convert Kelvin to Celsius, use - ``ax.dual%(x)s(lambda x: x - 273.15)``. To convert kilometers - to meters, use ``ax.dual%(x)s(lambda x: x*1e3)``. + ``ax.dualx(lambda x: x - 273.15)``. To convert kilometers + to meters, use ``ax.dualx(lambda x: x * 1e3)``. * A 2-tuple of such functions. The second function must be the *inverse* of the first. For example, to apply the square, use - ``ax.dual%(x)s((lambda x: x**2, lambda x: x**0.5))``. + ``ax.dualx((lambda x: x**2, lambda x: x**0.5))``. Again, if the first function is linear or involutory, you do not need to provide the second! * A scale specification interpreted by the `~proplot.constructor.Scale` constructor function. The forward transformation, inverse transformation, and default axis locators and formatters are borrowed from the resulting `~matplotlib.scale.ScaleBase` instance. For example, to apply the - inverse, use ``ax.dual%(x)s('inverse')``. To apply the base-10 - exponential function, use ``ax.dual%(x)s(('exp', 10))``. + inverse, use ``ax.dualx('inverse')``. To apply the base-10 + exponential function, use ``ax.dualx(('exp', 10))``. invert : bool, optional If ``True``, the forward and inverse functions are *swapped*. @@ -898,10 +893,6 @@ def transform_non_affine(self, a): return 1.0 / a -# Monkey patch matplotlib scale factory with custom scale factory that -# accepts ScaleBase instances. This permits set_xscale and set_yscale to accept -# axis scales returned by Scale constructor and makes things constistent with -# the other constructor functions. def _scale_factory(scale, axis, *args, **kwargs): # noqa: U100 """ If `scale` is a `~matplotlib.scale.ScaleBase` instance, do nothing. If @@ -909,19 +900,20 @@ def _scale_factory(scale, axis, *args, **kwargs): # noqa: U100 """ if isinstance(scale, mscale.ScaleBase): if args or kwargs: - warnings._warn_proplot( - f'Ignoring args {args} and keyword args {kwargs}.' - ) + warnings._warn_proplot(f'Ignoring args {args} and keyword args {kwargs}.') return scale # do nothing else: scale = scale.lower() if scale not in scales: raise ValueError( - f'Unknown scale {scale!r}. Options are ' + f'Unknown axis scale {scale!r}. Options are ' + ', '.join(map(repr, scales.keys())) + '.' ) return scales[scale](*args, **kwargs) +# Monkey patch matplotlib scale factory with version that accepts ScaleBase instances. +# This lets set_xscale and set_yscale accept axis scales returned by Scale constructor +# and makes things constistent with the other constructor functions. if mscale.scale_factory is not _scale_factory: mscale.scale_factory = _scale_factory diff --git a/proplot/ticker.py b/proplot/ticker.py index 8d87e1653..d95903ed8 100644 --- a/proplot/ticker.py +++ b/proplot/ticker.py @@ -11,7 +11,7 @@ import numpy as np from .internals import ic # noqa: F401 -from .internals import _dummy_context, _not_none, _state_context, docstring +from .internals import _empty_context, _not_none, _state_context, docstring try: import cartopy.crs as ccrs @@ -173,10 +173,10 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def __call__(self, value, pos=None): - if self.axis is not None: - context = _state_context(self.axis.axes, projection=ccrs.PlateCarree()) + if self.axis is None: + context = _empty_context() else: - context = _dummy_context() + context = _state_context(self.axis.axes, projection=ccrs.PlateCarree()) with context: return super().__call__(value, pos) diff --git a/proplot/ui.py b/proplot/ui.py index 6c455125c..ea2578267 100644 --- a/proplot/ui.py +++ b/proplot/ui.py @@ -28,9 +28,12 @@ 'isinteractive', ] -docstring.snippets['pyplot.statement'] = """ + +# Docstrings +_pyplot_docstring = """ This is included so you don't have to import `~matplotlib.pyplot`. """ +docstring.snippets['pyplot.statement'] = _pyplot_docstring # Width or (width, height) dimensions for common journal specifications @@ -122,7 +125,7 @@ def isinteractive(): return plt.isinteractive() -def _journals(journal): +def _journal_size(journal): """ Return the width and height corresponding to the given journal. """ @@ -192,6 +195,7 @@ def _axes_dict(naxs, value, kw=False, default=None): return kwargs +@docstring.add_snippets def subplots( array=None, ncols=1, nrows=1, order='C', ref=1, refaspect=None, refwidth=None, refheight=None, @@ -199,7 +203,8 @@ def subplots( figwidth=None, figheight=None, figsize=None, width=None, height=None, # deprecated journal=None, - hspace=None, wspace=None, space=None, + wspace=None, hspace=None, space=None, + wpad=None, hpad=None, pad=None, hratios=None, wratios=None, width_ratios=None, height_ratios=None, wequal=None, hequal=None, equal=None, @@ -274,6 +279,12 @@ def subplots( ``subplots(ncols=3, tight=True, wspace=('2em', None))`` fixes the space between the first 2 columns but uses the tight layout algorithm between the last 2 columns. + wpad, hpad, pad : float or str or list thereof, optional + The tight layout padding between grid columns, grid rows, and both, + respectively. Unlike ``space``, these arguments control the padding between + subplot content (including ticks, text, etc.) rather than subplot edges. + Default is :rcraw:`subplots.panelpad`. See the below ``innerpad``, + ``outerpad``, and ``panelpad`` arguments for details. wequal, hequal, equal : bool, optional Whether to make the tight layout algorithm apply equal spacing between columns, rows, or both. Default is ``False``. @@ -360,8 +371,9 @@ def subplots( Other parameters ---------------- + %(figure.figure)s **kwargs - Passed to `proplot.figure.Figure`. + Passed to `matplotlib.figure.Figure`. Returns ------- @@ -380,7 +392,7 @@ def subplots( References ---------- For academic journal figure size recommendations, see the - `Nature `_ `AAAS `_, `PNAS `_, `AGU `_, + `Nature `_, `AAAS `_, `PNAS `_, `AGU `_, and `AMS `_ web pages. .. _aaas: https://www.sciencemag.org/authors/instructions-preparing-initial-manuscript @@ -398,21 +410,21 @@ def subplots( if array is None: array = np.arange(1, nrows * ncols + 1)[..., None] array = array.reshape((nrows, ncols), order=order) + # Standardize array try: array = np.array(array, dtype=int) # enforce array type if array.ndim == 1: # interpret as single row or column array = array[None, :] if order == 'C' else array[:, None] elif array.ndim != 2: - raise ValueError( - f'Array must be 1-2 dimensional, but got {array.ndim} dims.' - ) - array[array == None] = 0 # use zero for placeholder # noqa + raise ValueError(f'Array must be 1d or 2d, but got {array.ndim}d.') + array[array == None] = 0 # None or 0 both valid placeholders # noqa: E711 except (TypeError, ValueError): raise ValueError( f'Invalid subplot array {array!r}. ' 'Must be 1d or 2d array of integers.' ) + # Get other props nums = np.unique(array[array != 0]) naxs = len(nums) @@ -430,8 +442,8 @@ def subplots( nrows, ncols = array.shape # Get some axes properties, where locations are sorted by axes id. + # NOTE: The entry 0 stands for empty # NOTE: These ranges are endpoint exclusive, like a slice object! - # NOTE: 0 stands for empty axids = [np.where(array == i) for i in np.sort(np.unique(array)) if i > 0] xrange = np.array([[x.min(), x.max()] for _, x in axids]) yrange = np.array([[y.min(), y.max()] for y, _ in axids]) @@ -479,8 +491,7 @@ def subplots( figwidth = _not_none(figwidth=figwidth, width=width) figheight = _not_none(figheight=figheight, height=height) if journal: - # if user passed width= , will use that journal size - figsize = _journals(journal) + figsize = _journal_size(journal) spec = f'journal={journal!r}' names = ('refwidth', 'refheight', 'figwidth') values = (refwidth, refheight, figwidth) @@ -499,81 +510,67 @@ def subplots( spec = ', '.join(spec) names = ('refwidth', 'refheight') values = (refwidth, refheight) - # Raise warning + + # Warn sizing conflicts for name, value in zip(names, values): if value is not None: warnings._warn_proplot( - f'You specified both {spec} and {name}={value!r}. ' - f'Ignoring {name!r}.' + f'You specified both {spec} and {name}={value!r}. Ignoring {name!r}.' ) - # Standardized dimensions - figwidth, figheight = units(figwidth), units(figheight) - refwidth, refheight = units(refwidth), units(refheight) - - # Standardized user input border spaces - left, right = units(left), units(right) - bottom, top = units(bottom), units(top) - - # Standardized user input spaces - wspace = np.atleast_1d(units(_not_none(wspace, space))) - hspace = np.atleast_1d(units(_not_none(hspace, space))) - if len(wspace) == 1: - wspace = np.repeat(wspace, (ncols - 1,)) - if len(wspace) != ncols - 1: - raise ValueError( - f'Require {ncols-1} width spacings for {ncols} columns, ' - 'got {len(wspace)}.' - ) - if len(hspace) == 1: - hspace = np.repeat(hspace, (nrows - 1,)) - if len(hspace) != nrows - 1: - raise ValueError( - f'Require {nrows-1} height spacings for {nrows} rows, ' - 'got {len(hspace)}.' - ) - - # Standardized user input ratios - wratios = np.atleast_1d(_not_none( - width_ratios=width_ratios, wratios=wratios, default=1, - )) - hratios = np.atleast_1d(_not_none( - height_ratios=height_ratios, hratios=hratios, default=1, - )) - if len(wratios) == 1: - wratios = np.repeat(wratios, (ncols,)) - if len(hratios) == 1: - hratios = np.repeat(hratios, (nrows,)) - if len(wratios) != ncols: - raise ValueError(f'Got {ncols} columns, but {len(wratios)} wratios.') - if len(hratios) != nrows: - raise ValueError(f'Got {nrows} rows, but {len(hratios)} hratios.') - - # Fill subplots_orig_kw with user input values + # Helper functions + def _to_list(*args, n, descrip, **kwargs): # noqa: E301 + value = _not_none(*args, **kwargs) + value = np.atleast_1d(units(value, 'em', 'in')) + if len(value) == 1: + value = np.repeat(value, (n,)) + if len(value) != n: + raise ValueError(f'Expected {n} {descrip}, but got {len(value)}.') + return value.tolist() + + # Translate dimensions, space, pad, and ratios input # NOTE: 'Ratios' are only fixed for panel axes, but we store entire array - wspace, hspace = wspace.tolist(), hspace.tolist() - wratios, hratios = wratios.tolist(), hratios.tolist() + left = units(left, 'em', 'in') + right = units(right, 'em', 'in') + bottom = units(bottom, 'em', 'in') + top = units(top, 'em', 'in') + pad = _not_none(kwargs.get('innerpad', None), pad, rc['subplots.innerpad']) + wpad = _to_list(wpad, pad, n=ncols - 1, descrip='width spaces') + hpad = _to_list(hpad, pad, n=nrows - 1, descrip='height spaces') + wspace = _to_list(wspace, space, n=ncols - 1, descrip='width pads') + hspace = _to_list(hspace, space, n=nrows - 1, descrip='height pads') + wequal = _not_none(wequal, equal, False) + hequal = _not_none(hequal, equal, False) + figwidth = units(figwidth, 'in') + figheight = units(figheight, 'in') + refwidth = units(refwidth, 'in') + refheight = units(refheight, 'in') + wratios = _to_list( + wratios=wratios, width_ratios=width_ratios, default=1, + n=ncols, descrip='width ratios' + ) + hratios = _to_list( + hratios=hratios, width_ratios=height_ratios, default=1, + n=nrows, descrip='height ratios' + ) subplots_orig_kw = { 'left': left, 'right': right, 'top': top, 'bottom': bottom, - 'wspace': wspace, 'hspace': hspace, + 'wspace': wspace, 'hspace': hspace, 'wpad': wpad, 'hpad': hpad, } - # Apply default settings + # Apply defaults for unspecified dimensions + _fill_with = lambda arr, default: [_not_none(_, default) for _ in arr] # noqa: E731 share = kwargs.get('share', None) sharex = _not_none(kwargs.get('sharex', None), share, rc['subplots.share']) sharey = _not_none(kwargs.get('sharey', None), share, rc['subplots.share']) - left = _not_none(left, pgridspec._default_space('left')) right = _not_none(right, pgridspec._default_space('right')) bottom = _not_none(bottom, pgridspec._default_space('bottom')) top = _not_none(top, pgridspec._default_space('top')) - - wspace, hspace = np.array(wspace), np.array(hspace) # also copies! - wspace[wspace == None] = pgridspec._default_space('wspace', sharex) # noqa: E711 - hspace[hspace == None] = pgridspec._default_space('hspace', sharey) # noqa: E711 - - wratios, hratios = list(wratios), list(hratios) - wspace, hspace = list(wspace), list(hspace) + wspace = _fill_with(wspace, pgridspec._default_space('wspace', sharex)) + hspace = _fill_with(hspace, pgridspec._default_space('hspace', sharey)) + wpad = _fill_with(wpad, pad) + hpad = _fill_with(hpad, pad) # Parse arguments, fix dimensions in light of desired aspect ratio figsize, gridspec_kw, subplots_kw = pgridspec._calc_geometry( @@ -582,8 +579,9 @@ def subplots( figwidth=figwidth, figheight=figheight, refaspect=refaspect, refwidth=refwidth, refheight=refheight, refxrange=refxrange, refyrange=refyrange, - wratios=wratios, hratios=hratios, wspace=wspace, hspace=hspace, - wequal=bool(wequal), hequal=bool(hequal), equal=bool(equal), + wratios=wratios, hratios=hratios, + wspace=wspace, hspace=hspace, wpad=wpad, hpad=hpad, + wequal=wequal, hequal=hequal, wpanels=[''] * ncols, hpanels=[''] * nrows, ) fig = plt.figure( diff --git a/proplot/utils.py b/proplot/utils.py index fe3e06b2f..32c8ac3e6 100644 --- a/proplot/utils.py +++ b/proplot/utils.py @@ -12,7 +12,7 @@ from .externals import hsluv from .internals import ic # noqa: F401 -from .internals import docstring, warnings +from .internals import _not_none, docstring, warnings __all__ = [ 'arange', @@ -44,12 +44,23 @@ 'dm': 3.937, 'cm': 0.3937, 'mm': 0.03937, - 'pt': 1 / 72.0, 'pc': 1 / 6.0, + 'pt': 1 / 72.0, 'ly': 3.725e+17, } -# Shared parameters + +# Unit docstrings +_units_docstring = ( + 'If float, units are {units}. ' + 'If string, units are interpreted by `~proplot.utils.units`.' +) +docstring.snippets['units.pt'] = _units_docstring.format(units='points') +docstring.snippets['units.in'] = _units_docstring.format(units='inches') +docstring.snippets['units.em'] = _units_docstring.format(units='font size-relative') + + +# Color docstrings docstring.snippets['param.rgba'] = """ color : color-spec The color. Sanitized with `to_rgba`. @@ -75,8 +86,6 @@ The hue-saturation-luminance-like colorspace used to transform the color. Default is the perceptually uniform colorspace ``'hcl'``. """ - -# Shared return values docstring.snippets['return.hex'] = """ color : str A HEX string. @@ -655,7 +664,9 @@ def to_xyza(color, space='hcl'): @warnings._rename_kwargs('0.6', units='dest') -def units(value, dest='in', axes=None, figure=None, width=True): +def units( + value, numeric='in', dest=None, *, axes=None, figure=None, width=True, fontsize=None +): """ Convert values and lists of values between arbitrary physical units. This is used internally all over ProPlot, permitting flexible units for various @@ -664,10 +675,11 @@ def units(value, dest='in', axes=None, figure=None, width=True): Parameters ---------- value : float or str or list thereof - A size specifier or *list thereof*. If numeric, nothing is done. - If string, it is converted to the units `dest`. The string should look - like ``'123.456unit'``, where the number is the magnitude and - ``'unit'`` is one of the following. + A size specifier or *list thereof*. If numeric, units are converted from + `numeric` to `dest`. If string, units are converted to `dest` according + to the string specifier. The string should look like ``'123.456unit'``, + where the number is the magnitude and ``'unit'`` matches a key in + the below table. .. _units_table :: @@ -681,8 +693,8 @@ def units(value, dest='in', axes=None, figure=None, width=True): ``'yd'`` Yards ``'ft'`` Feet ``'in'`` Inches - ``'pt'`` `Points `_ (1/72 inches) ``'pc'`` `Pica `_ (1/6 inches) + ``'pt'`` `Points `_ (1/72 inches) ``'px'`` Pixels on screen, using dpi of :rcraw:`figure.dpi` ``'pp'`` Pixels once printed, using dpi of :rcraw:`savefig.dpi` ``'em'`` `Em square `_ for :rcraw:`font.size` @@ -699,23 +711,29 @@ def units(value, dest='in', axes=None, figure=None, width=True): .. _em: https://en.wikipedia.org/wiki/Em_(typography) .. _en: https://en.wikipedia.org/wiki/En_(typography) + numeric : str, optional + How to interpret numeric units. Default is inches. dest : str, optional - The destination units. Default is inches, i.e. ``'in'``. + The destination units. Default is the same as `numeric`. axes : `~matplotlib.axes.Axes`, optional The axes to use for scaling units that look like ``'0.1ax'``. figure : `~matplotlib.figure.Figure`, optional - The figure to use for scaling units that look like ``'0.1fig'``. If - ``None`` we try to get the figure from ``axes.figure``. + The figure to use for scaling units that look like ``'0.1fig'``. + If ``None`` we try to get the figure from ``axes.figure``. width : bool, optional - Whether to use the width or height for the axes and figure relative - coordinates. + Whether to use the width or height for the axes and figure + relative coordinates. + fontsize : size-spec, optional + The font size in points used for scaling. Default is + :rcraw:`font.size` for ``em`` and ``en`` units and + :rcraw:`axes.titlesize` for ``Em`` and ``En`` units. """ # Font unit scales # NOTE: Delay font_manager import, because want to avoid rebuilding font # cache, which means import must come after TTFPATH added to environ # by register_fonts()! - fontsize_small = rcParams['font.size'] # must be absolute - fontsize_large = rcParams['axes.titlesize'] + fontsize_small = _not_none(fontsize, rcParams['font.size']) # must be absolute + fontsize_large = _not_none(fontsize, rcParams['axes.titlesize']) if isinstance(fontsize_large, str): scale = mfonts.font_scalings.get(fontsize_large, 1) fontsize_large = fontsize_small * scale @@ -743,47 +761,41 @@ def units(value, dest='in', axes=None, figure=None, width=True): unit_dict['ax'] = axes.get_size_inches()[1 - int(width)] if figure is None: figure = getattr(axes, 'figure', None) - if figure is not None and hasattr( - figure, 'get_size_inches'): # proplot axes + if figure is not None and hasattr(figure, 'get_size_inches'): unit_dict['fig'] = figure.get_size_inches()[1 - int(width)] # Scale for converting inches to arbitrary other unit + dest = _not_none(dest, numeric) + suffix = 'Valid units are ' + ', '.join(map(repr, unit_dict)) + '.' try: - scale = unit_dict[dest] + nscale = unit_dict[numeric] except KeyError: - raise ValueError( - f'Invalid destination units {dest!r}. Valid units are ' - + ', '.join(map(repr, unit_dict.keys())) + '.' - ) + raise ValueError(f'Invalid numeric units {numeric!r}. ' + suffix) + try: + dscale = unit_dict[dest] + except KeyError: + raise ValueError(f'Invalid destination units {dest!r}. ' + suffix) # Convert units for each value in list result = [] singleton = not np.iterable(value) or isinstance(value, str) for val in ((value,) if singleton else value): - if val is None or isinstance(val, Number): + if val is None: result.append(val) continue - elif not isinstance(val, str): - raise ValueError( - f'Size spec must be string or number or list thereof. ' - f'Got {value!r}.' - ) + if isinstance(val, Number): + result.append(val * nscale / dscale) + continue + if not isinstance(val, str): + raise ValueError(f'Invalid size spec {val!r}. Must be string or number.') regex = NUMBER.match(val) if not regex: - raise ValueError( - f'Invalid size spec {val!r}. Valid units are ' - + ', '.join(map(repr, unit_dict.keys())) + '.' - ) + raise ValueError(f'Invalid size spec {val!r}. ' + suffix) number, units = regex.groups() # second group is exponential try: - result.append( - float(number) * (unit_dict[units] / scale if units else 1) - ) + result.append(float(number) * (unit_dict[units] / dscale if units else 1)) except (KeyError, ValueError): - raise ValueError( - f'Invalid size spec {val!r}. Valid units are ' - + ', '.join(map(repr, unit_dict.keys())) + '.' - ) + raise ValueError(f'Invalid size spec {val!r}. ' + suffix) if singleton: result = result[0] return result