""" Helper classes to adjust the positions of multiple axes at drawing time. """ import functools import numpy as np import matplotlib as mpl from matplotlib import _api from matplotlib.gridspec import SubplotSpec import matplotlib.transforms as mtransforms from . import axes_size as Size class Divider: """ An Axes positioning class. The divider is initialized with lists of horizontal and vertical sizes (:mod:`mpl_toolkits.axes_grid1.axes_size`) based on which a given rectangular area will be divided. The `new_locator` method then creates a callable object that can be used as the *axes_locator* of the axes. """ def __init__(self, fig, pos, horizontal, vertical, aspect=None, anchor="C"): """ Parameters ---------- fig : Figure pos : tuple of 4 floats Position of the rectangle that will be divided. horizontal : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` Sizes for horizontal division. vertical : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` Sizes for vertical division. aspect : bool, optional Whether overall rectangular area is reduced so that the relative part of the horizontal and vertical scales have the same scale. anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ 'NW', 'W'}, default: 'C' Placement of the reduced rectangle, when *aspect* is True. """ self._fig = fig self._pos = pos self._horizontal = horizontal self._vertical = vertical self._anchor = anchor self.set_anchor(anchor) self._aspect = aspect self._xrefindex = 0 self._yrefindex = 0 self._locator = None def get_horizontal_sizes(self, renderer): return np.array([s.get_size(renderer) for s in self.get_horizontal()]) def get_vertical_sizes(self, renderer): return np.array([s.get_size(renderer) for s in self.get_vertical()]) def set_position(self, pos): """ Set the position of the rectangle. Parameters ---------- pos : tuple of 4 floats position of the rectangle that will be divided """ self._pos = pos def get_position(self): """Return the position of the rectangle.""" return self._pos def set_anchor(self, anchor): """ Parameters ---------- anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ 'NW', 'W'} Either an (*x*, *y*) pair of relative coordinates (0 is left or bottom, 1 is right or top), 'C' (center), or a cardinal direction ('SW', southwest, is bottom left, etc.). See Also -------- .Axes.set_anchor """ if isinstance(anchor, str): _api.check_in_list(mtransforms.Bbox.coefs, anchor=anchor) elif not isinstance(anchor, (tuple, list)) or len(anchor) != 2: raise TypeError("anchor must be str or 2-tuple") self._anchor = anchor def get_anchor(self): """Return the anchor.""" return self._anchor def get_subplotspec(self): return None def set_horizontal(self, h): """ Parameters ---------- h : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` sizes for horizontal division """ self._horizontal = h def get_horizontal(self): """Return horizontal sizes.""" return self._horizontal def set_vertical(self, v): """ Parameters ---------- v : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` sizes for vertical division """ self._vertical = v def get_vertical(self): """Return vertical sizes.""" return self._vertical def set_aspect(self, aspect=False): """ Parameters ---------- aspect : bool """ self._aspect = aspect def get_aspect(self): """Return aspect.""" return self._aspect def set_locator(self, _locator): self._locator = _locator def get_locator(self): return self._locator def get_position_runtime(self, ax, renderer): if self._locator is None: return self.get_position() else: return self._locator(ax, renderer).bounds @staticmethod def _calc_k(sizes, total): # sizes is a (n, 2) array of (rel_size, abs_size); this method finds # the k factor such that sum(rel_size * k + abs_size) == total. rel_sum, abs_sum = sizes.sum(0) return (total - abs_sum) / rel_sum if rel_sum else 0 @staticmethod def _calc_offsets(sizes, k): # Apply k factors to (n, 2) sizes array of (rel_size, abs_size); return # the resulting cumulative offset positions. return np.cumsum([0, *(sizes @ [k, 1])]) def new_locator(self, nx, ny, nx1=None, ny1=None): """ Return an axes locator callable for the specified cell. Parameters ---------- nx, nx1 : int Integers specifying the column-position of the cell. When *nx1* is None, a single *nx*-th column is specified. Otherwise, location of columns spanning between *nx* to *nx1* (but excluding *nx1*-th column) is specified. ny, ny1 : int Same as *nx* and *nx1*, but for row positions. """ if nx1 is None: nx1 = nx + 1 if ny1 is None: ny1 = ny + 1 # append_size("left") adds a new size at the beginning of the # horizontal size lists; this shift transforms e.g. # new_locator(nx=2, ...) into effectively new_locator(nx=3, ...). To # take that into account, instead of recording nx, we record # nx-self._xrefindex, where _xrefindex is shifted by 1 by each # append_size("left"), and re-add self._xrefindex back to nx in # _locate, when the actual axes position is computed. Ditto for y. xref = self._xrefindex yref = self._yrefindex locator = functools.partial( self._locate, nx - xref, ny - yref, nx1 - xref, ny1 - yref) locator.get_subplotspec = self.get_subplotspec return locator @_api.deprecated( "3.8", alternative="divider.new_locator(...)(ax, renderer)") def locate(self, nx, ny, nx1=None, ny1=None, axes=None, renderer=None): """ Implementation of ``divider.new_locator().__call__``. Parameters ---------- nx, nx1 : int Integers specifying the column-position of the cell. When *nx1* is None, a single *nx*-th column is specified. Otherwise, the location of columns spanning between *nx* to *nx1* (but excluding *nx1*-th column) is specified. ny, ny1 : int Same as *nx* and *nx1*, but for row positions. axes renderer """ xref = self._xrefindex yref = self._yrefindex return self._locate( nx - xref, (nx + 1 if nx1 is None else nx1) - xref, ny - yref, (ny + 1 if ny1 is None else ny1) - yref, axes, renderer) def _locate(self, nx, ny, nx1, ny1, axes, renderer): """ Implementation of ``divider.new_locator().__call__``. The axes locator callable returned by ``new_locator()`` is created as a `functools.partial` of this method with *nx*, *ny*, *nx1*, and *ny1* specifying the requested cell. """ nx += self._xrefindex nx1 += self._xrefindex ny += self._yrefindex ny1 += self._yrefindex fig_w, fig_h = self._fig.bbox.size / self._fig.dpi x, y, w, h = self.get_position_runtime(axes, renderer) hsizes = self.get_horizontal_sizes(renderer) vsizes = self.get_vertical_sizes(renderer) k_h = self._calc_k(hsizes, fig_w * w) k_v = self._calc_k(vsizes, fig_h * h) if self.get_aspect(): k = min(k_h, k_v) ox = self._calc_offsets(hsizes, k) oy = self._calc_offsets(vsizes, k) ww = (ox[-1] - ox[0]) / fig_w hh = (oy[-1] - oy[0]) / fig_h pb = mtransforms.Bbox.from_bounds(x, y, w, h) pb1 = mtransforms.Bbox.from_bounds(x, y, ww, hh) x0, y0 = pb1.anchored(self.get_anchor(), pb).p0 else: ox = self._calc_offsets(hsizes, k_h) oy = self._calc_offsets(vsizes, k_v) x0, y0 = x, y if nx1 is None: nx1 = -1 if ny1 is None: ny1 = -1 x1, w1 = x0 + ox[nx] / fig_w, (ox[nx1] - ox[nx]) / fig_w y1, h1 = y0 + oy[ny] / fig_h, (oy[ny1] - oy[ny]) / fig_h return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) def append_size(self, position, size): _api.check_in_list(["left", "right", "bottom", "top"], position=position) if position == "left": self._horizontal.insert(0, size) self._xrefindex += 1 elif position == "right": self._horizontal.append(size) elif position == "bottom": self._vertical.insert(0, size) self._yrefindex += 1 else: # 'top' self._vertical.append(size) def add_auto_adjustable_area(self, use_axes, pad=0.1, adjust_dirs=None): """ Add auto-adjustable padding around *use_axes* to take their decorations (title, labels, ticks, ticklabels) into account during layout. Parameters ---------- use_axes : `~matplotlib.axes.Axes` or list of `~matplotlib.axes.Axes` The Axes whose decorations are taken into account. pad : float, default: 0.1 Additional padding in inches. adjust_dirs : list of {"left", "right", "bottom", "top"}, optional The sides where padding is added; defaults to all four sides. """ if adjust_dirs is None: adjust_dirs = ["left", "right", "bottom", "top"] for d in adjust_dirs: self.append_size(d, Size._AxesDecorationsSize(use_axes, d) + pad) @_api.deprecated("3.8") class AxesLocator: """ A callable object which returns the position and size of a given `.AxesDivider` cell. """ def __init__(self, axes_divider, nx, ny, nx1=None, ny1=None): """ Parameters ---------- axes_divider : `~mpl_toolkits.axes_grid1.axes_divider.AxesDivider` nx, nx1 : int Integers specifying the column-position of the cell. When *nx1* is None, a single *nx*-th column is specified. Otherwise, location of columns spanning between *nx* to *nx1* (but excluding *nx1*-th column) is specified. ny, ny1 : int Same as *nx* and *nx1*, but for row positions. """ self._axes_divider = axes_divider _xrefindex = axes_divider._xrefindex _yrefindex = axes_divider._yrefindex self._nx, self._ny = nx - _xrefindex, ny - _yrefindex if nx1 is None: nx1 = len(self._axes_divider) if ny1 is None: ny1 = len(self._axes_divider[0]) self._nx1 = nx1 - _xrefindex self._ny1 = ny1 - _yrefindex def __call__(self, axes, renderer): _xrefindex = self._axes_divider._xrefindex _yrefindex = self._axes_divider._yrefindex return self._axes_divider.locate(self._nx + _xrefindex, self._ny + _yrefindex, self._nx1 + _xrefindex, self._ny1 + _yrefindex, axes, renderer) def get_subplotspec(self): return self._axes_divider.get_subplotspec() class SubplotDivider(Divider): """ The Divider class whose rectangle area is specified as a subplot geometry. """ def __init__(self, fig, *args, horizontal=None, vertical=None, aspect=None, anchor='C'): """ Parameters ---------- fig : `~matplotlib.figure.Figure` *args : tuple (*nrows*, *ncols*, *index*) or int The array of subplots in the figure has dimensions ``(nrows, ncols)``, and *index* is the index of the subplot being created. *index* starts at 1 in the upper left corner and increases to the right. If *nrows*, *ncols*, and *index* are all single digit numbers, then *args* can be passed as a single 3-digit number (e.g. 234 for (2, 3, 4)). horizontal : list of :mod:`~mpl_toolkits.axes_grid1.axes_size`, optional Sizes for horizontal division. vertical : list of :mod:`~mpl_toolkits.axes_grid1.axes_size`, optional Sizes for vertical division. aspect : bool, optional Whether overall rectangular area is reduced so that the relative part of the horizontal and vertical scales have the same scale. anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ 'NW', 'W'}, default: 'C' Placement of the reduced rectangle, when *aspect* is True. """ self.figure = fig super().__init__(fig, [0, 0, 1, 1], horizontal=horizontal or [], vertical=vertical or [], aspect=aspect, anchor=anchor) self.set_subplotspec(SubplotSpec._from_subplot_args(fig, args)) def get_position(self): """Return the bounds of the subplot box.""" return self.get_subplotspec().get_position(self.figure).bounds def get_subplotspec(self): """Get the SubplotSpec instance.""" return self._subplotspec def set_subplotspec(self, subplotspec): """Set the SubplotSpec instance.""" self._subplotspec = subplotspec self.set_position(subplotspec.get_position(self.figure)) class AxesDivider(Divider): """ Divider based on the preexisting axes. """ def __init__(self, axes, xref=None, yref=None): """ Parameters ---------- axes : :class:`~matplotlib.axes.Axes` xref yref """ self._axes = axes if xref is None: self._xref = Size.AxesX(axes) else: self._xref = xref if yref is None: self._yref = Size.AxesY(axes) else: self._yref = yref super().__init__(fig=axes.get_figure(), pos=None, horizontal=[self._xref], vertical=[self._yref], aspect=None, anchor="C") def _get_new_axes(self, *, axes_class=None, **kwargs): axes = self._axes if axes_class is None: axes_class = type(axes) return axes_class(axes.get_figure(), axes.get_position(original=True), **kwargs) def new_horizontal(self, size, pad=None, pack_start=False, **kwargs): """ Helper method for ``append_axes("left")`` and ``append_axes("right")``. See the documentation of `append_axes` for more details. :meta private: """ if pad is None: pad = mpl.rcParams["figure.subplot.wspace"] * self._xref pos = "left" if pack_start else "right" if pad: if not isinstance(pad, Size._Base): pad = Size.from_any(pad, fraction_ref=self._xref) self.append_size(pos, pad) if not isinstance(size, Size._Base): size = Size.from_any(size, fraction_ref=self._xref) self.append_size(pos, size) locator = self.new_locator( nx=0 if pack_start else len(self._horizontal) - 1, ny=self._yrefindex) ax = self._get_new_axes(**kwargs) ax.set_axes_locator(locator) return ax def new_vertical(self, size, pad=None, pack_start=False, **kwargs): """ Helper method for ``append_axes("top")`` and ``append_axes("bottom")``. See the documentation of `append_axes` for more details. :meta private: """ if pad is None: pad = mpl.rcParams["figure.subplot.hspace"] * self._yref pos = "bottom" if pack_start else "top" if pad: if not isinstance(pad, Size._Base): pad = Size.from_any(pad, fraction_ref=self._yref) self.append_size(pos, pad) if not isinstance(size, Size._Base): size = Size.from_any(size, fraction_ref=self._yref) self.append_size(pos, size) locator = self.new_locator( nx=self._xrefindex, ny=0 if pack_start else len(self._vertical) - 1) ax = self._get_new_axes(**kwargs) ax.set_axes_locator(locator) return ax def append_axes(self, position, size, pad=None, *, axes_class=None, **kwargs): """ Add a new axes on a given side of the main axes. Parameters ---------- position : {"left", "right", "bottom", "top"} Where the new axes is positioned relative to the main axes. size : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str The axes width or height. float or str arguments are interpreted as ``axes_size.from_any(size, AxesX())`` for left or right axes, and likewise with ``AxesY`` for bottom or top axes. pad : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str Padding between the axes. float or str arguments are interpreted as for *size*. Defaults to :rc:`figure.subplot.wspace` times the main Axes width (left or right axes) or :rc:`figure.subplot.hspace` times the main Axes height (bottom or top axes). axes_class : subclass type of `~.axes.Axes`, optional The type of the new axes. Defaults to the type of the main axes. **kwargs All extra keywords arguments are passed to the created axes. """ create_axes, pack_start = _api.check_getitem({ "left": (self.new_horizontal, True), "right": (self.new_horizontal, False), "bottom": (self.new_vertical, True), "top": (self.new_vertical, False), }, position=position) ax = create_axes( size, pad, pack_start=pack_start, axes_class=axes_class, **kwargs) self._fig.add_axes(ax) return ax def get_aspect(self): if self._aspect is None: aspect = self._axes.get_aspect() if aspect == "auto": return False else: return True else: return self._aspect def get_position(self): if self._pos is None: bbox = self._axes.get_position(original=True) return bbox.bounds else: return self._pos def get_anchor(self): if self._anchor is None: return self._axes.get_anchor() else: return self._anchor def get_subplotspec(self): return self._axes.get_subplotspec() # Helper for HBoxDivider/VBoxDivider. # The variable names are written for a horizontal layout, but the calculations # work identically for vertical layouts. def _locate(x, y, w, h, summed_widths, equal_heights, fig_w, fig_h, anchor): total_width = fig_w * w max_height = fig_h * h # Determine the k factors. n = len(equal_heights) eq_rels, eq_abss = equal_heights.T sm_rels, sm_abss = summed_widths.T A = np.diag([*eq_rels, 0]) A[:n, -1] = -1 A[-1, :-1] = sm_rels B = [*(-eq_abss), total_width - sm_abss.sum()] # A @ K = B: This finds factors {k_0, ..., k_{N-1}, H} so that # eq_rel_i * k_i + eq_abs_i = H for all i: all axes have the same height # sum(sm_rel_i * k_i + sm_abs_i) = total_width: fixed total width # (foo_rel_i * k_i + foo_abs_i will end up being the size of foo.) *karray, height = np.linalg.solve(A, B) if height > max_height: # Additionally, upper-bound the height. karray = (max_height - eq_abss) / eq_rels # Compute the offsets corresponding to these factors. ox = np.cumsum([0, *(sm_rels * karray + sm_abss)]) ww = (ox[-1] - ox[0]) / fig_w h0_rel, h0_abs = equal_heights[0] hh = (karray[0]*h0_rel + h0_abs) / fig_h pb = mtransforms.Bbox.from_bounds(x, y, w, h) pb1 = mtransforms.Bbox.from_bounds(x, y, ww, hh) x0, y0 = pb1.anchored(anchor, pb).p0 return x0, y0, ox, hh class HBoxDivider(SubplotDivider): """ A `.SubplotDivider` for laying out axes horizontally, while ensuring that they have equal heights. Examples -------- .. plot:: gallery/axes_grid1/demo_axes_hbox_divider.py """ def new_locator(self, nx, nx1=None): """ Create an axes locator callable for the specified cell. Parameters ---------- nx, nx1 : int Integers specifying the column-position of the cell. When *nx1* is None, a single *nx*-th column is specified. Otherwise, location of columns spanning between *nx* to *nx1* (but excluding *nx1*-th column) is specified. """ return super().new_locator(nx, 0, nx1, 0) def _locate(self, nx, ny, nx1, ny1, axes, renderer): # docstring inherited nx += self._xrefindex nx1 += self._xrefindex fig_w, fig_h = self._fig.bbox.size / self._fig.dpi x, y, w, h = self.get_position_runtime(axes, renderer) summed_ws = self.get_horizontal_sizes(renderer) equal_hs = self.get_vertical_sizes(renderer) x0, y0, ox, hh = _locate( x, y, w, h, summed_ws, equal_hs, fig_w, fig_h, self.get_anchor()) if nx1 is None: nx1 = -1 x1, w1 = x0 + ox[nx] / fig_w, (ox[nx1] - ox[nx]) / fig_w y1, h1 = y0, hh return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) class VBoxDivider(SubplotDivider): """ A `.SubplotDivider` for laying out axes vertically, while ensuring that they have equal widths. """ def new_locator(self, ny, ny1=None): """ Create an axes locator callable for the specified cell. Parameters ---------- ny, ny1 : int Integers specifying the row-position of the cell. When *ny1* is None, a single *ny*-th row is specified. Otherwise, location of rows spanning between *ny* to *ny1* (but excluding *ny1*-th row) is specified. """ return super().new_locator(0, ny, 0, ny1) def _locate(self, nx, ny, nx1, ny1, axes, renderer): # docstring inherited ny += self._yrefindex ny1 += self._yrefindex fig_w, fig_h = self._fig.bbox.size / self._fig.dpi x, y, w, h = self.get_position_runtime(axes, renderer) summed_hs = self.get_vertical_sizes(renderer) equal_ws = self.get_horizontal_sizes(renderer) y0, x0, oy, ww = _locate( y, x, h, w, summed_hs, equal_ws, fig_h, fig_w, self.get_anchor()) if ny1 is None: ny1 = -1 x1, w1 = x0, ww y1, h1 = y0 + oy[ny] / fig_h, (oy[ny1] - oy[ny]) / fig_h return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) def make_axes_locatable(axes): divider = AxesDivider(axes) locator = divider.new_locator(nx=0, ny=0) axes.set_axes_locator(locator) return divider def make_axes_area_auto_adjustable( ax, use_axes=None, pad=0.1, adjust_dirs=None): """ Add auto-adjustable padding around *ax* to take its decorations (title, labels, ticks, ticklabels) into account during layout, using `.Divider.add_auto_adjustable_area`. By default, padding is determined from the decorations of *ax*. Pass *use_axes* to consider the decorations of other Axes instead. """ if adjust_dirs is None: adjust_dirs = ["left", "right", "bottom", "top"] divider = make_axes_locatable(ax) if use_axes is None: use_axes = ax divider.add_auto_adjustable_area(use_axes=use_axes, pad=pad, adjust_dirs=adjust_dirs)