#!/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.
"""
# 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
from .. import colors as pcolors
from .. import constructor
from .. import ticker as pticker
from ..config import rc
from ..internals import ic # noqa: F401
from ..internals import (
_dummy_context,
_getattr_flexible,
_not_none,
_pop_props,
_state_context,
docstring,
warnings,
)
from ..utils import edges, edges2d, to_rgb, to_xyz, units
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',
'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
# (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'),
}
# 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'),
}
docstring.snippets['axes.autoformat'] = """
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 \
<https://matplotlib.org/stable/gallery/misc/keyword_plotting.html>`__
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'] = """
cmap : colormap spec, optional
The colormap specifer, passed to the `~proplot.constructor.Colormap`
constructor.
cmap_kw : dict-like, optional
Passed to `~proplot.constructor.Colormap`.
norm : normalizer spec, optional
The colormap normalizer, used to warp data before passing it
to `~proplot.colors.DiscreteNorm`. This is passed to the
`~proplot.constructor.Norm` constructor.
norm_kw : dict-like, optional
Passed to `~proplot.constructor.Norm`.
extend : {{'neither', 'min', 'max', 'both'}}, optional
Whether to assign unique colors to out-of-bounds data and draw
"extensions" (triangles, by default) on the colorbar.
"""
docstring.snippets['axes.levels_values'] = """
N
Shorthand for `levels`.
levels : int or list of float, optional
The number of level edges or a list of level edges. If the former,
`locator` is used to generate this many level edges at "nice" intervals.
If the latter, the levels should be monotonically increasing or
decreasing (note that decreasing levels will only work with ``pcolor``
plots, not ``contour`` plots). Default is :rc:`image.levels`.
values : int or list of float, optional
The number of level centers or a list of level centers. If the former,
`locator` is used to generate this many level centers at "nice" intervals.
If the latter, levels are inferred using `~proplot.utils.edges`.
This will override any `levels` input.
discrete : bool, optional
If ``False``, the `~proplot.colors.DiscreteNorm` is not applied to the
colormap when ``levels=N`` or ``levels=array_of_values`` are not
explicitly requested. Instead, the number of levels in the colormap will be
roughly controlled by :rcraw:`image.lut`. This has a similar effect
to using `levels=large_number` but it may improve rendering speed.
By default, this is ``False`` only for `~matplotlib.axes.Axes.imshow`,
`~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'] = """
inbounds : bool, optional
If ``True`` (the edefault), 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`.
locator : locator-spec, optional
The locator used to determine level locations if `levels` or `values`
is an integer and `vmin` and `vmax` were not provided. Passed to the
`~proplot.constructor.Locator` constructor. Default is
`~matplotlib.ticker.MaxNLocator` with ``levels`` integer levels.
locator_kw : dict-like, optional
Passed to `~proplot.constructor.Locator`.
symmetric : bool, optional
If ``True``, automatically generated levels are symmetric
about zero.
positive : bool, optional
If ``True``, automatically generated levels are positive
with a minimum at zero.
negative : bool, optional
If ``True``, automatically generated levels are negative
with a maximum at zero.
nozero : bool, optional
If ``True``, ``0`` is removed from the level list. This is
mainly useful for `~matplotlib.axes.Axes.contour` plots.
"""
_lines_docstring = """
Plot {orientation} lines with flexible positional arguments and optionally
use different colors for "negative" and "positive" lines.
Important
---------
This function wraps `~matplotlib.axes.Axes.{prefix}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``.
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.
negpos : bool, optional
Whether to color lines greater than zero with `poscolor` and lines less
than zero with `negcolor`.
negcolor, poscolor : color-spec, optional
Colors to use for the negative and positive lines. Ignored if `negpos`
is ``False``. Defaults are :rc:`negcolor` and :rc:`poscolor`.
color, colors : color-spec or list thereof, optional
The line color(s).
linestyle, linestyles : linestyle-spec or list thereof, optional
The line style(s).
lw, linewidth, linewidths : linewidth-spec or list thereof, optional
The line width(s).
See also
--------
standardize_1d
apply_cycle
"""
docstring.snippets['axes.vlines'] = _lines_docstring.format(
x='x', y='y', prefix='v', orientation='vertical',
)
docstring.snippets['axes.hlines'] = _lines_docstring.format(
x='y', y='x', prefix='h', orientation='horizontal',
)
_scatter_docstring = """
Support `apply_cmap` features and support keywords that are more
consistent with `~{package}.axes.Axes.plot{suffix}` keywords.
Important
---------
This function wraps `~{package}.axes.Axes.scatter{suffix}`.
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}*.
s, size, markersize : float or list of float, optional
The marker size(s). The units are optionally scaled by
`smin` and `smax`.
smin, smax : float, optional
The minimum and maximum marker size in units ``points^2`` used to scale
`s`. If not provided, the marker sizes are equivalent to the values in `s`.
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
lw, linewidth, linewidths, markeredgewidth, markeredgewidths \
: float or list thereof, optional
The marker edge width.
edgecolors, markeredgecolor, markeredgecolors \
: color-spec or list thereof, optional
The marker edge color.
Other parameters
----------------
**kwargs
Passed to `~{package}.axes.Axes.scatter{suffix}`.
See also
--------
{package}.axes.Axes.bar{suffix}
standardize_1d
indicate_error
apply_cycle
"""
docstring.snippets['axes.scatter'] = _scatter_docstring.format(
x='x', y='y', suffix='', package='matplotlib',
) % docstring.snippets
docstring.snippets['axes.scatterx'] = _scatter_docstring.format(
x='y', y='x', suffix='', package='proplot',
) % docstring.snippets
_fill_between_docstring = """
Support overlaying and stacking successive columns of data, and permits
using different colors for "negative" and "positive" regions.
Important
---------
This function wraps `~matplotlib.axes.Axes.fill_between{suffix}` and
`~proplot.axes.Axes.area{suffix}`.
Parameters
----------
*args : ({y}1,), ({x}, {y}1), or ({x}, {y}1, {y}2)
The *{x}* and *{y}* coordinates. If `{x}` is not provided, it will be
inferred from `{y}1`. If `{y}1` and `{y}2` are provided, this function will
shade between 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``.
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.
negpos : bool, optional
Whether to shade where ``{y}1 >= {y}2`` with `poscolor` and where ``{y}1 < {y}2``
with `negcolor`. For example, to shade positive values red and negative values
blue, simply use ``ax.fill_between{suffix}({x}, {y}, negpos=True)``.
negcolor, poscolor : color-spec, optional
Colors to use for the negative and positive shaded regions. Ignored if `negpos`
is ``False``. Defaults are :rc:`negcolor` and :rc:`poscolor`.
where : ndarray, optional
Boolean ndarray mask for points you want to shade. See `this example \
<https://matplotlib.org/stable/gallery/pyplots/whats_new_98_4_fill_between.html>`__.
lw, linewidth : float, optional
The edge width for the area patches.
edgecolor : color-spec, optional
The edge color for the area patches.
Other parameters
----------------
**kwargs
Passed to `~matplotlib.axes.Axes.fill_between`.
See also
--------
matplotlib.axes.Axes.fill_between{suffix}
proplot.axes.Axes.area{suffix}
standardize_1d
apply_cycle
"""
docstring.snippets['axes.fill_between'] = _fill_between_docstring.format(
x='x', y='y', suffix='',
)
docstring.snippets['axes.fill_betweenx'] = _fill_between_docstring.format(
x='y', y='x', suffix='x',
)
_bar_docstring = """
Support grouping and stacking successive columns of data, and changes
the default bar style.
Important
---------
This function wraps `~matplotlib.axes.Axes.bar{suffix}`.
Parameters
----------
{x}, height, width, {bottom} : float or list of float, optional
The dimensions of the bars. If the *{x}* coordinates are not provided,
they are set to ``np.arange(0, len(height))``. 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.
Other parameters
----------------
**kwargs
Passed to `~matplotlib.axes.Axes.bar{suffix}`.
See also
--------
matplotlib.axes.Axes.bar{suffix}
standardize_1d
indicate_error
apply_cycle
"""
docstring.snippets['axes.bar'] = _bar_docstring.format(
x='x', bottom='bottom', suffix='',
)
docstring.snippets['axes.barh'] = _bar_docstring.format(
x='y', bottom='left', suffix='h',
)
def _load_objects():
"""
Delay loading expensive modules. We just want to detect if *input
arrays* belong to these types -- and if this is the case, it means the
module has already been imported! So, we only try loading these classes
within autoformat calls. This saves >~500ms of import time.
"""
global DataArray, DataFrame, Series, Index, ndarray
ndarray = np.ndarray
DataArray = getattr(sys.modules.get('xarray', None), 'DataArray', ndarray)
DataFrame = getattr(sys.modules.get('pandas', None), 'DataFrame', ndarray)
Series = getattr(sys.modules.get('pandas', None), 'Series', ndarray)
Index = getattr(sys.modules.get('pandas', None), 'Index', ndarray)
_load_objects()
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)
def _is_string(data):
"""
Test whether input is array of strings.
"""
return len(data) and isinstance(_to_ndarray(data).flat[0], str)
def _to_arraylike(data):
"""
Convert list of lists to array-like type.
"""
_load_objects()
if data is None:
raise ValueError('Cannot convert None data.')
return None
if not isinstance(data, (ndarray, DataArray, DataFrame, Series, Index)):
data = np.asarray(data)
if not np.iterable(data):
data = np.atleast_1d(data)
return data
def _to_ndarray(data):
"""
Convert arbitrary input to ndarray cleanly. Returns a masked
array if input is a masked array.
"""
return np.atleast_1d(getattr(data, 'values', data))
def _mask_array(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
[docs]def default_latlon(self, *args, latlon=True, **kwargs):
"""
Makes ``latlon=True`` the default for basemap plots.
This means you no longer have to pass ``latlon=True`` if your data
coordinates are longitude and latitude.
Important
---------
This function wraps {methods} for `~proplot.axes.BasemapAxes`.
"""
method = kwargs.pop('_method')
return method(self, *args, latlon=latlon, **kwargs)
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)
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)
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
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
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
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:
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)
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:
kwargs['values'] = _to_ndarray(labels)
# 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
[docs]@docstring.add_snippets
def standardize_1d(self, *args, data=None, autoformat=None, **kwargs):
"""
Interpret positional arguments for the "1D" plotting methods so usage is
consistent. Positional arguments are standardized as follows:
* If a 2D array is passed, the corresponding plot command is called for
each column of data (except for ``boxplot`` and ``violinplot``, in which
case each column is interpreted as a distribution).
* If *x* and *y* or *latitude* and *longitude* coordinates were not provided,
and a `~pandas.DataFrame` or `~xarray.DataArray`, we try to infer them from
the metadata. Otherwise, ``np.arange(0, data.shape[0])`` is used.
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 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):
xmin, xmax = self.projection.lonmin, self.projection.lonmax
x_orig, ys_orig = x, ys
ys = []
for y_orig in ys_orig:
x, y = _fix_bounds(*_fix_latlon(x_orig, y_orig), xmin, xmax)
ys.append(y)
# 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 _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)}.'
)
return x, y
def _fix_bounds(x, y, xmin, xmax):
"""
Ensure data for basemap plots is restricted between the minimum and
maximum longitude of the projection. Input is the ``x`` and ``y``
coordinates. The ``y`` coordinates are rolled along the rightmost axis.
"""
if x.ndim != 1:
return x, y
# Roll in same direction if some points on right-edge extend
# more than 360 above min longitude; *they* should be on left side
lonroll = np.where(x > xmin + 360)[0] # tuple of ids
if lonroll.size: # non-empty
roll = x.size - lonroll.min()
x = np.roll(x, roll)
y = np.roll(y, roll, axis=-1)
x[:roll] -= 360 # make monotonic
# Set NaN where data not in range xmin, xmax. Must be done
# for regional smaller projections or get weird side-effects due
# to having valid data way outside of the map boundaries
y = y.copy()
if x.size - 1 == y.shape[-1]: # test western/eastern grid cell edges
y[..., (x[1:] < xmin) | (x[:-1] > xmax)] = np.nan
elif x.size == y.shape[-1]: # test the centers and pad by one for safety
where = np.where((x < xmin) | (x > xmax))[0]
y[..., where[1:-1]] = np.nan
return x, y
def _fix_latlon(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_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 _fix_cartopy(x, y, *zs, globe=False):
"""
Fix cartopy geographic data arrays.
"""
# Fix coordinates
x, y = _fix_latlon(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 = _fix_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 _fix_basemap(x, y, *zs, globe=False, projection=None):
"""
Fix basemap geographic data arrays.
"""
# Fix coordinates
x, y = _fix_latlon(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_bounds(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 = _fix_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)
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 = projection(x, y)
return x, y, *zs
[docs]@docstring.add_snippets
def standardize_2d(
self, *args, data=None, autoformat=None, order='C', globe=False, **kwargs
):
"""
Interpret positional arguments for the "2D" plotting methods so usage is
consistent. Positional arguments are standardized as follows:
* If *x* and *y* or *latitude* and *longitude* coordinates were not
provided, and a `~pandas.DataFrame` or `~xarray.DataArray` is passed, we
try to infer them from the metadata. Otherwise, ``np.arange(0, data.shape[0])``
and ``np.arange(0, data.shape[1])`` are used.
* For ``pcolor`` and ``pcolormesh``, coordinate *edges* are calculated
if *centers* were provided. 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)
):
x, y, *zs = _fix_cartopy(x, y, *zs, globe=globe)
# Basemap projection axes
elif (
not allow1d and getattr(self, 'name', None) == 'proplot_basemap'
and kwargs.get('latlon', None)
):
x, y, *zs = _fix_basemap(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
label_default = 'uncertainty'
err = _to_ndarray(errdata)
if (
err.ndim not in (1, 2)
or err.shape[-1] != y.shape[-1]
or err.ndim == 2 and err.shape[0] != 2
):
raise ValueError(
f'errdata must have shape (2, {y.shape[-1]}), but got {err.shape}.'
)
if err.ndim == 1:
abserr = err
err = np.empty((2, err.size))
err[0, :] = y - abserr # translated back to absolute deviations below
err[1, :] = y + abserr
elif stds is not None:
# 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
[docs]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
):
"""
Adds support for drawing error bars and error shading on-the-fly. Includes
options for interpreting columns of data as *samples*, representing the mean
or median of each sample with lines, points, or bars, and drawing error bars
representing percentile ranges or standard deviation multiples for each sample.
Also supports specifying error bar data explicitly.
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
)
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, (list, tuple)): # avoid BarContainer
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: This should not affect internal matplotlib calls to these funcs
# NOTE: Avoid expanding matplolib collections that are list subclasses here
if eobjs:
return (*res, *eobjs) if isinstance(res, (list, tuple)) else (res, *eobjs)
else:
return res
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 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 _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
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))
):
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 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
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)
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)
):
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 _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:
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
[docs]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
):
"""
Adds features for controlling colors in the property cycler and drawing
legends or colorbars in one go.
Important
---------
This function wraps {methods}
This wrapper also *standardizes acceptable input* -- these methods now all
accept 2D arrays holding columns of data, and *x*-coordinates are always
optional. Note this alters the behavior of `~matplotlib.axes.Axes.boxplot`
and `~matplotlib.axes.Axes.violinplot`, which now compile statistics on
*columns* of data instead of *rows*.
Parameters
----------
cycle : cycle-spec, optional
The cycle specifer, passed to the `~proplot.constructor.Cycle`
constructor. If the returned list of colors is unchanged from the
current axes color cycler, the axes cycle will **not** be reset to the
first position.
cycle_kw : dict-like, optional
Passed to `~proplot.constructor.Cycle`.
label : float or str, optional
The legend label to be used for this plotted element.
labels, values : list of float or list of str, optional
Used with 2D input arrays. The legend labels or colorbar coordinates for
each column in the array. Can be numeric or string, and must match
the number of columns in the 2D array.
legend : bool, int, or str, optional
If not ``None``, this is a location specifying where to draw an *inset*
or *panel* legend from the resulting handle(s). If ``True``, the
default location is used. Valid locations are described in
`~proplot.axes.Axes.legend`.
legend_kw : dict-like, optional
Ignored if `legend` is ``None``. Extra keyword args for 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])
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
# NOTE: Must convert to ndarray or can get singleton DataArrays
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.')
if labels is not None:
labels = [str(_not_none(label, '')) for label in _to_ndarray(labels)]
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)
)
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
elif box:
return objs[0] # always return singleton
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)
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
# 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.
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)
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)
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])
):
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:
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)
# 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
)
# Generate DiscreteNorm and update "child" norm with vmin and vmax from
# levels. This lets the colorbar set tick locations properly!
# TODO: Move these to DiscreteNorm?
if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
norm = pcolors.DiscreteNorm(
levels, cmap=cmap, norm=norm, descending=descending, unique=extend,
)
if descending:
cmap = cmap.reversed()
return norm, cmap, levels, 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
[docs]@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
):
"""
Adds several new keyword args and features for specifying the colormap,
levels, and normalizers. Uses the `~proplot.colors.DiscreteNorm`
normalizer to bin data into discrete color levels (see notes).
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 \
<https://stackoverflow.com/q/8263769/4970632>`__
and `white-lines-between-pcolor-rectangles \
<https://stackoverflow.com/q/27092991/4970632>`__
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.'
)
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)
if getattr(cmap, '_cyclic', None) and extend != 'neither':
warnings._warn_proplot(
'Cyclic colormap requires extend="neither". '
f'Overriding user input extend={extend!r}.'
)
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)
else:
raise RuntimeError(f'Not possible to add labels to {name!r} pplt.')
# 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)
return obj
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
):
# 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 = _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}.'
)
# 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, *_ = _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 _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.'
)
# 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
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 _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