#!/usr/bin/env python3
"""
The axes classes used for all ProPlot figures.
"""
import numpy as np
import warnings
import functools
from numbers import Integral
import matplotlib.projections as mproj
import matplotlib.axes as maxes
import matplotlib.dates as mdates
import matplotlib.scale as mscale
import matplotlib.text as mtext
import matplotlib.path as mpath
import matplotlib.ticker as mticker
import matplotlib.patches as mpatches
import matplotlib.gridspec as mgridspec
import matplotlib.transforms as mtransforms
import matplotlib.collections as mcollections
from . import utils, projs, axistools
from .utils import _notNone, units
from .rctools import rc, RC_NODOTSNAMES
from .wrappers import (
_get_transform, _norecurse, _redirect,
_add_errorbars, _bar_wrapper, _barh_wrapper, _boxplot_wrapper,
_default_crs, _default_latlon, _default_transform, _cmap_changer,
_cycle_changer, _fill_between_wrapper, _fill_betweenx_wrapper,
_hist_wrapper, _plot_wrapper, _scatter_wrapper,
_standardize_1d, _standardize_2d,
_text_wrapper, _violinplot_wrapper,
colorbar_wrapper, legend_wrapper,
)
try:
from cartopy.mpl.geoaxes import GeoAxes
except ModuleNotFoundError:
GeoAxes = object
__all__ = [
'Axes',
'BasemapAxes',
'GeoAxes',
'PolarAxes', 'ProjAxes',
'XYAxes',
]
# Translator for inset colorbars and legends
ABC_STRING = 'abcdefghijklmnopqrstuvwxyz'
LOC_TRANSLATE = {
None: None,
'inset': 'best',
'i': 'best',
0: 'best',
1: 'upper right',
2: 'upper left',
3: 'lower left',
4: 'lower right',
5: 'center left',
6: 'center right',
7: 'lower center',
8: 'upper center',
9: 'center',
'l': 'left',
'r': 'right',
'b': 'bottom',
't': 'top',
'c': 'center',
'ur': 'upper right',
'ul': 'upper left',
'll': 'lower left',
'lr': 'lower right',
'cr': 'center right',
'cl': 'center left',
'uc': 'upper center',
'lc': 'lower center',
}
SIDE_TRANSLATE = {
'l': 'left',
'r': 'right',
'b': 'bottom',
't': 'top',
}
def _abc(i):
"""Function for a-b-c labeling, returns a...z...aa...zz...aaa...zzz."""
if i < 26:
return ABC_STRING[i]
else:
return _abc(i - 26) + ABC_STRING[i % 26] # sexy sexy recursion
def _disable_decorator(msg):
"""
Generate decorators that disable methods. Also sets __doc__ to None so
that ProPlot fork of automodapi doesn't add these methods to the website
documentation. Users can still call help(ax.method) because python looks
for superclass method docstrings if a docstring is empty.
"""
def decorator(func):
@functools.wraps(func)
def _wrapper(self, *args, **kwargs):
raise RuntimeError(msg.format(func.__name__))
_wrapper.__doc__ = None
return _wrapper
return decorator
[docs]class Axes(maxes.Axes):
"""Lowest-level axes subclass. Handles titles and axis
sharing. Adds several new methods and overrides existing ones."""
def __init__(self, *args, number=None,
sharex=0, sharey=0,
spanx=None, spany=None, alignx=None, aligny=None,
main=False,
**kwargs):
"""
Parameters
----------
number : int
The subplot number, used for a-b-c labelling (see
`~Axes.format`).
sharex, sharey : {3, 2, 1, 0}, optional
The "axis sharing level" for the *x* axis, *y* axis, or both
axes. See `~proplot.subplots.subplots` for details.
spanx, spany : bool, optional
Boolean toggle for whether spanning labels are enabled for the
*x* and *y* axes. See `~proplot.subplots.subplots` for details.
alignx, aligny : bool, optional
Boolean toggle for whether aligned axis labels are enabled for the
*x* and *y* axes. See `~proplot.subplots.subplots` for details.
main : bool, optional
Used internally, indicates whether this is a "main axes" rather
than a twin, panel, or inset axes.
See also
--------
:py:obj:`matplotlib.axes.Axes`,
:py:obj:`XYAxes`,
:py:obj:`PolarAxes`,
:py:obj:`ProjAxes`
"""
# Call parent
super().__init__(*args, **kwargs)
# Properties
self._number = number # for abc numbering
self._abc_loc = None
self._abc_text = None
self._titles_dict = {} # title text objects and their locations
self._title_loc = None # location of main title
# so we can copy to top panel
self._title_pad = rc.get('axes.titlepad')
self._title_above_panel = True # TODO: add rc prop?
# Children and related properties
self._bpanels = []
self._tpanels = []
self._lpanels = []
self._rpanels = []
self._tightbbox = None # bounding boxes are saved
self._panel_side = None
self._panel_share = False # True when "filled" with cbar/legend
self._panel_parent = None
self._panel_filled = False # True when "filled" with cbar/legend
self._inset_parent = None
self._inset_zoom = False
self._inset_zoom_data = None
self._alty_child = None
self._altx_child = None
self._alty_parent = None
self._altx_parent = None
self._auto_colorbar = {} # stores handles and kwargs for auto colorbar
self._auto_legend = {}
# Text labels
# TODO: Add text labels as panels instead of as axes children?
coltransform = mtransforms.blended_transform_factory(
self.transAxes, self.figure.transFigure)
rowtransform = mtransforms.blended_transform_factory(
self.figure.transFigure, self.transAxes)
self._llabel = self.text(
0.05, 0.5, '', va='center', ha='right', transform=rowtransform)
self._rlabel = self.text(
0.95, 0.5, '', va='center', ha='left', transform=rowtransform)
self._blabel = self.text(
0.5, 0.05, '', va='top', ha='center', transform=coltransform)
self._tlabel = self.text(
0.5, 0.95, '', va='bottom', ha='center', transform=coltransform)
# Shared and spanning axes
if main:
self.figure._axes_main.append(self)
self._spanx_on = spanx
self._spany_on = spany
self._alignx_on = alignx
self._aligny_on = aligny
self._sharex_level = sharex
self._sharey_level = sharey
self.format(mode=1) # mode == 1 applies the rcShortParams
def _draw_auto_legends_colorbars(self):
"""Generate automatic legends and colorbars. Wrapper funcs
let user add handles to location lists with successive calls to
make successive calls to plotting commands."""
for loc, (handles, kwargs) in self._auto_colorbar.items():
self.colorbar(handles, **kwargs)
for loc, (handles, kwargs) in self._auto_legend.items():
self.legend(handles, **kwargs)
self._auto_legend = {}
self._auto_colorbar = {}
def _get_side_axes(self, side):
"""Returns axes whose left, right, top, or bottom side abutts
against the same row or column as this axes."""
s = side[0]
if s not in 'lrbt':
raise ValueError(f'Invalid side {side!r}.')
if not hasattr(self, 'get_subplotspec'):
return [self]
x = ('x' if s in 'lr' else 'y')
idx = (0 if s in 'lt' else 1) # which side of range to test
coord = self._range_gridspec(x)[idx] # side for a particular axes
axs = [ax for ax in self.figure._axes_main
if ax._range_gridspec(x)[idx] == coord]
if not axs:
return [self]
else:
return axs
def _get_extent_axes(self, x):
"""Returns axes whose horizontal or vertical extent in the main
gridspec matches the horizontal or vertical extend of this axes.
Also sorts the list so the leftmost or bottommost axes is at the
start of the list."""
if not hasattr(self, 'get_subplotspec'):
return [self]
y = ('y' if x == 'x' else 'x')
idx = (0 if x == 'x' else 1)
argfunc = (np.argmax if x == 'x' else np.argmin)
irange = self._range_gridspec(x)
axs = [ax for ax in self.figure._axes_main
if ax._range_gridspec(x) == irange]
if not axs:
return [self]
else:
pax = axs.pop(argfunc([ax._range_gridspec(y)[idx] for ax in axs]))
return [pax, *axs]
def _get_title_props(self, abc=False, loc=None):
"""Returns standardized location name, position keyword arguments, and
setting keyword arguments for the relevant title or a-b-c label at
location `loc`."""
# Props
# NOTE: Sometimes we load all properties from rc object, sometimes
# just changed ones. This is important if e.g. user calls in two
# lines ax.format(titleweight='bold') then ax.format(title='text'),
# don't want to override custom setting with rc default setting.
def props(cache):
return rc.fill({
'fontsize': f'{prefix}.size',
'weight': f'{prefix}.weight',
'color': f'{prefix}.color',
'border': f'{prefix}.border',
'linewidth': f'{prefix}.linewidth',
'fontfamily': 'font.family',
}, cache=cache)
# Location string and position coordinates
cache = True
prefix = 'abc' if abc else 'title'
loc = _notNone(loc, rc[f'{prefix}.loc'])
iloc = getattr(self, '_' + ('abc' if abc else 'title') + '_loc') # old
if loc is None:
loc = iloc
elif iloc is not None and loc != iloc:
cache = False
# Above axes
loc = LOC_TRANSLATE.get(loc, loc)
if loc in ('top', 'bottom'):
raise ValueError(f'Invalid title location {loc!r}.')
elif loc in ('left', 'right', 'center'):
kw = props(cache)
kw.pop('border', None) # no border for titles outside axes
kw.pop('linewidth', None)
if loc == 'center':
obj = self.title
else:
obj = getattr(self, '_' + loc + '_title')
# Inside axes
elif loc in self._titles_dict:
kw = props(cache)
obj = self._titles_dict[loc]
else:
kw = props(False)
width, height = self.get_size_inches()
if loc in ('upper center', 'lower center'):
x, ha = 0.5, 'center'
elif loc in ('upper left', 'lower left'):
xpad = rc.get('axes.titlepad') / (72 * width)
x, ha = 1.5 * xpad, 'left'
elif loc in ('upper right', 'lower right'):
xpad = rc.get('axes.titlepad') / (72 * width)
x, ha = 1 - 1.5 * xpad, 'right'
else:
raise ValueError(f'Invalid title or abc "loc" {loc}.')
if loc in ('upper left', 'upper right', 'upper center'):
ypad = rc.get('axes.titlepad') / (72 * height)
y, va = 1 - 1.5 * ypad, 'top'
elif loc in ('lower left', 'lower right', 'lower center'):
ypad = rc.get('axes.titlepad') / (72 * height)
y, va = 1.5 * ypad, 'bottom'
obj = self.text(x, y, '', ha=ha, va=va, transform=self.transAxes)
obj.set_transform(self.transAxes)
return loc, obj, kw
@staticmethod
def _loc_translate(loc, **kwargs):
"""Translates location string `loc` into a standardized form."""
if loc is True:
loc = 'r' # for on-the-fly colorbars and legends
elif isinstance(loc, (str, Integral)):
loc = LOC_TRANSLATE.get(loc, loc)
return loc
def _make_inset_locator(self, bounds, trans):
"""Helper function, copied from private matplotlib version."""
def inset_locator(ax, renderer):
bbox = mtransforms.Bbox.from_bounds(*bounds)
bb = mtransforms.TransformedBbox(bbox, trans)
tr = self.figure.transFigure.inverted()
bb = mtransforms.TransformedBbox(bb, tr)
return bb
return inset_locator
def _range_gridspec(self, x):
"""Gets the column or row range for the axes."""
subplotspec = self.get_subplotspec()
if x == 'x':
_, _, _, _, col1, col2 = subplotspec.get_active_rows_columns()
return col1, col2
else:
_, _, row1, row2, _, _ = subplotspec.get_active_rows_columns()
return row1, row2
def _range_tightbbox(self, x):
"""Gets span of tight bounding box, including twin axes and panels
which are not considered real children and so aren't ordinarily
included in the tight bounding box calc.
`~proplot.axes.Axes.get_tightbbox` caches tight bounding boxes when
`~Figure.get_tightbbox` is called."""
# TODO: Better resting for axes visibility
bbox = self._tightbbox
if bbox is None:
return np.nan, np.nan
if x == 'x':
return bbox.xmin, bbox.xmax
else:
return bbox.ymin, bbox.ymax
def _reassign_suplabel(self, side):
"""Re-assigns the column and row labels to panel axes, if they exist.
This is called by `~proplot.subplots.Figure._align_suplabel`."""
# Place column and row labels on panels instead of axes -- works when
# this is called on the main axes *or* on the relevant panel itself
# TODO: Mixed figure panels with super labels? How does that work?
s = side[0]
side = SIDE_TRANSLATE[s]
if s == self._panel_side:
ax = self._panel_parent
else:
ax = self
paxs = getattr(ax, '_' + s + 'panels')
if not paxs:
return ax
idx = (0 if s in 'lt' else -1)
pax = paxs[idx]
kw = {}
obj = getattr(ax, '_' + s + 'label')
for key in ('color', 'fontproperties'): # TODO: add to this?
kw[key] = getattr(obj, 'get_' + key)()
pobj = getattr(pax, '_' + s + 'label')
pobj.update(kw)
text = obj.get_text()
if text:
obj.set_text('')
pobj.set_text(text)
return pax
def _reassign_title(self):
"""Re-assigns title to the first upper panel if present. We cannot
simply add upper panel as child axes, because then title will be offset
but still belong to main axes, which messes up tight bounding box."""
# Reassign title from main axes to top panel -- works when this is
# called on the main axes *or* on the top panel itself. This is
# critical for bounding box calcs; not always clear whether draw() and
# get_tightbbox() are called on the main axes or panel first
if self._panel_side == 'top' and self._panel_parent:
ax, taxs = self._panel_parent, [self]
else:
ax, taxs = self, self._tpanels
if not taxs or not ax._title_above_panel:
tax = ax
else:
tax = taxs[0]
tax._title_pad = ax._title_pad
for loc, obj in ax._titles_dict.items():
if not obj.get_text() or loc not in (
'left', 'center', 'right'):
continue
kw = {}
loc, tobj, _ = tax._get_title_props(loc=loc)
for key in ('text', 'color', 'fontproperties'): # add to this?
kw[key] = getattr(obj, 'get_' + key)()
tobj.update(kw)
tax._titles_dict[loc] = tobj
obj.set_text('')
# Push title above tick marks -- this is known matplotlib problem,
# but especially annoying with top panels!
# TODO: Make sure this is robust. Seems 'default' is returned usually
# when tick label sides is actually *both*. Also makes sure axis is
# visible; if not, this is a filled cbar/legend, no padding needed
pad = 0
pos = tax.xaxis.get_ticks_position()
labs = tax.xaxis.get_ticklabels()
if pos == 'default' or (pos == 'top' and not len(labs)) or (
pos == 'unknown' and tax._panel_side == 'top'
and not len(labs) and tax.xaxis.get_visible()):
pad = tax.xaxis.get_tick_padding()
tax._set_title_offset_trans(self._title_pad + pad)
def _sharex_setup(self, sharex, level):
"""Sets up panel axis sharing."""
if level not in range(4):
raise ValueError(
'Level can be 0 (share nothing), '
'1 (do not share limits, just hide axis labels), '
'2 (share limits, but do not hide tick labels), or '
'3 (share limits and hide tick labels). Got {level}.')
# enforce, e.g. if doing panel sharing
self._sharex_level = max(self._sharex_level, level)
self._share_short_axis(sharex, 'l', level)
self._share_short_axis(sharex, 'r', level)
self._share_long_axis(sharex, 'b', level)
self._share_long_axis(sharex, 't', level)
def _sharey_setup(self, sharey, level):
"""Sets up panel axis sharing."""
if level not in range(4):
raise ValueError(
'Level can be 0 (share nothing), '
'1 (do not share limits, just hide axis labels), '
'2 (share limits, but do not hide tick labels), or '
'3 (share limits and hide tick labels). Got {level}.')
self._sharey_level = max(self._sharey_level, level)
self._share_short_axis(sharey, 'b', level)
self._share_short_axis(sharey, 't', level)
self._share_long_axis(sharey, 'l', level)
self._share_long_axis(sharey, 'r', level)
def _share_setup(self):
"""Applies axis sharing for axes that share the same horizontal or
vertical extent, and for their panels."""
# Panel axes sharing, between main subplot and its panels
# Top and bottom
def shared(paxs):
return [
pax for pax in paxs if not pax._panel_filled
and pax._panel_share]
if not self._panel_side: # this is a main axes
bottom = self
paxs = shared(self._bpanels)
if paxs:
bottom = paxs[-1]
for iax in (self, *paxs[:-1]):
# parent is *bottom-most* panel
iax._sharex_setup(bottom, 3)
paxs = shared(self._tpanels)
for iax in paxs:
iax._sharex_setup(bottom, 3)
# Left and right
left = self
paxs = shared(self._lpanels)
if paxs:
left = paxs[0]
for iax in (*paxs[1:], self):
iax._sharey_setup(left, 3) # parent is *bottom-most* panel
paxs = shared(self._rpanels)
for iax in paxs:
iax._sharey_setup(left, 3)
# Main axes, sometimes overrides panel axes sharing
# TODO: This can get very repetitive, but probably minimal impact
# on performance?
# Share x axes
parent, *children = self._get_extent_axes('x')
for child in children:
child._sharex_setup(parent, parent._sharex_level)
# Share y axes
parent, *children = self._get_extent_axes('y')
for child in children:
child._sharey_setup(parent, parent._sharey_level)
def _share_short_axis(self, share, side, level):
"""Share the "short" axes of panels along a main subplot with panels
along an external subplot."""
if share is None or self._panel_side: # not None
return
s = side[0]
axis = 'x' if s in 'lr' else 'y'
caxs = getattr(self, '_' + s + 'panels')
paxs = getattr(share, '_' + s + 'panels')
caxs = [pax for pax in caxs if not pax._panel_filled]
paxs = [pax for pax in paxs if not pax._panel_filled]
for cax, pax in zip(caxs, paxs): # may be uneven
getattr(cax, '_share' + axis + '_setup')(pax, level)
def _share_long_axis(self, share, side, level):
"""Share the "long" axes of panels along a main subplot with the
axis from an external subplot."""
# NOTE: We do not check _panel_share because that only controls
# sharing with main subplot, not other subplots
if share is None or self._panel_side:
return
s = side[0]
axis = 'x' if s in 'tb' else 'y'
paxs = getattr(self, '_' + s + 'panels')
paxs = [pax for pax in paxs if not pax._panel_filled]
for pax in paxs:
getattr(pax, '_share' + axis + '_setup')(share, level)
def _update_axislabels(self, x='x', **kwargs):
"""Apply axis labels to the relevant shared axis. If spanning
labels are toggled this keeps the labels synced for all subplots in
the same row or column. Label positions will be adjusted at draw-time
with figure._align_axislabels."""
if x not in 'xy':
return
# Update label on this axes
axis = getattr(self, x + 'axis')
axis.label.update(kwargs)
kwargs.pop('color', None)
# Defer to parent (main) axes if possible, then get the axes
# shared by that parent
ax = self._panel_parent or self
ax = getattr(ax, '_share' + x) or ax
# Apply to spanning axes and their panels
axs = [ax]
if getattr(ax, '_span' + x + '_on'):
s = axis.get_label_position()[0]
if s in 'lb':
axs = ax._get_side_axes(s)
for ax in axs:
getattr(ax, x + 'axis').label.update(kwargs) # apply to main axes
pax = getattr(ax, '_share' + x)
if pax is not None: # apply to panel?
getattr(pax, x + 'axis').label.update(kwargs)
def _update_title(self, obj, **kwargs):
"""Redraws title if updating with input keyword args failed."""
# Try to just return updated object, redraw may be necessary
# WARNING: Making text instances invisible seems to mess up tight
# bounding box calculations and cause other issues. Just reset text.
keys = ('border', 'lw', 'linewidth', 'bordercolor', 'invert')
kwextra = {key: value for key, value in kwargs.items() if key in keys}
kwargs = {key: value for key,
value in kwargs.items() if key not in keys}
obj.update(kwargs)
if kwextra:
obj.set_text('')
else:
return obj
# Get properties from old object
for key in ('ha', 'va', 'color', 'transform', 'fontproperties'):
kwextra[key] = getattr(obj, 'get_' + key)() # copy over attrs
text = kwargs.pop('text', obj.get_text())
x, y = kwargs.pop('position', (None, None))
pos = obj.get_position()
x = _notNone(kwargs.pop('x', x), pos[0])
y = _notNone(kwargs.pop('y', y), pos[1])
return self.text(x, y, text, **kwextra)
[docs] def context(self, *, mode=2, rc_kw=None, **kwargs):
"""
For internal use. Sets up temporary `~proplot.rctools.rc` settings by
returning the result of `~proplot.rctools.rc_configurator.context`.
Parameters
----------
rc_kw : dict, optional
A dictionary containing "rc" configuration settings that will
be applied to this axes. Temporarily updates the
`~proplot.rctools.rc` object.
**kwargs
Any of three options:
* A keyword arg for `Axes.format`, `XYAxes.format`,
or `ProjAxes.format`.
* A global "rc" keyword arg, like ``linewidth`` or ``color``.
* A standard "rc" keyword arg **with the dots omitted**,
like ``landcolor`` instead of ``land.color``.
The latter two options update the `~proplot.rctools.rc`
object, just like `rc_kw`.
Other parameters
----------------
mode : int, optional
The "getitem mode". This is used under-the-hood -- you shouldn't
have to use it directly. Determines whether queries to the
`~proplot.rctools.rc` object will ignore
`rcParams <https://matplotlib.org/users/customizing.html>`__.
This can help prevent a massive number of unnecessary lookups
when the settings haven't been changed by the user.
See `~proplot.rctools.rc_configurator` for details.
Returns
-------
`~proplot.rctools.rc_configurator`
The `proplot.rctools.rc` object primed for use in a "with"
statement.
dict
Dictionary of keyword arguments that are not `~proplot.rctools.rc`
properties, to be passed to the ``format`` methods.
"""
# Figure out which kwargs are valid rc settings
# TODO: Support for 'small', 'large', etc. font
kw = {} # for format
rc_kw = rc_kw or {}
for key, value in kwargs.items():
key_fixed = RC_NODOTSNAMES.get(key, None)
if key_fixed is None:
kw[key] = value
else:
rc_kw[key_fixed] = value
rc._getitem_mode = 0 # might still be non-zero if had error
# Return "context object", which is just the configurator itself
# primed for use in a "with" statement
return rc.context(rc_kw, mode=mode), kw
[docs] def area(self, *args, **kwargs):
"""Alias for `~matplotlib.axes.Axes.fill_between`."""
# NOTE: *Cannot* assign area = axes.Axes.fill_between because the
# wrapper won't be applied and for some reason it messes up
# automodsumm, which tries to put the matplotlib docstring on website
return self.fill_between(*args, **kwargs)
[docs] def areax(self, *args, **kwargs):
"""Alias for `~matplotlib.axes.Axes.fill_betweenx`."""
return self.fill_betweenx(*args, **kwargs)
[docs] def boxes(self, *args, **kwargs):
"""Alias for `~matplotlib.axes.Axes.boxplot`."""
return self.boxplot(*args, **kwargs)
[docs] def colorbar(self, *args, loc=None, pad=None,
length=None, width=None, space=None, frame=None, frameon=None,
alpha=None, linewidth=None, edgecolor=None, facecolor=None,
**kwargs):
"""
Adds colorbar as an *inset* or along the outside edge of the axes.
See `~proplot.wrappers.colorbar_wrapper` for details.
Parameters
----------
loc : str, optional
The colorbar location. Default is :rc:`colorbar.loc`. The
following location keys are valid.
================== ==================================
Location Valid keys
================== ==================================
outer left ``'left'``, ``'l'``
outer right ``'right'``, ``'r'``
outer bottom ``'bottom'``, ``'b'``
outer top ``'top'``, ``'t'``
default inset ``'inset'``, ``'i'``, ``0``
upper right inset ``'upper right'``, ``'ur'``, ``1``
upper left inset ``'upper left'``, ``'ul'``, ``2``
lower left inset ``'lower left'``, ``'ll'``, ``3``
lower right inset ``'lower right'``, ``'lr'``, ``4``
================== ==================================
pad : float or str, optional
The space between the axes edge and the colorbar. For inset
colorbars only. Units are interpreted by `~proplot.utils.units`.
Default is :rc:`colorbar.axespad`.
length : float or str, optional
The colorbar length. For outer colorbars, units are relative to the
axes width or height. Default is :rc:`colorbar.length`. For inset
colorbars, units are interpreted by `~proplot.utils.units`. Default
is :rc:`colorbar.insetlength`.
width : float or str, optional
The colorbar width. Units are interpreted by
`~proplot.utils.units`. Default is :rc:`colorbar.width` or
:rc:`colorbar.insetwidth`.
space : float or str, optional
The space between the colorbar and the main axes. For outer
colorbars only. Units are interpreted by `~proplot.utils.units`.
When :rcraw:`tight` is ``True``, this is adjusted automatically.
Otherwise, defaut is :rc:`subplots.panelspace`.
frame, frameon : bool, optional
Whether to draw a frame around inset colorbars, just like
`~matplotlib.axes.Axes.legend`.
Default is :rc:`colorbar.frameon`.
alpha, linewidth, edgecolor, facecolor : optional
Transparency, edge width, edge color, and face color for the frame
around the inset colorbar. Default is
:rc:`colorbar.framealpha`, :rc:`axes.linewidth`,
:rc:`axes.edgecolor`, and :rc:`axes.facecolor`,
respectively.
**kwargs
Passed to `~proplot.wrappers.colorbar_wrapper`.
"""
# TODO: add option to pad inset away from axes edge!
kwargs.update({'edgecolor': edgecolor, 'linewidth': linewidth})
loc = _notNone(loc, rc['colorbar.loc'])
loc = self._loc_translate(loc)
if loc == 'best': # a white lie
loc = 'lower right'
if not isinstance(loc, str): # e.g. 2-tuple or ndarray
raise ValueError(f'Invalid colorbar location {loc!r}.')
# Generate panel
if loc in ('left', 'right', 'top', 'bottom'):
ax = self.panel_axes(loc, width=width, space=space, filled=True)
return ax.colorbar(loc='_fill', *args, length=length, **kwargs)
# Filled colorbar
if loc == '_fill':
# Hide content and resize panel
# NOTE: Do not run self.clear in case we want title above this
for s in self.spines.values():
s.set_visible(False)
self.xaxis.set_visible(False)
self.yaxis.set_visible(False)
self.patch.set_alpha(0)
self._panel_filled = True
# Draw colorbar with arbitrary length relative to full length
# of panel
side = self._panel_side
length = _notNone(length, rc['colorbar.length'])
subplotspec = self.get_subplotspec()
if length <= 0 or length > 1:
raise ValueError(
f'Panel colorbar length must satisfy 0 < length <= 1, '
f'got length={length!r}.')
if side in ('bottom', 'top'):
gridspec = mgridspec.GridSpecFromSubplotSpec(
nrows=1, ncols=3, wspace=0,
subplot_spec=subplotspec,
width_ratios=((1 - length) / 2, length, (1 - length) / 2),
)
subplotspec = gridspec[1]
else:
gridspec = mgridspec.GridSpecFromSubplotSpec(
nrows=3, ncols=1, hspace=0,
subplot_spec=subplotspec,
height_ratios=((1 - length) / 2, length, (1 - length) / 2),
)
subplotspec = gridspec[1]
with self.figure._unlock():
ax = self.figure.add_subplot(subplotspec, projection=None)
if ax is self:
raise ValueError(f'Uh oh.')
self.add_child_axes(ax)
# Location
if side in ('bottom', 'top'):
outside, inside = 'bottom', 'top'
if side == 'top':
outside, inside = inside, outside
ticklocation = outside
orientation = 'horizontal'
else:
outside, inside = 'left', 'right'
if side == 'right':
outside, inside = inside, outside
ticklocation = outside
orientation = 'vertical'
# Keyword args and add as child axes
orient = kwargs.get('orientation', None)
if orient is not None and orient != orientation:
warnings.warn(f'Overriding input orientation={orient!r}.')
ticklocation = kwargs.pop('tickloc', None) or ticklocation
ticklocation = kwargs.pop('ticklocation', None) or ticklocation
kwargs.update({'orientation': orientation,
'ticklocation': ticklocation})
# Inset colorbar
else:
# Default props
cbwidth, cblength = width, length
width, height = self.get_size_inches()
extend = units(_notNone(
kwargs.get('extendsize', None), rc['colorbar.insetextend']))
cbwidth = units(_notNone(
cbwidth, rc['colorbar.insetwidth'])) / height
cblength = units(_notNone(
cblength, rc['colorbar.insetlength'])) / width
pad = units(_notNone(pad, rc['colorbar.axespad']))
xpad, ypad = pad / width, pad / height
# Get location in axes-relative coordinates
# Bounds are x0, y0, width, height in axes-relative coordinates
xspace = rc['xtick.major.size'] / 72
if kwargs.get('label', ''):
xspace += 2.4 * rc['font.size'] / 72
else:
xspace += 1.2 * rc['font.size'] / 72
xspace /= height # space for labels
if loc == 'upper right':
bounds = (1 - xpad - cblength, 1 - ypad - cbwidth)
fbounds = (1 - 2 * xpad - cblength,
1 - 2 * ypad - cbwidth - xspace)
elif loc == 'upper left':
bounds = (xpad, 1 - ypad - cbwidth)
fbounds = (0, 1 - 2 * ypad - cbwidth - xspace)
elif loc == 'lower left':
bounds = (xpad, ypad + xspace)
fbounds = (0, 0)
elif loc == 'lower right':
bounds = (1 - xpad - cblength, ypad + xspace)
fbounds = (1 - 2 * xpad - cblength, 0)
else:
raise ValueError(f'Invalid colorbar location {loc!r}.')
bounds = (bounds[0], bounds[1], cblength, cbwidth)
fbounds = (fbounds[0], fbounds[1],
2 * xpad + cblength, 2 * ypad + cbwidth + xspace)
# Make frame
# NOTE: We do not allow shadow effects or fancy edges effect.
# Also keep zorder same as with legend.
frameon = _notNone(
frame, frameon, rc['colorbar.frameon'],
names=('frame', 'frameon'))
if frameon:
# Make patch object
xmin, ymin, width, height = fbounds
patch = mpatches.Rectangle(
(xmin, ymin), width, height,
snap=True, zorder=4, transform=self.transAxes)
# Update patch props
alpha = _notNone(alpha, rc['colorbar.framealpha'])
linewidth = _notNone(linewidth, rc['axes.linewidth'])
edgecolor = _notNone(edgecolor, rc['axes.edgecolor'])
facecolor = _notNone(facecolor, rc['axes.facecolor'])
patch.update({'alpha': alpha, 'linewidth': linewidth,
'edgecolor': edgecolor, 'facecolor': facecolor})
self.add_artist(patch)
# Make axes
locator = self._make_inset_locator(bounds, self.transAxes)
bbox = locator(None, None)
ax = maxes.Axes(self.figure, bbox.bounds, zorder=5)
ax.set_axes_locator(locator)
self.add_child_axes(ax)
# Default keyword args
orient = kwargs.pop('orientation', None)
if orient is not None and orient != 'horizontal':
warnings.warn(
f'Orientation for inset colorbars must be horizontal, '
f'ignoring orient={orient!r}.')
ticklocation = kwargs.pop('tickloc', None)
ticklocation = kwargs.pop('ticklocation', None) or ticklocation
if ticklocation is not None and ticklocation != 'bottom':
warnings.warn(
f'Inset colorbars can only have ticks on the bottom.')
kwargs.update({'orientation': 'horizontal',
'ticklocation': 'bottom'})
kwargs.setdefault('maxn', 5)
kwargs.setdefault('extendsize', extend)
# Generate colorbar
return colorbar_wrapper(ax, *args, **kwargs)
[docs] def legend(self, *args, loc=None, width=None, space=None, **kwargs):
"""
Adds an *inset* legend or *outer* legend along the edge of the axes.
See `~proplot.wrappers.legend_wrapper` for details.
Parameters
----------
loc : int or str, optional
The legend location or panel location. The following location keys
are valid. Note that if a panel does not exist, it will be
generated on-the-fly.
================== =======================================
Location Valid keys
================== =======================================
left panel ``'left'``, ``'l'``
right panel ``'right'``, ``'r'``
bottom panel ``'bottom'``, ``'b'``
top panel ``'top'``, ``'t'``
"best" inset ``'best'``, ``'inset'``, ``'i'``, ``0``
upper right inset ``'upper right'``, ``'ur'``, ``1``
upper left inset ``'upper left'``, ``'ul'``, ``2``
lower left inset ``'lower left'``, ``'ll'``, ``3``
lower right inset ``'lower right'``, ``'lr'``, ``4``
center left inset ``'center left'``, ``'cl'``, ``5``
center right inset ``'center right'``, ``'cr'``, ``6``
lower center inset ``'lower center'``, ``'lc'``, ``7``
upper center inset ``'upper center'``, ``'uc'``, ``8``
center inset ``'center'``, ``'c'``, ``9``
================== =======================================
width : float or str, optional
The space allocated for outer legends. This does nothing
if :rcraw:`tight` is ``True``. Units are interpreted by
`~proplot.utils.units`.
space : float or str, optional
The space between the axes and the legend for outer legends.
Units are interpreted by `~proplot.utils.units`.
When :rcraw:`tight` is ``True``, this is adjusted automatically.
Otherwise, defaut is :rc:`subplots.panelspace`.
Other parameters
----------------
*args, **kwargs
Passed to `~proplot.wrappers.legend_wrapper`.
"""
loc = self._loc_translate(loc, width=width, space=space)
if isinstance(loc, np.ndarray):
loc = loc.tolist()
# Generate panel
if loc in ('left', 'right', 'top', 'bottom'):
ax = self.panel_axes(loc, width=width, space=space, filled=True)
return ax.legend(*args, loc='_fill', **kwargs)
# Fill
if loc == '_fill':
# Hide content
for s in self.spines.values():
s.set_visible(False)
self.xaxis.set_visible(False)
self.yaxis.set_visible(False)
self.patch.set_alpha(0)
self._panel_filled = True
# Try to make handles and stuff flush against the axes edge
kwargs.setdefault('borderaxespad', 0)
frameon = _notNone(kwargs.get('frame', None), kwargs.get(
'frameon', None), rc['legend.frameon'])
if not frameon:
kwargs.setdefault('borderpad', 0)
# Apply legend location
side = self._panel_side
if side == 'bottom':
loc = 'upper center'
elif side == 'right':
loc = 'center left'
elif side == 'left':
loc = 'center right'
elif side == 'top':
loc = 'lower center'
else:
raise ValueError(f'Invalid panel side {side!r}.')
# Draw legend
return legend_wrapper(self, *args, loc=loc, **kwargs)
[docs] def draw(self, renderer=None, *args, **kwargs):
"""Adds post-processing steps before axes is drawn."""
self._reassign_title()
super().draw(renderer, *args, **kwargs)
[docs] def get_size_inches(self):
"""Returns the width and the height of the axes in inches."""
width, height = self.figure.get_size_inches()
width = width * abs(self.get_position().width)
height = height * abs(self.get_position().height)
return width, height
[docs] def get_tightbbox(self, renderer, *args, **kwargs):
"""Adds post-processing steps before tight bounding box is
calculated, and stores the bounding box as an attribute."""
self._reassign_title()
bbox = super().get_tightbbox(renderer, *args, **kwargs)
self._tightbbox = bbox
return bbox
[docs] def heatmap(self, *args, **kwargs):
"""Calls `~matplotlib.axes.Axes.pcolormesh` and applies default formatting
that is suitable for heatmaps: no gridlines, no minor ticks, and major
ticks at the center of each grid box."""
obj = self.pcolormesh(*args, **kwargs)
xlocator, ylocator = None, None
if hasattr(obj, '_coordinates'):
coords = obj._coordinates
coords = (coords[1:, ...] + coords[:-1, ...]) / 2
coords = (coords[:, 1:, :] + coords[:, :-1, :]) / 2
xlocator, ylocator = coords[0, :, 0], coords[:, 0, 1]
self.format(
xgrid=False, ygrid=False, xtickminor=False, ytickminor=False,
xlocator=xlocator, ylocator=ylocator,
)
return obj
[docs] def inset_axes(self, bounds, *, transform=None, zorder=4,
zoom=True, zoom_kw=None, **kwargs):
"""
Like the builtin `~matplotlib.axes.Axes.inset_axes` method, but
draws an inset `XYAxes` axes and adds some options.
Parameters
----------
bounds : list of float
The bounds for the inset axes, listed as ``(x, y, width, height)``.
transform : {'data', 'axes', 'figure'} or \
`~matplotlib.transforms.Transform`, optional
The transform used to interpret `bounds`. Can be a
`~matplotlib.transforms.Transform` object or a string representing
the `~matplotlib.axes.Axes.transData`,
`~matplotlib.axes.Axes.transAxes`,
or `~matplotlib.figure.Figure.transFigure` transforms. Default is
``'axes'``, i.e. `bounds` is in axes-relative coordinates.
zorder : float, optional
The `zorder \
<https://matplotlib.org/3.1.1/gallery/misc/zorder_demo.html>`__
of the axes, should be greater than the zorder of
elements in the parent axes. Default is ``4``.
zoom : bool, optional
Whether to draw lines indicating the inset zoom using
`~Axes.indicate_inset_zoom`. The lines will automatically
adjust whenever the parent axes or inset axes limits are changed.
Default is ``True``.
zoom_kw : dict, optional
Passed to `~Axes.indicate_inset_zoom`.
Other parameters
----------------
**kwargs
Passed to `XYAxes`.
"""
# Carbon copy with my custom axes
if not transform:
transform = self.transAxes
else:
transform = _get_transform(self, transform)
label = kwargs.pop('label', 'inset_axes')
# This puts the rectangle into figure-relative coordinates.
locator = self._make_inset_locator(bounds, transform)
bb = locator(None, None)
ax = XYAxes(self.figure, bb.bounds,
zorder=zorder, label=label, **kwargs)
# The following locator lets the axes move if we used data coordinates,
# is called by ax.apply_aspect()
ax.set_axes_locator(locator)
self.add_child_axes(ax)
ax._inset_zoom = zoom
ax._inset_parent = self
# Zoom indicator (NOTE: Requires version >=3.0)
if zoom:
zoom_kw = zoom_kw or {}
ax.indicate_inset_zoom(**zoom_kw)
return ax
[docs] def indicate_inset_zoom(self, alpha=None,
lw=None, linewidth=None,
color=None, edgecolor=None, **kwargs):
"""
Called automatically when using `~Axes.inset` with ``zoom=True``.
Like `~matplotlib.axes.Axes.indicate_inset_zoom`, but *refreshes* the
lines at draw-time.
This method is called from the *inset* axes, not the parent axes.
Parameters
----------
alpha : float, optional
The transparency of the zoom box fill.
lw, linewidth : float, optional
The width of the zoom lines and box outline in points.
color, edgecolor : color-spec, optional
The color of the zoom lines and box outline.
**kwargs
Passed to `~matplotlib.axes.Axes.indicate_inset`.
"""
# Should be called from the inset axes
parent = self._inset_parent
alpha = alpha or 1.0
linewidth = _notNone(
lw, linewidth, rc['axes.linewidth'],
names=('lw', 'linewidth'))
edgecolor = _notNone(
color, edgecolor, rc['axes.edgecolor'],
names=('color', 'edgecolor'))
if not parent:
raise ValueError(f'{self} is not an inset axes.')
xlim, ylim = self.get_xlim(), self.get_ylim()
rect = (xlim[0], ylim[0], xlim[1] - xlim[0], ylim[1] - ylim[0])
# Call indicate_inset
rectpatch, connects = parent.indicate_inset(
rect, self, linewidth=linewidth, edgecolor=edgecolor, alpha=alpha,
**kwargs)
# Update zoom or adopt properties from old one
if self._inset_zoom_data:
rectpatch_old, connects_old = self._inset_zoom_data
rectpatch.update_from(rectpatch_old)
rectpatch_old.set_visible(False)
for line, line_old in zip(connects, connects_old):
visible = line.get_visible()
line.update_from(line_old)
line.set_linewidth(line_old.get_linewidth())
line.set_visible(visible)
line_old.set_visible(False)
else:
for line in connects:
line.set_linewidth(linewidth)
line.set_color(edgecolor)
line.set_alpha(alpha)
self._inset_zoom_data = (rectpatch, connects)
return (rectpatch, connects)
[docs] def panel_axes(self, side, **kwargs):
"""
Returns a panel drawn along the edge of an axes.
Parameters
----------
ax : `~proplot.axes.Axes`
The axes for which we are drawing a panel.
width : float or str or list thereof, optional
The panel width. Units are interpreted by `~proplot.utils.units`.
Default is :rc:`subplots.panelwidth`.
space : float or str or list thereof, optional
Empty space between the main subplot and the panel.
When :rcraw:`tight` is ``True``, this is adjusted automatically.
Otherwise, defaut is :rc:`subplots.panelspace`.
share : bool, optional
Whether to enable axis sharing between the *x* and *y* axes of the
main subplot and the panel long axes for each panel in the stack.
Sharing between the panel short axis and other panel short axes
is determined by figure-wide `sharex` and `sharey` settings.
Returns
-------
`~proplot.axes.Axes`
The panel axes.
"""
return self.figure._add_axes_panel(self, side, **kwargs)
[docs] @_standardize_1d
@_cmap_changer
def parametric(self, *args, values=None,
cmap=None, norm=None,
interp=0, **kwargs):
"""
Invoked when you pass the `cmap` keyword argument to
`~matplotlib.axes.Axes.plot`. Draws a "colormap line",
i.e. a line whose color changes as a function of the parametric
coordinate ``values``. using the input colormap ``cmap``.
This is actually a collection of lines, added as a
`~matplotlib.collections.LineCollection` instance. See
`this matplotlib example \
<https://matplotlib.org/gallery/lines_bars_and_markers/multicolored_line>`__.
Parameters
----------
*args : (y,) or (x,y)
The coordinates. If `x` is not provided, it is inferred from `y`.
cmap : colormap spec, optional
The colormap specifier, passed to `~proplot.styletools.Colormap`.
values : list of float
The parametric values used to map points on the line to colors
in the colormap.
norm : normalizer spec, optional
The normalizer, passed to `~proplot.styletools.Norm`.
interp : int, optional
If greater than ``0``, we interpolate to additional points
between the `values` coordinates. The number corresponds to the
number of additional color levels between the line joints
and the halfway points between line joints.
"""
# First error check
# WARNING: So far this only works for 1D *x* and *y* coordinates.
# Cannot draw multiple colormap lines at once
if values is None:
raise ValueError('Requires a "values" keyword arg.')
if len(args) not in (1, 2):
raise ValueError(f'Requires 1-2 arguments, got {len(args)}.')
y = np.array(args[-1]).squeeze()
x = np.arange(
y.shape[-1]) if len(args) == 1 else np.array(args[0]).squeeze()
values = np.array(values).squeeze()
if x.ndim != 1 or y.ndim != 1 or values.ndim != 1:
raise ValueError(
f'x ({x.ndim}d), y ({y.ndim}d), and values ({values.ndim}d)'
' must be 1-dimensional.')
if len(x) != len(y) or len(x) != len(values) or len(y) != len(values):
raise ValueError(
f'{len(x)} xs, {len(y)} ys, but {len(values)} '
' colormap values.')
# Interpolate values to allow for smooth gradations between values
# (bins=False) or color switchover halfway between points (bins=True)
# Then optionally interpolate the corresponding colormap values
if interp > 0:
xorig, yorig, vorig = x, y, values
x, y, values = [], [], []
for j in range(xorig.shape[0] - 1):
idx = (
slice(None, -1) if j + 1 < xorig.shape[0] - 1
else slice(None))
x.extend(np.linspace(
xorig[j], xorig[j + 1], interp + 2)[idx].flat)
y.extend(np.linspace(
yorig[j], yorig[j + 1], interp + 2)[idx].flat)
values.extend(np.linspace(
vorig[j], vorig[j + 1], interp + 2)[idx].flat)
x, y, values = np.array(x), np.array(y), np.array(values)
coords = []
levels = utils.edges(values)
for j in range(y.shape[0]):
# Get x/y coordinates and values for points to the 'left' and
# 'right' of each joint
if j == 0:
xleft, yleft = [], []
else:
xleft = [(x[j - 1] + x[j]) / 2, x[j]]
yleft = [(y[j - 1] + y[j]) / 2, y[j]]
if j + 1 == y.shape[0]:
xright, yright = [], []
else:
xleft = xleft[:-1] # prevent repetition when joined with right
yleft = yleft[:-1]
xright = [x[j], (x[j + 1] + x[j]) / 2]
yright = [y[j], (y[j + 1] + y[j]) / 2]
pleft = np.stack((xleft, yleft), axis=1)
pright = np.stack((xright, yright), axis=1)
coords.append(np.concatenate((pleft, pright), axis=0))
# Create LineCollection and update with values
hs = mcollections.LineCollection(
np.array(coords), cmap=cmap, norm=norm,
linestyles='-', capstyle='butt', joinstyle='miter')
hs.set_array(np.array(values))
hs.update({key: value for key, value in kwargs.items()
if key not in ('color',)})
# Add collection, with some custom attributes
self.add_collection(hs)
if self.get_autoscale_on() and self.ignore_existing_data_limits:
self.autoscale_view() # data limits not updated otherwise
hs.values = values
hs.levels = levels # needed for other functions some
return hs
[docs] def violins(self, *args, **kwargs):
"""Alias for `~matplotlib.axes.Axes.violinplot`."""
return self.violinplot(*args, **kwargs)
#: Alias for `~Axes.panel_axes`.
panel = panel_axes
#: Alias for `~Axes.inset_axes`.
inset = inset_axes
@property
def number(self):
"""The axes number, controls a-b-c label order and order of
appearence in the `~proplot.subplots.subplot_grid` returned by
`~proplot.subplots.subplots`."""
return self._number
def _iter_panels(self, sides='lrbt'):
"""Iterates over axes and child panel axes."""
axs = [self] if self.get_visible() else []
if not ({*sides} <= {*'lrbt'}):
raise ValueError(f'Invalid sides {sides!r}.')
for s in sides:
for ax in getattr(self, '_' + s + 'panels'):
if not ax or not ax.get_visible():
continue
axs.append(ax)
return axs
# Wrapped by special functions
# Also support redirecting to Basemap methods
text = _text_wrapper(
maxes.Axes.text
)
plot = _plot_wrapper(_standardize_1d(_add_errorbars(_cycle_changer(
maxes.Axes.plot
))))
scatter = _scatter_wrapper(_standardize_1d(_add_errorbars(_cycle_changer(
maxes.Axes.scatter
))))
bar = _bar_wrapper(_standardize_1d(_add_errorbars(_cycle_changer(
maxes.Axes.bar
))))
barh = _barh_wrapper(
maxes.Axes.barh
) # calls self.bar
hist = _hist_wrapper(_standardize_1d(_cycle_changer(
maxes.Axes.hist
)))
boxplot = _boxplot_wrapper(_standardize_1d(_cycle_changer(
maxes.Axes.boxplot
)))
violinplot = _violinplot_wrapper(_standardize_1d(_add_errorbars(
_cycle_changer(maxes.Axes.violinplot)
)))
fill_between = _fill_between_wrapper(_standardize_1d(_cycle_changer(
maxes.Axes.fill_between
)))
fill_betweenx = _fill_betweenx_wrapper(_standardize_1d(_cycle_changer(
maxes.Axes.fill_betweenx
)))
# Wrapped by cycle wrapper and standardized
pie = _standardize_1d(_cycle_changer(
maxes.Axes.pie
))
stem = _standardize_1d(_cycle_changer(
maxes.Axes.stem
))
step = _standardize_1d(_cycle_changer(
maxes.Axes.step
))
# Wrapped by cmap wrapper and standardized
# Also support redirecting to Basemap methods
hexbin = _standardize_1d(_cmap_changer(
maxes.Axes.hexbin
))
contour = _standardize_2d(_cmap_changer(
maxes.Axes.contour
))
contourf = _standardize_2d(_cmap_changer(
maxes.Axes.contourf
))
pcolor = _standardize_2d(_cmap_changer(
maxes.Axes.pcolor
))
pcolormesh = _standardize_2d(_cmap_changer(
maxes.Axes.pcolormesh
))
quiver = _standardize_2d(_cmap_changer(
maxes.Axes.quiver
))
streamplot = _standardize_2d(_cmap_changer(
maxes.Axes.streamplot
))
barbs = _standardize_2d(_cmap_changer(
maxes.Axes.barbs
))
imshow = _cmap_changer(
maxes.Axes.imshow
)
# Wrapped only by cmap wrapper
tripcolor = _cmap_changer(
maxes.Axes.tripcolor
)
tricontour = _cmap_changer(
maxes.Axes.tricontour
)
tricontourf = _cmap_changer(
maxes.Axes.tricontourf
)
hist2d = _cmap_changer(
maxes.Axes.hist2d
)
spy = _cmap_changer(
maxes.Axes.spy
)
matshow = _cmap_changer(
maxes.Axes.matshow
)
dualxy_kwargs = (
'label', 'locator', 'formatter', 'ticks', 'ticklabels',
'minorlocator', 'minorticks', 'tickminor',
'ticklen', 'tickrange', 'tickdir', 'ticklabeldir', 'tickrotation',
'bounds', 'margin', 'color', 'grid', 'gridminor', 'gridcolor',
)
dualxy_descrip = """
Makes a secondary *%(x)s* axis for denoting equivalent *%(x)s*
coordinates in *alternate units*.
Parameters
----------
forward : function, optional
Function used to transform units from the original axis to the
secondary axis. Should take 1 value and perform a *forward
linear transformation*. For example, to convert Kelvin to Celsius,
use ``ax.dual%(x)s(lambda x: x - 273.15)``. To convert kilometers to
meters, use ``ax.dual%(x)s(lambda x: x*1e3)``.
inverse : function, optional
Function used to transform units from the secondary axis back to
the original axis. If `forward` was a non-linear function, you
*must* provide this, or the transformation will be incorrect!
For example, to apply the square, use
``ax.dual%(x)s(lambda x: x**2, lambda x: x**0.5)``.
scale : scale-spec, optional
The axis scale from which forward and inverse transformations
are inferred. Passed to `~proplot.axistools.Scale`.
For example, to apply the inverse, use ``ax.dual%(x)s('inverse')``;
To apply the base-10 exponential function, use
``ax.dual%(x)s(('exp', 10, 1, 10))``
or ``ax.dual%(x)s(plot.Scale('exp', 10))``.
scale_kw : dict-like, optional
Ignored if `scale` is ``None``. Passed to
`~proplot.axistools.Scale`.
%(args)s : optional
Prepended with ``'y'`` and passed to `Axes.format`.
"""
altxy_descrip = """
Alias and more intuitive name for `~XYAxes.twin%(y)s`.
The matplotlib `~matplotlib.axes.Axes.twiny` function
generates two *x* axes with a shared ("twin") *y* axis.
Enforces the following settings.
* Places the old *%(x)s* axis on the %(x1)s and the new *%(x)s* axis
on the %(x2)s.
* Makes the old %(x2)s spine invisible and the new %(x1)s, %(y1)s,
and %(y2)s spines invisible.
* Adjusts the *%(x)s* axis tick, tick label, and axis label positions
according to the visible spine positions.
* Locks the old and new *%(y)s* axis limits and scales, and makes the new
%(y)s axis labels invisible.
"""
twinxy_descrip = """
Mimics matplotlib's `~matplotlib.axes.Axes.twin%(y)s`.
Enforces the following settings.
* Places the old *%(x)s* axis on the %(x1)s and the new *%(x)s* axis
on the %(x2)s.
* Makes the old %(x2)s spine invisible and the new %(x1)s, %(y1)s,
and %(y2)s spines invisible.
* Adjusts the *%(x)s* axis tick, tick label, and axis label positions
according to the visible spine positions.
* Locks the old and new *%(y)s* axis limits and scales, and makes the new
%(y)s axis labels invisible.
"""
def _parse_dualxy_args(x, transform, transform_kw, kwargs):
"""Interprets the dualx and dualy transform and various keyword
arguments. Returns a list of forward transform, inverse transform, and
overrides for default locators and formatters."""
# Transform using input functions
# TODO: Also support transforms? Probably not -- transforms are a huge
# group that include ND and non-invertable transformations, but transforms
# used for axis scales are subset of invertible 1D functions
funcscale_kw = {}
transform_kw = transform_kw or {}
if isinstance(transform, (str, mscale.ScaleBase)) or transform_kw:
transform = transform or 'linear'
scale = axistools.Scale(transform, **transform_kw)
transform = scale.get_transform()
funcscale_funcs = (transform.transform, transform.inverted().transform)
for key in ('major_locator', 'minor_locator',
'major_formatter', 'minor_formatter'):
default = getattr(scale, '_' + key, None)
if default:
funcscale_kw[key] = default
elif (np.iterable(transform) and len(transform) == 2
and all(callable(itransform) for itransform in transform)):
funcscale_funcs = transform
elif callable(transform):
funcscale_funcs = (transform, lambda x: x)
else:
raise ValueError(
f'Invalid transform {transform!r}. '
'Must be function, tuple of two functions, or scale name.')
# Parse keyword args intended for format() command
kwargs_bad = {}
for key in (*kwargs.keys(),):
value = kwargs.pop(key)
if key[0] == x and key[1:] in dualxy_kwargs:
warnings.warn(
f'dual{x}() keyword arg {key!r} is deprecated. '
f'Use {key[1:]!r} instead.')
kwargs[key] = value
elif key in dualxy_kwargs:
kwargs[x + key] = value
else:
kwargs_bad[key] = value
if kwargs_bad:
raise TypeError(
f'dual{x}() got unexpected keyword argument(s): {kwargs_bad}')
return funcscale_funcs, funcscale_kw, kwargs
def _rcloc_to_stringloc(x, string): # figures out string location
"""Gets *location string* from the *boolean* "left", "right", "top", and
"bottom" rc settings, e.g. :rc:`axes.spines.left` or :rc:`ytick.left`.
Might be ``None`` if settings are unchanged."""
# For x axes
if x == 'x':
top = rc[f'{string}.top']
bottom = rc[f'{string}.bottom']
if top is None and bottom is None:
return None
elif top and bottom:
return 'both'
elif top:
return 'top'
elif bottom:
return 'bottom'
else:
return 'neither'
# For y axes
else:
left = rc[f'{string}.left']
right = rc[f'{string}.right']
if left is None and right is None:
return None
elif left and right:
return 'both'
elif left:
return 'left'
elif right:
return 'right'
else:
return 'neither'
[docs]class XYAxes(Axes):
"""
Axes subclass for ordinary 2D cartesian coordinates. Adds several new
methods and overrides existing ones.
"""
#: The registered projection name.
name = 'xy'
def __init__(self, *args, **kwargs):
"""
See also
--------
`~proplot.subplots.subplots`, `Axes`
"""
# Impose default formatter
super().__init__(*args, **kwargs)
formatter = axistools.Formatter('auto')
self.xaxis.set_major_formatter(formatter)
self.yaxis.set_major_formatter(formatter)
self.xaxis.isDefault_majfmt = True
self.yaxis.isDefault_majfmt = True
# Custom attributes
self._datex_rotated = False # whether manual rotation has been applied
self._dualy_data = None # for scaling units on opposite side of ax
self._dualx_data = None
def _altx_overrides(self):
"""Applies alternate *x* axis overrides."""
# Unlike matplotlib API, we strong arm user into certain twin axes
# settings... doesn't really make sense to have twin axes without this
if self._altx_child is not None: # altx was called on this axes
self._shared_y_axes.join(self, self._altx_child)
self.spines['top'].set_visible(False)
self.spines['bottom'].set_visible(True)
self.xaxis.tick_bottom()
self.xaxis.set_label_position('bottom')
elif self._altx_parent is not None: # this axes is the result of altx
self.spines['bottom'].set_visible(False)
self.spines['top'].set_visible(True)
self.spines['left'].set_visible(False)
self.spines['right'].set_visible(False)
self.xaxis.tick_top()
self.xaxis.set_label_position('top')
self.yaxis.set_visible(False)
self.patch.set_visible(False)
def _alty_overrides(self):
"""Applies alternate *y* axis overrides."""
if self._alty_child is not None:
self._shared_x_axes.join(self, self._alty_child)
self.spines['right'].set_visible(False)
self.spines['left'].set_visible(True)
self.yaxis.tick_left()
self.yaxis.set_label_position('left')
elif self._alty_parent is not None:
self.spines['left'].set_visible(False)
self.spines['right'].set_visible(True)
self.spines['top'].set_visible(False)
self.spines['bottom'].set_visible(False)
self.yaxis.tick_right()
self.yaxis.set_label_position('right')
self.xaxis.set_visible(False)
self.patch.set_visible(False)
def _datex_rotate(self):
"""Applies default rotation to datetime axis coordinates."""
# NOTE: Rotation is done *before* horizontal/vertical alignment,
# cannot change alignment with set_tick_params. Must apply to text
# objects. fig.autofmt_date calls subplots_adjust, so cannot use it.
if (not isinstance(self.xaxis.converter, mdates.DateConverter)
or self._datex_rotated):
return
rotation = rc['axes.formatter.timerotation']
kw = {'rotation': rotation}
if rotation not in (0, 90, -90):
kw['ha'] = ('right' if rotation > 0 else 'left')
for label in self.xaxis.get_ticklabels():
label.update(kw)
self._datex_rotated = True # do not need to apply more than once
def _dualx_overrides(self):
"""Locks child "dual" *x* axis limits to the parent."""
# Why did I copy and paste the dualx/dualy code you ask? Copy
# pasting is bad, but so are a bunch of ugly getattr(attr)() calls
data = self._dualx_data
if data is None:
return
funcscale_funcs, funcscale_kw = data
# Build the FuncScale
# Sometimes we do *not* want to apply default locator and formatter
# overrides, in case user has manually changed them! Also, sometimes
# we want to borrow method that sets default from the scale from which
# forward and inverse funcs were drawn, instead of from FuncScale.
child = self._altx_child
scale_parent = self.xaxis._scale
scale_func = axistools.Scale(
'function', funcscale_funcs[::-1],
scale_parent.get_transform(),
**funcscale_kw
)
scale_defaults = scale_func if isinstance(
scale_parent, mscale.LinearScale) else scale_parent
try:
scale_defaults.set_default_locators_and_formatters(
child.xaxis, only_if_default=True,
)
except TypeError:
pass # do nothing if axis has native matplotlib scale
child.xaxis._scale = scale_func
child._update_transScale()
child.stale = True
child.autoscale_view(scaley=False)
# Transform axis limits
# If the transform flipped the limits, when we set axis limits, it
# will get flipped again! So reverse the flip
lim = self.get_xlim()
nlim = list(map(funcscale_funcs[0], np.array(lim)))
if np.sign(np.diff(lim)) != np.sign(np.diff(nlim)):
nlim = nlim[::-1]
child.set_xlim(nlim)
def _dualy_overrides(self):
"""Locks child "dual" *y* axis limits to the parent."""
data = self._dualy_data
if data is None:
return
funcscale_funcs, funcscale_kw = data
child = self._alty_child
scale_parent = self.yaxis._scale
scale_func = axistools.Scale(
'function', funcscale_funcs[::-1], scale_parent.get_transform(),
**funcscale_kw
)
scale_defaults = scale_func if isinstance(
scale_parent, mscale.LinearScale) else scale_parent
try:
scale_defaults.set_default_locators_and_formatters(
child.xaxis, only_if_default=True,
)
except TypeError:
pass
child.yaxis._scale = scale_func
child._update_transScale()
child.stale = True
child.autoscale_view(scalex=False)
lim = self.get_ylim()
nlim = list(map(funcscale_funcs[0], np.array(lim)))
if np.sign(np.diff(lim)) != np.sign(np.diff(nlim)):
nlim = nlim[::-1]
child.set_ylim(nlim)
def _hide_labels(self):
"""Function called at drawtime that enforces "shared" axis and
tick labels. If this is not called at drawtime, "shared" labels can
be inadvertantly turned off e.g. when the axis scale is changed."""
for x in 'xy':
# "Shared" axis and tick labels
axis = getattr(self, x + 'axis')
share = getattr(self, '_share' + x)
if share is not None:
level = getattr(self, '_share' + x + '_level')
if level > 0:
axis.label.set_visible(False)
if level > 2:
axis.set_major_formatter(mticker.NullFormatter())
# Enforce no minor ticks labels
# TODO: Document this?
axis.set_minor_formatter(mticker.NullFormatter())
def _sharex_setup(self, sharex, level):
"""Sets up shared axes. The input is the 'parent' axes, from which
this one will draw its properties."""
# Call Axes method
super()._sharex_setup(sharex, level) # sets up panels
if sharex in (None, self) or not isinstance(sharex, XYAxes):
return
# Builtin sharing features
if level > 0:
self._sharex = sharex
if level > 1:
self._shared_x_axes.join(self, sharex)
def _sharey_setup(self, sharey, level):
"""Sets up shared axes. The input is the 'parent' axes, from which
this one will draw its properties."""
# Call Axes method
super()._sharey_setup(sharey, level)
if sharey in (None, self) or not isinstance(sharey, XYAxes):
return
# Builtin features
if level > 0:
self._sharey = sharey
if level > 1:
self._shared_y_axes.join(self, sharey)
[docs] def altx(self, *args, **kwargs):
# Cannot wrap twiny() because we want to use XYAxes, not
# matplotlib Axes. Instead use hidden method _make_twin_axes.
# See https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/axes/_subplots.py # noqa
if self._altx_child:
raise RuntimeError('No more than *two* twin axes!')
if self._altx_parent:
raise RuntimeError('This *is* a twin axes!')
with self.figure._unlock():
ax = self._make_twin_axes(sharey=self, projection='xy')
# shared axes must have matching autoscale
ax.set_autoscaley_on(self.get_autoscaley_on())
ax.grid(False)
self._altx_child = ax
ax._altx_parent = self
self._altx_overrides()
ax._altx_overrides()
self.add_child_axes(ax)
return ax
[docs] def alty(self):
if self._alty_child:
raise RuntimeError('No more than *two* twin axes!')
if self._alty_parent:
raise RuntimeError('This *is* a twin axes!')
with self.figure._unlock():
ax = self._make_twin_axes(sharex=self, projection='xy')
# shared axes must have matching autoscale
ax.set_autoscalex_on(self.get_autoscalex_on())
ax.grid(False)
self._alty_child = ax
ax._alty_parent = self
self._alty_overrides()
ax._alty_overrides()
self.add_child_axes(ax)
return ax
[docs] def dualx(self, transform, transform_kw=None, **kwargs):
# The axis scale is used to transform units on the left axis, linearly
# spaced, to units on the right axis... so the right scale must scale
# its data with the *inverse* of this transform. We do this below.
# NOTE: Matplotlib 3.1 has a 'secondary axis' feature. This one is
# simpler, because it does not implement the function transform as
# an axis scale (meaning user just has to supply the forward
# transformation, not the backwards one), and does not invent a new
# class with a bunch of complicated setters.
ax = self.altx()
funcscale_funcs, funcscale_kw, kwargs = _parse_dualxy_args(
'x', transform, transform_kw, kwargs
)
self._dualx_data = (funcscale_funcs, funcscale_kw)
self._dualx_overrides()
ax.format(**kwargs)
return ax
[docs] def dualy(self, transform, transform_kw=None, **kwargs):
ax = self.alty()
funcscale_funcs, funcscale_kw, kwargs = _parse_dualxy_args(
'y', transform, transform_kw, kwargs
)
self._dualy_data = (funcscale_funcs, funcscale_kw)
self._dualy_overrides()
ax.format(**kwargs)
return ax
[docs] def draw(self, renderer=None, *args, **kwargs):
"""Adds post-processing steps before axes is drawn."""
# NOTE: This mimics matplotlib API, which calls identical
# post-processing steps in both draw() and get_tightbbox()
self._hide_labels()
self._altx_overrides()
self._alty_overrides()
self._dualx_overrides()
self._dualy_overrides()
self._datex_rotate()
if self._inset_parent is not None and self._inset_zoom:
self.indicate_inset_zoom()
super().draw(renderer, *args, **kwargs)
[docs] def get_tightbbox(self, renderer, *args, **kwargs):
"""Adds post-processing steps before tight bounding box is
calculated."""
self._hide_labels()
self._altx_overrides()
self._alty_overrides()
self._dualx_overrides()
self._dualy_overrides()
self._datex_rotate()
if self._inset_parent is not None and self._inset_zoom:
self.indicate_inset_zoom()
return super().get_tightbbox(renderer, *args, **kwargs)
[docs] def twinx(self):
return self.alty()
[docs] def twiny(self):
return self.altx()
altx.__doc__ = altxy_descrip % {
'x': 'x', 'x1': 'bottom', 'x2': 'top',
'y': 'y', 'y1': 'left', 'y2': 'right',
}
alty.__doc__ = altxy_descrip % {
'x': 'y', 'x1': 'left', 'x2': 'right',
'y': 'x', 'y1': 'bottom', 'y2': 'top',
}
dualx.__doc__ = dualxy_descrip % {
'x': 'x', 'args': ', '.join(dualxy_kwargs)
}
dualy.__doc__ = dualxy_descrip % {
'x': 'y', 'args': ', '.join(dualxy_kwargs)
}
twinx.__doc__ = twinxy_descrip % {
'x': 'y', 'x1': 'left', 'x2': 'right',
'y': 'x', 'y1': 'bottom', 'y2': 'top',
}
twiny.__doc__ = twinxy_descrip % {
'x': 'x', 'x1': 'bottom', 'x2': 'top',
'y': 'y', 'y1': 'left', 'y2': 'right',
}
[docs]class PolarAxes(Axes, mproj.PolarAxes):
"""Intermediate class, mixes `ProjAxes` with
`~matplotlib.projections.polar.PolarAxes`."""
#: The registered projection name.
name = 'polar'
def __init__(self, *args, **kwargs):
"""
See also
--------
`~proplot.subplots.subplots`, `Axes`
"""
# Set tick length to zero so azimuthal labels are not too offset
# Change default radial axis formatter but keep default theta one
super().__init__(*args, **kwargs)
formatter = axistools.Formatter('auto')
self.yaxis.set_major_formatter(formatter)
self.yaxis.isDefault_majfmt = True
for axis in (self.xaxis, self.yaxis):
axis.set_tick_params(which='both', size=0)
# Disabled methods suitable only for cartesian axes
_disable = _disable_decorator(
'Invalid plotting method {!r} for polar axes.')
twinx = _disable(Axes.twinx)
twiny = _disable(Axes.twiny)
matshow = _disable(Axes.matshow)
imshow = _disable(Axes.imshow)
spy = _disable(Axes.spy)
hist = _disable(Axes.hist)
hist2d = _disable(Axes.hist2d)
boxplot = _disable(Axes.boxplot)
violinplot = _disable(Axes.violinplot)
step = _disable(Axes.step)
stem = _disable(Axes.stem)
stackplot = _disable(Axes.stackplot)
table = _disable(Axes.table)
eventplot = _disable(Axes.eventplot)
pie = _disable(Axes.pie)
xcorr = _disable(Axes.xcorr)
acorr = _disable(Axes.acorr)
psd = _disable(Axes.psd)
csd = _disable(Axes.csd)
cohere = _disable(Axes.cohere)
specgram = _disable(Axes.specgram)
angle_spectrum = _disable(Axes.angle_spectrum)
phase_spectrum = _disable(Axes.phase_spectrum)
magnitude_spectrum = _disable(Axes.magnitude_spectrum)
def _circle_path(N=100):
"""Return a circle `~matplotlib.path.Path` used as the outline
for polar stereographic, azimuthal equidistant, and Lambert
conformal projections. This was developed from `this cartopy example \
<https://scitools.org.uk/cartopy/docs/v0.15/examples/always_circular_stereo.html>`__.""" # noqa
theta = np.linspace(0, 2 * np.pi, N)
center, radius = [0.5, 0.5], 0.5
verts = np.vstack([np.sin(theta), np.cos(theta)]).T
return mpath.Path(verts * radius + center)
[docs]class ProjAxes(Axes):
"""Intermediate class, shared by `GeoAxes` and
`BasemapAxes`. Disables methods that are inappropriate for map
projections and adds `ProjAxes.format`, so that arguments
passed to `Axes.format` are identical for `GeoAxes`
and `BasemapAxes`."""
def __init__(self, *args, **kwargs):
"""
See also
--------
`~proplot.subplots.subplots`, `Axes`, `GeoAxes`, `BasemapAxes`
"""
# Store props that let us dynamically and incrementally modify
# line locations and settings like with Cartesian axes
self._boundinglat = None
self._latmax = None
self._latlines = None
self._lonlines = None
self._lonlines_values = None
self._latlines_values = None
self._lonlines_labels = None
self._latlines_labels = None
super().__init__(*args, **kwargs)
# Disabled methods suitable only for cartesian axes
_disable = _disable_decorator(
'Invalid plotting method {!r} for map projection axes.')
bar = _disable(Axes.bar)
barh = _disable(Axes.barh)
twinx = _disable(Axes.twinx)
twiny = _disable(Axes.twiny)
matshow = _disable(Axes.matshow)
imshow = _disable(Axes.imshow)
spy = _disable(Axes.spy)
hist = _disable(Axes.hist)
hist2d = _disable(Axes.hist2d)
boxplot = _disable(Axes.boxplot)
violinplot = _disable(Axes.violinplot)
step = _disable(Axes.step)
stem = _disable(Axes.stem)
stackplot = _disable(Axes.stackplot)
table = _disable(Axes.table)
eventplot = _disable(Axes.eventplot)
pie = _disable(Axes.pie)
xcorr = _disable(Axes.xcorr)
acorr = _disable(Axes.acorr)
psd = _disable(Axes.psd)
csd = _disable(Axes.csd)
cohere = _disable(Axes.cohere)
specgram = _disable(Axes.specgram)
angle_spectrum = _disable(Axes.angle_spectrum)
phase_spectrum = _disable(Axes.phase_spectrum)
magnitude_spectrum = _disable(Axes.magnitude_spectrum)
def _add_gridline_label(self, value, axis, upper_end):
"""Gridliner method monkey patch. Always print number in range
(180W, 180E)."""
# Have 3 choices (see Issue #78):
# 1. lonlines go from -180 to 180, but get double 180 labels at dateline
# 2. lonlines go from -180 to e.g. 150, but no lines from 150 to dateline
# 3. lonlines go from lon_0 - 180 to lon_0 + 180 mod 360, but results
# in non-monotonic array causing double gridlines east of dateline
# 4. lonlines go from lon_0 - 180 to lon_0 + 180 monotonic, but prevents
# labels from being drawn outside of range (-180, 180)
# These monkey patches choose #4 and permit labels being drawn
# outside of (-180 180)
if axis == 'x':
value = (value + 180) % 360 - 180
return type(self)._add_gridline_label(self, value, axis, upper_end)
def _axes_domain(self, *args, **kwargs):
"""Gridliner method monkey patch. Filter valid label coordinates to values
between lon_0 - 180 and lon_0 + 180."""
# See _add_gridline_label for detials
lon_0 = self.axes.projection.proj4_params.get('lon_0', 0)
x_range, y_range = type(self)._axes_domain(self, *args, **kwargs)
x_range = np.asarray(x_range) + lon_0
return x_range, y_range
[docs]class GeoAxes(ProjAxes, GeoAxes):
"""Axes subclass for plotting `cartopy \
<https://scitools.org.uk/cartopy/docs/latest/>`__ projections. Initializes
the `cartopy.crs.Projection` instance, enforces `global extent \
<https://stackoverflow.com/a/48956844/4970632>`__
for most projections by default, and draws `circular boundaries \
<https://scitools.org.uk/cartopy/docs/latest/gallery/always_circular_stereo.html>`__
around polar azimuthal, stereographic, and Gnomonic projections bounded at
the equator by default.""" # noqa
#: The registered projection name.
name = 'geo'
_n_points = 100 # number of points for drawing circle map boundary
def __init__(self, *args, map_projection=None, **kwargs):
"""
Parameters
----------
map_projection : `~cartopy.crs.Projection`
The `~cartopy.crs.Projection` instance.
*args, **kwargs
Passed to `~cartopy.mpl.geoaxes.GeoAxes`.
See also
--------
`~proplot.subplots.subplots`, `Axes`, `~proplot.projs.Proj`
"""
# GeoAxes initialization. Note that critical attributes like
# outline_patch needed by _format_apply are added before it is called.
import cartopy.crs as ccrs
if not isinstance(map_projection, ccrs.Projection):
raise ValueError(
'GeoAxes requires map_projection=cartopy.crs.Projection.')
super().__init__(*args, map_projection=map_projection, **kwargs)
# Zero out ticks so gridlines are not offset
for axis in (self.xaxis, self.yaxis):
axis.set_tick_params(which='both', size=0)
# Set extent and boundary extent for projections
# The default bounding latitude is set in _format_apply
# NOTE: set_global does not mess up non-global projections like OSNI
if isinstance(self.projection, (
ccrs.NorthPolarStereo, ccrs.SouthPolarStereo,
projs.NorthPolarGnomonic, projs.SouthPolarGnomonic,
projs.NorthPolarAzimuthalEquidistant,
projs.NorthPolarLambertAzimuthalEqualArea,
projs.SouthPolarAzimuthalEquidistant,
projs.SouthPolarLambertAzimuthalEqualArea)):
self.set_boundary(_circle_path(100), transform=self.transAxes)
else:
self.set_global()
def _format_apply(self, patch_kw, lonlim, latlim, boundinglat,
lonlines, latlines, latmax, lonarray, latarray):
"""Apply formatting to cartopy axes."""
import cartopy.feature as cfeature
import cartopy.crs as ccrs
from cartopy.mpl import gridliner
# Initial gridliner object, which ProPlot passively modifies
# TODO: Flexible formatter?
if not self._gridliners:
gl = self.gridlines(zorder=2.5) # below text only
gl._axes_domain = _axes_domain.__get__(gl) # apply monkey patches
gl._add_gridline_label = _add_gridline_label.__get__(gl)
gl.xlines = False
gl.ylines = False
try:
lonformat = gridliner.LongitudeFormatter # newer
latformat = gridliner.LatitudeFormatter
except AttributeError:
lonformat = gridliner.LONGITUDE_FORMATTER # older
latformat = gridliner.LATITUDE_FORMATTER
gl.xformatter = lonformat
gl.yformatter = latformat
gl.xlabels_top = False
gl.xlabels_bottom = False
gl.ylabels_left = False
gl.ylabels_right = False
# Projection extent
# NOTE: They may add this as part of set_xlim and set_ylim in future
# See: https://github.com/SciTools/cartopy/blob/master/lib/cartopy/mpl/geoaxes.py#L638 # noqa
# WARNING: The set_extent method tries to set a *rectangle* between
# the *4* (x,y) coordinate pairs (each corner), so something like
# (-180,180,-90,90) will result in *line*, causing error!
proj = self.projection.proj4_params['proj']
north = isinstance(self.projection, (
ccrs.NorthPolarStereo, projs.NorthPolarGnomonic,
projs.NorthPolarAzimuthalEquidistant,
projs.NorthPolarLambertAzimuthalEqualArea))
south = isinstance(self.projection, (
ccrs.SouthPolarStereo, projs.SouthPolarGnomonic,
projs.SouthPolarAzimuthalEquidistant,
projs.SouthPolarLambertAzimuthalEqualArea))
if north or south:
if (lonlim is not None or latlim is not None):
warnings.warn(
f'{proj!r} extent is controlled by "boundinglat", '
f'ignoring lonlim={lonlim!r} and latlim={latlim!r}.')
if self._boundinglat is None:
if isinstance(self.projection, projs.NorthPolarGnomonic):
boundinglat = 30
elif isinstance(self.projection, projs.SouthPolarGnomonic):
boundinglat = -30
else:
boundinglat = 0
if boundinglat is not None and boundinglat != self._boundinglat:
eps = 1e-10 # bug with full -180, 180 range when lon_0 != 0
lat0 = (90 if north else -90)
lon0 = self.projection.proj4_params.get('lon_0', 0)
extent = [lon0 - 180 + eps,
lon0 + 180 - eps, boundinglat, lat0]
self.set_extent(extent, crs=ccrs.PlateCarree())
self._boundinglat = boundinglat
else:
if boundinglat is not None:
warnings.warn(
f'{proj!r} extent is controlled by "lonlim" and "latlim", '
f'ignoring boundinglat={boundinglat!r}.')
if lonlim is not None or latlim is not None:
lonlim = lonlim or [None, None]
latlim = latlim or [None, None]
lonlim, latlim = [*lonlim], [*latlim]
lon_0 = self.projection.proj4_params.get('lon_0', 0)
if lonlim[0] is None:
lonlim[0] = lon_0 - 180
if lonlim[1] is None:
lonlim[1] = lon_0 + 180
eps = 1e-10 # bug with full -180, 180 range when lon_0 != 0
lonlim[0] += eps
if latlim[0] is None:
latlim[0] = -90
if latlim[1] is None:
latlim[1] = 90
extent = [*lonlim, *latlim]
self.set_extent(extent, crs=ccrs.PlateCarree())
# Draw gridlines, manage them with one custom gridliner generated
# by ProPlot, user may want to use griliner API directly
gl = self._gridliners[0]
# Collection props, see GoeAxes.gridlines() source code
kw = rc.fill({
'alpha': 'geogrid.alpha',
'color': 'geogrid.color',
'linewidth': 'geogrid.linewidth',
'linestyle': 'geogrid.linestyle',
}) # cached changes
gl.collection_kwargs.update(kw)
# Grid locations
eps = 1e-10
if lonlines is not None:
if len(lonlines) == 0:
gl.xlines = False
else:
gl.xlines = True
gl.xlocator = mticker.FixedLocator(lonlines)
if latlines is not None:
if len(latlines) == 0:
gl.ylines = False
else:
gl.ylines = True
if latlines[0] == -90:
latlines[0] += eps
if latlines[-1] == 90:
latlines[-1] -= eps
gl.ylocator = mticker.FixedLocator(latlines)
# Grid label toggling
# Issue warning instead of error!
if not isinstance(self.projection, (ccrs.Mercator, ccrs.PlateCarree)):
if latarray is not None and any(latarray):
warnings.warn(
'Cannot add gridline labels to cartopy '
f'{type(self.projection).__name__} projection.')
latarray = [0] * 4
if lonarray is not None and any(lonarray):
warnings.warn(
'Cannot add gridline labels to cartopy '
f'{type(self.projection).__name__} projection.')
lonarray = [0] * 4
if latarray is not None:
gl.ylabels_left = latarray[0]
gl.ylabels_right = latarray[1]
if lonarray is not None:
gl.xlabels_bottom = lonarray[2]
gl.xlabels_top = lonarray[3]
# Geographic features
# WARNING: Seems cartopy features can't be updated!
# See: https://scitools.org.uk/cartopy/docs/v0.14/_modules/cartopy/feature.html#Feature # noqa
# Change the _kwargs property also does *nothing*
# WARNING: Changing linewidth is impossible with cfeature. Bug?
# See: https://stackoverflow.com/questions/43671240/changing-line-width-of-cartopy-borders # noqa
# TODO: Editing existing natural features? Creating natural features
# at __init__ time and hiding them?
# NOTE: The natural_earth_shp method is deprecated, use add_feature.
# See: https://cartopy-pelson.readthedocs.io/en/readthedocs/whats_new.html # noqa
# NOTE: The e.g. cfeature.COASTLINE features are just for convenience,
# hi res versions. Use cfeature.COASTLINE.name to see how it can be
# looked up with NaturalEarthFeature.
reso = rc.get('reso')
if reso not in ('lo', 'med', 'hi'):
raise ValueError(f'Invalid resolution {reso}.')
reso = {
'lo': '110m',
'med': '50m',
'hi': '10m',
}.get(reso)
features = {
'land': ('physical', 'land'),
'ocean': ('physical', 'ocean'),
'lakes': ('physical', 'lakes'),
'coast': ('physical', 'coastline'),
'rivers': ('physical', 'rivers_lake_centerlines'),
'borders': ('cultural', 'admin_0_boundary_lines_land'),
'innerborders': ('cultural', 'admin_1_states_provinces_lakes'),
}
for name, args in features.items():
# Get feature
if not rc.get(name): # toggled
continue
if getattr(self, '_' + name, None): # already drawn
continue
feat = cfeature.NaturalEarthFeature(*args, reso)
# For 'lines', need to specify edgecolor and facecolor
# See: https://github.com/SciTools/cartopy/issues/803
kw = rc.category(name, cache=False)
if name in ('coast', 'rivers', 'borders', 'innerborders'):
kw['edgecolor'] = kw.pop('color')
kw['facecolor'] = 'none'
else:
kw['linewidth'] = 0
if name in ('ocean',):
kw['zorder'] = 0.5 # below everything!
self.add_feature(feat, **kw)
setattr(self, '_' + name, feat)
# Update patch
kw_face = rc.fill({
'facecolor': 'geoaxes.facecolor'
})
kw_face.update(patch_kw)
self.background_patch.update(kw_face)
kw_edge = rc.fill({
'edgecolor': 'geoaxes.edgecolor',
'linewidth': 'geoaxes.linewidth'
})
self.outline_patch.update(kw_edge)
def _hide_labels(self):
"""No-op for now. In future will hide meridian and parallel labels
for rectangular projections."""
pass
[docs] def get_tightbbox(self, renderer, *args, **kwargs):
"""Draw gridliner objects so tight bounding box algorithm will
incorporate gridliner labels."""
self._hide_labels()
if self.get_autoscale_on() and self.ignore_existing_data_limits:
self.autoscale_view()
if self.background_patch.reclip:
clipped_path = self.background_patch.orig_path.clip_to_bbox(
self.viewLim)
self.background_patch._path = clipped_path
self.apply_aspect()
for gl in self._gridliners:
try: # new versions only
gl._draw_gridliner(background_patch=self.background_patch,
renderer=renderer)
except TypeError:
gl._draw_gridliner(background_patch=self.background_patch)
self._gridliners = []
return super().get_tightbbox(renderer, *args, **kwargs)
# Document projection property
@property
def projection(self):
"""The `~cartopy.crs.Projection` instance associated with this axes."""
return self._map_projection
@projection.setter
def projection(self, map_projection):
self._map_projection = map_projection
# Wrapped methods
# TODO: Remove this duplication of Axes! Can do this when we implement
# all wrappers as decorators.
if GeoAxes is not object:
text = _text_wrapper(
GeoAxes.text
)
# Wrapped by standardize method
plot = _default_transform(_plot_wrapper(_standardize_1d(
_add_errorbars(_cycle_changer(GeoAxes.plot))
)))
scatter = _default_transform(_scatter_wrapper(_standardize_1d(
_add_errorbars(_cycle_changer(GeoAxes.scatter))
)))
fill_between = _fill_between_wrapper(_standardize_1d(_cycle_changer(
GeoAxes.fill_between
)))
fill_betweenx = _fill_betweenx_wrapper(_standardize_1d(_cycle_changer(
GeoAxes.fill_betweenx
)))
contour = _default_transform(_standardize_2d(_cmap_changer(
GeoAxes.contour
)))
contourf = _default_transform(_standardize_2d(_cmap_changer(
GeoAxes.contourf
)))
pcolor = _default_transform(_standardize_2d(_cmap_changer(
GeoAxes.pcolor
)))
pcolormesh = _default_transform(_standardize_2d(_cmap_changer(
GeoAxes.pcolormesh
)))
quiver = _default_transform(_standardize_2d(_cmap_changer(
GeoAxes.quiver
)))
streamplot = _default_transform(_standardize_2d(_cmap_changer(
GeoAxes.streamplot
)))
barbs = _default_transform(_standardize_2d(_cmap_changer(
GeoAxes.barbs
)))
# Wrapped only by cmap wrapper
tripcolor = _default_transform(_cmap_changer(
GeoAxes.tripcolor
))
tricontour = _default_transform(_cmap_changer(
GeoAxes.tricontour
))
tricontourf = _default_transform(_cmap_changer(
GeoAxes.tricontourf
))
# Special GeoAxes commands
get_extent = _default_crs(GeoAxes.get_extent)
set_extent = _default_crs(GeoAxes.set_extent)
set_xticks = _default_crs(GeoAxes.set_xticks)
set_yticks = _default_crs(GeoAxes.set_yticks)
[docs]class BasemapAxes(ProjAxes):
"""Axes subclass for plotting `~mpl_toolkits.basemap` projections. The
`~mpl_toolkits.basemap.Basemap` projection instance is added as
the `map_projection` attribute, but this is all abstracted away. You can
use `~matplotlib.axes.Axes` methods like `~matplotlib.axes.Axes.plot` and
`~matplotlib.axes.Axes.contour` with your raw longitude-latitude data."""
#: The registered projection name.
name = 'basemap'
_proj_non_rectangular = (
'ortho', 'geos', 'nsper',
'moll', 'hammer', 'robin',
'eck4', 'kav7', 'mbtfpq',
'sinu', 'vandg',
'npstere', 'spstere', 'nplaea',
'splaea', 'npaeqd', 'spaeqd',
) # do not use axes spines as boundaries
def __init__(self, *args, map_projection=None, **kwargs):
"""
Parameters
----------
map_projection : `~mpl_toolkits.basemap.Basemap`
The `~mpl_toolkits.basemap.Basemap` instance.
**kwargs
Passed to `Axes`.
See also
--------
`~proplot.subplots.subplots`, `Axes`, `~proplot.projs.Proj`
"""
# Map boundary notes
# * Must set boundary before-hand, otherwise the set_axes_limits method
# called by mcontourf/mpcolormesh/etc draws two mapboundary Patch
# objects called "limb1" and "limb2" automatically: one for fill and
# the other for the edges
# * Then, since the patch object in _mapboundarydrawn is only the
# fill-version, calling drawmapboundary again will replace only *that
# one*, but the original visible edges are still drawn -- so e.g. you
# can't change the color
# * If you instead call drawmapboundary right away, _mapboundarydrawn
# will contain both the edges and the fill; so calling it again will
# replace *both*
import mpl_toolkits.basemap as mbasemap # verify package is available
if not isinstance(map_projection, mbasemap.Basemap):
raise ValueError(
'BasemapAxes requires map_projection=basemap.Basemap')
self._map_projection = map_projection
self._map_boundary = None
self._has_recurred = False # use this to override plotting methods
super().__init__(*args, **kwargs)
def _format_apply(self, patch_kw, lonlim, latlim, boundinglat,
lonlines, latlines, latmax, lonarray, latarray):
"""Applies formatting to basemap axes."""
# Checks
if (lonlim is not None or latlim is not None
or boundinglat is not None):
warnings.warn(f'Got lonlim={lonlim!r}, latlim={latlim!r}, '
f'boundinglat={boundinglat!r}, but you cannot "zoom '
'into" a basemap projection after creating it. '
'Pass proj_kw in your call to subplots '
'with any of the following basemap keywords: '
"'boundinglat', 'llcrnrlon', 'llcrnrlat', "
"'urcrnrlon', 'urcrnrlat', 'llcrnrx', 'llcrnry', "
"'urcrnrx', 'urcrnry', 'width', or 'height'.")
# Map boundary
# * First have to *manually replace* the old boundary by just
# deleting the original one
# * If boundary is drawn successfully should be able to call
# self.projection._mapboundarydrawn.set_visible(False) and
# edges/fill color disappear
# * For now will enforce that map plots *always* have background
# whereas axes plots can have transparent background
kw_edge = rc.fill({
'linewidth': 'geoaxes.linewidth',
'edgecolor': 'geoaxes.edgecolor'
})
kw_face = rc.fill({
'facecolor': 'geoaxes.facecolor'
})
patch_kw = patch_kw or {}
kw_face.update(patch_kw)
self.axesPatch = self.patch # bugfix or something
if self.projection.projection in self._proj_non_rectangular:
self.patch.set_alpha(0) # make patch invisible
if not self.projection._mapboundarydrawn:
# set fill_color to 'none' to make transparent
p = self.projection.drawmapboundary(ax=self)
else:
p = self.projection._mapboundarydrawn
p.update({**kw_face, **kw_edge})
p.set_rasterized(False)
p.set_clip_on(False) # so edges denoting boundary aren't cut off
self._map_boundary = p
else:
self.patch.update({**kw_face, 'edgecolor': 'none'})
for spine in self.spines.values():
spine.update(kw_edge)
# Longitude/latitude lines
# Make sure to turn off clipping by invisible axes boundary; otherwise
# get these weird flat edges where map boundaries, parallel/meridian
# markers come up to the axes bbox
lkw = rc.fill({
'alpha': 'geogrid.alpha',
'color': 'geogrid.color',
'linewidth': 'geogrid.linewidth',
'linestyle': 'geogrid.linestyle',
}, cache=False)
tkw = rc.fill({
'color': 'geogrid.color',
'fontsize': 'geogrid.labelsize',
}, cache=False)
# Change from left/right/bottom/top to left/right/top/bottom
if lonarray is not None:
lonarray[2:] = lonarray[2:][::-1]
if latarray is not None:
latarray[2:] = latarray[2:][::-1]
# Parallel lines
if latlines is not None or latmax is not None or latarray is not None:
if self._latlines:
for pi in self._latlines.values():
for obj in [i for j in pi for i in j]: # magic
obj.set_visible(False)
ilatmax = _notNone(latmax, self._latmax)
latlines = _notNone(latlines, self._latlines_values)
latarray = _notNone(latarray, self._latlines_labels, [0] * 4)
p = self.projection.drawparallels(
latlines, latmax=ilatmax, labels=latarray, ax=self)
for pi in p.values(): # returns dict, where each one is tuple
# Tried passing clip_on to the below, but it does nothing
# Must set for lines created after the fact
for obj in [i for j in pi for i in j]:
if isinstance(obj, mtext.Text):
obj.update(tkw)
else:
obj.update(lkw)
self._latlines = p
# Meridian lines
if lonlines is not None or latmax is not None or lonarray is not None:
if self._lonlines:
for pi in self._lonlines.values():
for obj in [i for j in pi for i in j]: # magic
obj.set_visible(False)
ilatmax = _notNone(latmax, self._latmax)
lonlines = _notNone(lonlines, self._lonlines_values)
lonarray = _notNone(lonarray, self._lonlines_labels, [0] * 4)
p = self.projection.drawmeridians(
lonlines, latmax=ilatmax, labels=lonarray, ax=self)
for pi in p.values():
for obj in [i for j in pi for i in j]:
if isinstance(obj, mtext.Text):
obj.update(tkw)
else:
obj.update(lkw)
self._lonlines = p
# Geography
# TODO: Allow setting the zorder.
# NOTE: Also notable are drawcounties, blumarble, drawlsmask,
# shadedrelief, and etopo methods.
features = {
'land': 'fillcontinents',
'coast': 'drawcoastlines',
'rivers': 'drawrivers',
'borders': 'drawcountries',
'innerborders': 'drawstates',
}
for name, method in features.items():
if not rc.get(name): # toggled
continue
if getattr(self, f'_{name}', None): # already drawn
continue
kw = rc.category(name, cache=False)
feat = getattr(self.projection, method)(ax=self)
if isinstance(feat, (list, tuple)): # list of artists?
for obj in feat:
obj.update(kw)
else:
feat.update(kw)
setattr(self, '_' + name, feat)
# Document projection property
@property
def projection(self):
"""The `~mpl_toolkits.basemap.Basemap` instance associated with
this axes."""
return self._map_projection
@projection.setter
def projection(self, map_projection):
self._map_projection = map_projection
# Wrapped methods
plot = _norecurse(_default_latlon(_plot_wrapper(_standardize_1d(
_add_errorbars(_cycle_changer(_redirect(maxes.Axes.plot)))
))))
scatter = _norecurse(_default_latlon(_scatter_wrapper(_standardize_1d(
_add_errorbars(_cycle_changer(_redirect(maxes.Axes.scatter)))
))))
contour = _norecurse(_default_latlon(_standardize_2d(_cmap_changer(
_redirect(maxes.Axes.contour)
))))
contourf = _norecurse(_default_latlon(_standardize_2d(_cmap_changer(
_redirect(maxes.Axes.contourf)
))))
pcolor = _norecurse(_default_latlon(_standardize_2d(_cmap_changer(
_redirect(maxes.Axes.pcolor)
))))
pcolormesh = _norecurse(_default_latlon(_standardize_2d(_cmap_changer(
_redirect(maxes.Axes.pcolormesh)
))))
quiver = _norecurse(_default_latlon(_standardize_2d(_cmap_changer(
_redirect(maxes.Axes.quiver)
))))
streamplot = _norecurse(_default_latlon(_standardize_2d(_cmap_changer(
_redirect(maxes.Axes.streamplot)
))))
barbs = _norecurse(_default_latlon(_standardize_2d(_cmap_changer(
_redirect(maxes.Axes.barbs)
))))
hexbin = _norecurse(_standardize_1d(_cmap_changer(
_redirect(maxes.Axes.hexbin)
)))
imshow = _norecurse(_cmap_changer(
_redirect(maxes.Axes.imshow)
))
# Register the projections
mproj.register_projection(PolarAxes)
mproj.register_projection(XYAxes)
mproj.register_projection(GeoAxes)
mproj.register_projection(BasemapAxes)