diff --git a/proplot/axes/base.py b/proplot/axes/base.py index 576ee9d3f..5c93fc5b2 100644 --- a/proplot/axes/base.py +++ b/proplot/axes/base.py @@ -161,7 +161,7 @@ `~matplotlib.transforms.Transform` instance or a string representing the `~matplotlib.axes.Axes.transData`, `~matplotlib.axes.Axes.transAxes`, `~matplotlib.figure.Figure.transFigure`, or - `~matplotlib.figure.Figure.transSubfigure`, transforms. + `~matplotlib.figure.Figure.transSubfigure` transforms. """ docstring._snippet_manager['axes.transform'] = _transform_docstring @@ -319,10 +319,10 @@ center above axes ``'center'``, ``'c'`` left above axes ``'left'``, ``'l'`` right above axes ``'right'``, ``'r'`` - lower center inside axes ``'lower center'``, ``'lc'`` upper center inside axes ``'upper center'``, ``'uc'`` - upper right inside axes ``'upper right'``, ``'ur'`` upper left inside axes ``'upper left'``, ``'ul'`` + upper right inside axes ``'upper right'``, ``'ur'`` + lower center inside axes ``'lower center'``, ``'lc'`` lower left inside axes ``'lower left'``, ``'ll'`` lower right inside axes ``'lower right'``, ``'lr'`` ======================== ============================ @@ -331,7 +331,7 @@ Whether to draw a white border around titles and a-b-c labels positioned inside the axes. This can help them stand out on top of artists plotted inside the axes. -abcbbox, titlebbox : bool, default: :rc:`abc.bbox` and :rc:`title.bbox` +abcbbox, titlebbox : bool, default: :rc:`abc.box` and :rc:`title.box` Whether to draw a white bbox around titles and a-b-c labels positioned inside the axes. This can help them stand out on top of artists plotted inside the axes. @@ -641,6 +641,31 @@ docstring._snippet_manager['axes.legend_kwargs'] = _legend_kwargs_docstring +# Location table docstring +_loc_table_docstring = """ + ================== ============================================ + 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'``, ``'NE'``, ``1`` + upper left inset ``'upper left'``, ``'ul'``, ``'NW'``, ``2`` + lower left inset ``'lower left'``, ``'ll'``, ``'SW'``, ``3`` + lower right inset ``'lower right'``, ``'lr'``, ``'SE'``, ``4`` + center left inset ``'center left'``, ``'cl'``, ``'W'``, ``6`` + center right inset ``'center right'``, ``'cr'``, ``'E'``, ``7`` + lower center inset ``'lower center'``, ``'lc'``, ``'S'``, ``8`` + upper center inset ``'upper center'``, ``'uc'``, ``'N'``, ``9`` + center inset ``'center'``, ``'c'``, ``'C'``, ``10`` + "filled" ``'fill'`` + ================== ============================================ +""" +docstring._snippet_manager['axes.legend_loc'] = _loc_table_docstring + + def _align_bbox(align, length): """ Return a simple alignment bounding box for intersection calculations. @@ -761,6 +786,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Varous scalar properties + # NOTE: We use '_title_pad' and '_title_above' for both titles + # and a-b-c labels in order to keep them aligned. self._active_cycle = rc['axes.prop_cycle'] self._auto_format = None # manipulated by wrapper functions self._abc_border_kwargs = {} @@ -790,7 +817,7 @@ def __init__(self, *args, **kwargs): self.yaxis.isDefault_minloc = True # Various dictionary properties - # NOTE: Critical to use self.text() so they are patched with _update_label + # NOTE: Critical to use self.text() overrides rather than mtext.Text() self._legend_dict = {} self._colorbar_dict = {} d = self._panel_dict = {} @@ -799,17 +826,13 @@ def __init__(self, *args, **kwargs): d['bottom'] = [] d['top'] = [] d = self._title_dict = {} - kw = {'zorder': 3.5, 'transform': self.transAxes} - d['abc'] = self.text(0, 0, '', **kw) - d['left'] = self._left_title # WARNING: track in case mpl changes this + d['abc'] = self.text('', loc='center', zorder=3.5) + d['left'] = self._left_title d['center'] = self.title d['right'] = self._right_title - d['upper left'] = self.text(0, 0, '', va='top', ha='left', **kw) - d['upper center'] = self.text(0, 0.5, '', va='top', ha='center', **kw) - d['upper right'] = self.text(0, 1, '', va='top', ha='right', **kw) - d['lower left'] = self.text(0, 0, '', va='bottom', ha='left', **kw) - d['lower center'] = self.text(0, 0.5, '', va='bottom', ha='center', **kw) - d['lower right'] = self.text(0, 1, '', va='bottom', ha='right', **kw) + for v, h in itertools.product(('upper', 'lower'), ('left', 'center', 'right')): + loc = ' '.join((v, h)) + d[loc] = self.text('', loc=loc, zorder=3.5) # Subplot-specific settings # NOTE: Default number for any axes is None (i.e., no a-b-c labels allowed) @@ -1069,8 +1092,8 @@ def _add_colorbar( # TODO: Make this auto-adjust to the subplot size if extendsize is not None and extendfrac is not None: warnings._warn_proplot( - f'You cannot specify both an absolute extendsize={extendsize!r} ' - f"and a relative extendfrac={extendfrac!r}. Ignoring 'extendfrac'." + f'Got conflicting absolute extend length extendsize={extendsize!r} and ' + f"relative length extendfrac={extendfrac!r}. Ignoring 'extendfrac'." ) extendfrac = None if extendfrac is None: @@ -1324,18 +1347,23 @@ def _apply_title_above(self): paxs = self._panel_dict['top'] if not paxs: return - pax = paxs[-1] - names = ('left', 'center', 'right') - if self._abc_loc in names: - names += ('abc',) if not self._title_above: return - if pax._panel_hidden and self._title_above == 'panels': - return - pax._title_pad = self._title_pad - pax._abc_title_pad = self._abc_title_pad - for name in names: - labels._transfer_label(self._title_dict[name], pax._title_dict[name]) + keys = ('left', 'center', 'right') + if self._abc_loc in keys: + keys += ('abc',) + for pax, key in itertools.product(paxs[::-1], keys): + if pax._panel_hidden and self._title_above == 'panels': + continue + loc = self._abc_loc if key == 'abc' else key + bbox = _align_bbox(loc, 0) + if not any(bbox.overlaps(b) for b in pax._panel_align.values()): + if pax._panel_align: + self._title_dict[key].set_in_layout(False) + continue + pax._title_pad = self._title_pad + pax._abc_title_pad = self._abc_title_pad + labels._transfer_label(self._title_dict[key], pax._title_dict[key]) def _apply_auto_share(self): """ @@ -2298,11 +2326,11 @@ def _update_abc(self, **kwargs): { 'border': 'abc.border', 'borderwidth': 'abc.borderwidth', - 'bbox': 'abc.bbox', - 'bboxpad': 'abc.bboxpad', - 'bboxcolor': 'abc.bboxcolor', - 'bboxstyle': 'abc.bboxstyle', - 'bboxalpha': 'abc.bboxalpha', + 'box': 'abc.box', + 'boxpad': 'abc.boxpad', + 'boxcolor': 'abc.boxcolor', + 'boxstyle': 'abc.boxstyle', + 'boxalpha': 'abc.boxalpha', }, context=True, ) @@ -2340,7 +2368,7 @@ def _update_abc(self, **kwargs): if loc not in ('left', 'right', 'center'): kw.update(self._abc_border_kwargs) kw.update(kwargs) - self._title_dict['abc'].update(kw) + labels._update_label(self._title_dict['abc'], **kw) def _update_title(self, loc, title=None, **kwargs): """ @@ -2370,11 +2398,11 @@ def _update_title(self, loc, title=None, **kwargs): { 'border': 'title.border', 'borderwidth': 'title.borderwidth', - 'bbox': 'title.bbox', - 'bboxpad': 'title.bboxpad', - 'bboxcolor': 'title.bboxcolor', - 'bboxstyle': 'title.bboxstyle', - 'bboxalpha': 'title.bboxalpha', + 'box': 'title.box', + 'boxpad': 'title.boxpad', + 'boxcolor': 'title.boxcolor', + 'boxstyle': 'title.boxstyle', + 'boxalpha': 'title.boxalpha', }, context=True, ) @@ -2422,40 +2450,26 @@ def _update_title(self, loc, title=None, **kwargs): else: raise ValueError(f'Invalid title {title!r}. Must be string(s).') kw.update(kwargs) - self._title_dict[loc].update(kw) + labels._update_label(self._title_dict[loc], **kw) def _update_title_position(self, renderer): """ Update the position of inset titles and outer titles. This is called by matplotlib at drawtime. """ - # Update title positions + # Update title box padding # NOTE: Critical to do this every time in case padding changes or # we added or removed an a-b-c label in the same position as a title width, height = self._get_size_inches() - x_pad = self._title_pad / (72 * width) - y_pad = self._title_pad / (72 * height) for loc, obj in self._title_dict.items(): - x, y = (0, 1) - if loc == 'abc': # redirect + if not isinstance(obj, moffsetbox.AnchoredText): + continue + if loc == 'abc': loc = self._abc_loc - if loc == 'left': - x = 0 - elif loc == 'center': - x = 0.5 - elif loc == 'right': - x = 1 - if loc in ('upper center', 'lower center'): - x = 0.5 - elif loc in ('upper left', 'lower left'): - x = x_pad - elif loc in ('upper right', 'lower right'): - x = 1 - x_pad - if loc in ('upper left', 'upper right', 'upper center'): - y = 1 - y_pad - elif loc in ('lower left', 'lower right', 'lower center'): - y = y_pad - obj.set_position((x, y)) + if loc in ('left', 'center', 'right'): # invisible box + obj.borderpad = 0 + else: + obj.borderpad = self._title_pad / _fontsize_to_pt(rc['legend.fontsize']) # Get title padding. Push title above tick marks since matplotlib ignores them. # This is known matplotlib problem but especially annoying with top panels. @@ -2495,12 +2509,12 @@ def _update_title_position(self, renderer): obj.get_window_extent(renderer).transformed(self.transAxes.inverted()) .width for obj in (aobj, tobj) ) - ha = aobj.get_ha() pad = (abcpad / 72) / self._get_size_inches()[0] + halign = aobj.get_ha() aoffset = toffset = 0 - if ha == 'left': + if halign == 'left': toffset = awidth + pad - elif ha == 'right': + elif halign == 'right': aoffset = -(twidth + pad) else: # guaranteed center, there are others toffset = 0.5 * (awidth + pad) @@ -2693,7 +2707,7 @@ def draw(self, renderer=None, *args, **kwargs): self.indicate_inset_zoom() super().draw(renderer, *args, **kwargs) - def get_tightbbox(self, renderer, *args, **kwargs): + def get_tightbbox(self, renderer, *args, use_cache=False, **kwargs): # Perform extra post-processing steps # NOTE: This should be updated alongside draw(). We also cache the resulting # bounding box to speed up tight layout calculations (see _range_tightbbox). @@ -2703,8 +2717,10 @@ def get_tightbbox(self, renderer, *args, **kwargs): self._colorbar_fill.update_ticks(manual_only=True) # only if needed if self._inset_parent is not None and self._inset_zoom: self.indicate_inset_zoom() - self._tight_bbox = super().get_tightbbox(renderer, *args, **kwargs) - return self._tight_bbox + bbox = self._tight_bbox + if not use_cache or bbox is None: + bbox = self._tight_bbox = super().get_tightbbox(renderer, *args, **kwargs) + return bbox def get_default_bbox_extra_artists(self): # Further restrict artists to those with disabled clipping @@ -2801,25 +2817,14 @@ def colorbar(self, mappable, values=None, loc=None, location=None, **kwargs): Parameters ---------- %(axes.colorbar_args)s - loc, location : int or str, default: :rc:`colorbar.loc` - The colorbar location. Valid location keys are shown in the below table. + loc, location : int, str, or 2-tuple of float, default: :rc:`colorbar.loc` + The colorbar location key or location coordinates, dependent on + `bbox_to_anchor` and `bbox_transform`. Valid location keys are + shown in the below table. .. _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'`` - ================== ======================================= + %(axes.legend_loc)s shrink Alias for `length`. This is included for consistency with @@ -2878,30 +2883,14 @@ def legend(self, handles=None, labels=None, loc=None, location=None, **kwargs): Parameters ---------- %(axes.legend_args)s - loc, location : int or str, default: :rc:`legend.loc` - The legend location. Valid location keys are shown in the below table. + loc, location : int, str, or 2-tuple of float, default: :rc:`legend.loc` + The legend location key or location coordinates, dependent on + `bbox_to_anchor` and `bbox_transform`. Valid location keys are + shown in the below table. .. _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'`` - ================== ======================================= + %(axes.legend_loc)s width : unit-spec, optional For outer legends only. The space allocated for the legend @@ -2937,28 +2926,48 @@ def legend(self, handles=None, labels=None, loc=None, location=None, **kwargs): @docstring._concatenate_inherited @docstring._snippet_manager - def text( - self, *args, border=False, bbox=False, - bordercolor='w', borderwidth=2, borderinvert=False, borderstyle='miter', - bboxcolor='w', bboxstyle='round', bboxalpha=0.5, bboxpad=None, **kwargs - ): + def text(self, *args, loc=None, **kwargs): """ Add text to the axes. Parameters ---------- - x, y, [z] : float + x, y, [z] : float, optional The coordinates for the text. `~proplot.axes.ThreeAxes` accept an optional third coordinate. If only two are provided this automatically redirects to the `~mpl_toolkits.mplot3d.Axes3D.text2D` method. - s, text : str - The string for the text. + s, text : str, default: '' + The text string. %(axes.transform)s Other parameters ---------------- - border : bool, default: False - Whether to draw border around text. + loc : str, optional + The text location. If passed an `~matplotlib.offsetbox.AnchoredText` + instance is returned instead of a `~matplotlib.text.Text` instance. + This can be used instead of explicit x, y, [z] coordinates with e.g. + ``ax.text('label', loc='upper left')``). Valid location keys are + shown in the below table. + + .. _text_table: + + ============ =================================== + Location Valid keys + ============ =================================== + upper right ``'upper right'``, ``'ur'``, ``1`` + upper left ``'upper left'``, ``'ul'``, ``2`` + lower left ``'lower left'``, ``'ll'``, ``3`` + lower right ``'lower right'``, ``'lr'``, ``4`` + center left ``'center left'``, ``'cl'``, ``6`` + center right ``'center right'``, ``'cr'``, ``7`` + lower center ``'lower center'``, ``'lc'``, ``8`` + upper center ``'upper center'``, ``'uc'``, ``9`` + center ``'center'``, ``'c'``, ``10`` + ============ =================================== + + border : bool or dict, default: False + Whether to draw a border around the text. This can also be a + dictionary of `~matplotlib.patheffects.Stroke` properties. borderwidth : float, default: 2 The width of the text border. bordercolor : color-spec, default: 'w' @@ -2969,21 +2978,27 @@ def text( The `line join style \ `__ used for the border. - bbox : bool, default: False - Whether to draw a bounding box around text. - bboxcolor : color-spec, default: 'w' + box : bool or dict, default: False + Whether to draw a bounding box around the text. This can also be a + dictionary of `~matplotlib.patches.FancyBboxPatch` properties. + boxcolor : color-spec, default: 'w' The color of the text bounding box. - bboxstyle : boxstyle, default: 'round' + boxstyle : boxstyle, default: 'round' The style of the bounding box. - bboxalpha : float, default: 0.5 + boxalpha : float, default: 0.5 The alpha for the bounding box. - bboxpad : float, default: :rc:`title.bboxpad` + boxpad : float, default: :rc:`title.pad` The padding for the bounding box. %(artist.text)s **kwargs Passed to `matplotlib.axes.Axes.text`. + Returns + ------- + `matplotlib.text.Text` or `matplotlib.offsetbox.AnchoredText` + The text object. See `loc` for details. + See also -------- matplotlib.axes.Axes.text @@ -2991,43 +3006,34 @@ def text( # Translate positional args # Audo-redirect to text2D for 3D axes if not enough arguments passed # NOTE: The transform must be passed positionally for 3D axes with 2D coords - keys = 'xy' - func = super().text - if self._name == 'three': - if len(args) >= 4 or 'z' in kwargs: - keys += 'z' - else: - func = self.text2D - keys = (*keys, ('s', 'text'), 'transform') + driver = super().text + coords = 'xy' + if self._name == 'three' and (len(args) > 3 or 'z' in kwargs): + driver = self.text2D + coords += 'z' + keys = (*coords, ('s', 'text'), 'transform') args, kwargs = _kwargs_to_args(keys, *args, **kwargs) - *args, transform = args - if any(arg is None for arg in args): - raise TypeError('Missing required positional argument.') - if transform is None: - transform = self.transData + if all(arg is None for arg in args[2:]): # just 'text' or 'text', 'transform' + args = [None] * len(coords) + args[:2] + *coords, text, transform = args + text = _not_none(text, '') + transform = self._get_transform(transform, default=self.transData) + if loc is not None and all(arg is not None for arg in args[:len(coords)]): + warnings._warn_proplot( + 'Got conflicting explicit Text coordinates and preset ' + "AnchoredText location 'loc'. Ignoring the latter." + ) + if loc is None: + obj = driver(*coords, text, transform=transform) else: - transform = self._get_transform(transform) + pad = self._title_pad / rc['font.size'] # points to em-widths + obj = moffsetbox.AnchoredText('', loc=loc, borderpad=pad) + obj.patch.set_visible(False) # initially toggle this off + self.add_artist(obj) with warnings.catch_warnings(): # ignore duplicates (internal issues?) warnings.simplefilter('ignore', warnings.ProplotWarning) kwargs.update(_pop_props(kwargs, 'text')) - - # Update the text object using a monkey patch - obj = func(*args, transform=transform, **kwargs) - obj.update = labels._update_label.__get__(obj) - obj.update( - { - 'border': border, - 'bordercolor': bordercolor, - 'borderinvert': borderinvert, - 'borderwidth': borderwidth, - 'borderstyle': borderstyle, - 'bbox': bbox, - 'bboxcolor': bboxcolor, - 'bboxstyle': bboxstyle, - 'bboxalpha': bboxalpha, - 'bboxpad': bboxpad, - } - ) + labels._update_label(obj, **kwargs) return obj def _iter_axes(self, hidden=False, children=False, panels=True): diff --git a/proplot/config.py b/proplot/config.py index af8aa44ee..d30879c72 100644 --- a/proplot/config.py +++ b/proplot/config.py @@ -960,11 +960,11 @@ def _get_item_dicts(self, key, value, skip_cycle=False): kw_matplotlib['axes.prop_cycle'] = cycler.cycler('color', cmap.colors) kw_matplotlib['patch.facecolor'] = 'C0' - # Turning bounding box on should turn border off and vice versa - elif contains('abc.bbox', 'title.bbox', 'abc.border', 'title.border'): + # Turning box on should turn border off and vice versa + elif contains('abc.box', 'title.box', 'abc.border', 'title.border'): if value: name, this = key.split('.') - other = 'border' if this == 'bbox' else 'bbox' + other = 'border' if this == 'box' else 'box' kw_proplot[name + '.' + other] = False # Fontsize diff --git a/proplot/internals/guides.py b/proplot/internals/guides.py index e2b620853..2f61bbb2b 100644 --- a/proplot/internals/guides.py +++ b/proplot/internals/guides.py @@ -3,9 +3,12 @@ Utilties related to legends and colorbars. """ import matplotlib.artist as martist +import matplotlib.axes as maxes import matplotlib.colorbar as mcolorbar -import matplotlib.legend as mlegend # noqa: F401 +import matplotlib.offsetbox as moffsetbox +import matplotlib.projections as mprojections # noqa: F401 import matplotlib.ticker as mticker +import matplotlib.transforms as mtransforms import numpy as np from . import ic # noqa: F401 @@ -143,15 +146,93 @@ def _update_ticks(self, manual_only=False): self.minorticks_on() # at least turn them on -class _InsetColorbar(martist.Artist): - """ - Legend-like class for managing inset colorbars. - """ - # TODO: Write this! +class _AnchoredAxes(moffsetbox.AnchoredOffsetbox): + """ + An anchored child axes whose background patch and offset position is determined + by the tight bounding box. Analogous to `~matplotlib.offsetbox.AnchoredText`. + """ + def __init__(self, ax, width, height, **kwargs): + # Note the default bbox_to_anchor will be + # the axes bounding box. + bounds = [0, 0, 1, 1] # arbitrary initial bounds + child = maxes.Axes(ax.figure, bounds, zorder=self.zorder) + # cls = mprojections.get_projection_class('proplot_cartesian') # TODO + # child = cls(ax.figure, bounds, zorder=self.zorder) + super().__init__(child=child, bbox_to_anchor=ax.bbox, **kwargs) + ax.add_artist(self) # sets self.axes to ax and bbox_to_anchor to ax.bbox + self._child = child # ensure private attribute exists + self._width = width + self._height = height + + def draw(self, renderer): + # Just draw the patch (not the axes) + if not self.get_visible(): + return + if hasattr(self, '_update_offset_func'): + self._update_offset_func(renderer) + else: + warnings._warn_proplot( + 'Failed to update _AnchoredAxes offset function due to matplotlib ' + 'private API change. The resulting axes position may be incorrect.' + ) + bbox = self.get_window_extent(renderer) + self._update_patch(renderer, bbox=bbox) + bbox = self.get_child_extent(renderer, offset=True) + self._update_child(bbox) + self.patch.draw(renderer) + self._child.draw(renderer) + + def _update_child(self, bbox): + # Update the child bounding box + trans = getattr(self.figure, 'transSubfigure', self.figure.transFigure) + bbox = mtransforms.TransformedBbox(bbox, trans.inverted()) + getattr(self._child, '_set_position', self._child.set_position)(bbox) + + def _update_patch(self, renderer, bbox): + # Update the patch position + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + self.patch.set_bounds(bbox.x0, bbox.y0, bbox.width, bbox.height) + self.patch.set_mutation_scale(fontsize) + + def get_extent(self, renderer, offset=False): + # Return the extent of the child plus padding + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + bbox = self._child._tight_bbox = self._child.get_tightbbox(renderer) + # bbox = self._child.get_tightbbox(renderer, use_cache=True) # TODO + width = bbox.width + 2 * pad + height = bbox.height + 2 * pad + xd = yd = pad + if offset: + xd += self._child.bbox.x0 - bbox.x0 + yd += self._child.bbox.y0 - bbox.y0 + return width, height, xd, yd + + def get_child_extent(self, renderer, offset=False): + # Update the child position + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + x0, y0 = self._child.bbox.x0, self._child.bbox.y0 + if offset: # find offset position + self._update_child(self.get_child_extent(renderer)) + width, height, xd, yd = self.get_extent(renderer, offset=True) + x0, y0 = self.get_offset(width, height, xd, yd, renderer) + # bbox = self._child.get_tightbbox(use_cache=True) # TODO + xd += self._child.bbox.x0 - self._child._tight_bbox.x0 + yd += self._child.bbox.y0 - self._child._tight_bbox.y0 + width, height = self._width * fontsize, self._height * fontsize + return mtransforms.Bbox.from_bounds(x0, y0, width, height) + + def get_window_extent(self, renderer): + # Return the window bounding box + self._child.get_tightbbox(renderer) # reset the cache + self._update_child(self.get_child_extent(renderer)) + xi, yi, xd, yd = self.get_extent(renderer, offset=False) + ox, oy = self.get_offset(xi, yi, xd, yd, renderer) + return mtransforms.Bbox.from_bounds(ox - xd, oy - yd, xi, yi) class _CenteredLegend(martist.Artist): """ - Legend-like class for managing centered-row legends. + A legend-like subclass whose handles are grouped into centered rows of + `~matplotlib.offsetbox.HPacker` rather than `~matplotlib.offsetbox.VPacker` columns. """ - # TODO: Write this! diff --git a/proplot/internals/labels.py b/proplot/internals/labels.py index cbe4dbe66..00b24bca2 100644 --- a/proplot/internals/labels.py +++ b/proplot/internals/labels.py @@ -1,11 +1,26 @@ #!/usr/bin/env python3 """ -Utilities related to matplotlib text labels. +Utilities related to text labels. """ +import matplotlib.offsetbox as moffsetbox import matplotlib.patheffects as mpatheffects import matplotlib.text as mtext +import matplotlib.transforms as mtransforms +from matplotlib import rcParams as rc_matplotlib from . import ic # noqa: F401 +from . import _not_none, warnings + +# Default border and box values +DEFAULT_BORDERCOLOR = 'w' +DEFAULT_BORDERINVERT = False +DEFAULT_BORDERWIDTH = 2 +DEFAULT_BORDERSTYLE = 'miter' +DEFAULT_BOXFACECOLOR = 'w' +DEFAULT_BOXEDGECOLOR = 'k' +DEFAULT_BOXALPHA = 0.5 +DEFAULT_BOXSTYLE = 'round' +DEFAULT_BOXPAD = 0.5 def _transfer_label(src, dest): @@ -13,6 +28,14 @@ def _transfer_label(src, dest): Transfer the input text object properties and content to the destination text object. Then clear the input object text. """ + if isinstance(src, moffsetbox.AnchoredText): + src = src.txt + elif not isinstance(src, mtext.Text): + raise ValueError('Input must be Text or AnchoredText.') + if isinstance(dest, moffsetbox.AnchoredText): + dest = dest.txt._text + elif not isinstance(dest, mtext.Text): + raise ValueError('Input must be Text or AnchoredText.') text = src.get_text() dest.set_color(src.get_color()) # not a font property dest.set_fontproperties(src.get_fontproperties()) # size, weight, etc. @@ -22,61 +45,200 @@ def _transfer_label(src, dest): src.set_text('') -def _update_label(text, props=None, **kwargs): +@warnings._rename_kwargs( + '0.10', + bbox='box', + bboxcolor='boxcolor', + bboxalpha='boxalpha', + bboxstyle='boxstyle', + bboxpad='boxpad', +) +def _update_label( + obj, border=None, box=None, + bordercolor=None, borderwidth=None, borderinvert=None, borderstyle=None, + boxcolor=None, boxalpha=None, boxstyle=None, boxpad=None, **kwargs +): """ - Add a monkey patch for ``Text.update`` with pseudo "border" and "bbox" - properties without wrapping the entire class. This facillitates inset titles. + Update the text and (if applicable) offset box with "border" + and "bbox" properties. This facillitates inset titles. """ - props = props or {} - props = props.copy() # shallow copy - props.update(kwargs) + if isinstance(obj, mtext.Text): + text = obj + patch = obj.get_bbox_patch() # NOTE: this can be None + elif isinstance(obj, moffsetbox.AnchoredText): + text = obj.txt._text + patch = obj.patch + else: + raise ValueError('Input must be Text or AnchoredText.') + text.update(kwargs) - # Update border - border = props.pop('border', None) - bordercolor = props.pop('bordercolor', 'w') - borderinvert = props.pop('borderinvert', False) - borderwidth = props.pop('borderwidth', 2) - borderstyle = props.pop('borderstyle', 'miter') - if border: - facecolor, bgcolor = text.get_color(), bordercolor + # Update text border + border_props = {} + if isinstance(border, dict): + border_props.update(border) + border = True + if border is None: + pass + elif border: + textcolor = text.get_color() + patheffects = text.get_path_effects() + if not patheffects: # update with defaults + bordercolor = _not_none(bordercolor, DEFAULT_BORDERCOLOR) + borderinvert = _not_none(borderinvert, DEFAULT_BORDERINVERT) + borderwidth = _not_none(borderwidth, DEFAULT_BORDERWIDTH) + borderstyle = _not_none(borderstyle, DEFAULT_BORDERSTYLE) if borderinvert: - facecolor, bgcolor = bgcolor, facecolor - kw = { - 'linewidth': borderwidth, - 'foreground': bgcolor, - 'joinstyle': borderstyle, - } - text.set_color(facecolor) - text.set_path_effects( - [mpatheffects.Stroke(**kw), mpatheffects.Normal()], - ) + bordercolor = _not_none(bordercolor, DEFAULT_BORDERCOLOR) + textcolor, bordercolor = bordercolor, textcolor + if borderwidth is not None: + border_props.setdefault('linewidth', borderwidth) + if bordercolor is not None: + border_props.setdefault('foreground', bordercolor) + if borderstyle is not None: + border_props.setdefault('joinstyle', borderstyle) + if textcolor is not None: + text.set_color(textcolor) + if patheffects: # do not update with default + patheffects[0].update(border_props) + else: # instantiate and apply defaults + stroke = mpatheffects.Stroke(**border_props) + text.set_path_effects([stroke, mpatheffects.Normal()]) elif border is False: text.set_path_effects(None) # Update bounding box - # NOTE: We use '_title_pad' and '_title_above' for both titles and a-b-c - # labels because always want to keep them aligned. - # NOTE: For some reason using pad / 10 results in perfect alignment for - # med-large labels. Tried scaling to be font size relative but never works. - pad = text.axes._title_pad / 10 # default pad - bbox = props.pop('bbox', None) - bboxcolor = props.pop('bboxcolor', 'w') - bboxstyle = props.pop('bboxstyle', 'round') - bboxalpha = props.pop('bboxalpha', 0.5) - bboxpad = props.pop('bboxpad', None) - bboxpad = pad if bboxpad is None else bboxpad - if bbox is None: + # NOTE: AnchoredOffsetbox padding is relative to rc['legend.fontsize'] + box_props = {} + if isinstance(box, dict): + box_props.update(box) + box = True + if box is None: pass - elif isinstance(bbox, dict): # *native* matplotlib usage - props['bbox'] = bbox - elif not bbox: - props['bbox'] = None # disable the bbox + elif box: + edgecolor = None + if patch is None or not patch.get_visible(): + edgecolor = DEFAULT_BOXEDGECOLOR + boxcolor = _not_none(boxcolor, DEFAULT_BOXFACECOLOR) + boxalpha = _not_none(boxalpha, DEFAULT_BOXALPHA) + boxstyle = _not_none(boxstyle, DEFAULT_BOXSTYLE) + boxpad = _not_none(boxpad, text.axes._title_pad) + if edgecolor is not None: + box_props.setdefault('edgecolor', edgecolor) + if boxcolor is not None: + box_props.setdefault('facecolor', boxcolor) + if boxstyle is not None: + box_props.setdefault('boxstyle', boxstyle) + if boxalpha is not None: + box_props.setdefault('alpha', boxalpha) + if boxpad is not None: + boxpad /= rc_matplotlib['legend.fontsize'] # convert points to em-widths + box_props.setdefault('pad', boxpad) + if patch is None: # only possible for Text objects + text.set_bbox(box_props) + else: + patch.set_visible(True) + patch.update(box_props) else: - props['bbox'] = { - 'edgecolor': 'black', - 'facecolor': bboxcolor, - 'boxstyle': bboxstyle, - 'alpha': bboxalpha, - 'pad': bboxpad, - } - return mtext.Text.update(text, props) + if isinstance(obj, mtext.Text): + text.set_bbox(None) # disable the bbox + else: + patch.set_visible(False) + return obj + + +class _AnchoredLabel(moffsetbox.AnchoredOffsetbox): + """ + A class for storing anchored text. Embeds text in `~matplotlib.offsetbox.HPacker` + instances for optional a-b-c label and title pair storage, supports adding and + removing text from the packer, permits anchoring along bounding boxes of multiple + axes (for `~proplot.gridspec.SubplotGrid.text` support), and allows automatically + adjusted perpendicular offset and parallel alignment for "outer" locations when + requested. The tight bounding box algorithm will include labels appearing + between columns of the grid. A similar scheme might be used for auto-offsetting + hanging twin axes and legends and removing the "panel" obfuscation. + """ + def __init__(self, *args, loc=None, pad=None, sep=None, textprops=None, **kwargs): + # Accept arbitrarily many texts + from ..config import rc + loc = _not_none(loc, rc['title.loc']) + pad = _not_none(pad, rc['title.pad']) + sep = _not_none(sep, rc['abc.titlepad']) + txts = [moffsetbox.TextArea(arg, textprops=textprops) for arg in args] + pack = moffsetbox.HPacker(pad=pad, sep=sep, children=txts) + super().__init__(child=pack, **kwargs) + self.patch.set_visible(False) + + def _update_offset_func(self, renderer, fontsize=None): + # Update the offset function + # TODO: Override this for only perpendicular offsets + if fontsize is None: + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + + def _offset(w, h, xd, yd, renderer): + bbox = mtransforms.Bbox.from_bounds(0, 0, w, h) + borderpad = self.borderpad * fontsize + bbox_to_anchor = self.get_bbox_to_anchor() + x0, y0 = self._get_anchored_bbox(self.loc, bbox, bbox_to_anchor, borderpad) + self.axes._update_title_position(renderer) + y1 = self.axes.bbox.y0 + _, y2 = self.axes.title.get_position() + _, y2 = self.axes.title.get_transform().transform((0, y2)) + offset = y2 - y1 + return x0 + xd, y0 + yd + offset + + self.set_offset(_offset) + + def get_extent(self, renderer): + # Get the inset extent after adjusting for the title + # position and ignoring horizontal padding. + # TODO: Override this for only perpendicular offsets + w, h, xd, yd = self.get_child().get_extent(renderer) + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + return w + 2 * pad, h + 2 * pad, xd + pad, yd + pad + + def get_coord(self, renderer): + # Get the title offset coordinate axes + # NOTE: Since proplot adds "twins" as child axes + # they are already covered in the child axes iteration. + title = self._child._children[1] + x, _ = title.get_position() + title.set_position((x, 1.0)) + axs = self._twinned_axes.get_siblings(self) + for ax in self.child_axes: + if ax is None: + continue + locator = ax.get_axes_locator() + if locator: + pos = locator(self, renderer) + ax.apply_aspect(pos) + else: + ax.apply_aspect() + axs = axs + [ax] + # Get the coordinate + # TODO: Align groups of labels by the same baseline. Possibly make label + # groupings similar to twinned axes groupings. And possibly use custom + # logic for "aligned" axis labels rather than using matplotlib logic. + # NOTE: Since AnchoredOffsetbox already adjusts text baseline position by + # its window extent we don't need to use the extra check employed in + # matplotlib _update_title_position. Suggests this way is cleaner. + top = 0 + for ax in axs: + top = max(top, ax.bbox.ymax) + if ax.xaxis.get_visible() and ( + ax.xaxis.get_label_position() == 'top' + or ax.xaxis.get_ticks_position() in ('top', 'unknown') + ): + bb = ax.xaxis.get_tightbbox(renderer) + else: + bb = ax.get_window_extent(renderer) + if bb is None: + continue + ymax = bb.ymax + if ax.xaxis.get_visible() and any( + tick.tick2line.get_visible() and not tick.label2.get_visible() + for tick in ax.xaxis.majorTicks + ): + ymax += ax.xaxis.get_tick_padding() + top = max(top, ymax) + return top diff --git a/proplot/internals/rcsetup.py b/proplot/internals/rcsetup.py index be021c5ea..98533d567 100644 --- a/proplot/internals/rcsetup.py +++ b/proplot/internals/rcsetup.py @@ -78,17 +78,18 @@ 'fill': 'fill', 'inset': 'best', 'i': 'best', - 0: 'best', + 0: 'best', # offsetbox codes 1: 'upper right', 2: 'upper left', 3: 'lower left', 4: 'lower right', - 5: 'center left', - 6: 'center right', - 7: 'lower center', - 8: 'upper center', - 9: 'center', - 'l': 'left', + 5: 'center right', # weird, but see _get_anchored_bbox in matplotlib/offsetbox.py + 6: 'center left', + 7: 'center right', + 8: 'lower center', + 9: 'upper center', + 10: 'center', + 'l': 'left', # custom shorthands 'r': 'right', 'b': 'bottom', 't': 'top', @@ -97,14 +98,21 @@ 'ul': 'upper left', 'll': 'lower left', 'lr': 'lower right', - 'cr': 'center right', 'cl': 'center left', + 'cr': 'center right', 'uc': 'upper center', 'lc': 'lower center', + 'ne': 'upper right', # BboxBase.anchored shorthands + 'nw': 'upper left', + 'sw': 'lower left', + 'se': 'lower right', + 'w': 'center left', + 'e': 'center right', + 's': 'lower center', + 'n': 'upper center', } -for _loc in tuple(LEGEND_LOCS.values()): - if _loc not in LEGEND_LOCS: - LEGEND_LOCS[_loc] = _loc # identity assignments +for _loc in set(LEGEND_LOCS.values()): + LEGEND_LOCS[_loc] = _loc # identity assignments TEXT_LOCS = { key: val for key, val in LEGEND_LOCS.items() if val in ( 'left', 'center', 'right', @@ -808,28 +816,28 @@ def copy(self): _validate_pt, 'Width of the white border around a-b-c labels.' ), - 'abc.bbox': ( + 'abc.box': ( False, _validate_bool, 'Whether to draw semi-transparent bounding boxes around a-b-c labels ' 'when :rcraw:`abc.loc` is inside the axes.' ), - 'abc.bboxcolor': ( + 'abc.boxcolor': ( WHITE, _validate_color, 'a-b-c label bounding box color.' ), - 'abc.bboxstyle': ( - 'square', - _validate_boxstyle, - 'a-b-c label bounding box style.' - ), - 'abc.bboxalpha': ( + 'abc.boxalpha': ( 0.5, _validate_float, 'a-b-c label bounding box opacity.' ), - 'abc.bboxpad': ( + 'abc.boxstyle': ( + 'square', + _validate_boxstyle, + 'a-b-c label bounding box style.' + ), + 'abc.boxpad': ( None, _validate_or_none(_validate_pt), 'Padding for the a-b-c label bounding box. By default this is scaled ' @@ -1805,28 +1813,28 @@ def copy(self): _validate_pt, 'Width of the border around titles.' ), - 'title.bbox': ( + 'title.box': ( False, _validate_bool, 'Whether to draw semi-transparent bounding boxes around titles ' 'when :rcraw:`title.loc` is inside the axes.' ), - 'title.bboxcolor': ( + 'title.boxcolor': ( WHITE, _validate_color, 'Axes title bounding box color.' ), - 'title.bboxstyle': ( - 'square', - _validate_boxstyle, - 'Axes title bounding box style.' - ), - 'title.bboxalpha': ( + 'title.boxalpha': ( 0.5, _validate_float, 'Axes title bounding box opacity.' ), - 'title.bboxpad': ( + 'title.boxstyle': ( + 'square', + _validate_boxstyle, + 'Axes title bounding box style.' + ), + 'title.boxpad': ( None, _validate_or_none(_validate_pt), 'Padding for the title bounding box. By default this is scaled ' @@ -2047,6 +2055,16 @@ def copy(self): 'cartopy.circular': ('geo.round', '0.10.0'), 'cartopy.autoextent': ('geo.extent', '0.10.0'), 'colorbar.rasterize': ('colorbar.rasterized', '0.10.0'), + 'title.bbox': ('title.box', '0.11.0'), + 'title.bboxcolor': ('title.boxcolor', '0.11.0'), + 'title.bboxalpha': ('title.boxalpha', '0.11.0'), + 'title.bboxstyle': ('title.boxstyle', '0.11.0'), + 'title.bboxpad': ('title.boxpad', '0.11.0'), + 'abc.bbox': ('abc.box', '0.11.0'), + 'abc.bboxcolor': ('abc.boxcolor', '0.11.0'), + 'abc.bboxalpha': ('abc.boxalpha', '0.11.0'), + 'abc.bboxstyle': ('abc.boxstyle', '0.11.0'), + 'abc.bboxpad': ('abc.boxpad', '0.11.0'), } # Validate the default settings dictionaries using a custom proplot _RcParams