diff --git a/WHATSNEW.rst b/WHATSNEW.rst index 76c12c28a..ef0acb81f 100644 --- a/WHATSNEW.rst +++ b/WHATSNEW.rst @@ -91,8 +91,12 @@ ProPlot v0.7.0 (2021-06-30) Make the ``Browns1`` map the most colorful/vibrant one, just like ``Greens1`` and ``Blues1``; split up the ``RedPurple`` maps into ``Reds`` and ``Purples``; and add the ``Yellows`` category from the ``Oranges`` maps (:commit:`8be0473f`). -* Rename `abovetop` keyword for moving title/abc labels above or below top panels, - colorbars, and legends to :rcraw:`title.above` (:commit:`9ceacb7b`). +* Reduce matplotlib conflicts by replacing legends drawn in the same location + rather than drawing two legends (:pr:`254`). +* Until centered-row legends become a class, stop returning the ad hoc background patch, + consistent with inaccessible background patches for inset colorbars (:pr:`254`). +* Rename `abovetop` keyword for moving title/abc labels above top panels, colorbars, + and legends to :rcraw:`title.above` (:commit:`9ceacb7b`). * Rename seldom-used `Figure` argument `fallback_to_cm` to more understandable `mathtext_fallback` (:pr:`251`). @@ -102,6 +106,8 @@ ProPlot v0.7.0 (2021-06-30) users have a cartopy vs. basemap preference, they probably want to use it globally. * Add :rcraw:`cartopy.circular` setting for optionally disabling the "circular bounds on polar projections" feature (:commit:`c9ca0bdd`). +* Add `queue` keyword to `colorbar` and `legend` to support workflow where users + successively add handles to location (:pr:`254`). * Add `titlebbox` and `abcbbox` as alternatives to `titleborder` and `abcborder` for "inner" titles and a-b-c labels (:pr:`240`) by `Pratiman Patel`_. Default behavior uses borders. @@ -160,6 +166,8 @@ ProPlot v0.7.0 (2021-06-30) (:pr:`251`). * Fix issue where "twin" ("alternate") axes content always hidden beneath "parent" content due to adding as children (:issue:`223`). +* Fix issue where simple `pandas.DataFrame.plot` calls with ``legend=True`` fail + (:pr:`254`, :issue:`198`). * Fix issue where cannot set `rc.style = 'default'` (:pr:`240`) by `Pratiman Patel`_. * Fix issue where `get_legend` returns None even with legends present (:issue:`224`). * Fix issue where `~xarray.DataArray` string coordinates are not extracted from @@ -210,6 +218,8 @@ ProPlot v0.7.0 (2021-06-30) .. rubric:: Internals +* Significantly refactor `colorbar` and `legend` code to make more manageable and + expand the "queueing" feature beyond wrappers (:pr:`254`). * Add prefix ``'proplot_'`` to registered axes "projections" (:commit:`be7ef21e`). More clear and guards against conflicts with external packages and other mpl versions. diff --git a/proplot/axes/base.py b/proplot/axes/base.py index e2f52f3af..b1782bd6c 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -8,6 +8,7 @@ import matplotlib.axes as maxes import matplotlib.collections as mcollections +import matplotlib.legend as mlegend import matplotlib.patches as mpatches import matplotlib.projections as mprojections import matplotlib.ticker as mticker @@ -223,6 +224,9 @@ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): self.xaxis.isDefault_minloc = self.yaxis.isDefault_minloc = True # Properties + if main: + self.figure._axes_main.append(self) + self.number = number # for a-b-c numbering self._auto_format = None # manipulated by wrapper functions self._abc_loc = None self._abc_text = None @@ -232,10 +236,6 @@ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): self._title_above = rc['title.above'] self._title_pad = rc['title.pad'] self._title_pad_current = None - self._bottom_panels = [] - self._top_panels = [] - self._left_panels = [] - self._right_panels = [] self._tight_bbox = None # bounding boxes are saved self._panel_hidden = False # True when "filled" with cbar/legend self._panel_parent = None @@ -246,59 +246,43 @@ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): self._inset_parent = None self._inset_zoom = False self._inset_zoom_data = None - self.number = number # for abc numbering - if main: - self.figure._axes_main.append(self) - - # On-the-fly legends and colorbars - self._auto_colorbar = {} - self._auto_legend = {} - # Figure row and column labels + # Axes colorbars and legends + self._colorbar_dict = {} + self._legend_dict = {} + + # Axes panels + d = self._panel_dict = {} + d['left'] = [] # NOTE: panels will be sorted inside-to-outside + d['right'] = [] + d['bottom'] = [] + d['top'] = [] + + # Axes titles + d = self._title_dict = {} + ta = self.transAxes + d['abc'] = self.text(0, 0, '', transform=ta) + d['left'] = self._left_title # WARNING: track in case mpl changes this + d['center'] = self._center_title + d['right'] = self._right_title + d['upper left'] = self.text(0, 0, '', va='top', ha='left', transform=ta) + d['upper center'] = self.text(0, 0, '', va='top', ha='center', transform=ta) + d['upper right'] = self.text(0, 0, '', va='top', ha='right', transform=ta) + d['lower left'] = self.text(0, 0, '', va='bottom', ha='left', transform=ta) + d['lower center'] = self.text(0, 0, '', va='bottom', ha='center', transform=ta) + d['lower right'] = self.text(0, 0, '', va='bottom', ha='right', transform=ta) + + # Axes row and column labels # NOTE: Most of these sit empty for most subplots # TODO: Implement this with EdgeStack, avoid creating silly empty objects - coltransform = mtransforms.blended_transform_factory( - self.transAxes, self.figure.transFigure - ) - rowtransform = mtransforms.blended_transform_factory( - self.figure.transFigure, self.transAxes - ) - self._left_label = self.text( - 0, 0.5, '', va='center', ha='right', transform=rowtransform - ) - self._right_label = self.text( - 0, 0.5, '', va='center', ha='left', transform=rowtransform - ) - self._bottom_label = self.text( - 0.5, 0, '', va='top', ha='center', transform=coltransform - ) - self._top_label = self.text( - 0.5, 0, '', va='bottom', ha='center', transform=coltransform - ) - - # Axes inset title labels - transform = self.transAxes - self._upper_left_title = self.text( - 0, 0, '', va='top', ha='left', transform=transform, - ) - self._upper_center_title = self.text( - 0, 0, '', va='top', ha='center', transform=transform, - ) - self._upper_right_title = self.text( - 0, 0, '', va='top', ha='right', transform=transform, - ) - self._lower_left_title = self.text( - 0, 0, '', va='bottom', ha='left', transform=transform, - ) - self._lower_center_title = self.text( - 0, 0, '', va='bottom', ha='center', transform=transform, - ) - self._lower_right_title = self.text( - 0, 0, '', va='bottom', ha='right', transform=transform, - ) - - # Abc label - self._abc_label = self.text(0, 0, '', transform=transform) + d = self._label_dict = {} + tf = self.figure.transFigure + tc = mtransforms.blended_transform_factory(ta, tf) + tr = mtransforms.blended_transform_factory(tf, ta) + d['left'] = self.text(0, 0.5, '', va='center', ha='right', transform=tr) + d['right'] = self.text(0, 0.5, '', va='center', ha='left', transform=tr) + d['bottom'] = self.text(0.5, 0, '', va='top', ha='center', transform=tc) + d['top'] = self.text(0.5, 0, '', va='bottom', ha='center', transform=tc) # Subplot spec # WARNING: For mpl>=3.4.0 subplotspec assigned *after* initialization using @@ -309,12 +293,10 @@ def __init__(self, *args, number=None, main=False, _subplotspec=None, **kwargs): if _subplotspec is not None: self.set_subplotspec(_subplotspec) - # Automatic axis sharing - self._auto_share_setup() - - # Automatic formatting + # Default sharing and formatting # TODO: Apply specific setters instead of format() - self.format(rc_mode=1) # mode == 1 applies the custom proplot params + self._auto_share_setup() + self.format(rc_mode=1) # rc_mode == 1 applies the custom proplot params def _auto_share_setup(self): """ @@ -333,28 +315,28 @@ def shared(paxs): if not self._panel_side: # this is a main axes # Top and bottom bottom = self - paxs = shared(self._bottom_panels) + paxs = shared(self._panel_dict['bottom']) if paxs: bottom = paxs[-1] bottom._panel_sharex_group = False for iax in (self, *paxs[:-1]): iax._panel_sharex_group = True iax._sharex_setup(bottom) # parent is bottom-most - paxs = shared(self._top_panels) + paxs = shared(self._panel_dict['top']) for iax in paxs: iax._panel_sharex_group = True iax._sharex_setup(bottom) # Left and right # NOTE: Order of panel lists is always inside-to-outside left = self - paxs = shared(self._left_panels) + paxs = shared(self._panel_dict['left']) if paxs: left = paxs[-1] left._panel_sharey_group = False for iax in (self, *paxs[:-1]): iax._panel_sharey_group = True iax._sharey_setup(left) # parent is left-most - paxs = shared(self._right_panels) + paxs = shared(self._panel_dict['right']) for iax in paxs: iax._panel_sharey_group = True iax._sharey_setup(left) @@ -370,19 +352,6 @@ def shared(paxs): for child in children: child._sharey_setup(parent) - 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_extent_axes(self, x, panels=False): """ Return the axes whose horizontal or vertical extent in the main @@ -428,20 +397,11 @@ def _get_side_axes(self, side, panels=False): else: return axs - def _get_title(self, loc): - """ - Get the title at the corresponding location. - """ - if loc == 'abc': - return self._abc_label - else: - return getattr(self, '_' + loc.replace(' ', '_') + '_title') - def _hide_panel(self): """ - Hide axes contents but do *not* make the entire axes invisible. This - is used to fill "panels" surreptitiously added to the gridspec - for the purpose of drawing outer colorbars and legends. + Hide axes contents but do *not* make the entire axes invisible. This is used to + fill "panels" surreptitiously added to the gridspec for the purpose of drawing + outer colorbars and legends. """ # NOTE: Do not run self.clear in case we want to add a subplot title # above a colorbar on a top panel (see _reassign_title). @@ -468,21 +428,21 @@ def _is_panel_group_member(self, other): or other._panel_parent is self._panel_parent # sibling ) - def _loc_translate(self, loc, mode=None, allow_manual=True): + def _loc_translate(self, loc, mode=None): """ Return the location string `loc` translated into a standardized form. """ if mode == 'legend': - valid = tuple(LOC_TRANSLATE.values()) + options = tuple(LOC_TRANSLATE.values()) elif mode == 'panel': - valid = ('left', 'right', 'top', 'bottom') + options = ('left', 'right', 'top', 'bottom') elif mode == 'colorbar': - valid = ( + options = ( 'best', 'left', 'right', 'top', 'bottom', 'upper left', 'upper right', 'lower left', 'lower right', ) elif mode in ('abc', 'title'): - valid = ( + options = ( 'left', 'center', 'right', 'upper left', 'upper center', 'upper right', 'lower left', 'lower center', 'lower right', @@ -491,7 +451,7 @@ def _loc_translate(self, loc, mode=None, allow_manual=True): raise ValueError(f'Invalid mode {mode!r}.') loc_translate = { key: value for key, value in LOC_TRANSLATE.items() - if value in valid + if value in options } if loc in (None, True): context = mode in ('abc', 'title') @@ -510,13 +470,12 @@ def _loc_translate(self, loc, mode=None, allow_manual=True): + ', '.join(map(repr, loc_translate)) + '.' ) elif ( - allow_manual - and mode == 'legend' + mode == 'legend' and np.iterable(loc) and len(loc) == 2 and all(isinstance(l, Number) for l in loc) ): - loc = np.array(loc) + loc = tuple(loc) else: raise KeyError(f'Invalid {mode} location {loc!r}.') if mode == 'colorbar' and loc == 'best': # white lie @@ -568,23 +527,23 @@ def _range_tightbbox(self, x): else: return bbox.ymin, bbox.ymax - def _reassign_subplot_label(self, side): + def _reassign_label(self, side): """ Reassign the column and row labels to the relevant panel if present. This is called by `~proplot.figure.Figure._align_subplot_figure_labels`. """ # NOTE: Since panel axes are "children" main axes is always drawn first. - paxs = getattr(self, '_' + side + '_panels') + paxs = self._panel_dict[side] if not paxs: return self kw = {} pax = paxs[-1] # outermost - obj = getattr(self, '_' + side + '_label') - pobj = getattr(pax, '_' + side + '_label') + cobj = self._label_dict[side] + pobj = pax._label_dict[side] for key in ('text', 'color', 'fontproperties'): - kw[key] = getattr(obj, 'get_' + key)() + kw[key] = getattr(cobj, 'get_' + key)() pobj.update(kw) - obj.set_text('') + cobj.set_text('') return pax def _reassign_title(self): @@ -595,19 +554,19 @@ def _reassign_title(self): bounding box. """ # NOTE: Since panel axes are "children" main axes is always drawn first. - taxs = self._top_panels + taxs = self._panel_dict['top'] if not taxs or not self._title_above: return tax = taxs[-1] # outermost tax._title_pad = self._title_pad for loc in ('abc', 'left', 'center', 'right'): kw = {} - obj = self._get_title(loc) - tobj = tax._get_title(loc) + cobj = self._title_dict[loc] + tobj = tax._title_dict[loc] for key in ('text', 'color', 'fontproperties'): - kw[key] = getattr(obj, 'get_' + key)() + kw[key] = getattr(cobj, 'get_' + key)() tobj.update(kw) - obj.set_text('') + cobj.set_text('') def _sharex_setup(self, sharex): """ @@ -637,8 +596,8 @@ def _share_short_axis(self, share, side): if share is None or self._panel_side: return # if this is a panel axis = 'x' if side in ('left', 'right') else 'y' - caxs = getattr(self, '_' + side + '_panels') - paxs = getattr(share, '_' + side + '_panels') + caxs = self._panel_dict[side] + paxs = share._panel_dict[side] caxs = [pax for pax in caxs if not pax._panel_hidden] paxs = [pax for pax in paxs if not pax._panel_hidden] for cax, pax in zip(caxs, paxs): # may be uneven @@ -654,7 +613,7 @@ def _share_long_axis(self, share, side): if share is None or self._panel_side: return # if this is a panel axis = 'x' if side in ('top', 'bottom') else 'y' - paxs = getattr(self, '_' + side + '_panels') + paxs = self._panel_dict[side] paxs = [pax for pax in paxs if not pax._panel_hidden] for pax in paxs: getattr(pax, '_share' + axis + '_setup')(share) @@ -672,11 +631,11 @@ def _update_title_position(self, renderer): 'upper left', 'upper right', 'upper center', 'lower left', 'lower right', 'lower center', ): - obj = self._get_title(loc) - if loc == 'abc': + obj = self._title_dict[loc] + if loc == 'abc': # redirect loc = self._abc_loc - if loc in ('left', 'right', 'center'): - continue + if loc in ('left', 'right', 'center'): + continue x_pad = pad / (72 * width) if loc in ('upper center', 'lower center'): x = 0.5 @@ -720,9 +679,10 @@ def _update_title_position(self, renderer): # the a-b-c text to the relevant position. super()._update_title_position(renderer) if self._abc_loc in ('left', 'center', 'right'): - title = self._get_title(self._abc_loc) - self._abc_label.set_position(title.get_position()) - self._abc_label.set_transform(self.transAxes + self.titleOffsetTrans) + abc = self._title_dict['abc'] + title = self._title_dict[self._abc_loc] + abc.set_position(title.get_position()) + abc.set_transform(self.transAxes + self.titleOffsetTrans) @staticmethod @warnings._rename_kwargs('0.6', mode='rc_mode') @@ -964,25 +924,25 @@ def sanitize_kw(kw, loc): new = new.upper() if old == 'A' else new self._abc_text = style.replace(old, new, 1) - # Apply text - obj = self._abc_label + # Apply a-b-c text abc = rc.get('abc', context=True) + aobj = self._title_dict['abc'] if abc is not None: - obj.set_text(self._abc_text if bool(abc) else '') + aobj.set_text(self._abc_text if bool(abc) else '') - # Apply new settings + # Apply a-b-c settings loc = self._loc_translate(None, 'abc') loc_prev = self._abc_loc if loc is None: loc = loc_prev kw = sanitize_kw(kw, loc) if loc_prev is None or loc != loc_prev: - obj_ref = self._get_title(loc) - obj.set_ha(obj_ref.get_ha()) - obj.set_va(obj_ref.get_va()) - obj.set_transform(obj_ref.get_transform()) - obj.set_position(obj_ref.get_position()) - obj.update(kw) + tobj = self._title_dict[loc] + aobj.set_ha(tobj.get_ha()) + aobj.set_va(tobj.get_va()) + aobj.set_transform(tobj.get_transform()) + aobj.set_position(tobj.get_position()) + aobj.update(kw) self._abc_loc = loc # Titles, with two workflows here: @@ -1021,14 +981,11 @@ def sanitize_kw(kw, loc): # Workflow 2, want this to come first so workflow 1 gets priority for iloc, ititle in zip( ('l', 'r', 'c', 'ul', 'uc', 'ur', 'll', 'lc', 'lr'), - ( - ltitle, rtitle, ctitle, - ultitle, uctitle, urtitle, lltitle, lctitle, lrtitle - ), + (ltitle, rtitle, ctitle, ultitle, uctitle, urtitle, lltitle, lctitle, lrtitle), # noqa: E501 ): iloc = self._loc_translate(iloc, 'title') ikw = sanitize_kw(kw, iloc) - iobj = self._get_title(iloc) + iobj = self._title_dict[iloc] iobj.update(ikw) if ititle is not None: iobj.set_text(ititle) @@ -1041,16 +998,16 @@ def sanitize_kw(kw, loc): if loc is None: # never None first run loc = loc_prev # never None on subsequent runs - # Reset old text + # Remove previous text if loc_prev is not None and loc != loc_prev: - obj_prev = self._get_title(loc_prev) + obj_prev = self._title_dict[loc_prev] if title is None: title = obj_prev.get_text() obj_prev.set_text('') - # Update new text + # Add new text and settings kw = sanitize_kw(kw, loc) - obj = self._get_title(loc) + obj = self._title_dict[loc] obj.update(kw) if title is not None: obj.set_text(title) @@ -1077,347 +1034,6 @@ def boxes(self, *args, **kwargs): """ return self.boxplot(*args, **kwargs) - def colorbar( - self, *args, loc=None, pad=None, - length=None, shrink=None, width=None, space=None, frame=None, frameon=None, - alpha=None, linewidth=None, edgecolor=None, facecolor=None, - **kwargs - ): - """ - Add an *inset* colorbar or *outer* colorbar along the outside edge of - the axes. See `~proplot.axes.colorbar_wrapper` for details. - - Parameters - ---------- - loc : str, optional - The colorbar location. Default is :rc:`colorbar.loc`. The - following location keys are valid: - - .. _colorbar_table: - - ================== ======================================= - Location Valid keys - ================== ======================================= - outer left ``'left'``, ``'l'`` - outer right ``'right'``, ``'r'`` - outer bottom ``'bottom'``, ``'b'`` - outer top ``'top'``, ``'t'`` - default 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`` - "filled" ``'fill'`` - ================== ======================================= - - 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.insetpad`. - length : float or str, optional - The colorbar length. For outer colorbars, units are relative to the - axes width or height. Default is :rc:`colorbar.length`. For inset - colorbars, units are interpreted by `~proplot.utils.units`. Default - is :rc:`colorbar.insetlength`. - shrink : float, optional - Alias for `length`. This is included to match the - `matplotlib.figure.Figure.colorbar` keyword that has roughly the same - meaning as `length`. - width : float or str, optional - The colorbar width. Units are interpreted by - `~proplot.utils.units`. For outer colorbars, default is - :rc:`colorbar.width`. For inset colorbars, default is - :rc:`colorbar.insetwidth`. - space : float or str, optional - For outer colorbars only. The space between the colorbar and the - main axes. Units are interpreted by `~proplot.utils.units`. - When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. - frame, frameon : bool, optional - For inset colorbars, indicates whether to draw a "frame", just - like `~matplotlib.axes.Axes.legend`. Default is - :rc:`colorbar.frameon`. - alpha, linewidth, edgecolor, facecolor : optional - Transparency, edge width, edge color, and face color for the frame - around the inset colorbar. Default is - :rc:`colorbar.framealpha`, :rc:`axes.linewidth`, - :rc:`axes.edgecolor`, and :rc:`axes.facecolor`, - respectively. - - Other parameters - ---------------- - *args, **kwargs - Passed to `~proplot.axes.colorbar_wrapper`. - """ - # TODO: add option to pad inset away from axes edge! - # TODO: get "best" colorbar location from legend algorithm. - kwargs.update({'edgecolor': edgecolor, 'linewidth': linewidth}) - length = _not_none(length=length, shrink=shrink) - if loc != 'fill': - loc = self._loc_translate(loc, 'colorbar') - - # 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 - self._hide_panel() - - # Get subplotspec for colorbar axes - side = self._panel_side - length = _not_none(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 = pgridspec._GridSpecFromSubplotSpec( - nrows=1, ncols=3, wspace=0, - subplot_spec=subplotspec, - width_ratios=((1 - length) / 2, length, (1 - length) / 2), - ) - subplotspec = gridspec[1] - else: - gridspec = pgridspec._GridSpecFromSubplotSpec( - nrows=3, ncols=1, hspace=0, - subplot_spec=subplotspec, - height_ratios=((1 - length) / 2, length, (1 - length) / 2), - ) - subplotspec = gridspec[1] - - # Draw colorbar axes - with self.figure._context_authorize_add_subplot(): - ax = self.figure.add_subplot(subplotspec, projection='proplot_cartesian') # noqa: E501 - self.add_child_axes(ax) - - # Location - if side is None: # manual - orientation = kwargs.pop('orientation', None) - if orientation == 'vertical': - side = 'left' - else: - side = 'bottom' - 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 - orientation_user = kwargs.get('orientation', None) - if orientation_user and orientation_user != orientation: - warnings._warn_proplot( - f'Overriding input orientation={orientation_user!r}.' - ) - ticklocation = _not_none( - ticklocation=kwargs.pop('ticklocation', None), - tickloc=kwargs.pop('tickloc', None), - default=ticklocation, - ) - kwargs.update({ - 'orientation': orientation, - 'ticklocation': ticklocation - }) - - # Inset colorbar - else: - # Default props - cbwidth, cblength = width, length - width, height = self.get_size_inches() - extend = units(_not_none( - kwargs.get('extendsize', None), rc['colorbar.insetextend'] - )) - cbwidth = units(_not_none( - cbwidth, rc['colorbar.insetwidth'] - )) / height - cblength = units(_not_none( - cblength, rc['colorbar.insetlength'] - )) / width - pad = units(_not_none(pad, rc['colorbar.insetpad'])) - 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) - else: - bounds = (1 - xpad - cblength, ypad + xspace) - fbounds = (1 - 2 * xpad - cblength, 0) - 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 = _not_none( - frame=frame, frameon=frameon, default=rc['colorbar.frameon'], - ) - if frameon: - xmin, ymin, width, height = fbounds - patch = mpatches.Rectangle( - (xmin, ymin), width, height, - snap=True, zorder=4, transform=self.transAxes - ) - alpha = _not_none(alpha, rc['colorbar.framealpha']) - linewidth = _not_none(linewidth, rc['axes.linewidth']) - edgecolor = _not_none(edgecolor, rc['axes.edgecolor']) - facecolor = _not_none(facecolor, rc['axes.facecolor']) - patch.update({ - 'alpha': alpha, - 'linewidth': linewidth, - 'edgecolor': edgecolor, - 'facecolor': facecolor - }) - self.add_artist(patch) - - # Make axes - from .cartesian import CartesianAxes - locator = self._make_inset_locator(bounds, self.transAxes) - bbox = locator(None, None) - ax = CartesianAxes(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_proplot( - 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_proplot( - '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) - - def legend(self, *args, loc=None, width=None, space=None, **kwargs): - """ - Add an *inset* legend or *outer* legend along the edge of the axes. - See `~proplot.axes.legend_wrapper` for details. - - Parameters - ---------- - loc : int or str, optional - The legend location. The following location keys are valid: - - .. _legend_table: - - ================== ======================================= - Location Valid keys - ================== ======================================= - outer left ``'left'``, ``'l'`` - outer right ``'right'``, ``'r'`` - outer bottom ``'bottom'``, ``'b'`` - outer top ``'top'``, ``'t'`` - "best" inset ``'best'``, ``'inset'``, ``'i'``, ``0`` - upper right inset ``'upper right'``, ``'ur'``, ``1`` - upper left inset ``'upper left'``, ``'ul'``, ``2`` - lower left inset ``'lower left'``, ``'ll'``, ``3`` - lower right inset ``'lower right'``, ``'lr'``, ``4`` - center left inset ``'center left'``, ``'cl'``, ``5`` - center right inset ``'center right'``, ``'cr'``, ``6`` - lower center inset ``'lower center'``, ``'lc'``, ``7`` - upper center inset ``'upper center'``, ``'uc'``, ``8`` - center inset ``'center'``, ``'c'``, ``9`` - "filled" ``'fill'`` - ================== ======================================= - - width : float or str, optional - For outer legends only. The space allocated for the legend box. - This does nothing if :rcraw:`tight` is ``True``. Units are - interpreted by `~proplot.utils.units`. - space : float or str, optional - For outer legends only. The space between the axes and the legend - box. Units are interpreted by `~proplot.utils.units`. - When :rcraw:`tight` is ``True``, this is adjusted automatically. - Otherwise, the default is :rc:`subplots.panelpad`. - - Other parameters - ---------------- - *args, **kwargs - Passed to `~proplot.axes.legend_wrapper`. - """ - if loc != 'fill': - loc = self._loc_translate(loc, 'legend') - if isinstance(loc, np.ndarray): - loc = loc.tolist() - - # Generate panel - if loc in ('left', 'right', 'top', 'bottom'): - ax = self.panel_axes(loc, width=width, space=space, filled=True) - return ax.legend(*args, loc='fill', **kwargs) - - # Fill - if loc == 'fill': - # Hide content - self._hide_panel() - - # Try to make handles and stuff flush against the axes edge - kwargs.setdefault('borderaxespad', 0) - frameon = _not_none( - kwargs.get('frame', None), kwargs.get('frameon', None), - rc['legend.frameon'] - ) - if not frameon: - kwargs.setdefault('borderpad', 0) - - # Apply legend location - side = self._panel_side - if side == 'bottom': - loc = 'upper center' - elif side == 'right': - loc = 'center left' - elif side == 'left': - loc = 'center right' - elif side == 'top': - loc = 'lower center' - else: - raise ValueError(f'Invalid panel side {side!r}.') - - # Draw legend - return legend_wrapper(self, *args, loc=loc, **kwargs) - def draw(self, renderer=None, *args, **kwargs): # Perform extra post-processing steps self._reassign_title() @@ -1730,6 +1346,408 @@ def violins(self, *args, **kwargs): """ return self.violinplot(*args, **kwargs) + def _add_colorbar_legend(self, loc, obj, legend=False, **kwargs): + """ + Queue up or replace objects for legends and list-of-artist style colorbars. + """ + # Remove previous instances + # NOTE: No good way to remove inset colorbars right now until the bounding + # box and axes are merged into some colorbar subclass. Fine for now. + d = self._legend_dict if legend else self._colorbar_dict + if loc == 'fill': # will be index in *parent* instead + return + if loc in d and not isinstance(d[loc], tuple): + obj_prev = d.pop(loc) # possibly pop a queued object + if hasattr(self, 'legend_') and self.legend_ is obj_prev: + self.legend_ = None # was never added as artist + elif legend: + obj_prev.remove() # remove legends and inner colorbars + # Update queue or replace with instance + if not isinstance(obj, tuple) or any(isinstance(_, mlegend.Legend) for _ in obj): # noqa: E501 + d[loc] = obj + else: + handles, labels = obj + handles_full, labels_full, kwargs_full = d.setdefault(loc, ([], [], {})) + handles_full.extend(_not_none(handles, [])) + labels_full.extend(_not_none(labels, [])) + kwargs_full.update(kwargs) + + def _draw_colorbars_legends(self): + """ + Draw the queued-up legends and colorbars. Wrapper funcs and legend func let + user add handles to location lists with successive calls. + """ + # WARNING: Passing empty list labels=[] to legend causes matplotlib + # _parse_legend_args to search for everything. Ensure None if empty. + for loc, colorbar in self._colorbar_dict.items(): + if not isinstance(colorbar, tuple): + continue + handles, labels, kwargs = colorbar + self.colorbar(handles, labels or None, loc=loc, **kwargs) + for loc, legend in self._legend_dict.items(): + if not isinstance(legend, tuple): + continue + handles, labels, kwargs = legend + self.legend(handles, labels or None, loc=loc, **kwargs) + + def _fill_colorbar_axes(self, length=None, **kwargs): + """ + Return the axes and adjusted keyword args for a panel-filling colorbar. + """ + # Get subplotspec for colorbar axes + side = self._panel_side + length = _not_none(length, rc['colorbar.length']) + 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 = pgridspec._GridSpecFromSubplotSpec( + nrows=1, ncols=3, wspace=0, + subplot_spec=subplotspec, + width_ratios=((1 - length) / 2, length, (1 - length) / 2), + ) + subplotspec = gridspec[1] + else: + gridspec = pgridspec._GridSpecFromSubplotSpec( + nrows=3, ncols=1, hspace=0, + subplot_spec=subplotspec, + height_ratios=((1 - length) / 2, length, (1 - length) / 2), + ) + subplotspec = gridspec[1] + + # Draw colorbar axes within this one + self._hide_panel() + with self.figure._context_authorize_add_subplot(): + ax = self.figure.add_subplot(subplotspec, projection='proplot_cartesian') # noqa: E501 + self.add_child_axes(ax) + + # Location + if side is None: # manual + orientation = kwargs.pop('orientation', None) + if orientation == 'vertical': + side = 'left' + else: + side = 'bottom' + 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' + + # Update default keyword args + orientation_user = kwargs.get('orientation', None) + if orientation_user and orientation_user != orientation: + warnings._warn_proplot( + f'Overriding input orientation={orientation_user!r}.' + ) + ticklocation = _not_none( + ticklocation=kwargs.pop('ticklocation', None), + tickloc=kwargs.pop('tickloc', None), + default=ticklocation, + ) + kwargs.update({ + 'orientation': orientation, + 'ticklocation': ticklocation + }) + + return ax, kwargs + + def _inset_colorbar_axes( + self, loc=None, pad=None, width=None, length=None, frame=None, frameon=None, + alpha=None, linewidth=None, edgecolor=None, facecolor=None, **kwargs + ): + """ + Return the axes and adjusted keyword args for an inset colorbar. + """ + # Default properties + cbwidth, cblength = width, length + width, height = self.get_size_inches() + frame = _not_none(frame=frame, frameon=frameon, default=rc['colorbar.frameon']) + cbwidth = units(_not_none(cbwidth, rc['colorbar.insetwidth'])) / height + cblength = units(_not_none(cblength, rc['colorbar.insetlength'])) / width + extend = units(_not_none(kwargs.get('extendsize', None), rc['colorbar.insetextend'])) # noqa: E501 + pad = units(_not_none(pad, rc['colorbar.insetpad'])) + xpad, ypad = pad / width, pad / height + + # 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': + ibounds = (1 - xpad - cblength, 1 - ypad - cbwidth) + fbounds = (1 - 2 * xpad - cblength, 1 - 2 * ypad - cbwidth - xspace) + elif loc == 'upper left': + ibounds = (xpad, 1 - ypad - cbwidth) + fbounds = (0, 1 - 2 * ypad - cbwidth - xspace) + elif loc == 'lower left': + ibounds = (xpad, ypad + xspace) + fbounds = (0, 0) + else: + ibounds = (1 - xpad - cblength, ypad + xspace) + fbounds = (1 - 2 * xpad - cblength, 0) + ibounds = (ibounds[0], ibounds[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. + if frame: + xmin, ymin, width, height = fbounds + patch = mpatches.Rectangle( + (xmin, ymin), width, height, + snap=True, zorder=4, transform=self.transAxes + ) + alpha = _not_none(alpha, rc['colorbar.framealpha']) + linewidth = _not_none(linewidth, rc['axes.linewidth']) + edgecolor = _not_none(edgecolor, rc['axes.edgecolor']) + facecolor = _not_none(facecolor, rc['axes.facecolor']) + patch.update({ + 'alpha': alpha, + 'linewidth': linewidth, + 'edgecolor': edgecolor, + 'facecolor': facecolor + }) + self.add_artist(patch) + + # Make axes + from .cartesian import CartesianAxes + locator = self._make_inset_locator(ibounds, self.transAxes) + bbox = locator(None, None) + ax = CartesianAxes(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_proplot( + 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_proplot('Inset colorbars can only have ticks on the bottom.') + kwargs.update({'orientation': 'horizontal', 'ticklocation': 'bottom'}) + kwargs.setdefault('maxn', 5) + kwargs.setdefault('extendsize', extend) + kwargs.update({'edgecolor': edgecolor, 'linewidth': linewidth}) # cbar edge + + return ax, kwargs + + def colorbar( + self, mappable, values=None, *, loc=None, length=None, shrink=None, width=None, + space=None, pad=None, queue=False, **kwargs + ): + """ + Add an *inset* colorbar or *outer* colorbar along the outside edge of + the axes. See `~proplot.axes.colorbar_wrapper` for details. + + Parameters + ---------- + loc : str, optional + The colorbar location. Default is :rc:`colorbar.loc`. The + following location keys are valid: + + .. _colorbar_table: + + ================== ======================================= + Location Valid keys + ================== ======================================= + outer left ``'left'``, ``'l'`` + outer right ``'right'``, ``'r'`` + outer bottom ``'bottom'``, ``'b'`` + outer top ``'top'``, ``'t'`` + default 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`` + "filled" ``'fill'`` + ================== ======================================= + + 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`. + shrink : float, optional + Alias for `length`. This is included for consistency with + `matplotlib.figure.Figure.colorbar`. + width : float or str, optional + The colorbar width. Units are interpreted by `~proplot.utils.units`. + For outer colorbars, default is :rc:`colorbar.width`. For inset colorbars, + default is :rc:`colorbar.insetwidth`. + space : float or str, optional + For outer colorbars only. The space between the colorbar and the + main axes. Units are interpreted by `~proplot.utils.units`. + When :rcraw:`tight` is ``True``, this is adjusted automatically. + Otherwise, the default is :rc:`subplots.panelpad`. + pad : float or str, optional + For inset colorbars only. The space between the axes edge and the colorbar. + Units are interpreted by `~proplot.utils.units`. + Default is :rc:`colorbar.insetpad`. + frame, frameon : bool, optional + For inset colorbars only. Indicates whether to draw a "frame", just + like `~matplotlib.axes.Axes.legend`. Default is :rc:`colorbar.frameon`. + alpha, linewidth, edgecolor, facecolor : optional + For inset colorbars only. Controls the transparency, edge width, edge color, + and face color of the frame. Defaults are :rc:`colorbar.framealpha`, + :rc:`axes.linewidth`, :rc:`axes.edgecolor`, and :rc:`axes.facecolor`. + + Other parameters + ---------------- + *args, **kwargs + Passed to `~proplot.axes.colorbar_wrapper`. + """ + # TODO: Add option to pad the frame away from the axes edge + # TODO: Get the 'best' inset colorbar location using the legend algorithm. + length = _not_none(length=length, shrink=shrink) + if loc != 'fill': + loc = self._loc_translate(loc, 'colorbar') + + # Optionally add to queue + if queue: + obj = (mappable, values) + kwargs.update({'width': width, 'length': length, 'space': space, 'pad': pad}) # noqa: E501 + return self._add_colorbar_legend(loc, obj, legend=False, **kwargs) + + # Generate panel + if loc in ('left', 'right', 'top', 'bottom'): + ax = self.panel_axes(loc, width=width, space=space, filled=True) + obj = ax.colorbar(mappable, values, loc='fill', length=length, **kwargs) + self._add_colorbar_legend(loc, obj, legend=False) + return obj + + # Generate colorbar axes + if loc == 'fill': + ax, kwargs = self._fill_colorbar_axes(length=length, **kwargs) + else: + ax, kwargs = self._inset_colorbar_axes(width=width, length=length, pad=pad, **kwargs) # noqa: E501 + + # Generate colorbar + obj = colorbar_wrapper(ax, mappable, values, **kwargs) + self._add_colorbar_legend(loc, obj, legend=False) # possibly replace another + return obj + + def legend( + self, handles=None, labels=None, *, + loc=None, width=None, space=None, queue=False, **kwargs + ): + """ + Add an *inset* legend or *outer* legend along the edge of the axes. + See `~proplot.axes.legend_wrapper` for details. + + Parameters + ---------- + loc : int or str, optional + The legend location. The following location keys are valid: + + .. _legend_table: + + ================== ======================================= + Location Valid keys + ================== ======================================= + outer left ``'left'``, ``'l'`` + outer right ``'right'``, ``'r'`` + outer bottom ``'bottom'``, ``'b'`` + outer top ``'top'``, ``'t'`` + "best" inset ``'best'``, ``'inset'``, ``'i'``, ``0`` + upper right inset ``'upper right'``, ``'ur'``, ``1`` + upper left inset ``'upper left'``, ``'ul'``, ``2`` + lower left inset ``'lower left'``, ``'ll'``, ``3`` + lower right inset ``'lower right'``, ``'lr'``, ``4`` + center left inset ``'center left'``, ``'cl'``, ``5`` + center right inset ``'center right'``, ``'cr'``, ``6`` + lower center inset ``'lower center'``, ``'lc'``, ``7`` + upper center inset ``'upper center'``, ``'uc'``, ``8`` + center inset ``'center'``, ``'c'``, ``9`` + "filled" ``'fill'`` + ================== ======================================= + + width : float or str, optional + For outer legends only. The space allocated for the legend box. + This does nothing if :rcraw:`tight` is ``True``. Units are + interpreted by `~proplot.utils.units`. + space : float or str, optional + For outer legends only. The space between the axes and the legend + box. Units are interpreted by `~proplot.utils.units`. + When :rcraw:`tight` is ``True``, this is adjusted automatically. + Otherwise, the default is :rc:`subplots.panelpad`. + queue : bool, optional + If ``True`` and `loc` is the same as an existing legend, the `handles` + and `labels` are added to a queue and this function returns ``None``. + This is used to "update" the same legend with successive ``ax.legend(...)`` + calls. If ``False`` (the default) and `loc` is the same as an existing + legend, this function returns a `~matplotlib.legend.Legend` instance + and the old legend is removed from the axes. + + Other parameters + ---------------- + *args, **kwargs + Passed to `~proplot.axes.legend_wrapper`. + """ + if loc != 'fill': + loc = self._loc_translate(loc, 'legend') + if isinstance(loc, np.ndarray): + loc = loc.tolist() + + # Optionally add to queue + if queue: + obj = (handles, labels) + kwargs.update({'width': width, 'space': space}) + return self._add_colorbar_legend(loc, obj, legend=True, **kwargs) + + # Generate panel + if loc in ('left', 'right', 'top', 'bottom'): + ax = self.panel_axes(loc, width=width, space=space, filled=True) + obj = ax.legend(handles, labels, loc='fill', **kwargs) + self._add_colorbar_legend(loc, obj, legend=True) # add to *this* axes + return obj + + # Adjust settings + if loc == 'fill': + # Try to make handles and stuff flush against the axes edge + self._hide_panel() + kwargs.setdefault('borderaxespad', 0) + frameon = _not_none(kwargs.get('frame'), kwargs.get('frameon'), rc['legend.frameon']) # noqa: E501 + if not frameon: + kwargs.setdefault('borderpad', 0) + # Adjust location + side = self._panel_side + if side == 'bottom': + loc = 'upper center' + elif side == 'right': + loc = 'center left' + elif side == 'left': + loc = 'center right' + elif side == 'top': + loc = 'lower center' + else: + raise ValueError(f'Invalid panel side {side!r}.') + + # Generate legend + obj = legend_wrapper(self, handles, labels, loc=loc, **kwargs) + self._add_colorbar_legend(loc, obj, legend=True) # possibly replace another + return obj + def _iter_axes(self, panels=None, hidden=False, children=False): """ Return a list of axes and child panel axes. @@ -1746,13 +1764,7 @@ def _iter_axes(self, panels=None, hidden=False, children=False): panels = _not_none(panels, ('left', 'right', 'bottom', 'top')) if not set(panels) <= {'left', 'right', 'bottom', 'top'}: raise ValueError(f'Invalid sides {panels!r}.') - for iax in ( - self, - *( - jax for side in panels - for jax in getattr(self, '_' + side + '_panels') - ) - ): + for iax in (self, *(jax for side in panels for jax in self._panel_dict[side])): if not hidden and iax._panel_hidden: continue # ignore hidden panel and its colorbar/legend child for jax in ((iax, *iax.child_axes) if children else (iax,)): diff --git a/proplot/axes/plot.py b/proplot/axes/plot.py index 29e54bb5d..8b3bfc6dc 100644 --- a/proplot/axes/plot.py +++ b/proplot/axes/plot.py @@ -192,11 +192,11 @@ they are set to ``np.arange(0, len(height))``. Note that the units for `width` are now *relative*. orientation : {{'vertical', 'horizontal'}}, optional - The orientation of the bars. + The orientation of the bars. If ``'horizontal'``, bars are drawn horizontally + rather than vertically. vert : bool, optional - Alternative to the `orientation` keyword arg. If ``False``, horizontal - bars are drawn. This is for consistency with - `~matplotlib.axes.Axes.boxplot` and `~matplotlib.axes.Axes.violinplot`. + Alternative to the `orientation` keyword arg. If ``False``, bars are drawn + horizontally. Added for consistency with `~matplotlib.axes.Axes.boxplot`. stacked : bool, optional Whether to stack columns of input data, or plot the bars side-by-side. negpos : bool, optional @@ -459,28 +459,31 @@ def default_transform(self, func, *args, transform=None, **kwargs): def _axis_labels_title(data, axis=None, units=True): """ - Get data and label for pandas or xarray objects or their coordinates along axis - `axis`. If `units` is ``True`` also look for units on xarray data arrays. + Get "labels" and "title" for the coordinates along axis `axis` (if specified) or + assuming the object itself represents coordinates (if ``None``) for numpy, pandas, + or xarray objects. If `units` is ``True`` also look for units on xarray arrays. """ - label = '' + # TODO: Why not raise error here if bad dimensionality? Defer to later? + title = '' + labels = data _load_objects() if isinstance(data, ndarray): if axis is not None and data.ndim > axis: - data = np.arange(data.shape[axis]) + labels = np.arange(data.shape[axis]) # Xarray with common NetCDF attribute names elif isinstance(data, DataArray): if axis is not None and data.ndim > axis: - data = data.coords[data.dims[axis]] - label = getattr(data, 'name', '') or '' + labels = data.coords[data.dims[axis]] + title = getattr(labels, 'name', '') or '' for key in ('standard_name', 'long_name'): - label = data.attrs.get(key, label) + title = labels.attrs.get(key, title) if units: - units = data.attrs.get('units', '') - if label and units: - label = f'{label} ({units})' + units = labels.attrs.get('units', '') + if title and units: + title = f'{title} ({units})' elif units: - label = units + title = units # Pandas object with name attribute # if not label and isinstance(data, DataFrame) and data.columns.size == 1: @@ -488,16 +491,16 @@ def _axis_labels_title(data, axis=None, units=True): if axis == 0 and isinstance(data, Index): pass elif axis == 0 and isinstance(data, (DataFrame, Series)): - data = data.index + labels = data.index elif axis == 1 and isinstance(data, DataFrame): - data = data.columns + labels = data.columns elif axis == 1 and isinstance(data, (Series, Index)): - data = np.array([data.name]) # treat series name as the "column" data + labels = np.array([data.name]) # treat series name as the "column" data # DataFrame has no native name attribute but user can add one: # https://github.com/pandas-dev/pandas/issues/447 - label = getattr(data, 'name', '') or '' + title = getattr(labels, 'name', '') or '' - return data, str(label).strip() + return labels, str(title).strip() @docstring.add_snippets @@ -542,11 +545,7 @@ def standardize_1d(self, func, *args, autoformat=None, **kwargs): raise ValueError( f'{name}() takes up to 4 positional arguments but {len(args)} was given.' ) - vert = kwargs.get('vert', None) - if vert is not None: - orientation = ('vertical' if vert else 'horizontal') - else: - orientation = kwargs.get('orientation', 'vertical') + vert = kwargs.get('vert', kwargs.get('orientation', 'vertical') == 'vertical') # Iterate through list of ys that we assume are identical # Standardize based on the first y input @@ -579,15 +578,15 @@ def standardize_1d(self, func, *args, autoformat=None, **kwargs): # NOTE: Why IndexFormatter and not FixedFormatter? The former ensures labels # correspond to indices while the latter can mysteriously truncate labels. kw = {} - xname = 'y' if orientation == 'horizontal' else 'x' - yname = 'x' if xname == 'y' else 'y' + sx = 'x' if vert else 'y' + sy = 'y' if sx == 'x' else 'x' if _is_string(x) and name in ('hist',): kwargs.setdefault('labels', _to_ndarray(x)) elif _is_string(x): x_index = np.arange(len(x)) - kw[xname + 'locator'] = mticker.FixedLocator(x_index) - kw[xname + 'formatter'] = pticker._IndexFormatter(_to_ndarray(x)) - kw[xname + 'minorlocator'] = mticker.NullLocator() + kw[sx + 'locator'] = mticker.FixedLocator(x_index) + kw[sx + 'formatter'] = pticker._IndexFormatter(_to_ndarray(x)) + kw[sx + 'minorlocator'] = mticker.NullLocator() if name == 'boxplot': # otherwise IndexFormatter is overridden kwargs['labels'] = _to_ndarray(x) @@ -595,19 +594,24 @@ def standardize_1d(self, func, *args, autoformat=None, **kwargs): # NOTE: Do not overwrite existing labels! if autoformat: # Ylabel - y, label = _axis_labels_title(y) - iname = xname if name in ('hist',) else yname - if label and not getattr(self, f'get_{iname}label')(): + _, title = _axis_labels_title(y) + s = sx if name in ('hist',) else sy + if title and not getattr(self, f'get_{s}label')(): # For histograms, this label is used for *x* coordinates - kw[iname + 'label'] = label + kw[s + 'label'] = title if name not in ('hist',): # Xlabel - x, label = _axis_labels_title(x) - if label and not getattr(self, f'get_{xname}label')(): - kw[xname + 'label'] = label + _, title = _axis_labels_title(x) + if title and not getattr(self, f'get_{sx}label')(): + kw[sx + 'label'] = title # Reversed axis - if name not in ('scatter',) and x_index is None and len(x) > 1 and x[1] < x[0]: # noqa: E501 - kw[xname + 'reverse'] = True + if ( + name not in ('scatter',) + and x_index is None + and len(x) > 1 + and _to_ndarray(x)[1] < _to_ndarray(x)[0] + ): # noqa: E501 + kw[sx + 'reverse'] = True # Appply if kw: @@ -849,18 +853,18 @@ def standardize_2d( # Handle labels if 'autoformat' is on # NOTE: Do not overwrite existing labels! if autoformat: - for key, d in zip(('xlabel', 'ylabel'), (x, y)): + for s, d in zip('xy', (x, y)): # Axis label _, label = _axis_labels_title(d) - if label and not getattr(self, f'get_{key}')(): - kw[key] = label + if label and not getattr(self, f'get_{s}label')(): + kw[s + 'label'] = label # Reversed axis if ( len(d) > 1 and all(isinstance(d, Number) for d in d[:2]) - and d[1] < d[0] + and _to_ndarray(d)[1] < _to_ndarray(d)[0] ): - kw[key[0] + 'reverse'] = True + kw[s + 'reverse'] = True if kw: self.format(**kw) @@ -1361,7 +1365,7 @@ def indicate_error( # NOTE: Provide error objects for inclusion in legend, but *only* provide # the shading. Never want legend entries for error bars. xy = (x, data) if name == 'violinplot' else (x, y) - kwargs.setdefault('errobjs', errobjs[:int(shading + fading)]) + kwargs.setdefault('_errobjs', errobjs[:int(shading + fading)]) result = obj = func(self, *xy, *args, **kwargs) # Apply inferrred colors to objects @@ -1970,8 +1974,9 @@ def boxplot_wrapper( The linewidth of all objects. vert : bool, optional If ``False``, box plots are drawn horizontally. - orientation : {{None, 'horizontal', 'vertical'}}, optional - Alternative to the native `vert` keyword arg. Controls orientation. + orientation : {{'vertical', 'horizontal'}}, optional + Alternative to the native `vert` keyword arg. + Added for consistency with `~matplotlib.axes.Axes.bar`. marker : marker-spec, optional Marker style for the 'fliers', i.e. outliers. markersize : float, optional @@ -2091,8 +2096,9 @@ def violinplot_wrapper( The opacity of the violins. Default is ``1``. vert : bool, optional If ``False``, box plots are drawn horizontally. - orientation : {{None, 'horizontal', 'vertical'}}, optional - Alternative to the native `vert` keyword arg. Controls orientation. + orientation : {{'vertical', 'horizontal'}}, optional + Alternative to the native `vert` keyword arg. + Added for consistency with `~matplotlib.axes.Axes.bar`. boxrange, barrange : (float, float), optional Percentile ranges for the thick and thin central bars. The defaults are ``(25, 75)`` and ``(5, 95)``, respectively. @@ -2179,9 +2185,8 @@ def _get_transform(self, transform): def _update_text(self, props): """ - Monkey patch that adds pseudo "border" and "bbox" properties to text objects - without wrapping the entire class. We override update to facilitate - updating inset titles. + 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 @@ -2329,7 +2334,7 @@ def text_wrapper( return obj -def _iter_legend_objects(objs): +def _iter_objs_labels(objs): """ Retrieve the (object, label) pairs for objects with actual labels from nested lists and tuples of objects. @@ -2343,7 +2348,7 @@ def _iter_legend_objects(objs): yield (objs, label) elif isinstance(objs, (list, tuple)): for obj in objs: - yield from _iter_legend_objects(obj) + yield from _iter_objs_labels(obj) def cycle_changer( @@ -2352,7 +2357,7 @@ def cycle_changer( label=None, labels=None, values=None, legend=None, legend_kw=None, colorbar=None, colorbar_kw=None, - errobjs=None, + _errobjs=None, **kwargs ): """ @@ -2400,9 +2405,6 @@ def cycle_changer( colorbar_kw : dict-like, optional Ignored if `colorbar` is ``None``. Extra keyword args for our call to `~proplot.axes.Axes.colorbar`. - errobjs : `~matplotlib.artist.Artist` or list thereof, optional - Error bar objects to add to the legend. This is used internally and - should not be necessary for users. See `indicate_error`. Other parameters ---------------- @@ -2629,65 +2631,45 @@ def cycle_changer( else: # has x-coordinates, and maybe more than one y ixy = (ix, *iys) obj = func(self, *ixy, *args, **kw) - if type(obj) in (list, tuple) and len(obj) == 1: + 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: - # Add handles - loc = self._loc_translate(colorbar, 'colorbar', allow_manual=False) - if loc not in self._auto_colorbar: - self._auto_colorbar[loc] = ([], {}) - self._auto_colorbar[loc][0].extend(objs) - - # Add keywords - if loc != 'fill': - colorbar_kw.setdefault('loc', loc) if colorbar_legend_label: colorbar_kw.setdefault('label', colorbar_legend_label) - self._auto_colorbar[loc][1].update(colorbar_kw) + self.colorbar(objs, loc=colorbar, queue=True, **colorbar_kw) # Add legend if legend: # Get error objects. If they have separate label, allocate separate - # legend entry. If not, try to combine with current legend entry. - if type(errobjs) not in (list, tuple): - errobjs = (errobjs,) - errobjs = list(filter(None, errobjs)) - errobjs_join = [obj for obj in errobjs if not obj.get_label()] - errobjs_separate = [obj for obj in errobjs if obj.get_label()] - - # Get legend objects + # legend entry. If they do not, try to combine with current legend entry. + if not isinstance(_errobjs, (list, tuple)): + _errobjs = (_errobjs,) + _errobjs = list(filter(None, _errobjs)) + eobjs_join = [obj for obj in _errobjs if not obj.get_label()] + eobjs_separate = [obj for obj in _errobjs if obj.get_label()] + + # Call with legend objects # NOTE: It is not yet possible to draw error bounds *and* draw lines # with multiple columns of data. # NOTE: Put error bounds objects *before* line objects in the tuple, # so that line gets drawn on top of bounds. - legobjs = objs.copy() - if errobjs_join: - legobjs = [(*legobjs, *errobjs_join)[::-1]] - legobjs.extend(errobjs_separate) - try: - legobjs, labels = list(zip(*_iter_legend_objects(legobjs))) - except ValueError: - legobjs = labels = () - - # Add handles and labels - # NOTE: Important to add labels as *keyword* so users can override # NOTE: Use legend(handles, labels) syntax so we can assign labels # for tuples of artists. Otherwise they are label-less. - loc = self._loc_translate(legend, 'legend', allow_manual=False) - if loc not in self._auto_legend: - self._auto_legend[loc] = ([], {'labels': []}) - self._auto_legend[loc][0].extend(legobjs) - self._auto_legend[loc][1]['labels'].extend(labels) - - # Add other keywords - if loc != 'fill': - legend_kw.setdefault('loc', loc) + lobjs = [(*eobjs_join[::-1], *objs)] if eobjs_join else objs.copy() + lobjs.extend(eobjs_separate) + try: + lobjs, labels = list(zip(*_iter_objs_labels(lobjs))) + except ValueError: + lobjs = labels = () + labels = legend_kw.pop('labels', labels) if colorbar_legend_label: legend_kw.setdefault('label', colorbar_legend_label) - self._auto_legend[loc][1].update(legend_kw) + self.legend(lobjs, labels, loc=legend, queue=True, **legend_kw) # Return # WARNING: Make sure plot always returns tuple of objects, and bar always @@ -3297,12 +3279,12 @@ def cmap_changer( contour.set_linestyle('-') # Optionally add colorbar + if autoformat: + _, label = _axis_labels_title(Z_sample) # last one is data, we assume + if label: + colorbar_kw.setdefault('label', label) if colorbar: - loc = self._loc_translate(colorbar, 'colorbar', allow_manual=False) - if autoformat: - _, label = _axis_labels_title(Z_sample) # last one is data, we assume - if label: - colorbar_kw.setdefault('label', label) + loc = self._loc_translate(colorbar, 'colorbar') if name in ('parametric',) and values is not None: colorbar_kw.setdefault('values', values) if loc != 'fill': @@ -3312,899 +3294,905 @@ def cmap_changer( return obj -def _iter_legend_children(children): +def _generate_mappable( + mappable, values=None, *, orientation='horizontal', + locator=None, formatter=None, norm=None, norm_kw=None, rotation=None, +): """ - Iterate recursively through `_children` attributes of various `HPacker`, - `VPacker`, and `DrawingArea` classes. + 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). """ - for obj in children: - if hasattr(obj, '_children'): - yield from _iter_legend_children(obj._children) - else: - yield obj + # A colormap instance + # TODO: Pass remaining arguments through Colormap()? This is really + # niche usage so maybe not necessary. + if isinstance(mappable, mcolors.Colormap): + # NOTE: 'Values' makes no sense if this is just a colormap. Just + # use unique color for every segmentdata / colors color. + cmap = mappable + values = np.linspace(0, 1, cmap.N) + + # List of colors + elif np.iterable(mappable) and all( + isinstance(obj, str) or (np.iterable(obj) and len(obj) in (3, 4)) + for obj in mappable + ): + 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 (avoid overwriting colorbar 'label') + labs = [] + values = [] + for obj in mappable: + lab = value = None + if hasattr(obj, 'get_label'): + lab = obj.get_label() or None + if lab and lab[:1] == '_': # intended to be ignored by legend + lab = None + if lab: + try: + value = float(lab) + except (TypeError, ValueError): + pass + labs.append(lab) + values.append(value) + + # Use default values if labels are non-numeric (numeric labels are + # common when making on-the-fly colorbars). Try to use object labels + # for ticks with default vertical rotation, like datetime axes. + if any(value is None for value in values): + values = np.arange(len(mappable)) + if formatter is None and any(lab is not None for lab in labs): + formatter = labs # use these fixed values for ticks + if orientation == 'horizontal': + 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}.' + ) -def legend_wrapper( - self, handles=None, labels=None, *, ncol=None, ncols=None, - center=None, order='C', loc=None, label=None, title=None, - fontsize=None, fontweight=None, fontcolor=None, - color=None, marker=None, lw=None, linewidth=None, - dashes=None, linestyle=None, markersize=None, frameon=None, frame=None, + # 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( + values=values, + cmap=cmap, + norm=norm, + norm_kw=norm_kw, + extend='neither', + ) + mappable = mcm.ScalarMappable(norm, cmap) + + return mappable, rotation + + +def colorbar_wrapper( + self, mappable, values=None, *, # analogous to handles and labels + extend=None, extendsize=None, + title=None, label=None, + grid=None, tickminor=None, + reverse=False, tickloc=None, ticklocation=None, tickdir=None, tickdirection=None, + locator=None, ticks=None, maxn=None, maxn_minor=None, + minorlocator=None, minorticks=None, + locator_kw=None, minorlocator_kw=None, + formatter=None, ticklabels=None, formatter_kw=None, rotation=None, + norm=None, norm_kw=None, # normalizer to use when passing colors/lines + orientation='horizontal', + edgecolor=None, linewidth=None, + labelsize=None, labelweight=None, labelcolor=None, + ticklabelsize=None, ticklabelweight=None, ticklabelcolor=None, **kwargs ): """ - Adds useful features for controlling legends, including "centered-row" - legends. + Adds useful features for controlling colorbars. Note ---- - This function wraps `proplot.axes.Axes.legend` - and `proplot.figure.Figure.legend`. + This function wraps `proplot.axes.Axes.colorbar` + and `proplot.figure.Figure.colorbar`. Parameters ---------- - handles : list of `~matplotlib.artist.Artist`, optional - List of artists instances, or list of lists of artist instances (see - the `center` keyword). If ``None``, the artists are retrieved with - `~matplotlib.axes.Axes.get_legend_handles_labels`. - labels : list of str, optional - Matching list of string labels, or list of lists of string labels (see - the `center` keywod). If ``None``, the labels are retrieved by calling - `~matplotlib.artist.Artist.get_label` on each - `~matplotlib.artist.Artist` in `handles`. - ncol, ncols : int, optional - The number of columns. `ncols` is an alias, added - for consistency with `~matplotlib.pyplot.subplots`. - order : {'C', 'F'}, optional - Whether legend handles are drawn in row-major (``'C'``) or column-major - (``'F'``) order. Analagous to `numpy.array` ordering. For some reason - ``'F'`` was the original matplotlib default. Default is ``'C'``. - center : bool, optional - Whether to center each legend row individually. If ``True``, we - actually draw successive single-row legends stacked on top of each - other. - - If ``None``, we infer this setting from `handles`. Default is ``True`` - if `handles` is a list of lists; each sublist is used as a *row* - in the legend. Otherwise, default is ``False``. - loc : int or str, optional - The legend location. The following location keys are valid: + mappable : mappable, list of plot handles, list of color-spec, \ +or colormap-spec + There are four options here: - ================== ================================================ - Location Valid keys - ================== ================================================ - "best" possible ``0``, ``'best'``, ``'b'``, ``'i'``, ``'inset'`` - upper right ``1``, ``'upper right'``, ``'ur'`` - upper left ``2``, ``'upper left'``, ``'ul'`` - lower left ``3``, ``'lower left'``, ``'ll'`` - lower right ``4``, ``'lower right'``, ``'lr'`` - center left ``5``, ``'center left'``, ``'cl'`` - center right ``6``, ``'center right'``, ``'cr'`` - lower center ``7``, ``'lower center'``, ``'lc'`` - upper center ``8``, ``'upper center'``, ``'uc'`` - center ``9``, ``'center'``, ``'c'`` - ================== ================================================ + 1. A mappable object. Basically, any object with a ``get_cmap`` method, + like the objects returned by `~matplotlib.axes.Axes.contourf` and + `~matplotlib.axes.Axes.pcolormesh`. + 2. A list of "plot handles". Basically, any object with a ``get_color`` + method, like `~matplotlib.lines.Line2D` instances. A colormap will + be generated from the colors of these objects, and colorbar levels + will be selected using `values`. If `values` is ``None``, we try + to infer them by converting the handle labels returned by + `~matplotlib.artist.Artist.get_label` to `float`. Otherwise, it is + set to ``np.linspace(0, 1, len(mappable))``. + 3. A list of hex strings, color string names, or RGB tuples. A colormap + will be generated from these colors, and colorbar levels will be + selected using `values`. If `values` is ``None``, it is set to + ``np.linspace(0, 1, len(mappable))``. + 4. A `~matplotlib.colors.Colormap` instance. In this case, a colorbar + will be drawn using this colormap and with levels determined by + `values`. If `values` is ``None``, it is set to + ``np.linspace(0, 1, cmap.N)``. + values : list of float, optional + Ignored if `mappable` is a mappable object. This maps each color or + plot handle in the `mappable` list to numeric values, from which a + colormap and normalizer are constructed. + norm : normalizer spec, optional + Ignored if `values` is ``None``. The normalizer for converting `values` + to colormap colors. Passed to `~proplot.constructor.Norm`. + norm_kw : dict-like, optional + The normalizer settings. Passed to `~proplot.constructor.Norm`. + extend : {None, 'neither', 'both', 'min', 'max'}, optional + Direction for drawing colorbar "extensions" (i.e. references to + out-of-bounds data with a unique color). These are triangles by + default. If ``None``, we try to use the ``extend`` attribute on the + mappable object. If the attribute is unavailable, we use ``'neither'``. + extendsize : float or str, optional + The length of the colorbar "extensions" in *physical units*. + If float, units are inches. If string, units are interpreted + by `~proplot.utils.units`. Default is :rc:`colorbar.insetextend` + for inset colorbars and :rc:`colorbar.extend` for outer colorbars. + reverse : bool, optional + Whether to reverse the direction of the colorbar. + tickloc, ticklocation : {'bottom', 'top', 'left', 'right'}, optional + Where to draw tick marks on the colorbar. + tickdir, tickdirection : {'out', 'in', 'inout'}, optional + Direction that major and minor tick marks point. + tickminor : bool, optional + Whether to add minor ticks to the colorbar with + `~matplotlib.colorbar.ColorbarBase.minorticks_on`. + grid : bool, optional + Whether to draw "gridlines" between each level of the colorbar. + Default is :rc:`colorbar.grid`. label, title : str, optional - The legend title. The `label` keyword is also accepted, for consistency - with `colorbar`. - fontsize, fontweight, fontcolor : optional - The font size, weight, and color for legend text. - color, lw, linewidth, marker, linestyle, dashes, markersize : \ -property-spec, optional - Properties used to override the legend handles. For example, if you - want a legend that describes variations in line style ignoring - variations in color, you might want to use ``color='k'``. For now this - does not include `facecolor`, `edgecolor`, and `alpha`, because - `~matplotlib.axes.Axes.legend` uses these keyword args to modify the - frame properties. + The colorbar label. The `title` keyword is also accepted for + consistency with `legend`. + locator, ticks : locator spec, optional + Used to determine the colorbar tick positions. Passed to the + `~proplot.constructor.Locator` constructor. + maxn : int, optional + Used if `locator` is ``None``. Determines the maximum number of levels + that are ticked. Default depends on the colorbar length relative + to the font size. The keyword name "maxn" is meant to mimic + the `~matplotlib.ticker.MaxNLocator` class name. + locator_kw : dict-like, optional + The locator settings. Passed to `~proplot.constructor.Locator`. + minorlocator, minorticks, maxn_minor, minorlocator_kw + As with `locator`, `maxn`, and `locator_kw`, but for the minor ticks. + formatter, ticklabels : formatter spec, optional + The tick label format. Passed to the `~proplot.constructor.Formatter` + constructor. + formatter_kw : dict-like, optional + The formatter settings. Passed to `~proplot.constructor.Formatter`. + rotation : float, optional + The tick label rotation. Default is ``0``. + edgecolor, linewidth : optional + The edge color and line width for the colorbar outline. + labelsize, labelweight, labelcolor : optional + The font size, weight, and color for colorbar label text. + ticklabelsize, ticklabelweight, ticklabelcolor : optional + The font size, weight, and color for colorbar tick labels. + orientation : {{'horizontal', 'vertical'}}, optional + The colorbar orientation. You should not have to explicitly set this. Other parameters ---------------- **kwargs - Passed to `~matplotlib.axes.Axes.legend`. + Passed to `~matplotlib.figure.Figure.colorbar`. """ + # NOTE: There is a weird problem with colorbars when simultaneously + # passing levels and norm object to a mappable; fixed by passing vmin/vmax + # instead of levels. (see: https://stackoverflow.com/q/40116968/4970632). + # NOTE: Often want levels instead of vmin/vmax, while simultaneously + # using a Normalize (for example) to determine colors between the levels + # (see: https://stackoverflow.com/q/42723538/4970632). Workaround makes + # sure locators are in vmin/vmax range exclusively; cannot match values. + # NOTE: In legend_wrapper() we try to add to the objects accepted by + # legend() using handler_map. We can't really do anything similar for + # colorbars; input must just be insnace of mixin class cm.ScalarMappable + # Mutable args + norm_kw = norm_kw or {} + formatter_kw = formatter_kw or {} + locator_kw = locator_kw or {} + minorlocator_kw = minorlocator_kw or {} + # Parse input args - # TODO: Legend entries for colormap or scatterplot objects! Idea is we - # pass a scatter plot or contourf or whatever, and legend is generated by - # drawing patch rectangles or markers using data values and their - # corresponding cmap colors! For scatterplots just test get_facecolor() - # to see if it contains more than one color. - # TODO: It is *also* often desirable to label a colormap object with - # one data value. Maybe add a legend option for the *number of samples* - # or the *sample points* when drawing legends for colormap objects. - # Look into "legend handlers", might just want to add own handlers by - # passing handler_map to legend() and get_legend_handles_labels(). - if order not in ('F', 'C'): - raise ValueError( - f'Invalid order {order!r}. Choose from ' - '"C" (row-major, default) and "F" (column-major).' - ) - ncol = _not_none(ncols=ncols, ncol=ncol) - title = _not_none(label=label, title=title) - frameon = _not_none( - frame=frame, frameon=frameon, default=rc['legend.frameon'] - ) - if handles is not None and not np.iterable(handles): # e.g. a mappable object - handles = [handles] - if labels is not None and (not np.iterable(labels) or isinstance(labels, str)): - labels = [labels] - if title is not None: - kwargs['title'] = title - if frameon is not None: - kwargs['frameon'] = frameon - if fontsize is not None: - kwargs['fontsize'] = rc._scale_font(fontsize) + label = _not_none(title=title, label=label) + locator = _not_none(ticks=ticks, locator=locator) + minorlocator = _not_none(minorticks=minorticks, minorlocator=minorlocator) + ticklocation = _not_none(tickloc=tickloc, ticklocation=ticklocation) + tickdirection = _not_none(tickdir=tickdir, tickdirection=tickdirection) + formatter = _not_none(ticklabels=ticklabels, formatter=formatter) - # Handle and text properties that are applied after-the-fact - # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds - # shading in legend entry. This change is not noticable in other situations. - kw_text = {} - for key, value in ( - ('color', fontcolor), - ('weight', fontweight), - ): - if value is not None: - kw_text[key] = value - kw_handle = {'solid_capstyle': 'butt'} - for key, value in ( - ('color', color), - ('marker', marker), - ('linewidth', lw), - ('linewidth', linewidth), - ('markersize', markersize), - ('linestyle', linestyle), - ('dashes', dashes), - ): - if value is not None: - kw_handle[key] = value - - # Legend box properties - outline = rc.fill( - { - 'linewidth': 'axes.linewidth', - 'edgecolor': 'axes.edgecolor', - 'facecolor': 'axes.facecolor', - 'alpha': 'legend.framealpha', - } - ) - for key in (*outline,): - if key != 'linewidth': - if kwargs.get(key, None): - outline.pop(key, None) + # Colorbar kwargs + grid = _not_none(grid, rc['colorbar.grid']) + kwargs.update({ + 'cax': self, + 'use_gridspec': True, + 'orientation': orientation, + 'spacing': 'uniform', + }) + kwargs.setdefault('drawedges', grid) - # Get axes for legend handle detection - # TODO: Update this when no longer use "filled panels" for outer legends - axs = [self] - if self._panel_hidden: - if self._panel_parent: # axes panel - axs = list(self._panel_parent._iter_axes(hidden=False, children=True)) - else: - axs = list(self.figure._iter_axes(hidden=False, children=True)) + # Special case where auto colorbar is generated from 1d methods, a list is + # always passed, but some 1d methods (scatter) do have colormaps. + if ( + np.iterable(mappable) + and len(mappable) == 1 + and hasattr(mappable[0], 'get_cmap') + ): + mappable = mappable[0] - # Handle list of lists (centered row legends) - # NOTE: Avoid very common plot() error where users draw individual lines - # with plot() and add singleton tuples to a list of handles. If matplotlib - # gets a list like this but gets no 'labels' argument, it raises error. - list_of_lists = False - if handles is not None: - handles = [ - handle[0] if type(handle) is tuple and len(handle) == 1 else handle - for handle in handles - ] - list_of_lists = any(type(handle) in (list, np.ndarray) for handle in handles) - if handles is not None and labels is not None and len(handles) != len(labels): - raise ValueError( - f'Got {len(handles)} handles and {len(labels)} labels.' - ) - if list_of_lists: - if any(not np.iterable(_) for _ in handles): - raise ValueError(f'Invalid handles={handles!r}.') - if not labels: - labels = [None] * len(handles) - elif not all(np.iterable(_) and not isinstance(_, str) for _ in labels): - # e.g. handles=[obj1, [obj2, obj3]] requires labels=[lab1, [lab2, lab3]] - raise ValueError( - f'Invalid labels={labels!r} for handles={handles!r}.' - ) + # For container objects, we just assume color is the same for every item. + # Works for ErrorbarContainer, StemContainer, BarContainer. + if ( + np.iterable(mappable) + and len(mappable) > 0 + and all(isinstance(obj, mcontainer.Container) for obj in mappable) + ): + mappable = [obj[0] for obj in mappable] - # Parse handles and legends with native matplotlib parser - if not list_of_lists: - if isinstance(handles, np.ndarray): - handles = handles.tolist() - if isinstance(labels, np.ndarray): - labels = labels.tolist() - handles, labels, *_ = mlegend._parse_legend_args( - axs, handles=handles, labels=labels, + # Test if we were given a mappable, or iterable of stuff; note Container + # and PolyCollection matplotlib classes are iterable. + if not isinstance(mappable, (martist.Artist, mcontour.ContourSet)): + mappable, rotation = _generate_mappable( + mappable, values, locator=locator, formatter=formatter, + norm=norm, norm_kw=norm_kw, orientation=orientation, rotation=rotation ) - pairs = list(zip(handles, labels)) - else: - pairs = [] - for ihandles, ilabels in zip(handles, labels): - if isinstance(ihandles, np.ndarray): - ihandles = ihandles.tolist() - if isinstance(ilabels, np.ndarray): - ilabels = ilabels.tolist() - ihandles, ilabels, *_ = mlegend._parse_legend_args( - axs, handles=ihandles, labels=ilabels, - ) - pairs.append(list(zip(ihandles, ilabels))) - # Manage pairs in context of 'center' option - center = _not_none(center, list_of_lists) - if not center and list_of_lists: # standardize format based on input - list_of_lists = False # no longer is list of lists - pairs = [pair for ipairs in pairs for pair in ipairs] - elif center and not list_of_lists: - list_of_lists = True - ncol = _not_none(ncol, 3) - pairs = [pairs[i * ncol:(i + 1) * ncol] for i in range(len(pairs))] - ncol = None - if list_of_lists: # remove empty lists, pops up in some examples - pairs = [ipairs for ipairs in pairs if ipairs] + # Define text property keyword args + kw_label = {} + for key, value in ( + ('size', labelsize), + ('weight', labelweight), + ('color', labelcolor), + ): + if value is not None: + kw_label[key] = value + kw_ticklabels = {} + for key, value in ( + ('size', ticklabelsize), + ('weight', ticklabelweight), + ('color', ticklabelcolor), + ('rotation', rotation), + ): + if value is not None: + kw_ticklabels[key] = value - # Bail if no pairs - if not pairs: - return mlegend.Legend(self, [], [], ncol=ncol, loc=loc, **kwargs) + # Try to get tick locations from *levels* or from *values* rather than + # random points along the axis. + # NOTE: Do not necessarily want e.g. minor tick locations at logminor + # for LogNorm! In _build_discrete_norm we sometimes select evenly spaced + # levels in log-space *between* powers of 10, so logminor ticks would be + # misaligned with levels. + if locator is None: + locator = getattr(mappable, '_colorbar_ticks', None) + if locator is None: + # This should only happen if user calls plotting method on native + # matplotlib axes. + if isinstance(norm, mcolors.LogNorm): + locator = 'log' + elif isinstance(norm, mcolors.SymLogNorm): + locator = 'symlog' + locator_kw.setdefault('linthresh', norm.linthresh) + else: + locator = 'auto' - # Individual legend - legs = [] - width, height = self.get_size_inches() - if not center: - # Optionally change order - # See: https://stackoverflow.com/q/10101141/4970632 - # Example: If 5 columns, but final row length 3, columns 0-2 have - # N rows but 3-4 have N-1 rows. - ncol = _not_none(ncol, 3) - if order == 'C': - split = [ # split into rows - pairs[i * ncol:(i + 1) * ncol] - for i in range(len(pairs) // ncol + 1) - ] - nrowsmax = len(split) # max possible row count - nfinalrow = len(split[-1]) # columns in final row - nrows = ( - [nrowsmax] * nfinalrow + [nrowsmax - 1] * (ncol - nfinalrow) - ) - fpairs = [] - for col, nrow in enumerate(nrows): # iterate through cols - fpairs.extend(split[row][col] for row in range(nrow)) - pairs = fpairs + elif not isinstance(locator, mticker.Locator): + # Get default maxn, try to allot 2em squares per label maybe? + # NOTE: Cannot use Axes.get_size_inches because this is a + # native matplotlib axes + width, height = self.figure.get_size_inches() + if orientation == 'horizontal': + scale = 3 # em squares alotted for labels + length = width * abs(self.get_position().width) + fontsize = kw_ticklabels.get('size', rc['xtick.labelsize']) + else: + scale = 1 + length = height * abs(self.get_position().height) + fontsize = kw_ticklabels.get('size', rc['ytick.labelsize']) + fontsize = rc._scale_font(fontsize) + maxn = _not_none(maxn, int(length / (scale * fontsize / 72))) + maxn_minor = _not_none(maxn_minor, int(length / (0.5 * fontsize / 72))) - # Draw legend - leg = mlegend.Legend(self, *zip(*pairs), ncol=ncol, loc=loc, **kwargs) - legs = [leg] + # Get locator + if tickminor and minorlocator is None: + step = 1 + len(locator) // max(1, maxn_minor) + minorlocator = locator[::step] + step = 1 + len(locator) // max(1, maxn) + locator = locator[::step] - # Legend with centered rows, accomplished by drawing separate legends for - # each row. The label spacing/border spacing will be exactly replicated. + # Get extend triangles in physical units + width, height = self.figure.get_size_inches() + if orientation == 'horizontal': + scale = width * abs(self.get_position().width) else: - # Message when overriding some properties - overridden = [] - kwargs.pop('frameon', None) # then add back later! - for override in ('bbox_transform', 'bbox_to_anchor'): - prop = kwargs.pop(override, None) - if prop is not None: - overridden.append(override) - if ncol is not None: - warnings._warn_proplot( - 'Detected list of *lists* of legend handles. ' - 'Ignoring user input property "ncol".' - ) - if overridden: - warnings._warn_proplot( - 'Ignoring user input properties ' - + ', '.join(map(repr, overridden)) - + ' for centered-row legend.' - ) - - # Determine space we want sub-legend to occupy as fraction of height - # NOTE: Empirical testing shows spacing fudge factor necessary to - # exactly replicate the spacing of standard aligned legends. - fontsize = kwargs.get('fontsize', None) or rc['legend.fontsize'] - fontsize = rc._scale_font(fontsize) - spacing = kwargs.get('labelspacing', None) or rc['legend.labelspacing'] - if pairs: - interval = 1 / len(pairs) # split up axes - interval = (((1 + spacing * 0.85) * fontsize) / 72) / height - - # Iterate and draw - # NOTE: We confine possible bounding box in *y*-direction, but do not - # confine it in *x*-direction. Matplotlib will automatically move - # left-to-right if you request this. - ymin, ymax = None, None - if order == 'F': - raise NotImplementedError( - 'When center=True, ProPlot vertically stacks successive ' - 'single-row legends. Column-major (order="F") ordering ' - 'is un-supported.' - ) - loc = _not_none(loc, 'upper center') - if not isinstance(loc, str): - raise ValueError( - f'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.' - ) + scale = height * abs(self.get_position().height) + extendsize = units(_not_none(extendsize, rc['colorbar.extend'])) + extendsize = extendsize / (scale - 2 * extendsize) - # Iterate through sublists - for i, ipairs in enumerate(pairs): - if i == 1: - kwargs.pop('title', None) - if i >= 1 and title is not None: - i += 1 # extra space! - - # Legend position - if 'upper' in loc: - y1 = 1 - (i + 1) * interval - y2 = 1 - i * interval - elif 'lower' in loc: - y1 = (len(pairs) + i - 2) * interval - y2 = (len(pairs) + i - 1) * interval - else: # center - y1 = 0.5 + interval * len(pairs) / 2 - (i + 1) * interval - y2 = 0.5 + interval * len(pairs) / 2 - i * interval - ymin = min(y1, _not_none(ymin, y1)) - ymax = max(y2, _not_none(ymax, y2)) - - # Draw legend - bbox = mtransforms.Bbox([[0, y1], [1, y2]]) - leg = mlegend.Legend( - self, *zip(*ipairs), loc=loc, ncol=len(ipairs), - bbox_transform=self.transAxes, bbox_to_anchor=bbox, - frameon=False, **kwargs - ) - legs.append(leg) + # Draw the colorbar + # NOTE: Set default formatter here because we optionally apply a FixedFormatter + # using *labels* from handle input. + extend = _not_none(extend, getattr(mappable, '_colorbar_extend', 'neither')) + locator = constructor.Locator(locator, **locator_kw) + formatter = constructor.Formatter(_not_none(formatter, 'auto'), **formatter_kw) + kwargs.update({ + 'ticks': locator, + 'format': formatter, + 'ticklocation': ticklocation, + 'extendfrac': extendsize, + 'extend': extend, + }) + if isinstance(mappable, mcontour.ContourSet): + mappable.extend = extend # required in mpl >= 3.3, else optional + else: + kwargs['extend'] = extend + cb = self.figure.colorbar(mappable, **kwargs) + axis = self.xaxis if orientation == 'horizontal' else self.yaxis - # Add legends manually so matplotlib does not remove old ones - for leg in legs: - if hasattr(self, 'legend_') and self.legend_ is None: - self.legend_ = leg # set *first* legend accessible with get_legend() + # The minor locator + # TODO: Document the improved minor locator functionality! + # NOTE: Colorbar._use_auto_colorbar_locator() is never True because we use + # the custom DiscreteNorm normalizer. Colorbar._ticks() always called. + if minorlocator is None: + if tickminor: + cb.minorticks_on() else: - self.add_artist(leg) - leg.legendPatch.update(outline) # or get_frame() + cb.minorticks_off() + elif not hasattr(cb, '_ticker'): + warnings._warn_proplot( + 'Matplotlib colorbar API has changed. ' + f'Cannot use custom minor tick locator {minorlocator!r}.' + ) + cb.minorticks_on() # at least turn them on + else: + # Set the minor ticks just like matplotlib internally sets the + # major ticks. Private API is the only way! + minorlocator = constructor.Locator(minorlocator, **minorlocator_kw) + ticks, *_ = cb._ticker(minorlocator, mticker.NullFormatter()) + axis.set_ticks(ticks, minor=True) + axis.set_ticklabels([], minor=True) - # Apply *overrides* to legend elements - # WARNING: legendHandles only contains the *first* artist per legend because - # HandlerBase.legend_artist() called in Legend._init_legend_box() only - # returns the first artist. Instead we try to iterate through offset boxes. - # TODO: Remove this feature? Idea was this lets users create *categorical* - # legends in clunky way, e.g. entries denoting *colors* and entries denoting - # *markers*. But would be better to add capacity for categorical labels in a - # *single* legend like seaborn rather than multiple legends. - for leg in legs: - try: - children = leg._legend_handle_box._children - except AttributeError: # older versions maybe? - children = [] - for obj in _iter_legend_children(children): - # account for mixed legends, e.g. line on top of - # error bounds shading. - if isinstance(obj, mtext.Text): - leg.update(kw_text) - else: - for key, value in kw_handle.items(): - getattr(obj, f'set_{key}', lambda value: None)(value) + # Label and tick label settings + # WARNING: Must use colorbar set_label to set text, calling set_text on + # the axis will do nothing! + if label is not None: + cb.set_label(label) + axis.label.update(kw_label) + for obj in axis.get_ticklabels(): + obj.update(kw_ticklabels) - # Draw manual fancy bounding box for un-aligned legend - # WARNING: The matplotlib legendPatch transform is the default transform, - # i.e. universal coordinates in points. Means we have to transform - # mutation scale into transAxes sizes. - # WARNING: Tempting to use legendPatch for everything but for some reason - # coordinates are messed up. In some tests all coordinates were just result - # of get window extent multiplied by 2 (???). Anyway actual box is found in - # _legend_box attribute, which is accessed by get_window_extent. - if center and frameon: - if len(legs) == 1: - # Use builtin frame - legs[0].set_frame_on(True) - else: - # Get coordinates - renderer = self.figure._get_renderer() - bboxs = [ - leg.get_window_extent(renderer).transformed(self.transAxes.inverted()) - for leg in legs - ] - xmin = min(bbox.xmin for bbox in bboxs) - xmax = max(bbox.xmax for bbox in bboxs) - ymin = min(bbox.ymin for bbox in bboxs) - ymax = max(bbox.ymax for bbox in bboxs) - fontsize = (fontsize / 72) / width # axes relative units - fontsize = renderer.points_to_pixels(fontsize) - - # Draw and format patch - patch = mpatches.FancyBboxPatch( - (xmin, ymin), xmax - xmin, ymax - ymin, - snap=True, zorder=4.5, - mutation_scale=fontsize, - transform=self.transAxes - ) - if kwargs.get('fancybox', rc['legend.fancybox']): - patch.set_boxstyle('round', pad=0, rounding_size=0.2) - else: - patch.set_boxstyle('square', pad=0) - patch.set_clip_on(False) - patch.update(outline) - self.add_artist(patch) + # Ticks consistent with rc settings and overrides + s = axis.axis_name + for which in ('minor', 'major'): + kw = rc.category(s + 'tick.' + which) + kw.pop('visible', None) + if tickdirection: + kw['direction'] = tickdirection + if edgecolor: + kw['color'] = edgecolor + if linewidth: + kw['width'] = linewidth + axis.set_tick_params(which=which, **kw) + axis.set_ticks_position(ticklocation) - # Add shadow - # TODO: This does not work, figure out - if kwargs.get('shadow', rc['legend.shadow']): - shadow = mpatches.Shadow(patch, 20, -20) - self.add_artist(shadow) + # Fix alpha-blending issues. Cannot set edgecolor to 'face' because blending will + # occur, end up with colored lines instead of white ones. Need manual blending. + # NOTE: For some reason cb solids uses listed colormap with always 1.0 alpha, + # then alpha is applied after. See: https://stackoverflow.com/a/35672224/4970632 + cmap = cb.cmap + blend = 'pcolormesh.snap' not in rc or not rc['pcolormesh.snap'] + if not cmap._isinit: + cmap._init() + if blend and any(cmap._lut[:-1, 3] < 1): + warnings._warn_proplot( + f'Using manual alpha-blending for {cmap.name!r} colorbar solids.' + ) + # Generate "secret" copy of the colormap! + lut = cmap._lut.copy() + cmap = mcolors.Colormap('_cbar_fix', N=cmap.N) + cmap._isinit = True + cmap._init = lambda: None + # Manually fill lookup table with alpha-blended RGB colors! + for i in range(lut.shape[0] - 1): + alpha = lut[i, 3] + lut[i, :3] = (1 - alpha) * 1 + alpha * lut[i, :3] # blend *white* + lut[i, 3] = 1 + cmap._lut = lut + # Update colorbar + cb.cmap = cmap + cb.draw_all() - # Add patch to list - legs = (patch, *legs) + # Fix colorbar outline + kw_outline = { + 'edgecolor': _not_none(edgecolor, rc['axes.edgecolor']), + 'linewidth': _not_none(linewidth, rc['axes.linewidth']), + } + if cb.outline is not None: + cb.outline.update(kw_outline) + if cb.dividers is not None: + cb.dividers.update(kw_outline) - # Append attributes and return, and set clip property!!! This is critical - # for tight bounding box calcs! - for leg in legs: - leg.set_clip_on(False) - return legs[0] if len(legs) == 1 else tuple(legs) + # *Never* rasterize because it causes misalignment with border lines + if cb.solids: + cb.solids.set_rasterized(False) + cb.solids.set_linewidth(0.4) + cb.solids.set_edgecolor('face') + # Invert the axis if descending DiscreteNorm + norm = mappable.norm + if getattr(norm, '_descending', None): + axis.set_inverted(True) + if reverse: # potentially double reverse, although that would be weird... + axis.set_inverted(True) + return cb -def colorbar_wrapper( - self, mappable, values=None, - extend=None, extendsize=None, - title=None, label=None, - grid=None, tickminor=None, - reverse=False, tickloc=None, ticklocation=None, tickdir=None, tickdirection=None, - locator=None, ticks=None, maxn=None, maxn_minor=None, - minorlocator=None, minorticks=None, - locator_kw=None, minorlocator_kw=None, - formatter=None, ticklabels=None, formatter_kw=None, rotation=None, - norm=None, norm_kw=None, # normalizer to use when passing colors/lines - orientation='horizontal', - edgecolor=None, linewidth=None, - labelsize=None, labelweight=None, labelcolor=None, - ticklabelsize=None, ticklabelweight=None, ticklabelcolor=None, - **kwargs -): + +def _iter_legend_children(children): """ - Adds useful features for controlling colorbars. + 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 - Note - ---- - This function wraps `proplot.axes.Axes.colorbar` - and `proplot.figure.Figure.colorbar`. - Parameters - ---------- - mappable : mappable, list of plot handles, list of color-spec, \ -or colormap-spec - There are four options here: +def _individual_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)) - 1. A mappable object. Basically, any object with a ``get_cmap`` method, - like the objects returned by `~matplotlib.axes.Axes.contourf` and - `~matplotlib.axes.Axes.pcolormesh`. - 2. A list of "plot handles". Basically, any object with a ``get_color`` - method, like `~matplotlib.lines.Line2D` instances. A colormap will - be generated from the colors of these objects, and colorbar levels - will be selected using `values`. If `values` is ``None``, we try - to infer them by converting the handle labels returned by - `~matplotlib.artist.Artist.get_label` to `float`. Otherwise, it is - set to ``np.linspace(0, 1, len(mappable))``. - 3. A list of hex strings, color string names, or RGB tuples. A colormap - will be generated from these colors, and colorbar levels will be - selected using `values`. If `values` is ``None``, it is set to - ``np.linspace(0, 1, len(mappable))``. - 4. A `~matplotlib.colors.Colormap` instance. In this case, a colorbar - will be drawn using this colormap and with levels determined by - `values`. If `values` is ``None``, it is set to - ``np.linspace(0, 1, cmap.N)``. + # Draw legend + return mlegend.Legend(self, *zip(*pairs), ncol=ncol, **kwargs) - values : list of float, optional - Ignored if `mappable` is a mappable object. This maps each color or - plot handle in the `mappable` list to numeric values, from which a - colormap and normalizer are constructed. - norm : normalizer spec, optional - Ignored if `values` is ``None``. The normalizer for converting `values` - to colormap colors. Passed to `~proplot.constructor.Norm`. - norm_kw : dict-like, optional - The normalizer settings. Passed to `~proplot.constructor.Norm`. - extend : {None, 'neither', 'both', 'min', 'max'}, optional - Direction for drawing colorbar "extensions" (i.e. references to - out-of-bounds data with a unique color). These are triangles by - default. If ``None``, we try to use the ``extend`` attribute on the - mappable object. If the attribute is unavailable, we use ``'neither'``. - extendsize : float or str, optional - The length of the colorbar "extensions" in *physical units*. - If float, units are inches. If string, units are interpreted - by `~proplot.utils.units`. Default is :rc:`colorbar.insetextend` - for inset colorbars and :rc:`colorbar.extend` for outer colorbars. - reverse : bool, optional - Whether to reverse the direction of the colorbar. - tickloc, ticklocation : {'bottom', 'top', 'left', 'right'}, optional - Where to draw tick marks on the colorbar. - tickdir, tickdirection : {'out', 'in', 'inout'}, optional - Direction that major and minor tick marks point. - tickminor : bool, optional - Whether to add minor ticks to the colorbar with - `~matplotlib.colorbar.ColorbarBase.minorticks_on`. - grid : bool, optional - Whether to draw "gridlines" between each level of the colorbar. - Default is :rc:`colorbar.grid`. - label, title : str, optional - The colorbar label. The `title` keyword is also accepted for - consistency with `legend`. - locator, ticks : locator spec, optional - Used to determine the colorbar tick positions. Passed to the - `~proplot.constructor.Locator` constructor. - maxn : int, optional - Used if `locator` is ``None``. Determines the maximum number of levels - that are ticked. Default depends on the colorbar length relative - to the font size. The keyword name "maxn" is meant to mimic - the `~matplotlib.ticker.MaxNLocator` class name. - locator_kw : dict-like, optional - The locator settings. Passed to `~proplot.constructor.Locator`. - minorlocator, minorticks, maxn_minor, minorlocator_kw - As with `locator`, `maxn`, and `locator_kw`, but for the minor ticks. - formatter, ticklabels : formatter spec, optional - The tick label format. Passed to the `~proplot.constructor.Formatter` - constructor. - formatter_kw : dict-like, optional - The formatter settings. Passed to `~proplot.constructor.Formatter`. - rotation : float, optional - The tick label rotation. Default is ``0``. - edgecolor, linewidth : optional - The edge color and line width for the colorbar outline. - labelsize, labelweight, labelcolor : optional - The font size, weight, and color for colorbar label text. - ticklabelsize, ticklabelweight, ticklabelcolor : optional - The font size, weight, and color for colorbar tick labels. - orientation : {{'horizontal', 'vertical'}}, optional - The colorbar orientation. You should not have to explicitly set this. - Other parameters - ---------------- - **kwargs - Passed to `~matplotlib.figure.Figure.colorbar`. +def _multiple_legend(self, pairs, loc=None, ncol=None, order=None, **kwargs): """ - # NOTE: There is a weird problem with colorbars when simultaneously - # passing levels and norm object to a mappable; fixed by passing vmin/vmax - # instead of levels. (see: https://stackoverflow.com/q/40116968/4970632). - # NOTE: Often want levels instead of vmin/vmax, while simultaneously - # using a Normalize (for example) to determine colors between the levels - # (see: https://stackoverflow.com/q/42723538/4970632). Workaround makes - # sure locators are in vmin/vmax range exclusively; cannot match values. - # NOTE: In legend_wrapper() we try to add to the objects accepted by - # legend() using handler_map. We can't really do anything similar for - # colorbars; input must just be insnace of mixin class cm.ScalarMappable - # Mutable args - norm_kw = norm_kw or {} - formatter_kw = formatter_kw or {} - locator_kw = locator_kw or {} - minorlocator_kw = minorlocator_kw or {} + 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.' + ) - # Parse input args - label = _not_none(title=title, label=label) - locator = _not_none(ticks=ticks, locator=locator) - minorlocator = _not_none(minorticks=minorticks, minorlocator=minorlocator) - ticklocation = _not_none(tickloc=tickloc, ticklocation=ticklocation) - tickdirection = _not_none(tickdir=tickdir, tickdirection=tickdirection) - formatter = _not_none(ticklabels=ticklabels, formatter=formatter) + # 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() + fontsize = kwargs.get('fontsize', None) or rc['legend.fontsize'] + fontsize = rc._scale_font(fontsize) + spacing = kwargs.get('labelspacing', None) or rc['legend.labelspacing'] + if pairs: + interval = 1 / len(pairs) # split up axes + interval = (((1 + spacing * 0.85) * fontsize) / 72) / height + + # Iterate and draw + # NOTE: We confine possible bounding box in *y*-direction, but do not + # confine it in *x*-direction. Matplotlib will automatically move + # left-to-right if you request this. + ymin, ymax = None, None + if order == 'F': + raise NotImplementedError( + 'When center=True, ProPlot vertically stacks successive ' + 'single-row legends. Column-major (order="F") ordering ' + 'is un-supported.' + ) + loc = _not_none(loc, 'upper center') + if not isinstance(loc, str): + raise ValueError( + f'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.' + ) - # Colorbar kwargs - grid = _not_none(grid, rc['colorbar.grid']) - kwargs.update({ - 'cax': self, - 'use_gridspec': True, - 'orientation': orientation, - 'spacing': 'uniform', - }) - kwargs.setdefault('drawedges', grid) + # 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)) - # Text property keyword args - kw_label = {} - for key, value in ( - ('size', labelsize), - ('weight', labelweight), - ('color', labelcolor), - ): - if value is not None: - kw_label[key] = value - kw_ticklabels = {} - for key, value in ( - ('size', ticklabelsize), - ('weight', ticklabelweight), - ('color', ticklabelcolor), - ('rotation', rotation), - ): - if value is not None: - kw_ticklabels[key] = value + # 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) - # Special case where auto colorbar is generated from 1d methods, a list is - # always passed, but some 1d methods (scatter) do have colormaps. - if ( - np.iterable(mappable) - and len(mappable) == 1 - and hasattr(mappable[0], 'get_cmap') - ): - mappable = mappable[0] + # Simple cases + if not frameon: + return legs + if len(legs) == 1: + legs[0].set_frame_on(True) + return legs - # For container objects, we just assume color is the same for every item. - # Works for ErrorbarContainer, StemContainer, BarContainer. - if ( - np.iterable(mappable) - and len(mappable) > 0 - and all(isinstance(obj, mcontainer.Container) for obj in mappable) - ): - mappable = [obj[0] for obj in mappable] + # 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) - # Test if we were given a mappable, or iterable of stuff; note Container - # and PolyCollection matplotlib classes are iterable. - cmap = None - if not isinstance(mappable, (martist.Artist, mcontour.ContourSet)): - # A colormap instance - # TODO: Pass remaining arguments through Colormap()? This is really - # niche usage so maybe not necessary. - if isinstance(mappable, mcolors.Colormap): - # NOTE: 'Values' makes no sense if this is just a colormap. Just - # use unique color for every segmentdata / colors color. - cmap = mappable - values = np.linspace(0, 1, cmap.N) - - # List of colors - elif np.iterable(mappable) and all( - isinstance(obj, str) or (np.iterable(obj) and len(obj) in (3, 4)) - for obj in mappable - ): - colors = list(mappable) - cmap = mcolors.ListedColormap(colors, '_no_name') - if values is None: - values = np.arange(len(colors)) - locator = _not_none(locator, values) # tick *all* values by default - - # List of artists - # NOTE: Do not check for isinstance(Artist) in case it is an mpl collection - elif np.iterable(mappable) and all( - hasattr(obj, 'get_color') or hasattr(obj, 'get_facecolor') - for obj in mappable - ): - # Generate colormap from colors and infer tick labels - colors = [] - for obj in mappable: - if hasattr(obj, 'get_color'): - color = obj.get_color() - else: - color = obj.get_facecolor() - if isinstance(color, np.ndarray): - color = color.squeeze() # e.g. scatter plot - if color.ndim != 1: - raise ValueError( - 'Cannot make colorbar from list of artists ' - f'with more than one color: {color!r}.' - ) - colors.append(to_rgb(color)) - cmap = mcolors.ListedColormap(colors, '_no_name') - - # Try to infer tick values and tick labels from Artist labels - if values is None: - # Get object labels and values (avoid overwriting colorbar 'label') - labs = [] - values = [] - for obj in mappable: - lab = value = None - if hasattr(obj, 'get_label'): - lab = obj.get_label() or None - if lab and lab[:1] == '_': # intended to be ignored by legend - lab = None - if lab: - try: - value = float(lab) - except (TypeError, ValueError): - pass - labs.append(lab) - values.append(value) - # Use default values if labels are non-numeric (numeric labels are - # common when making on-the-fly colorbars). Try to use object labels - # for ticks with default vertical rotation, like datetime axes. - if any(value is None for value in values): - values = np.arange(len(mappable)) - if formatter is None and any(lab is not None for lab in labs): - formatter = labs # use these fixed values for ticks - if orientation == 'horizontal': - kw_ticklabels.setdefault('rotation', 90) - locator = _not_none(locator, values) # tick *all* values by default + # 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) - else: - raise ValueError( - 'Input mappable must be a matplotlib artist, ' - 'list of objects, list of colors, or colormap. ' - f'Got {mappable!r}.' - ) + # Add patch to list + return patch, *legs - # Build ad hoc ScalarMappable object from colors - if cmap is not None: - if np.iterable(mappable) and len(values) != len(mappable): - raise ValueError( - f'Passed {len(values)} values, but only {len(mappable)} ' - f'objects or colors.' - ) - norm, *_ = _build_discrete_norm( - values=values, - cmap=cmap, - norm=norm, - norm_kw=norm_kw, - extend='neither', - ) - mappable = mcm.ScalarMappable(norm, cmap) - # Try to get tick locations from *levels* or from *values* rather than - # random points along the axis. - # NOTE: Do not necessarily want e.g. minor tick locations at logminor - # for LogNorm! In _build_discrete_norm we sometimes select evenly spaced - # levels in log-space *between* powers of 10, so logminor ticks would be - # misaligned with levels. - if locator is None: - locator = getattr(mappable, '_colorbar_ticks', None) - if locator is None: - # This should only happen if user calls plotting method on native - # matplotlib axes. - if isinstance(norm, mcolors.LogNorm): - locator = 'log' - elif isinstance(norm, mcolors.SymLogNorm): - locator = 'symlog' - locator_kw.setdefault('linthresh', norm.linthresh) - else: - locator = 'auto' +def legend_wrapper( + self, handles=None, labels=None, *, loc=None, ncol=None, ncols=None, + center=None, order='C', label=None, title=None, + fontsize=None, fontweight=None, fontcolor=None, + color=None, marker=None, lw=None, linewidth=None, + dashes=None, linestyle=None, markersize=None, frameon=None, frame=None, + **kwargs +): + """ + Adds useful features for controlling legends, including "centered-row" + legends. - elif not isinstance(locator, mticker.Locator): - # Get default maxn, try to allot 2em squares per label maybe? - # NOTE: Cannot use Axes.get_size_inches because this is a - # native matplotlib axes - width, height = self.figure.get_size_inches() - if orientation == 'horizontal': - scale = 3 # em squares alotted for labels - length = width * abs(self.get_position().width) - fontsize = kw_ticklabels.get('size', rc['xtick.labelsize']) - else: - scale = 1 - length = height * abs(self.get_position().height) - fontsize = kw_ticklabels.get('size', rc['ytick.labelsize']) - fontsize = rc._scale_font(fontsize) - maxn = _not_none(maxn, int(length / (scale * fontsize / 72))) - maxn_minor = _not_none(maxn_minor, int(length / (0.5 * fontsize / 72))) + Note + ---- + This function wraps `proplot.axes.Axes.legend` + and `proplot.figure.Figure.legend`. - # Get locator - if tickminor and minorlocator is None: - step = 1 + len(locator) // max(1, maxn_minor) - minorlocator = locator[::step] - step = 1 + len(locator) // max(1, maxn) - locator = locator[::step] + Parameters + ---------- + handles : list of `~matplotlib.artist.Artist`, optional + List of artists instances, or list of lists of artist instances (see + the `center` keyword). If ``None``, the artists are retrieved with + `~matplotlib.axes.Axes.get_legend_handles_labels`. + labels : list of str, optional + Matching list of string labels, or list of lists of string labels (see + the `center` keywod). If ``None``, the labels are retrieved by calling + `~matplotlib.artist.Artist.get_label` on each + `~matplotlib.artist.Artist` in `handles`. + loc : int or str, optional + The legend location. The following location keys are valid: - # Get extend triangles in physical units - width, height = self.figure.get_size_inches() - if orientation == 'horizontal': - scale = width * abs(self.get_position().width) - else: - scale = height * abs(self.get_position().height) - extendsize = units(_not_none(extendsize, rc['colorbar.extend'])) - extendsize = extendsize / (scale - 2 * extendsize) + ================== ================================================ + Location Valid keys + ================== ================================================ + "best" possible ``0``, ``'best'``, ``'b'``, ``'i'``, ``'inset'`` + upper right ``1``, ``'upper right'``, ``'ur'`` + upper left ``2``, ``'upper left'``, ``'ul'`` + lower left ``3``, ``'lower left'``, ``'ll'`` + lower right ``4``, ``'lower right'``, ``'lr'`` + center left ``5``, ``'center left'``, ``'cl'`` + center right ``6``, ``'center right'``, ``'cr'`` + lower center ``7``, ``'lower center'``, ``'lc'`` + upper center ``8``, ``'upper center'``, ``'uc'`` + center ``9``, ``'center'``, ``'c'`` + ================== ================================================ - # Draw the colorbar - # NOTE: Set default formatter here because we optionally apply a FixedFormatter - # using *labels* from handle input. - extend = _not_none(extend, getattr(mappable, '_colorbar_extend', 'neither')) - locator = constructor.Locator(locator, **locator_kw) - formatter = constructor.Formatter(_not_none(formatter, 'auto'), **formatter_kw) - kwargs.update({ - 'ticks': locator, - 'format': formatter, - 'ticklocation': ticklocation, - 'extendfrac': extendsize, - 'extend': extend, - }) - if isinstance(mappable, mcontour.ContourSet): - mappable.extend = extend # required in mpl >= 3.3, else optional - else: - kwargs['extend'] = extend - cb = self.figure.colorbar(mappable, **kwargs) - axis = self.xaxis if orientation == 'horizontal' else self.yaxis + ncol, ncols : int, optional + The number of columns. `ncols` is an alias, added + for consistency with `~matplotlib.pyplot.subplots`. + order : {'C', 'F'}, optional + Whether legend handles are drawn in row-major (``'C'``) or column-major + (``'F'``) order. Analagous to `numpy.array` ordering. For some reason + ``'F'`` was the original matplotlib default. Default is ``'C'``. + center : bool, optional + Whether to center each legend row individually. If ``True``, we + actually draw successive single-row legends stacked on top of each + other. If ``None``, we infer this setting from `handles`. Default is + ``True`` if `handles` is a list of lists (each sublist is used as a *row* + in the legend). Otherwise, default is ``False``. + title, label : str, optional + The legend title. The `label` keyword is also accepted, for consistency + with `colorbar`. + fontsize, fontweight, fontcolor : optional + The font size, weight, and color for legend text. + color, lw, linewidth, marker, linestyle, dashes, markersize : \ +property-spec, optional + Properties used to override the legend handles. For example, for a + legend describing variations in line style ignoring variations in color, you + might want to use ``color='k'``. For now this does not include `facecolor`, + `edgecolor`, and `alpha`, because `~matplotlib.axes.Axes.legend` uses these + keyword args to modify the frame properties. - # The minor locator - # TODO: Document the improved minor locator functionality! - # NOTE: Colorbar._use_auto_colorbar_locator() is never True because we use - # the custom DiscreteNorm normalizer. Colorbar._ticks() always called. - if minorlocator is None: - if tickminor: - cb.minorticks_on() - else: - cb.minorticks_off() - elif not hasattr(cb, '_ticker'): - warnings._warn_proplot( - 'Matplotlib colorbar API has changed. ' - f'Cannot use custom minor tick locator {minorlocator!r}.' + Other parameters + ---------------- + **kwargs + Passed to `~matplotlib.axes.Axes.legend`. + """ + # Parse input args + # TODO: Legend entries for colormap or scatterplot objects! Idea is we + # pass a scatter plot or contourf or whatever, and legend is generated by + # drawing patch rectangles or markers using data values and their + # corresponding cmap colors! For scatterplots just test get_facecolor() + # to see if it contains more than one color. + # TODO: It is *also* often desirable to label a colormap object with + # one data value. Maybe add a legend option for the *number of samples* + # or the *sample points* when drawing legends for colormap objects. + # Look into "legend handlers", might just want to add own handlers by + # passing handler_map to legend() and get_legend_handles_labels(). + if order not in ('F', 'C'): + raise ValueError( + f'Invalid order {order!r}. Choose from ' + '"C" (row-major, default) and "F" (column-major).' ) - cb.minorticks_on() # at least turn them on - else: - # Set the minor ticks just like matplotlib internally sets the - # major ticks. Private API is the only way! - minorlocator = constructor.Locator(minorlocator, **minorlocator_kw) - ticks, *_ = cb._ticker(minorlocator, mticker.NullFormatter()) - axis.set_ticks(ticks, minor=True) - axis.set_ticklabels([], minor=True) + ncol = _not_none(ncols=ncols, ncol=ncol) + title = _not_none(label=label, title=title) + frameon = _not_none(frame=frame, frameon=frameon, default=rc['legend.frameon']) + if handles is not None and not np.iterable(handles): # e.g. a mappable object + handles = [handles] + if labels is not None and (not np.iterable(labels) or isinstance(labels, str)): + labels = [labels] + if title is not None: + kwargs['title'] = title + if frameon is not None: + kwargs['frameon'] = frameon + if fontsize is not None: + kwargs['fontsize'] = rc._scale_font(fontsize) - # Label and tick label settings - # WARNING: Must use colorbar set_label to set text, calling set_text on - # the axis will do nothing! - if label is not None: - cb.set_label(label) - axis.label.update(kw_label) - for obj in axis.get_ticklabels(): - obj.update(kw_ticklabels) + # Handle and text properties that are applied after-the-fact + # NOTE: Set solid_capstyle to 'butt' so line does not extend past error bounds + # shading in legend entry. This change is not noticable in other situations. + kw_text = {} + for key, value in (('color', fontcolor), ('weight', fontweight)): + if value is not None: + kw_text[key] = value + kw_handle = {'solid_capstyle': 'butt'} + for key, value in ( + ('color', color), + ('marker', marker), + ('linewidth', lw), + ('linewidth', linewidth), + ('markersize', markersize), + ('linestyle', linestyle), + ('dashes', dashes), + ): + if value is not None: + kw_handle[key] = value - # Ticks consistent with rc settings and overrides - s = axis.axis_name - for which in ('minor', 'major'): - kw = rc.category(s + 'tick.' + which) - kw.pop('visible', None) - if tickdirection: - kw['direction'] = tickdirection - if edgecolor: - kw['color'] = edgecolor - if linewidth: - kw['width'] = linewidth - axis.set_tick_params(which=which, **kw) - axis.set_ticks_position(ticklocation) + # Get axes for legend handle detection + # TODO: Update this when no longer use "filled panels" for outer legends + axs = [self] + if self._panel_hidden: + if self._panel_parent: # axes panel + axs = list(self._panel_parent._iter_axes(hidden=False, children=True)) + else: + axs = list(self.figure._iter_axes(hidden=False, children=True)) - # Fix alpha-blending issues. - # Cannot set edgecolor to 'face' if alpha non-zero because blending will - # occur, will get colored lines instead of white ones. Need manual blending - # NOTE: For some reason cb solids uses listed colormap with always 1.0 - # alpha, then alpha is applied after. - # See: https://stackoverflow.com/a/35672224/4970632 - cmap = cb.cmap - if not cmap._isinit: - cmap._init() - if ( - any(cmap._lut[:-1, 3] < 1) - and ('pcolormesh.snap' not in rc or not rc['pcolormesh.snap']) - ): - warnings._warn_proplot( - f'Using manual alpha-blending for {cmap.name!r} colorbar solids.' + # Handle list of lists (centered row legends) + # NOTE: Avoid very common plot() error where users draw individual lines + # with plot() and add singleton tuples to a list of handles. If matplotlib + # gets a list like this but gets no 'labels' argument, it raises error. + list_of_lists = False + if handles is not None: + handles = [h[0] if isinstance(h, tuple) and len(h) == 1 else h for h in handles] + list_of_lists = any(isinstance(h, (list, np.ndarray)) for h in handles) + if list_of_lists: + if any(not np.iterable(_) for _ in handles): + raise ValueError(f'Invalid handles={handles!r}.') + if not labels: + labels = [None] * len(handles) + elif not all(np.iterable(_) and not isinstance(_, str) for _ in labels): + # e.g. handles=[obj1, [obj2, obj3]] requires labels=[lab1, [lab2, lab3]] + raise ValueError(f'Invalid labels={labels!r} for handles={handles!r}.') + + # Parse handles and legends with native matplotlib parser + if not list_of_lists: + if isinstance(handles, np.ndarray): + handles = handles.tolist() + if isinstance(labels, np.ndarray): + labels = labels.tolist() + handles, labels, *_ = mlegend._parse_legend_args( + axs, handles=handles, labels=labels, ) - # Generate "secret" copy of the colormap! - lut = cmap._lut.copy() - cmap = mcolors.Colormap('_cbar_fix', N=cmap.N) - cmap._isinit = True - cmap._init = lambda: None - # Manually fill lookup table with alpha-blended RGB colors! - for i in range(lut.shape[0] - 1): - alpha = lut[i, 3] - lut[i, :3] = (1 - alpha) * 1 + alpha * lut[i, :3] # blend *white* - lut[i, 3] = 1 - cmap._lut = lut - # Update colorbar - cb.cmap = cmap - cb.draw_all() + pairs = list(zip(handles, labels)) + else: + pairs = [] + for ihandles, ilabels in zip(handles, labels): + if isinstance(ihandles, np.ndarray): + ihandles = ihandles.tolist() + if isinstance(ilabels, np.ndarray): + ilabels = ilabels.tolist() + ihandles, ilabels, *_ = mlegend._parse_legend_args( + axs, handles=ihandles, labels=ilabels, + ) + pairs.append(list(zip(ihandles, ilabels))) - # Fix colorbar outline - kw_outline = { - 'edgecolor': _not_none(edgecolor, rc['axes.edgecolor']), - 'linewidth': _not_none(linewidth, rc['axes.linewidth']), - } - if cb.outline is not None: - cb.outline.update(kw_outline) - if cb.dividers is not None: - cb.dividers.update(kw_outline) + # Manage pairs in context of 'center' option + center = _not_none(center, list_of_lists) + if not center and list_of_lists: # standardize format based on input + list_of_lists = False # no longer is list of lists + pairs = [pair for ipairs in pairs for pair in ipairs] + elif center and not list_of_lists: + list_of_lists = True + ncol = _not_none(ncol, 3) + pairs = [pairs[i * ncol:(i + 1) * ncol] for i in range(len(pairs))] + ncol = None + if list_of_lists: # remove empty lists, pops up in some examples + pairs = [ipairs for ipairs in pairs if ipairs] - # *Never* rasterize because it causes misalignment with border lines - if cb.solids: - cb.solids.set_rasterized(False) - cb.solids.set_linewidth(0.4) - cb.solids.set_edgecolor('face') + # Bail if no pairs + if not pairs: + return mlegend.Legend(self, [], [], loc=loc, ncol=ncol, **kwargs) + # Multiple-legend pseudo-legend + elif center: + objs = _multiple_legend(self, pairs, loc=loc, ncol=ncol, order=order, **kwargs) + # Individual legend + else: + objs = [_individual_legend(self, pairs, loc=loc, ncol=ncol, **kwargs)] - # Invert the axis if descending DiscreteNorm - norm = mappable.norm - if getattr(norm, '_descending', None): - axis.set_inverted(True) - if reverse: # potentially double reverse, although that would be weird... - axis.set_inverted(True) - return cb + # Add legends manually so matplotlib does not remove old ones + for obj in objs: + if isinstance(obj, mpatches.FancyBboxPatch): + continue + if hasattr(self, 'legend_') and self.legend_ is None: + self.legend_ = obj # set *first* legend accessible with get_legend() + else: + self.add_artist(obj) + + # Apply legend box properties + outline = rc.fill({ + 'linewidth': 'axes.linewidth', + 'edgecolor': 'axes.edgecolor', + 'facecolor': 'axes.facecolor', + 'alpha': 'legend.framealpha', + }) + for key in (*outline,): + if key != 'linewidth': + if kwargs.get(key, None): + outline.pop(key, None) + for obj in objs: + if isinstance(obj, mpatches.FancyBboxPatch): + obj.update(outline) # the multiple-legend bounding box + else: + obj.legendPatch.update(outline) # no-op if frame is off + + # Apply *overrides* to legend elements + # WARNING: legendHandles only contains the *first* artist per legend because + # HandlerBase.legend_artist() called in Legend._init_legend_box() only + # returns the first artist. Instead we try to iterate through offset boxes. + # TODO: Remove this feature? Idea was this lets users create *categorical* + # legends in clunky way, e.g. entries denoting *colors* and entries denoting + # *markers*. But would be better to add capacity for categorical labels in a + # *single* legend like seaborn rather than multiple legends. + for obj in objs: + try: + children = obj._legend_handle_box._children + except AttributeError: # older versions maybe? + children = [] + for obj in _iter_legend_children(children): + # Account for mixed legends, e.g. line on top of error bounds shading + if isinstance(obj, mtext.Text): + obj.update(kw_text) + else: + for key, value in kw_handle.items(): + getattr(obj, f'set_{key}', lambda value: None)(value) + + # Append attributes and return, and set clip property!!! This is critical + # for tight bounding box calcs! + for obj in objs: + obj.set_clip_on(False) + if isinstance(objs[0], mpatches.FancyBboxPatch): + objs = objs[1:] + return objs[0] if len(objs) == 1 else tuple(objs) def _basemap_redirect(func): diff --git a/proplot/figure.py b/proplot/figure.py index 0ef35f952..13c1d3e5f 100644 --- a/proplot/figure.py +++ b/proplot/figure.py @@ -225,8 +225,8 @@ def __init__( **kwargs Passed to `matplotlib.figure.Figure`. """ - # Initialize first, because need to provide fully initialized figure - # as argument to gridspec, because matplotlib tight_layout does that + # Initialize first because need to provide fully initialized figure + # as argument to gridspec (since matplotlib tight_layout does that) tight_layout = kwargs.pop('tight_layout', None) constrained_layout = kwargs.pop('constrained_layout', None) if tight_layout or constrained_layout: @@ -241,7 +241,7 @@ def __init__( self._is_autoresizing = False super().__init__(**kwargs) - # Axes sharing and spanning settings + # Sharing and spanning settings sharex = _not_none(sharex, share, rc['subplots.share']) sharey = _not_none(sharey, share, rc['subplots.share']) spanx = _not_none(spanx, span, 0 if sharex == 0 else None, rc['subplots.span']) @@ -275,31 +275,35 @@ def __init__( self._spanx = bool(spanx) self._spany = bool(spany) - # Various other attributes + # Properties gridspec_kw = gridspec_kw or {} gridspec = pgridspec.GridSpec(self, **gridspec_kw) nrows, ncols = gridspec.get_active_geometry() self._pad = units(_not_none(pad, rc['subplots.pad'])) - self._axpad = units(_not_none(axpad, rc['subplots.axpad'])) - self._panelpad = units(_not_none(panelpad, rc['subplots.panelpad'])) + self._ax_pad = units(_not_none(axpad, rc['subplots.axpad'])) + self._panel_pad = units(_not_none(panelpad, rc['subplots.panelpad'])) self._auto_tight = _not_none(tight, rc['subplots.tight']) self._include_panels = includepanels self._mathtext_fallback = mathtext_fallback self._ref_num = ref self._axes_main = [] - self._subplots_orig_kw = subplots_orig_kw self._subplots_kw = subplots_kw - self._bottom_panels = [] - self._top_panels = [] - self._left_panels = [] - self._right_panels = [] - self._bottom_array = np.empty((0, ncols), dtype=bool) - self._top_array = np.empty((0, ncols), dtype=bool) - self._left_array = np.empty((0, nrows), dtype=bool) - self._right_array = np.empty((0, nrows), dtype=bool) + self._subplots_orig_kw = subplots_orig_kw self._gridspec_main = gridspec self.suptitle('') # add _suptitle attribute + # Figure panels + d = self._panel_dict = {} + d['left'] = [] # NOTE: panels will be sorted inside-to-outside + d['right'] = [] + d['bottom'] = [] + d['top'] = [] + d = self._panel_array = {} # array representation of overlap + d['left'] = np.empty((0, nrows), dtype=bool) + d['right'] = np.empty((0, nrows), dtype=bool) + d['bottom'] = np.empty((0, ncols), dtype=bool) + d['top'] = np.empty((0, ncols), dtype=bool) + def _add_axes_panel(self, ax, side, filled=False, **kwargs): """ Hidden method that powers `~proplot.axes.panel_axes`. @@ -318,7 +322,7 @@ def _add_axes_panel(self, ax, side, filled=False, **kwargs): # Get gridspec and subplotspec indices subplotspec = ax.get_subplotspec() *_, row1, row2, col1, col2 = subplotspec.get_active_rows_columns() - pgrid = getattr(ax, '_' + side + '_panels') + pgrid = ax._panel_dict[side] offset = len(pgrid) * bool(pgrid) + 1 if side in ('left', 'right'): iratio = col1 - offset if side == 'left' else col2 + offset @@ -393,7 +397,7 @@ def _add_figure_panel( panels, nacross = subplots_kw['hpanels'], subplots_kw['ncols'] else: panels, nacross = subplots_kw['wpanels'], subplots_kw['nrows'] - array = getattr(self, '_' + side + '_array') + array = self._panel_array[side] npanels, nalong = array.shape # Check span array @@ -432,7 +436,7 @@ def _add_figure_panel( iarray = np.zeros((1, nalong), dtype=bool) iarray[0, start:stop] = True array = np.concatenate((array, iarray), axis=0) - setattr(self, '_' + side + '_array', array) + self._panel_array[side] = array # update array # Get gridspec and subplotspec indices idxs, = np.where(np.array(panels) == '') @@ -451,7 +455,7 @@ def _add_figure_panel( # Draw and setup panel with self._context_authorize_add_subplot(): pax = self.add_subplot(gridspec[idx1, idx2], projection='proplot_cartesian') - pgrid = getattr(self, '_' + side + '_panels') + pgrid = self._panel_dict[side] pgrid.append(pax) pax._panel_side = side pax._panel_share = False @@ -551,8 +555,8 @@ def _align_subplot_super_labels(self, renderer): s = 'y' panels = ('left', 'right') axs = self._get_align_axes(side) - axs = [ax._reassign_subplot_label(side) for ax in axs] - labels = [getattr(ax, '_' + side + '_label') for ax in axs] + axs = [ax._reassign_label(side) for ax in axs] + labels = [ax._label_dict[side] for ax in axs] coords = [None] * len(axs) if side == 'top' and suptitle_on: supaxs = axs @@ -642,7 +646,7 @@ def _context_preprocessing(self, cache=True): kwargs['_cachedRenderer'] = None # __exit__ will restore previous value return _state_context(self, _is_preprocessing=True, **kwargs) - def _draw_auto_legends_colorbars(self): + def _draw_colorbars_legends(self): """ Draw legends and colorbars requested via plotting commands. Drawing is deferred so that successive calls to the plotting commands can successively @@ -650,7 +654,7 @@ def _draw_auto_legends_colorbars(self): """ for ax in self._iter_axes(hidden=False, children=True): if isinstance(ax, paxes.Axes): - ax._draw_auto_legends_colorbars() # may insert panels + ax._draw_colorbars_legends() # may insert panels def _get_align_coord(self, side, axs): """ @@ -861,7 +865,7 @@ def _update_subplot_labels(self, ax, side, labels, **kwargs): f'{len(axs)} axes along that side.' ) for ax, label in zip(axs, labels): - obj = getattr(ax, '_' + side + '_label') + obj = ax._label_dict[side] if label is not None and obj.get_text() != label: obj.set_text(label) if kwargs: @@ -960,8 +964,8 @@ def _update_geometry_from_spacing(self, renderer, resize=True): subplots_kw[key] = _not_none(previous, current - offset + pad) # Get arrays storing gridspec spacing args - axpad = self._axpad - panelpad = self._panelpad + axpad = self._ax_pad + panelpad = self._panel_pad gridspec = self._gridspec_main nrows, ncols = gridspec.get_active_geometry() wspace = subplots_kw['wspace'] @@ -1102,7 +1106,7 @@ def auto_layout(self, renderer=None, resize=None, aspect=None, tight=None): resize = 'nbagg' not in backend and 'ipympl' not in backend # Draw objects that will affect tight layout - self._draw_auto_legends_colorbars() + self._draw_colorbars_legends() # Aspect ratio adjustment if aspect: @@ -1117,8 +1121,7 @@ def auto_layout(self, renderer=None, resize=None, aspect=None, tight=None): self._align_subplot_super_labels(renderer) def colorbar( - self, *args, - loc='r', width=None, space=None, + self, mappable, values=None, *, loc='r', width=None, space=None, row=None, col=None, rows=None, cols=None, span=None, **kwargs ): @@ -1153,11 +1156,16 @@ def colorbar( left two columns of subplots. By default, the colorbar will span all rows and columns. space : float or str, optional - The space between the main subplot grid and the colorbar, or the - space between successively stacked colorbars. Units are interpreted - by `~proplot.utils.units`. By default, this is determined by - the "tight layout" algorithm, or is :rc:`subplots.panelpad` - if "tight layout" is off. + The space between the main subplot grid and the colorbar, or the space + between successively stacked colorbars. Units are interpreted by + `~proplot.utils.units`. By default, this is determined by the "tight layout" + algorithm, or is :rc:`subplots.panelpad` if "tight layout" is off. + length : float or str, optional + The colorbar length. Units are relative to the span of the rows and + columns of subplots. Default is :rc:`colorbar.length`. + shrink : float, optional + Alias for `length`. This is included for consistency with + `matplotlib.figure.Figure.colorbar`. width : float or str, optional The colorbar width. Units are interpreted by `~proplot.utils.units`. Default is :rc:`colorbar.width`. @@ -1172,11 +1180,11 @@ def colorbar( # Fill this axes if cax is not None: - return super().colorbar(*args, cax=cax, **kwargs) + return super().colorbar(mappable, cax=cax, **kwargs) # Generate axes panel - elif ax is not None: - return ax.colorbar(*args, space=space, width=width, **kwargs) + if ax is not None: + return ax.colorbar(mappable, values, space=space, width=width, **kwargs) # Generate figure panel loc = self._axes_main[0]._loc_translate(loc, 'panel') @@ -1184,11 +1192,10 @@ def colorbar( loc, space=space, width=width, span=span, row=row, col=col, rows=rows, cols=cols ) - return ax.colorbar(*args, loc='fill', **kwargs) + return ax.colorbar(mappable, values, loc='fill', **kwargs) def legend( - self, *args, - loc='r', width=None, space=None, + self, handles=None, labels=None, *, loc='r', width=None, space=None, row=None, col=None, rows=None, cols=None, span=None, **kwargs ): @@ -1238,7 +1245,7 @@ def legend( # Generate axes panel if ax is not None: - return ax.legend(*args, space=space, width=width, **kwargs) + return ax.legend(handles, labels, space=space, width=width, **kwargs) # Generate figure panel loc = self._axes_main[0]._loc_translate(loc, 'panel') @@ -1246,7 +1253,7 @@ def legend( loc, space=space, width=width, span=span, row=row, col=col, rows=rows, cols=cols ) - return ax.legend(*args, loc='fill', **kwargs) + return ax.legend(handles, labels, loc='fill', **kwargs) def save(self, filename, **kwargs): """ @@ -1406,8 +1413,7 @@ def _iter_axes(self, hidden=False, children=False): """ for ax in ( *self._axes_main, - *self._left_panels, *self._right_panels, - *self._bottom_panels, *self._top_panels + *(pax for _ in self._panel_dict.values() for pax in _) ): if not hidden and ax._panel_hidden: continue # ignore hidden panel and its colorbar/legend child diff --git a/proplot/internals/rcsetup.py b/proplot/internals/rcsetup.py index f5e8503a6..72236cbc5 100644 --- a/proplot/internals/rcsetup.py +++ b/proplot/internals/rcsetup.py @@ -458,11 +458,11 @@ 'Length of inset colorbars. ' + _addendum_units ), 'colorbar.insetpad': ( - '0.5em', + '0.75em', 'Padding between axes edge and inset colorbars. ' + _addendum_units ), 'colorbar.insetwidth': ( - '1.2em', + '1.25em', 'Width of inset colorbars. ' + _addendum_units ), 'colorbar.length': (