Source code for proplot.figure

#!/usr/bin/env python3
"""
The figure class used for all ProPlot figures.
"""
import os
import numpy as np
import matplotlib.figure as mfigure
import matplotlib.transforms as mtransforms
from numbers import Integral
from . import axes as paxes
from . import gridspec as pgridspec
from .config import rc
from .utils import units
from .internals import ic  # noqa: F401
from .internals import warnings, _not_none, _dummy_context, _set_state

__all__ = ['Figure']


def _parse_panel_args(
    side, share=None, width=None, space=None,
    filled=False, figure=False
):
    """
    Return default properties for new axes and figure panels.
    """
    if side not in ('left', 'right', 'bottom', 'top'):
        raise ValueError(f'Invalid panel location {side!r}.')

    space = space_user = units(space)
    if share is None:
        share = not filled

    if width is None:
        if filled:
            width = rc['colorbar.width']
        else:
            width = rc['subplots.panelwidth']
    width = units(width)

    if space is None:
        key = 'wspace' if side in ('left', 'right') else 'hspace'
        pad = rc['subplots.axpad'] if figure else rc['subplots.panelpad']
        space = pgridspec._default_space(key, share, pad=pad)

    return share, width, space, space_user


def _canvas_preprocessor(canvas, method):
    """
    Return a pre-processer that can be used to override instance-level
    canvas draw() and print_figure() methods. This applies tight layout
    and aspect ratio-conserving adjustments and aligns labels. Required so that
    the canvas methods instantiate renderers with the correct dimensions.
    """
    # NOTE: Renderer must be (1) initialized with the correct figure size or
    # (2) changed inplace during draw, but vector graphic renderers *cannot*
    # be changed inplace. So options include (1) monkey patch
    # canvas.get_width_height, overriding figure.get_size_inches, and exploit
    # the FigureCanvasAgg.get_renderer() implementation (because FigureCanvasAgg
    # queries the bbox directly rather than using get_width_height() so requires
    # workaround), (2) override bbox and bbox_inches as *properties* (but these
    # are really complicated, dangerous, and result in unnecessary extra draws),
    # or (3) simply override canvas draw methods. Our choice is (3).
    def _preprocess(self, *args, **kwargs):
        fig = self.figure  # update even if not stale! needed after saves
        func = getattr(type(self), method)  # the original method

        # *Impossible* to get notebook backend to work with auto resizing so we
        # just do the tight layout adjustments and skip resizing.
        resize = rc['backend'] != 'nbAgg'

        # When re-generating inline figures, the tight layout algorithm can get
        # figure size *or* spacing wrong unless we force additional draw! Seems to
        # have no adverse effects when calling savefig.
        if method == 'print_figure':
            self.draw()

        # Bail out if we are already pre-processing
        # NOTE: The _is_autoresizing check necessary when inserting new gridspec
        # rows or columns with the qt backend.
        # NOTE: Return value for macosx _draw is the renderer, for qt draw is
        # nothing, and for print_figure is some figure object, but this block
        # has never been invoked when calling print_figure.
        renderer = fig._get_renderer()  # any renderer will do for now
        if fig._is_autoresizing or fig._is_preprocessing:
            if method == '_draw':  # macosx backend
                return renderer
            else:
                return

        # Apply formatting
        with fig._context_preprocessing():
            # Add legends and colorbars
            for ax in fig._iter_axes(hidden=False, children=True):
                if isinstance(ax, paxes.Axes):
                    ax._draw_auto_legends_colorbars()  # may insert panels:

            # Aspect ratio adjustment
            fig._update_geometry_from_aspect(resize=resize)  # resizes figure

            # Tight layout
            if fig._auto_tight:
                fig._update_geometry_from_tight_layout(renderer, resize=resize)

            # Align labels
            fig._align_labels_axis(True)
            fig._align_labels_figure(renderer)

            # Call main function
            fallback = _not_none(fig._fallback_to_cm, rc['mathtext.fallback_to_cm'])
            with rc.context({'mathtext.fallback_to_cm': fallback}):
                result = func(self, *args, **kwargs)
        return result

    return _preprocess.__get__(canvas)  # ...I don't get it either


class _hide_artists(object):
    """
    Hide objects temporarily so they are ignored by the tight bounding box
    algorithm.
    """
    # NOTE: This will be removed when labels are implemented with AxesStack!
    def __init__(self, *args):
        self._artists = args

    def __enter__(self):
        for artist in self._artists:
            artist.set_visible(False)

    def __exit__(self, *args):  # noqa: U100
        for artist in self._artists:
            artist.set_visible(True)


[docs]class Figure(mfigure.Figure): """ The `~matplotlib.figure.Figure` class returned by `~proplot.ui.subplots`. At draw-time, an improved tight layout algorithm is employed, and the space around the figure edge, between subplots, and between panels is changed to accommodate subplot content. Figure dimensions may be automatically scaled to preserve subplot aspect ratios. """ def __init__( self, tight=None, ref=1, pad=None, axpad=None, panelpad=None, includepanels=False, span=None, spanx=None, spany=None, align=None, alignx=None, aligny=None, share=None, sharex=None, sharey=None, autoformat=True, fallback_to_cm=None, gridspec_kw=None, subplots_kw=None, subplots_orig_kw=None, **kwargs ): """ Parameters ---------- tight : bool, optional Toggles automatic tight layout adjustments. Default is :rc:`subplots.tight`. If you manually specified a spacing in the call to `~proplot.ui.subplots`, it will be used to override the tight layout spacing. For example, with ``left=0.1``, the left margin is set to 0.1 inches wide, while the remaining margin widths are calculated automatically. ref : int, optional The reference subplot number. See `~proplot.ui.subplots` for details. Default is ``1``. pad : float or str, optional Padding around edge of figure. Units are interpreted by `~proplot.utils.units`. Default is :rc:`subplots.pad`. axpad : float or str, optional Padding between subplots in adjacent columns and rows. Units are interpreted by `~proplot.utils.units`. Default is :rc:`subplots.axpad`. panelpad : float or str, optional Padding between subplots and axes panels, and between "stacked" panels. Units are interpreted by `~proplot.utils.units`. Default is :rc:`subplots.panelpad`. includepanels : bool, optional Whether to include panels when centering *x* axis labels, *y* axis labels, and figure "super titles" along the edge of the subplot grid. Default is ``False``. sharex, sharey, share : {3, 2, 1, 0}, optional The "axis sharing level" for the *x* axis, *y* axis, or both axes. Default is :rc:`subplots.share`. Options are as follows: 0. No axis sharing. Also sets the default `spanx` and `spany` values to ``False``. 1. Only draw *axis label* on the leftmost column (*y*) or bottommost row (*x*) of subplots. Axis tick labels still appear on every subplot. 2. As in 1, but forces the axis limits to be identical. Axis tick labels still appear on every subplot. 3. As in 2, but only show the *axis tick labels* on the leftmost column (*y*) or bottommost row (*x*) of subplots. spanx, spany, span : bool or {0, 1}, optional Toggles "spanning" axis labels for the *x* axis, *y* axis, or both axes. Default is ``False`` if `sharex`, `sharey`, or `share` are ``0``, :rc:`subplots.span` otherwise. When ``True``, a single, centered axis label is used for all axes with bottom and left edges in the same row or column. This can considerably redundancy in your figure. "Spanning" labels integrate with "shared" axes. For example, for a 3-row, 3-column figure, with ``sharey > 1`` and ``spany=1``, your figure will have 1 ylabel instead of 9. alignx, aligny, align : bool or {0, 1}, optional Whether to `align axis labels \ <https://matplotlib.org/3.1.1/gallery/subplots_axes_and_figures/align_labels_demo.html>`__ for the *x* axis, *y* axis, or both axes. Only has an effect when `spanx`, `spany`, or `span` are ``False``. Default is :rc:`subplots.align`. autoformat : bool, optional Whether to automatically configure *x* axis labels, *y* axis labels, axis formatters, axes titles, colorbar labels, and legend labels when a `~pandas.Series`, `~pandas.DataFrame` or `~xarray.DataArray` with relevant metadata is passed to a plotting command. fallback_to_cm : bool, optional Whether to replace unavailable glyphs with a glyph from Computer Modern or the "ยค" dummy character. See `mathtext \ <https://matplotlib.org/3.1.1/tutorials/text/mathtext.html#custom-fonts>`__ for details. gridspec_kw, subplots_kw, subplots_orig_kw Keywords used for initializing the main gridspec, for initializing the figure, and original spacing keyword args used for initializing the figure that override tight layout spacing. Other parameters ---------------- **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 tight_layout = kwargs.pop('tight_layout', None) constrained_layout = kwargs.pop('constrained_layout', None) if tight_layout or constrained_layout: warnings._warn_proplot( f'Ignoring tight_layout={tight_layout} and ' f'contrained_layout={constrained_layout}. ProPlot uses its ' 'own tight layout algorithm, activated by default or with ' 'plot.subplots(tight=True).' ) self._authorized_add_subplot = False self._is_preprocessing = False self._is_autoresizing = False super().__init__(**kwargs) # Axes 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']) spany = _not_none(spany, span, 0 if sharey == 0 else None, rc['subplots.span']) if spanx and (alignx or align): warnings._warn_proplot('"alignx" has no effect when spanx=True.') if spany and (aligny or align): warnings._warn_proplot('"aligny" has no effect when spany=True.') alignx = _not_none(alignx, align, rc['subplots.align']) aligny = _not_none(aligny, align, rc['subplots.align']) self.set_alignx(alignx) self.set_aligny(aligny) self.set_sharex(sharex) self.set_sharey(sharey) self.set_spanx(spanx) self.set_spany(spany) # Various other attributes 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._auto_format = autoformat self._auto_tight = _not_none(tight, rc['subplots.tight']) self._include_panels = includepanels self._fallback_to_cm = fallback_to_cm 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._gridspec_main = gridspec self.suptitle('') # add _suptitle attribute def _add_axes_panel(self, ax, side, filled=False, **kwargs): """ Hidden method that powers `~proplot.axes.panel_axes`. """ # Interpret args # NOTE: Axis sharing not implemented for figure panels, 99% of the # time this is just used as construct for adding global colorbars and # legends, really not worth implementing axis sharing if side not in ('left', 'right', 'bottom', 'top'): raise ValueError(f'Invalid side {side!r}.') ax = ax._panel_parent or ax # redirect to main axes share, width, space, space_orig = _parse_panel_args( side, filled=filled, figure=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') offset = len(pgrid) * bool(pgrid) + 1 if side in ('left', 'right'): iratio = col1 - offset if side == 'left' else col2 + offset idx1 = slice(row1, row2 + 1) idx2 = iratio else: iratio = row1 - offset if side == 'top' else row2 + offset idx1 = iratio idx2 = slice(col1, col2 + 1) gridspec_prev = self._gridspec_main gridspec = self._insert_row_column( side, iratio, width, space, space_orig, figure=False ) if gridspec is not gridspec_prev: if side == 'top': idx1 += 1 elif side == 'left': idx2 += 1 # Draw and setup panel with self._context_authorize_add_subplot(): pax = self.add_subplot(gridspec[idx1, idx2], projection='cartesian') # noqa: E501 pgrid.append(pax) pax._panel_side = side pax._panel_share = share pax._panel_parent = ax # Axis sharing and axis setup only for non-legend or colorbar axes if not filled: for ax in self._axes_main: ax._auto_share_setup() axis = pax.yaxis if side in ('left', 'right') else pax.xaxis getattr(axis, 'tick_' + side)() # set tick and label positions axis.set_label_position(side) return pax def _add_figure_panel( self, side, span=None, row=None, col=None, rows=None, cols=None, **kwargs ): """ Add a figure panel. Also modifies the panel attribute stored on the figure to include these panels. """ # Interpret args and enforce sensible keyword args if side not in ('left', 'right', 'bottom', 'top'): raise ValueError(f'Invalid side {side!r}.') _, width, space, space_orig = _parse_panel_args( side, filled=True, figure=True, **kwargs ) if side in ('left', 'right'): for key, value in (('col', col), ('cols', cols)): if value is not None: raise ValueError( f'Invalid keyword arg {key!r} for figure panel ' f'on side {side!r}.' ) span = _not_none(span=span, row=row, rows=rows) else: for key, value in (('row', row), ('rows', rows)): if value is not None: raise ValueError( f'Invalid keyword arg {key!r} for figure panel ' f'on side {side!r}.' ) span = _not_none(span=span, col=col, cols=cols) # Get props subplots_kw = self._subplots_kw if side in ('left', 'right'): panels, nacross = subplots_kw['hpanels'], subplots_kw['ncols'] else: panels, nacross = subplots_kw['wpanels'], subplots_kw['nrows'] array = getattr(self, '_' + side + '_array') npanels, nalong = array.shape # Check span array span = _not_none(span, (1, nalong)) if not np.iterable(span) or len(span) == 1: span = 2 * np.atleast_1d(span).tolist() if len(span) != 2: raise ValueError(f'Invalid span {span!r}.') if span[0] < 1 or span[1] > nalong: raise ValueError( f'Invalid coordinates in span={span!r}. Coordinates ' f'must satisfy 1 <= c <= {nalong}.' ) start, stop = span[0] - 1, span[1] # zero-indexed # See if there is room for panel in current figure panels # The 'array' is an array of boolean values, where each row corresponds # to another figure panel, moving toward the outside, and boolean # True indicates the slot has been filled iratio = -1 if side in ('left', 'top') else nacross # default values for i in range(npanels): if not any(array[i, start:stop]): array[i, start:stop] = True if side in ('left', 'top'): # descending moves us closer to 0 # npanels=1, i=0 --> iratio=0 # npanels=2, i=0 --> iratio=1 # npanels=2, i=1 --> iratio=0 iratio = npanels - 1 - i else: # descending array moves us closer to nacross-1 # npanels=1, i=0 --> iratio=nacross-1 # npanels=2, i=0 --> iratio=nacross-2 # npanels=2, i=1 --> iratio=nacross-1 iratio = nacross - (npanels - i) break if iratio in (-1, nacross): # add to array iarray = np.zeros((1, nalong), dtype=bool) iarray[0, start:stop] = True array = np.concatenate((array, iarray), axis=0) setattr(self, '_' + side + '_array', array) # Get gridspec and subplotspec indices idxs, = np.where(np.array(panels) == '') if len(idxs) != nalong: raise RuntimeError if side in ('left', 'right'): idx1 = slice(idxs[start], idxs[stop - 1] + 1) idx2 = max(iratio, 0) else: idx1 = max(iratio, 0) idx2 = slice(idxs[start], idxs[stop - 1] + 1) gridspec = self._insert_row_column( side, iratio, width, space, space_orig, figure=True ) # Draw and setup panel with self._context_authorize_add_subplot(): pax = self.add_subplot(gridspec[idx1, idx2], projection='cartesian') # noqa: E501 pgrid = getattr(self, '_' + side + '_panels') pgrid.append(pax) pax._panel_side = side pax._panel_share = False pax._panel_parent = None return pax def _align_labels_axis(self, b=True): """ Align spanning *x* and *y* axis labels in the perpendicular direction and, if `b` is ``True``, the parallel direction. """ # TODO: Ensure this is robust to complex panels and shared axes # NOTE: Need to turn off aligned labels before # _update_geometry_from_tight_layout # call, so cannot put this inside Axes draw xaxs_updated = set() for ax in self._axes_main: if not isinstance(ax, paxes.CartesianAxes): continue for x, axis in zip('xy', (ax.xaxis, ax.yaxis)): side = axis.get_label_position() span = getattr(self, '_span' + x) align = getattr(self, '_align' + x) if side not in ('bottom', 'left') or axis in xaxs_updated: continue # Get panels for axes on each side (2 levels deep is maximum) axs = ax._get_side_axes(side, panels=False) axs = [getattr(ax, '_share' + x) or ax for ax in axs] axs = [getattr(ax, '_share' + x) or ax for ax in axs] # Align axis label offsets xaxs = [getattr(ax, x + 'axis') for ax in axs] xaxs_updated.update(xaxs) if span or align: group = getattr(self, '_align_' + x + 'label_grp', None) if group is not None: for ax in axs[1:]: group.join(axs[0], ax) # add to grouper elif align: warnings._warn_proplot( 'Aligning *x* and *y* axis labels requires ' 'matplotlib >=3.1.0' ) if not span: continue # Get spanning label position c, ax_span = self._get_align_coord(side, axs) ax_span = getattr(ax_span, '_share' + x) or ax_span ax_span = getattr(ax_span, '_share' + x) or ax_span axis_span = getattr(ax_span, x + 'axis') label_span = axis_span.label if not hasattr(label_span, '_orig_transform'): label_span._orig_transform = label_span.get_transform() label_span._orig_position = label_span.get_position() if not b: # toggle off, done before tight layout label_span.set_transform(label_span._orig_transform) label_span.set_position(label_span._orig_position) for axis in xaxs: axis.label.set_visible(True) else: # toggle on, done after tight layout if x == 'x': position = (c, 1) transform = mtransforms.blended_transform_factory( self.transFigure, mtransforms.IdentityTransform() ) else: position = (1, c) transform = mtransforms.blended_transform_factory( mtransforms.IdentityTransform(), self.transFigure ) for axis in xaxs: axis.label.set_visible((axis is axis_span)) label_span.update({'position': position, 'transform': transform}) def _align_labels_figure(self, renderer): """ Adjust the position of row and column labels, and align figure super title accounting for figure margins and axes and figure panels. """ # Offset using tight bounding boxes # TODO: Super labels fail with popup backend!! Fix this # NOTE: Must use get_tightbbox so (1) this will work if tight layout # mode if off and (2) actually need *two* tight bounding boxes when # labels are present: 1 not including the labels, used to position # them, and 1 including the labels, used to determine figure borders suptitle = self._suptitle suptitle_on = suptitle.get_text().strip() width, height = self.get_size_inches() for side in ('left', 'right', 'bottom', 'top'): # Get axes and offset the label to relevant panel if side in ('left', 'right'): x = 'x' panels = ('bottom', 'top') else: x = '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] coords = [None] * len(axs) if side == 'top' and suptitle_on: supaxs = axs # Adjust the labels with _hide_artists(*labels): for i, (ax, label) in enumerate(zip(axs, labels)): label_on = label.get_text().strip() if not label_on: continue # Get coord from tight bounding box # Include twin axes and panels along the same side icoords = [] for iax in ax._iter_axes(panels=panels, children=False): bbox = iax.get_tightbbox(renderer) if side == 'left': jcoords = (bbox.xmin, 0) elif side == 'right': jcoords = (bbox.xmax, 0) elif side == 'top': jcoords = (0, bbox.ymax) else: jcoords = (0, bbox.ymin) c = self.transFigure.inverted().transform(jcoords) c = c[0] if side in ('left', 'right') else c[1] icoords.append(c) # Offset, and offset a bit extra for left/right labels # https://matplotlib.org/api/text_api.html#matplotlib.text.Text.set_linespacing fontsize = label.get_fontsize() if side in ('left', 'right'): scale1, scale2 = 0.6, width else: scale1, scale2 = 0.3, height if side in ('left', 'bottom'): coords[i] = min(icoords) - ( scale1 * fontsize / 72 ) / scale2 else: coords[i] = max(icoords) + ( scale1 * fontsize / 72 ) / scale2 # Assign coords coords = [i for i in coords if i is not None] if coords: if side in ('left', 'bottom'): c = min(coords) else: c = max(coords) for label in labels: label.update({x: c}) # Update super title position # If no axes on the top row are visible, do not try to align! if suptitle_on and supaxs: ys = [] for ax in supaxs: bbox = ax.get_tightbbox(renderer) _, y = self.transFigure.inverted().transform((0, bbox.ymax)) ys.append(y) x, _ = self._get_align_coord('top', supaxs) y = max(ys) + (0.3 * suptitle.get_fontsize() / 72) / height kw = { 'x': x, 'y': y, 'ha': 'center', 'va': 'bottom', 'transform': self.transFigure } suptitle.update(kw) def _context_authorize_add_subplot(self): """ Prevent warning message when adding subplots one-by-one. Used internally. """ return _set_state(self, _authorized_add_subplot=True) def _context_autoresizing(self): """ Ensure backend calls to `~matplotlib.figure.Figure.set_size_inches` during pre-processing are not interpreted as *manual* resizing. """ return _set_state(self, _is_autoresizing=True) def _context_preprocessing(self): """ Prevent re-running pre-processing steps due to draws triggered by figure resizes during pre-processing. """ return _set_state(self, _is_preprocessing=True) def _fix_figure_dimensions(self, subplots_kw): """ Fix the figure geometry. """ width, height = self.get_size_inches() subplots_kw = subplots_kw.copy() subplots_kw.update(width=width, height=height) return subplots_kw def _get_align_coord(self, side, axs): """ Return the figure coordinate for spanning labels or super titles. The `x` can be ``'x'`` or ``'y'``. """ # Get position in figure relative coordinates if side in ('left', 'right'): x = 'y' panels = ('top', 'bottom') else: x = 'x' panels = ('left', 'right') if self._include_panels: axs = [ iax for ax in axs for iax in ax._iter_axes(panels=panels, children=False) ] # Get coordinates ranges = np.array([ax._range_gridspec(x) for ax in axs]) min_, max_ = ranges[:, 0].min(), ranges[:, 1].max() ax_lo = axs[np.where(ranges[:, 0] == min_)[0][0]] ax_hi = axs[np.where(ranges[:, 1] == max_)[0][0]] box_lo = ax_lo.get_subplotspec().get_position(self) box_hi = ax_hi.get_subplotspec().get_position(self) if x == 'x': pos = 0.5 * (box_lo.x0 + box_hi.x1) else: pos = 0.5 * (box_lo.y1 + box_hi.y0) # 'lo' is actually on top of figure # Return axis suitable for spanning position ax_span = axs[(np.argmin(ranges[:, 0]) + np.argmax(ranges[:, 1])) // 2] ax_span = ax_span._panel_parent or ax_span return pos, ax_span def _get_align_axes(self, side): """ Return the main axes along the left, right, bottom, or top sides of the figure. """ # Initial stuff idx = 0 if side in ('left', 'top') else 1 if side in ('left', 'right'): x, y = 'x', 'y' else: x, y = 'y', 'x' # Get edge index axs = self._axes_main if not axs: return [] ranges = np.array([ax._range_gridspec(x) for ax in axs]) min_, max_ = ranges[:, 0].min(), ranges[:, 1].max() edge = min_ if side in ('left', 'top') else max_ # Return axes on edge sorted by order of appearance axs = [ ax for ax in self._axes_main if ax._range_gridspec(x)[idx] == edge ] ranges = [ax._range_gridspec(y)[0] for ax in axs] return [ax for _, ax in sorted(zip(ranges, axs)) if ax.get_visible()] def _get_renderer(self): """ Get a renderer at all costs, even if it means generating a brand new one! Used for updating the figure bounding box when it is accessed and calculating centered-row legend bounding boxes. This is copied from tight_layout.py in matplotlib. """ if self._cachedRenderer: renderer = self._cachedRenderer else: canvas = self.canvas if canvas and hasattr(canvas, 'get_renderer'): renderer = canvas.get_renderer() else: from matplotlib.backends.backend_agg import FigureCanvasAgg canvas = FigureCanvasAgg(self) renderer = canvas.get_renderer() return renderer def _insert_row_column( self, side, idx, ratio, space, space_orig, figure=False, ): """ "Overwrite" the main figure gridspec to make room for a panel. The `side` is the panel side, the `idx` is the slot you want the panel to occupy, and the remaining args are the panel widths and spacings. """ # Constants and stuff # Insert spaces to the left of right panels or to the right of # left panels. And note that since .insert() pushes everything in # that column to the right, actually must insert 1 slot farther to # the right when inserting left panels/spaces if side not in ('left', 'right', 'bottom', 'top'): raise ValueError(f'Invalid side {side}.') idx_space = idx - 1 * bool(side in ('bottom', 'right')) idx_offset = 1 * bool(side in ('top', 'left')) if side in ('left', 'right'): w, ncols = 'w', 'ncols' else: w, ncols = 'h', 'nrows' # Load arrays and test if we need to insert subplots_kw = self._subplots_kw subplots_orig_kw = self._subplots_orig_kw panels = subplots_kw[w + 'panels'] ratios = subplots_kw[w + 'ratios'] spaces = subplots_kw[w + 'space'] spaces_orig = subplots_orig_kw[w + 'space'] # Adjust space, ratio, and panel indicator arrays slot_type = 'f' if figure else side[0] slot_exists = idx not in (-1, len(panels)) and panels[idx] == slot_type if slot_exists: # Slot already exists if spaces_orig[idx_space] is None: spaces_orig[idx_space] = units(space_orig) spaces[idx_space] = _not_none(spaces_orig[idx_space], space) else: # Modify basic geometry and insert new slot idx += idx_offset idx_space += idx_offset subplots_kw[ncols] += 1 spaces_orig.insert(idx_space, space_orig) spaces.insert(idx_space, space) ratios.insert(idx, ratio) panels.insert(idx, slot_type) # Update figure figsize, gridspec_kw, _ = pgridspec._calc_geometry(**subplots_kw) if slot_exists: gridspec = self._gridspec_main gridspec.update(**gridspec_kw) else: # Make new gridspec gridspec = pgridspec.GridSpec(self, **gridspec_kw) self._gridspec_main.figure = None self._gridspec_main = gridspec # Reassign subplotspecs to all axes and update positions for ax in self._iter_axes(hidden=True, children=True): # Get old index # NOTE: Endpoints are inclusive, not exclusive! if not hasattr(ax, 'get_subplotspec'): continue if side in ('left', 'right'): inserts = (None, None, idx, idx) else: inserts = (idx, idx, None, None) subplotspec = ax.get_subplotspec() gridspec_ss = subplotspec.get_gridspec() subplotspec_top = subplotspec.get_topmost_subplotspec() # Apply new subplotspec _, _, *coords = subplotspec_top.get_active_rows_columns() for i in range(4): if inserts[i] is not None and coords[i] >= inserts[i]: coords[i] += 1 row1, row2, col1, col2 = coords subplotspec_new = gridspec[row1:row2 + 1, col1:col2 + 1] if subplotspec_top is subplotspec: ax.set_subplotspec(subplotspec_new) elif subplotspec_top is gridspec_ss._subplot_spec: gridspec_ss._subplot_spec = subplotspec_new else: raise ValueError( 'Unexpected GridSpecFromSubplotSpec nesting.' ) ax.update_params() ax.set_position(ax.figbox) # Adjust figure size *after* gridspecs are fixed self.set_size_inches(figsize, auto=True) return gridspec def _update_super_title(self, title, **kwargs): """ Assign the figure "super title" and update settings. """ if title is not None and self._suptitle.get_text() != title: self._suptitle.set_text(title) if kwargs: self._suptitle.update(kwargs) def _update_subplot_labels(self, ax, side, labels, **kwargs): """ Assign the side labels and update settings. """ if side not in ('left', 'right', 'bottom', 'top'): raise ValueError(f'Invalid label side {side!r}.') # Get main axes on the edge axs = self._get_align_axes(side) if not axs: return # occurs if called while adding axes # Update label text for axes on the edge if labels is None or isinstance(labels, str): # common during testing labels = [labels] * len(axs) if len(labels) != len(axs): raise ValueError( f'Got {len(labels)} {side}labels, but there are ' f'{len(axs)} axes along that side.' ) for ax, label in zip(axs, labels): obj = getattr(ax, '_' + side + '_label') if label is not None and obj.get_text() != label: obj.set_text(label) if kwargs: obj.update(kwargs) def _update_geometry_from_aspect(self, resize=True): """ Adjust the average aspect ratio used for gridspec calculations. This fixes grids with identically fixed aspect ratios, e.g. identically zoomed-in cartopy projections and imshow images. """ # Get aspect ratio if not self._axes_main: return ax = self._axes_main[self._ref_num - 1] curaspect = ax.get_aspect() if isinstance(curaspect, str): if curaspect == 'auto': return elif curaspect != 'equal': raise RuntimeError(f'Unknown aspect ratio mode {curaspect!r}.') # Compare to current aspect subplots_kw = self._subplots_kw xscale, yscale = ax.get_xscale(), ax.get_yscale() if not isinstance(curaspect, str): aspect = curaspect elif xscale == 'linear' and yscale == 'linear': aspect = 1.0 / ax.get_data_ratio() elif xscale == 'log' and yscale == 'log': aspect = 1.0 / ax.get_data_ratio_log() else: return # matplotlib should have issued warning if np.isclose(aspect, subplots_kw['aspect']): return # Apply new aspect subplots_kw['aspect'] = aspect if not resize: subplots_kw = self._fix_figure_dimensions(subplots_kw) figsize, gridspec_kw, _ = pgridspec._calc_geometry(**subplots_kw) self._gridspec_main.update(**gridspec_kw) if resize: self.set_size_inches(figsize, auto=True) def _update_geometry_from_tight_layout(self, renderer, resize=True): """ Apply tight layout scaling that permits flexible figure dimensions and preserves panel widths and subplot aspect ratios. """ # Initial stuff axs = list(self._iter_axes(hidden=True, children=False)) subplots_kw = self._subplots_kw subplots_orig_kw = self._subplots_orig_kw # tight layout overrides if not axs or not subplots_kw or not subplots_orig_kw: return # Temporarily disable spanning labels and get correct # positions for labels and suptitle self._align_labels_axis(False) self._align_labels_figure(renderer) # Tight box *around* figure # Get bounds from old bounding box pad = self._pad obox = self.bbox_inches # original bbox bbox = self.get_tightbbox(renderer) left = bbox.xmin bottom = bbox.ymin right = obox.xmax - bbox.xmax top = obox.ymax - bbox.ymax # Apply new bounds, permitting user overrides # TODO: Account for bounding box NaNs? for key, offset in zip( ('left', 'right', 'top', 'bottom'), (left, right, top, bottom) ): previous = subplots_orig_kw[key] current = subplots_kw[key] subplots_kw[key] = _not_none(previous, current - offset + pad) # Get arrays storing gridspec spacing args axpad = self._axpad panelpad = self._panelpad gridspec = self._gridspec_main nrows, ncols = gridspec.get_active_geometry() wspace = subplots_kw['wspace'] hspace = subplots_kw['hspace'] wspace_orig = subplots_orig_kw['wspace'] hspace_orig = subplots_orig_kw['hspace'] # Get new subplot spacings, axes panel spacing, figure panel spacing spaces = [] for (w, x, y, nacross, ispace, ispace_orig) in zip( 'wh', 'xy', 'yx', (nrows, ncols), (wspace, hspace), (wspace_orig, hspace_orig), ): # Determine which rows and columns correspond to panels panels = subplots_kw[w + 'panels'] jspace = [*ispace] ralong = np.array([ax._range_gridspec(x) for ax in axs]) racross = np.array([ax._range_gridspec(y) for ax in axs]) for i, (space, space_orig) in enumerate(zip(ispace, ispace_orig)): # Figure out whether this is a normal space, or a # panel stack space/axes panel space if ( panels[i] in ('l', 't') and panels[i + 1] in ('l', 't', '') or panels[i] in ('', 'r', 'b') and panels[i + 1] in ('r', 'b') or panels[i] == 'f' and panels[i + 1] == 'f' ): pad = panelpad else: pad = axpad # Find axes that abutt aginst this space on each row groups = [] filt1 = ralong[:, 1] == i # i.e. right/bottom edge abutts against this filt2 = ralong[:, 0] == i + 1 # i.e. left/top edge abutts against this for j in range(nacross): # e.g. each row # Get indices filt = (racross[:, 0] <= j) & (j <= racross[:, 1]) if sum(filt) < 2: # no interface here continue idx1, = np.where(filt & filt1) idx2, = np.where(filt & filt2) if idx1.size > 1 or idx2.size > 2: warnings._warn_proplot('This should never happen.') continue elif not idx1.size or not idx2.size: continue idx1, idx2 = idx1[0], idx2[0] # Put these axes into unique groups. Store groups as # (left axes, right axes) or (bottom axes, top axes) pairs. ax1, ax2 = axs[idx1], axs[idx2] if x != 'x': # order bottom-to-top ax1, ax2 = ax2, ax1 newgroup = True for (group1, group2) in groups: if ax1 in group1 or ax2 in group2: newgroup = False group1.add(ax1) group2.add(ax2) break if newgroup: groups.append([{ax1}, {ax2}]) # form new group # Get spaces # Layout is lspace, lspaces[0], rspaces[0], wspace, ... # so panels spaces are located where i % 3 is 1 or 2 jspaces = [] for (group1, group2) in groups: x1 = max(ax._range_tightbbox(x)[1] for ax in group1) x2 = min(ax._range_tightbbox(x)[0] for ax in group2) jspaces.append((x2 - x1) / self.dpi) if jspaces: space = max(0, space - min(jspaces) + pad) space = _not_none(space_orig, space) # overwritten by user jspace[i] = space spaces.append(jspace) # Apply new spacing subplots_kw.update({'wspace': spaces[0], 'hspace': spaces[1]}) if not resize: subplots_kw = self._fix_figure_dimensions(subplots_kw) figsize, gridspec_kw, _ = pgridspec._calc_geometry(**subplots_kw) self._gridspec_main.update(**gridspec_kw) if resize: self.set_size_inches(figsize, auto=True)
[docs] def add_subplot(self, *args, **kwargs): """ Issues warning for new users that try to call `~matplotlib.figure.Figure.add_subplot` manually. """ if not self._authorized_add_subplot: warnings._warn_proplot( 'Using "fig.add_subplot()" with ProPlot figures may result in ' 'unexpected behavior. Please use "proplot.subplots()" instead.' ) return super().add_subplot(*args, **kwargs)
[docs] def colorbar( self, *args, loc='r', width=None, space=None, row=None, col=None, rows=None, cols=None, span=None, **kwargs ): """ Draw a colorbar along the left, right, bottom, or top side of the figure, centered between the leftmost and rightmost (or topmost and bottommost) main axes. Parameters ---------- loc : str, optional The colorbar location. Valid location keys are as follows. =========== ===================== Location Valid keys =========== ===================== left edge ``'l'``, ``'left'`` right edge ``'r'``, ``'right'`` bottom edge ``'b'``, ``'bottom'`` top edge ``'t'``, ``'top'`` =========== ===================== row, rows : optional Aliases for `span` for panels on the left or right side. col, cols : optional Aliases for `span` for panels on the top or bottom side. span : int or (int, int), optional Describes how the colorbar spans rows and columns of subplots. For example, ``fig.colorbar(loc='b', col=1)`` draws a colorbar beneath the leftmost column of subplots, and ``fig.colorbar(loc='b', cols=(1,2))`` draws a colorbar beneath the left two columns of subplots. By default, the colorbar will span all rows and columns. space : float or str, optional The space between the main subplot grid and the colorbar, or the space between successively stacked colorbars. Units are interpreted by `~proplot.utils.units`. By default, this is determined by the "tight layout" algorithm, or is :rc:`subplots.panelpad` if "tight layout" is off. width : float or str, optional The colorbar width. Units are interpreted by `~proplot.utils.units`. Default is :rc:`colorbar.width`. Other parameters ---------------- *args, **kwargs Passed to `~proplot.axes.colorbar_wrapper`. """ ax = kwargs.pop('ax', None) cax = kwargs.pop('cax', None) # Fill this axes if cax is not None: return super().colorbar(*args, cax=cax, **kwargs) # Generate axes panel elif ax is not None: return ax.colorbar(*args, space=space, width=width, **kwargs) # Generate figure panel loc = self._axes_main[0]._loc_translate(loc, 'panel') ax = self._add_figure_panel( loc, space=space, width=width, span=span, row=row, col=col, rows=rows, cols=cols ) return ax.colorbar(*args, loc='fill', **kwargs)
def draw(self, renderer): # Certain backends *still* have issues with the tight layout # algorithm e.g. due to opening windows in *tabs*. Have not found way # to intervene in the FigureCanvas. For this reason we *also* apply # the algorithm inside Figure.draw in the same way that matplotlib # applies its tight layout algorithm. So far we just do this for Qt* # and MacOSX; corrections are generally *small* but notable! if not self.get_visible(): return if self._auto_tight and ( rc['backend'] == 'MacOSX' or rc['backend'][:2] == 'Qt' ): self._update_geometry_from_tight_layout(renderer, resize=False) self._align_labels_axis(True) # if spaces changed need to realign self._align_labels_figure(renderer) return super().draw(renderer)
[docs] def legend( self, *args, loc='r', width=None, space=None, row=None, col=None, rows=None, cols=None, span=None, **kwargs ): """ Draw a legend along the left, right, bottom, or top side of the figure, centered between the leftmost and rightmost (or topmost and bottommost) main axes. Parameters ---------- loc : str, optional The legend location. Valid location keys are as follows. =========== ===================== Location Valid keys =========== ===================== left edge ``'l'``, ``'left'`` right edge ``'r'``, ``'right'`` bottom edge ``'b'``, ``'bottom'`` top edge ``'t'``, ``'top'`` =========== ===================== row, rows : optional Aliases for `span` for panels on the left or right side. col, cols : optional Aliases for `span` for panels on the top or bottom side. span : int or (int, int), optional Describes how the legend spans rows and columns of subplots. For example, ``fig.legend(loc='b', col=1)`` draws a legend beneath the leftmost column of subplots, and ``fig.legend(loc='b', cols=(1,2))`` draws a legend beneath the left two columns of subplots. By default, the legend will span all rows and columns. space : float or str, optional The space between the main subplot grid and the legend, or the space between successively stacked colorbars. Units are interpreted by `~proplot.utils.units`. By default, this is adjusted automatically in the "tight layout" calculation, or is :rc:`subplots.panelpad` if "tight layout" is turned off. Other parameters ---------------- *args, **kwargs Passed to `~proplot.axes.legend_wrapper`. """ ax = kwargs.pop('ax', None) # Generate axes panel if ax is not None: return ax.legend(*args, space=space, width=width, **kwargs) # Generate figure panel loc = self._axes_main[0]._loc_translate(loc, 'panel') ax = self._add_figure_panel( loc, space=space, width=width, span=span, row=row, col=col, rows=rows, cols=cols ) return ax.legend(*args, loc='fill', **kwargs)
def save(self, filename, **kwargs): # Alias for `~Figure.savefig` because ``fig.savefig`` is redundant. # TODO: Concatenate docstrings. return self.savefig(filename, **kwargs) def savefig(self, filename, **kwargs): # Automatically expand user the user name. Undocumented because we # do not want to overwrite the matplotlib docstring. # TODO: Concatenate docstrings. super().savefig(os.path.expanduser(filename), **kwargs) def set_canvas(self, canvas): # Set the canvas and add monkey patches to the instance-level # `~matplotlib.backend_bases.FigureCanvasBase.draw_idle` and # `~matplotlib.backend_bases.FigureCanvasBase.print_figure` # methods. The latter is called by save() and by the inline backend. # See `_canvas_preprocessor` for details. # TODO: Concatenate docstrings. # NOTE: Cannot use draw_idle() because it causes complications for qt5 # backend (wrong figure size). if callable(getattr(canvas, '_draw', None)): # for macos backend canvas._draw = _canvas_preprocessor(canvas, '_draw') else: canvas.draw = _canvas_preprocessor(canvas, 'draw') canvas.print_figure = _canvas_preprocessor(canvas, 'print_figure') super().set_canvas(canvas) def set_size_inches(self, w, h=None, forward=True, auto=False): # Set the figure size and, if this is being called manually or from # an interactive backend, override the geometry tracker so users can # use interactive backends. If figure size is unchaged we *do not* # update the geometry tracker (figure backends often do this when # the figure is being initialized). See #76. Undocumented because this # is only relevant internally. # TODO: Concatenate docstrings. # NOTE: Bitmap renderers calculate the figure size in inches from # int(Figure.bbox.[width|height]) which rounds to whole pixels. When # renderer calls set_size_inches, size may be effectively the same, but # slightly changed due to roundoff error! Therefore, always compare to # *both* get_size_inches() and the truncated bbox dimensions times dpi. # NOTE: If we fail to detect 'manual' resize as manual, not only will # result be incorrect, but qt backend will crash because it detects a # recursive size change, since preprocessor size will differ. if h is None: width, height = w else: width, height = w, h if not all(np.isfinite(_) for _ in (width, height)): raise ValueError(f'Figure size must be finite, not ({width}, {height}).') width_true, height_true = self.get_size_inches() width_trunc = int(self.bbox.width) / self.dpi height_trunc = int(self.bbox.height) / self.dpi user = ( # detect user resize ( # sometimes get (width_trunc, height_true) or (width_true, height_trunc) width not in (width_true, width_trunc) or height not in (height_true, height_trunc) ) and not auto and not self._is_autoresizing and not self._is_preprocessing and not getattr(self.canvas, '_is_idle_drawing', None) and not getattr(self.canvas, '_is_drawing', None) and not getattr(self.canvas, '_draw_pending', None) ) if user: self._subplots_kw.update(width=width, height=height) context = self._context_autoresizing if auto or not user else _dummy_context with context(): super().set_size_inches(width, height, forward=forward)
[docs] def get_alignx(self): """ Return the *x* axis label alignment mode. """ return self._alignx
[docs] def get_aligny(self): """ Return the *y* axis label alignment mode. """ return self._aligny
[docs] def get_sharex(self): """ Return the *x* axis sharing level. """ return self._sharex
[docs] def get_sharey(self): """ Return the *y* axis sharing level. """ return self._sharey
[docs] def get_spanx(self): """ Return the *x* axis label spanning mode. """ return self._spanx
[docs] def get_spany(self): """ Return the *y* axis label spanning mode. """ return self._spany
[docs] def set_alignx(self, value): """ Set the *x* axis label alignment mode. """ self.stale = True self._alignx = bool(value)
[docs] def set_aligny(self, value): """ Set the *y* axis label alignment mode. """ self.stale = True self._aligny = bool(value)
[docs] def set_sharex(self, value): """ Set the *x* axis sharing level. """ value = int(value) if value not in range(4): raise ValueError( 'Invalid sharing level sharex={value!r}. ' 'Axis sharing level can be 0 (share nothing), ' '1 (hide axis labels), ' '2 (share limits and hide axis labels), or ' '3 (share limits and hide axis and tick labels).' ) self.stale = True self._sharex = value
[docs] def set_sharey(self, value): """ Set the *y* axis sharing level. """ value = int(value) if value not in range(4): raise ValueError( 'Invalid sharing level sharey={value!r}. ' 'Axis sharing level can be 0 (share nothing), ' '1 (hide axis labels), ' '2 (share limits and hide axis labels), or ' '3 (share limits and hide axis and tick labels).' ) self.stale = True self._sharey = value
[docs] def set_spanx(self, value): """ Set the *x* axis label spanning mode. """ self.stale = True self._spanx = bool(value)
[docs] def set_spany(self, value): """ Set the *y* axis label spanning mode. """ self.stale = True self._spany = bool(value)
@property def gridspec(self): """ The single `~proplot.gridspec.GridSpec` instance used for all subplots in the figure. """ return self._gridspec_main @property def ref(self): """ The reference axes number. The `axwidth`, `axheight`, and `aspect` arguments passed to `~proplot.ui.subplots` and `~proplot.ui.figure` arguments are applied to this axes, and aspect ratio is conserved for this axes in tight layout adjustment. """ return self._ref @ref.setter def ref(self, ref): if not isinstance(ref, Integral) or ref < 1: raise ValueError( f'Invalid axes number {ref!r}. Must be integer >=1.' ) self.stale = True self._ref = ref def _iter_axes(self, hidden=False, children=False): """ Iterate over all axes and panels in the figure belonging to the `~proplot.axes.Axes` class. Exclude inset and twin axes. Parameters ---------- hidden : bool, optional Include hidden panels? This is useful for tight layout calculations. children : bool, optional Include child axes? This is useful for tight layout calculations. Includes inset axes and, due to proplot change, "twin" axes. """ for ax in ( *self._axes_main, *self._left_panels, *self._right_panels, *self._bottom_panels, *self._top_panels ): if not hidden and ax._panel_hidden: continue # ignore hidden panel and its colorbar/legend child yield from ax._iter_axes(hidden=hidden, children=children)