diff options
author | shumkovnd <shumkovnd@yandex-team.com> | 2023-11-10 14:39:34 +0300 |
---|---|---|
committer | shumkovnd <shumkovnd@yandex-team.com> | 2023-11-10 16:42:24 +0300 |
commit | 77eb2d3fdcec5c978c64e025ced2764c57c00285 (patch) | |
tree | c51edb0748ca8d4a08d7c7323312c27ba1a8b79a /contrib/python/matplotlib/py3/mpl_toolkits | |
parent | dd6d20cadb65582270ac23f4b3b14ae189704b9d (diff) | |
download | ydb-77eb2d3fdcec5c978c64e025ced2764c57c00285.tar.gz |
KIKIMR-19287: add task_stats_drawing script
Diffstat (limited to 'contrib/python/matplotlib/py3/mpl_toolkits')
26 files changed, 12047 insertions, 0 deletions
diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/__init__.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/__init__.py new file mode 100644 index 0000000000..c55302485e --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/__init__.py @@ -0,0 +1,10 @@ +from . import axes_size as Size +from .axes_divider import Divider, SubplotDivider, make_axes_locatable +from .axes_grid import AxesGrid, Grid, ImageGrid + +from .parasite_axes import host_subplot, host_axes + +__all__ = ["Size", + "Divider", "SubplotDivider", "make_axes_locatable", + "AxesGrid", "Grid", "ImageGrid", + "host_subplot", "host_axes"] diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/anchored_artists.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/anchored_artists.py new file mode 100644 index 0000000000..1238310b46 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/anchored_artists.py @@ -0,0 +1,462 @@ +from matplotlib import _api, transforms +from matplotlib.offsetbox import (AnchoredOffsetbox, AuxTransformBox, + DrawingArea, TextArea, VPacker) +from matplotlib.patches import (Rectangle, Ellipse, ArrowStyle, + FancyArrowPatch, PathPatch) +from matplotlib.text import TextPath + +__all__ = ['AnchoredDrawingArea', 'AnchoredAuxTransformBox', + 'AnchoredEllipse', 'AnchoredSizeBar', 'AnchoredDirectionArrows'] + + +class AnchoredDrawingArea(AnchoredOffsetbox): + def __init__(self, width, height, xdescent, ydescent, + loc, pad=0.4, borderpad=0.5, prop=None, frameon=True, + **kwargs): + """ + An anchored container with a fixed size and fillable `.DrawingArea`. + + Artists added to the *drawing_area* will have their coordinates + interpreted as pixels. Any transformations set on the artists will be + overridden. + + Parameters + ---------- + width, height : float + Width and height of the container, in pixels. + xdescent, ydescent : float + Descent of the container in the x- and y- direction, in pixels. + loc : str + Location of this artist. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + pad : float, default: 0.4 + Padding around the child objects, in fraction of the font size. + borderpad : float, default: 0.5 + Border padding, in fraction of the font size. + prop : `~matplotlib.font_manager.FontProperties`, optional + Font property used as a reference for paddings. + frameon : bool, default: True + If True, draw a box around this artist. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + drawing_area : `~matplotlib.offsetbox.DrawingArea` + A container for artists to display. + + Examples + -------- + To display blue and red circles of different sizes in the upper right + of an Axes *ax*: + + >>> ada = AnchoredDrawingArea(20, 20, 0, 0, + ... loc='upper right', frameon=False) + >>> ada.drawing_area.add_artist(Circle((10, 10), 10, fc="b")) + >>> ada.drawing_area.add_artist(Circle((30, 10), 5, fc="r")) + >>> ax.add_artist(ada) + """ + self.da = DrawingArea(width, height, xdescent, ydescent) + self.drawing_area = self.da + + super().__init__( + loc, pad=pad, borderpad=borderpad, child=self.da, prop=None, + frameon=frameon, **kwargs + ) + + +class AnchoredAuxTransformBox(AnchoredOffsetbox): + def __init__(self, transform, loc, + pad=0.4, borderpad=0.5, prop=None, frameon=True, **kwargs): + """ + An anchored container with transformed coordinates. + + Artists added to the *drawing_area* are scaled according to the + coordinates of the transformation used. The dimensions of this artist + will scale to contain the artists added. + + Parameters + ---------- + transform : `~matplotlib.transforms.Transform` + The transformation object for the coordinate system in use, i.e., + :attr:`matplotlib.axes.Axes.transData`. + loc : str + Location of this artist. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + pad : float, default: 0.4 + Padding around the child objects, in fraction of the font size. + borderpad : float, default: 0.5 + Border padding, in fraction of the font size. + prop : `~matplotlib.font_manager.FontProperties`, optional + Font property used as a reference for paddings. + frameon : bool, default: True + If True, draw a box around this artist. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + drawing_area : `~matplotlib.offsetbox.AuxTransformBox` + A container for artists to display. + + Examples + -------- + To display an ellipse in the upper left, with a width of 0.1 and + height of 0.4 in data coordinates: + + >>> box = AnchoredAuxTransformBox(ax.transData, loc='upper left') + >>> el = Ellipse((0, 0), width=0.1, height=0.4, angle=30) + >>> box.drawing_area.add_artist(el) + >>> ax.add_artist(box) + """ + self.drawing_area = AuxTransformBox(transform) + + super().__init__(loc, pad=pad, borderpad=borderpad, + child=self.drawing_area, prop=prop, frameon=frameon, + **kwargs) + + +@_api.deprecated("3.8") +class AnchoredEllipse(AnchoredOffsetbox): + def __init__(self, transform, width, height, angle, loc, + pad=0.1, borderpad=0.1, prop=None, frameon=True, **kwargs): + """ + Draw an anchored ellipse of a given size. + + Parameters + ---------- + transform : `~matplotlib.transforms.Transform` + The transformation object for the coordinate system in use, i.e., + :attr:`matplotlib.axes.Axes.transData`. + width, height : float + Width and height of the ellipse, given in coordinates of + *transform*. + angle : float + Rotation of the ellipse, in degrees, anti-clockwise. + loc : str + Location of the ellipse. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + pad : float, default: 0.1 + Padding around the ellipse, in fraction of the font size. + borderpad : float, default: 0.1 + Border padding, in fraction of the font size. + frameon : bool, default: True + If True, draw a box around the ellipse. + prop : `~matplotlib.font_manager.FontProperties`, optional + Font property used as a reference for paddings. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + ellipse : `~matplotlib.patches.Ellipse` + Ellipse patch drawn. + """ + self._box = AuxTransformBox(transform) + self.ellipse = Ellipse((0, 0), width, height, angle=angle) + self._box.add_artist(self.ellipse) + + super().__init__(loc, pad=pad, borderpad=borderpad, child=self._box, + prop=prop, frameon=frameon, **kwargs) + + +class AnchoredSizeBar(AnchoredOffsetbox): + def __init__(self, transform, size, label, loc, + pad=0.1, borderpad=0.1, sep=2, + frameon=True, size_vertical=0, color='black', + label_top=False, fontproperties=None, fill_bar=None, + **kwargs): + """ + Draw a horizontal scale bar with a center-aligned label underneath. + + Parameters + ---------- + transform : `~matplotlib.transforms.Transform` + The transformation object for the coordinate system in use, i.e., + :attr:`matplotlib.axes.Axes.transData`. + size : float + Horizontal length of the size bar, given in coordinates of + *transform*. + label : str + Label to display. + loc : str + Location of the size bar. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + pad : float, default: 0.1 + Padding around the label and size bar, in fraction of the font + size. + borderpad : float, default: 0.1 + Border padding, in fraction of the font size. + sep : float, default: 2 + Separation between the label and the size bar, in points. + frameon : bool, default: True + If True, draw a box around the horizontal bar and label. + size_vertical : float, default: 0 + Vertical length of the size bar, given in coordinates of + *transform*. + color : str, default: 'black' + Color for the size bar and label. + label_top : bool, default: False + If True, the label will be over the size bar. + fontproperties : `~matplotlib.font_manager.FontProperties`, optional + Font properties for the label text. + fill_bar : bool, optional + If True and if *size_vertical* is nonzero, the size bar will + be filled in with the color specified by the size bar. + Defaults to True if *size_vertical* is greater than + zero and False otherwise. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + size_bar : `~matplotlib.offsetbox.AuxTransformBox` + Container for the size bar. + txt_label : `~matplotlib.offsetbox.TextArea` + Container for the label of the size bar. + + Notes + ----- + If *prop* is passed as a keyword argument, but *fontproperties* is + not, then *prop* is assumed to be the intended *fontproperties*. + Using both *prop* and *fontproperties* is not supported. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from mpl_toolkits.axes_grid1.anchored_artists import ( + ... AnchoredSizeBar) + >>> fig, ax = plt.subplots() + >>> ax.imshow(np.random.random((10, 10))) + >>> bar = AnchoredSizeBar(ax.transData, 3, '3 data units', 4) + >>> ax.add_artist(bar) + >>> fig.show() + + Using all the optional parameters + + >>> import matplotlib.font_manager as fm + >>> fontprops = fm.FontProperties(size=14, family='monospace') + >>> bar = AnchoredSizeBar(ax.transData, 3, '3 units', 4, pad=0.5, + ... sep=5, borderpad=0.5, frameon=False, + ... size_vertical=0.5, color='white', + ... fontproperties=fontprops) + """ + if fill_bar is None: + fill_bar = size_vertical > 0 + + self.size_bar = AuxTransformBox(transform) + self.size_bar.add_artist(Rectangle((0, 0), size, size_vertical, + fill=fill_bar, facecolor=color, + edgecolor=color)) + + if fontproperties is None and 'prop' in kwargs: + fontproperties = kwargs.pop('prop') + + if fontproperties is None: + textprops = {'color': color} + else: + textprops = {'color': color, 'fontproperties': fontproperties} + + self.txt_label = TextArea(label, textprops=textprops) + + if label_top: + _box_children = [self.txt_label, self.size_bar] + else: + _box_children = [self.size_bar, self.txt_label] + + self._box = VPacker(children=_box_children, + align="center", + pad=0, sep=sep) + + super().__init__(loc, pad=pad, borderpad=borderpad, child=self._box, + prop=fontproperties, frameon=frameon, **kwargs) + + +class AnchoredDirectionArrows(AnchoredOffsetbox): + def __init__(self, transform, label_x, label_y, length=0.15, + fontsize=0.08, loc='upper left', angle=0, aspect_ratio=1, + pad=0.4, borderpad=0.4, frameon=False, color='w', alpha=1, + sep_x=0.01, sep_y=0, fontproperties=None, back_length=0.15, + head_width=10, head_length=15, tail_width=2, + text_props=None, arrow_props=None, + **kwargs): + """ + Draw two perpendicular arrows to indicate directions. + + Parameters + ---------- + transform : `~matplotlib.transforms.Transform` + The transformation object for the coordinate system in use, i.e., + :attr:`matplotlib.axes.Axes.transAxes`. + label_x, label_y : str + Label text for the x and y arrows + length : float, default: 0.15 + Length of the arrow, given in coordinates of *transform*. + fontsize : float, default: 0.08 + Size of label strings, given in coordinates of *transform*. + loc : str, default: 'upper left' + Location of the arrow. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + angle : float, default: 0 + The angle of the arrows in degrees. + aspect_ratio : float, default: 1 + The ratio of the length of arrow_x and arrow_y. + Negative numbers can be used to change the direction. + pad : float, default: 0.4 + Padding around the labels and arrows, in fraction of the font size. + borderpad : float, default: 0.4 + Border padding, in fraction of the font size. + frameon : bool, default: False + If True, draw a box around the arrows and labels. + color : str, default: 'white' + Color for the arrows and labels. + alpha : float, default: 1 + Alpha values of the arrows and labels + sep_x, sep_y : float, default: 0.01 and 0 respectively + Separation between the arrows and labels in coordinates of + *transform*. + fontproperties : `~matplotlib.font_manager.FontProperties`, optional + Font properties for the label text. + back_length : float, default: 0.15 + Fraction of the arrow behind the arrow crossing. + head_width : float, default: 10 + Width of arrow head, sent to `.ArrowStyle`. + head_length : float, default: 15 + Length of arrow head, sent to `.ArrowStyle`. + tail_width : float, default: 2 + Width of arrow tail, sent to `.ArrowStyle`. + text_props, arrow_props : dict + Properties of the text and arrows, passed to `.TextPath` and + `.FancyArrowPatch`. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + arrow_x, arrow_y : `~matplotlib.patches.FancyArrowPatch` + Arrow x and y + text_path_x, text_path_y : `~matplotlib.text.TextPath` + Path for arrow labels + p_x, p_y : `~matplotlib.patches.PathPatch` + Patch for arrow labels + box : `~matplotlib.offsetbox.AuxTransformBox` + Container for the arrows and labels. + + Notes + ----- + If *prop* is passed as a keyword argument, but *fontproperties* is + not, then *prop* is assumed to be the intended *fontproperties*. + Using both *prop* and *fontproperties* is not supported. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from mpl_toolkits.axes_grid1.anchored_artists import ( + ... AnchoredDirectionArrows) + >>> fig, ax = plt.subplots() + >>> ax.imshow(np.random.random((10, 10))) + >>> arrows = AnchoredDirectionArrows(ax.transAxes, '111', '110') + >>> ax.add_artist(arrows) + >>> fig.show() + + Using several of the optional parameters, creating downward pointing + arrow and high contrast text labels. + + >>> import matplotlib.font_manager as fm + >>> fontprops = fm.FontProperties(family='monospace') + >>> arrows = AnchoredDirectionArrows(ax.transAxes, 'East', 'South', + ... loc='lower left', color='k', + ... aspect_ratio=-1, sep_x=0.02, + ... sep_y=-0.01, + ... text_props={'ec':'w', 'fc':'k'}, + ... fontproperties=fontprops) + """ + if arrow_props is None: + arrow_props = {} + + if text_props is None: + text_props = {} + + arrowstyle = ArrowStyle("Simple", + head_width=head_width, + head_length=head_length, + tail_width=tail_width) + + if fontproperties is None and 'prop' in kwargs: + fontproperties = kwargs.pop('prop') + + if 'color' not in arrow_props: + arrow_props['color'] = color + + if 'alpha' not in arrow_props: + arrow_props['alpha'] = alpha + + if 'color' not in text_props: + text_props['color'] = color + + if 'alpha' not in text_props: + text_props['alpha'] = alpha + + t_start = transform + t_end = t_start + transforms.Affine2D().rotate_deg(angle) + + self.box = AuxTransformBox(t_end) + + length_x = length + length_y = length*aspect_ratio + + self.arrow_x = FancyArrowPatch( + (0, back_length*length_y), + (length_x, back_length*length_y), + arrowstyle=arrowstyle, + shrinkA=0.0, + shrinkB=0.0, + **arrow_props) + + self.arrow_y = FancyArrowPatch( + (back_length*length_x, 0), + (back_length*length_x, length_y), + arrowstyle=arrowstyle, + shrinkA=0.0, + shrinkB=0.0, + **arrow_props) + + self.box.add_artist(self.arrow_x) + self.box.add_artist(self.arrow_y) + + text_path_x = TextPath(( + length_x+sep_x, back_length*length_y+sep_y), label_x, + size=fontsize, prop=fontproperties) + self.p_x = PathPatch(text_path_x, transform=t_start, **text_props) + self.box.add_artist(self.p_x) + + text_path_y = TextPath(( + length_x*back_length+sep_x, length_y*(1-back_length)+sep_y), + label_y, size=fontsize, prop=fontproperties) + self.p_y = PathPatch(text_path_y, **text_props) + self.box.add_artist(self.p_y) + + super().__init__(loc, pad=pad, borderpad=borderpad, child=self.box, + frameon=frameon, **kwargs) diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_divider.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_divider.py new file mode 100644 index 0000000000..f6c38f35db --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_divider.py @@ -0,0 +1,694 @@ +""" +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(<main_axes>))`` 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) diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_grid.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_grid.py new file mode 100644 index 0000000000..720d985414 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_grid.py @@ -0,0 +1,550 @@ +from numbers import Number +import functools +from types import MethodType + +import numpy as np + +from matplotlib import _api, cbook +from matplotlib.gridspec import SubplotSpec + +from .axes_divider import Size, SubplotDivider, Divider +from .mpl_axes import Axes, SimpleAxisArtist + + +class CbarAxesBase: + def __init__(self, *args, orientation, **kwargs): + self.orientation = orientation + super().__init__(*args, **kwargs) + + def colorbar(self, mappable, **kwargs): + return self.figure.colorbar( + mappable, cax=self, location=self.orientation, **kwargs) + + @_api.deprecated("3.8", alternative="ax.tick_params and colorbar.set_label") + def toggle_label(self, b): + axis = self.axis[self.orientation] + axis.toggle(ticklabels=b, label=b) + + +_cbaraxes_class_factory = cbook._make_class_factory(CbarAxesBase, "Cbar{}") + + +class Grid: + """ + A grid of Axes. + + In Matplotlib, the Axes location (and size) is specified in normalized + figure coordinates. This may not be ideal for images that needs to be + displayed with a given aspect ratio; for example, it is difficult to + display multiple images of a same size with some fixed padding between + them. AxesGrid can be used in such case. + """ + + _defaultAxesClass = Axes + + def __init__(self, fig, + rect, + nrows_ncols, + ngrids=None, + direction="row", + axes_pad=0.02, + *, + share_all=False, + share_x=True, + share_y=True, + label_mode="L", + axes_class=None, + aspect=False, + ): + """ + Parameters + ---------- + fig : `.Figure` + The parent figure. + rect : (float, float, float, float), (int, int, int), int, or \ + `~.SubplotSpec` + The axes position, as a ``(left, bottom, width, height)`` tuple, + as a three-digit subplot position code (e.g., ``(1, 2, 1)`` or + ``121``), or as a `~.SubplotSpec`. + nrows_ncols : (int, int) + Number of rows and columns in the grid. + ngrids : int or None, default: None + If not None, only the first *ngrids* axes in the grid are created. + direction : {"row", "column"}, default: "row" + Whether axes are created in row-major ("row by row") or + column-major order ("column by column"). This also affects the + order in which axes are accessed using indexing (``grid[index]``). + axes_pad : float or (float, float), default: 0.02 + Padding or (horizontal padding, vertical padding) between axes, in + inches. + share_all : bool, default: False + Whether all axes share their x- and y-axis. Overrides *share_x* + and *share_y*. + share_x : bool, default: True + Whether all axes of a column share their x-axis. + share_y : bool, default: True + Whether all axes of a row share their y-axis. + label_mode : {"L", "1", "all", "keep"}, default: "L" + Determines which axes will get tick labels: + + - "L": All axes on the left column get vertical tick labels; + all axes on the bottom row get horizontal tick labels. + - "1": Only the bottom left axes is labelled. + - "all": All axes are labelled. + - "keep": Do not do anything. + + axes_class : subclass of `matplotlib.axes.Axes`, default: None + aspect : bool, default: False + Whether the axes aspect ratio follows the aspect ratio of the data + limits. + """ + self._nrows, self._ncols = nrows_ncols + + if ngrids is None: + ngrids = self._nrows * self._ncols + else: + if not 0 < ngrids <= self._nrows * self._ncols: + raise ValueError( + "ngrids must be positive and not larger than nrows*ncols") + + self.ngrids = ngrids + + self._horiz_pad_size, self._vert_pad_size = map( + Size.Fixed, np.broadcast_to(axes_pad, 2)) + + _api.check_in_list(["column", "row"], direction=direction) + self._direction = direction + + if axes_class is None: + axes_class = self._defaultAxesClass + elif isinstance(axes_class, (list, tuple)): + cls, kwargs = axes_class + axes_class = functools.partial(cls, **kwargs) + + kw = dict(horizontal=[], vertical=[], aspect=aspect) + if isinstance(rect, (Number, SubplotSpec)): + self._divider = SubplotDivider(fig, rect, **kw) + elif len(rect) == 3: + self._divider = SubplotDivider(fig, *rect, **kw) + elif len(rect) == 4: + self._divider = Divider(fig, rect, **kw) + else: + raise TypeError("Incorrect rect format") + + rect = self._divider.get_position() + + axes_array = np.full((self._nrows, self._ncols), None, dtype=object) + for i in range(self.ngrids): + col, row = self._get_col_row(i) + if share_all: + sharex = sharey = axes_array[0, 0] + else: + sharex = axes_array[0, col] if share_x else None + sharey = axes_array[row, 0] if share_y else None + axes_array[row, col] = axes_class( + fig, rect, sharex=sharex, sharey=sharey) + self.axes_all = axes_array.ravel( + order="C" if self._direction == "row" else "F").tolist() + self.axes_column = axes_array.T.tolist() + self.axes_row = axes_array.tolist() + self.axes_llc = self.axes_column[0][-1] + + self._init_locators() + + for ax in self.axes_all: + fig.add_axes(ax) + + self.set_label_mode(label_mode) + + def _init_locators(self): + self._divider.set_horizontal( + [Size.Scaled(1), self._horiz_pad_size] * (self._ncols-1) + [Size.Scaled(1)]) + self._divider.set_vertical( + [Size.Scaled(1), self._vert_pad_size] * (self._nrows-1) + [Size.Scaled(1)]) + for i in range(self.ngrids): + col, row = self._get_col_row(i) + self.axes_all[i].set_axes_locator( + self._divider.new_locator(nx=2 * col, ny=2 * (self._nrows - 1 - row))) + + def _get_col_row(self, n): + if self._direction == "column": + col, row = divmod(n, self._nrows) + else: + row, col = divmod(n, self._ncols) + + return col, row + + # Good to propagate __len__ if we have __getitem__ + def __len__(self): + return len(self.axes_all) + + def __getitem__(self, i): + return self.axes_all[i] + + def get_geometry(self): + """ + Return the number of rows and columns of the grid as (nrows, ncols). + """ + return self._nrows, self._ncols + + def set_axes_pad(self, axes_pad): + """ + Set the padding between the axes. + + Parameters + ---------- + axes_pad : (float, float) + The padding (horizontal pad, vertical pad) in inches. + """ + self._horiz_pad_size.fixed_size = axes_pad[0] + self._vert_pad_size.fixed_size = axes_pad[1] + + def get_axes_pad(self): + """ + Return the axes padding. + + Returns + ------- + hpad, vpad + Padding (horizontal pad, vertical pad) in inches. + """ + return (self._horiz_pad_size.fixed_size, + self._vert_pad_size.fixed_size) + + def set_aspect(self, aspect): + """Set the aspect of the SubplotDivider.""" + self._divider.set_aspect(aspect) + + def get_aspect(self): + """Return the aspect of the SubplotDivider.""" + return self._divider.get_aspect() + + def set_label_mode(self, mode): + """ + Define which axes have tick labels. + + Parameters + ---------- + mode : {"L", "1", "all", "keep"} + The label mode: + + - "L": All axes on the left column get vertical tick labels; + all axes on the bottom row get horizontal tick labels. + - "1": Only the bottom left axes is labelled. + - "all": All axes are labelled. + - "keep": Do not do anything. + """ + is_last_row, is_first_col = ( + np.mgrid[:self._nrows, :self._ncols] == [[[self._nrows - 1]], [[0]]]) + if mode == "all": + bottom = left = np.full((self._nrows, self._ncols), True) + elif mode == "L": + bottom = is_last_row + left = is_first_col + elif mode == "1": + bottom = left = is_last_row & is_first_col + else: + # Use _api.check_in_list at the top of the method when deprecation + # period expires + if mode != 'keep': + _api.warn_deprecated( + '3.7', name="Grid label_mode", + message='Passing an undefined label_mode is deprecated ' + 'since %(since)s and will become an error ' + '%(removal)s. To silence this warning, pass ' + '"keep", which gives the same behaviour.') + return + for i in range(self._nrows): + for j in range(self._ncols): + ax = self.axes_row[i][j] + if isinstance(ax.axis, MethodType): + bottom_axis = SimpleAxisArtist(ax.xaxis, 1, ax.spines["bottom"]) + left_axis = SimpleAxisArtist(ax.yaxis, 1, ax.spines["left"]) + else: + bottom_axis = ax.axis["bottom"] + left_axis = ax.axis["left"] + bottom_axis.toggle(ticklabels=bottom[i, j], label=bottom[i, j]) + left_axis.toggle(ticklabels=left[i, j], label=left[i, j]) + + def get_divider(self): + return self._divider + + def set_axes_locator(self, locator): + self._divider.set_locator(locator) + + def get_axes_locator(self): + return self._divider.get_locator() + + +class ImageGrid(Grid): + """ + A grid of Axes for Image display. + + This class is a specialization of `~.axes_grid1.axes_grid.Grid` for displaying a + grid of images. In particular, it forces all axes in a column to share their x-axis + and all axes in a row to share their y-axis. It further provides helpers to add + colorbars to some or all axes. + """ + + def __init__(self, fig, + rect, + nrows_ncols, + ngrids=None, + direction="row", + axes_pad=0.02, + *, + share_all=False, + aspect=True, + label_mode="L", + cbar_mode=None, + cbar_location="right", + cbar_pad=None, + cbar_size="5%", + cbar_set_cax=True, + axes_class=None, + ): + """ + Parameters + ---------- + fig : `.Figure` + The parent figure. + rect : (float, float, float, float) or int + The axes position, as a ``(left, bottom, width, height)`` tuple or + as a three-digit subplot position code (e.g., "121"). + nrows_ncols : (int, int) + Number of rows and columns in the grid. + ngrids : int or None, default: None + If not None, only the first *ngrids* axes in the grid are created. + direction : {"row", "column"}, default: "row" + Whether axes are created in row-major ("row by row") or + column-major order ("column by column"). This also affects the + order in which axes are accessed using indexing (``grid[index]``). + axes_pad : float or (float, float), default: 0.02in + Padding or (horizontal padding, vertical padding) between axes, in + inches. + share_all : bool, default: False + Whether all axes share their x- and y-axis. Note that in any case, + all axes in a column share their x-axis and all axes in a row share + their y-axis. + aspect : bool, default: True + Whether the axes aspect ratio follows the aspect ratio of the data + limits. + label_mode : {"L", "1", "all"}, default: "L" + Determines which axes will get tick labels: + + - "L": All axes on the left column get vertical tick labels; + all axes on the bottom row get horizontal tick labels. + - "1": Only the bottom left axes is labelled. + - "all": all axes are labelled. + + cbar_mode : {"each", "single", "edge", None}, default: None + Whether to create a colorbar for "each" axes, a "single" colorbar + for the entire grid, colorbars only for axes on the "edge" + determined by *cbar_location*, or no colorbars. The colorbars are + stored in the :attr:`cbar_axes` attribute. + cbar_location : {"left", "right", "bottom", "top"}, default: "right" + cbar_pad : float, default: None + Padding between the image axes and the colorbar axes. + cbar_size : size specification (see `.Size.from_any`), default: "5%" + Colorbar size. + cbar_set_cax : bool, default: True + If True, each axes in the grid has a *cax* attribute that is bound + to associated *cbar_axes*. + axes_class : subclass of `matplotlib.axes.Axes`, default: None + """ + _api.check_in_list(["each", "single", "edge", None], + cbar_mode=cbar_mode) + _api.check_in_list(["left", "right", "bottom", "top"], + cbar_location=cbar_location) + self._colorbar_mode = cbar_mode + self._colorbar_location = cbar_location + self._colorbar_pad = cbar_pad + self._colorbar_size = cbar_size + # The colorbar axes are created in _init_locators(). + + super().__init__( + fig, rect, nrows_ncols, ngrids, + direction=direction, axes_pad=axes_pad, + share_all=share_all, share_x=True, share_y=True, aspect=aspect, + label_mode=label_mode, axes_class=axes_class) + + for ax in self.cbar_axes: + fig.add_axes(ax) + + if cbar_set_cax: + if self._colorbar_mode == "single": + for ax in self.axes_all: + ax.cax = self.cbar_axes[0] + elif self._colorbar_mode == "edge": + for index, ax in enumerate(self.axes_all): + col, row = self._get_col_row(index) + if self._colorbar_location in ("left", "right"): + ax.cax = self.cbar_axes[row] + else: + ax.cax = self.cbar_axes[col] + else: + for ax, cax in zip(self.axes_all, self.cbar_axes): + ax.cax = cax + + def _init_locators(self): + # Slightly abusing this method to inject colorbar creation into init. + + if self._colorbar_pad is None: + # horizontal or vertical arrangement? + if self._colorbar_location in ("left", "right"): + self._colorbar_pad = self._horiz_pad_size.fixed_size + else: + self._colorbar_pad = self._vert_pad_size.fixed_size + self.cbar_axes = [ + _cbaraxes_class_factory(self._defaultAxesClass)( + self.axes_all[0].figure, self._divider.get_position(), + orientation=self._colorbar_location) + for _ in range(self.ngrids)] + + cb_mode = self._colorbar_mode + cb_location = self._colorbar_location + + h = [] + v = [] + + h_ax_pos = [] + h_cb_pos = [] + if cb_mode == "single" and cb_location in ("left", "bottom"): + if cb_location == "left": + sz = self._nrows * Size.AxesX(self.axes_llc) + h.append(Size.from_any(self._colorbar_size, sz)) + h.append(Size.from_any(self._colorbar_pad, sz)) + locator = self._divider.new_locator(nx=0, ny=0, ny1=-1) + elif cb_location == "bottom": + sz = self._ncols * Size.AxesY(self.axes_llc) + v.append(Size.from_any(self._colorbar_size, sz)) + v.append(Size.from_any(self._colorbar_pad, sz)) + locator = self._divider.new_locator(nx=0, nx1=-1, ny=0) + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(False) + self.cbar_axes[0].set_axes_locator(locator) + self.cbar_axes[0].set_visible(True) + + for col, ax in enumerate(self.axes_row[0]): + if h: + h.append(self._horiz_pad_size) + + if ax: + sz = Size.AxesX(ax, aspect="axes", ref_ax=self.axes_all[0]) + else: + sz = Size.AxesX(self.axes_all[0], + aspect="axes", ref_ax=self.axes_all[0]) + + if (cb_location == "left" + and (cb_mode == "each" + or (cb_mode == "edge" and col == 0))): + h_cb_pos.append(len(h)) + h.append(Size.from_any(self._colorbar_size, sz)) + h.append(Size.from_any(self._colorbar_pad, sz)) + + h_ax_pos.append(len(h)) + h.append(sz) + + if (cb_location == "right" + and (cb_mode == "each" + or (cb_mode == "edge" and col == self._ncols - 1))): + h.append(Size.from_any(self._colorbar_pad, sz)) + h_cb_pos.append(len(h)) + h.append(Size.from_any(self._colorbar_size, sz)) + + v_ax_pos = [] + v_cb_pos = [] + for row, ax in enumerate(self.axes_column[0][::-1]): + if v: + v.append(self._vert_pad_size) + + if ax: + sz = Size.AxesY(ax, aspect="axes", ref_ax=self.axes_all[0]) + else: + sz = Size.AxesY(self.axes_all[0], + aspect="axes", ref_ax=self.axes_all[0]) + + if (cb_location == "bottom" + and (cb_mode == "each" + or (cb_mode == "edge" and row == 0))): + v_cb_pos.append(len(v)) + v.append(Size.from_any(self._colorbar_size, sz)) + v.append(Size.from_any(self._colorbar_pad, sz)) + + v_ax_pos.append(len(v)) + v.append(sz) + + if (cb_location == "top" + and (cb_mode == "each" + or (cb_mode == "edge" and row == self._nrows - 1))): + v.append(Size.from_any(self._colorbar_pad, sz)) + v_cb_pos.append(len(v)) + v.append(Size.from_any(self._colorbar_size, sz)) + + for i in range(self.ngrids): + col, row = self._get_col_row(i) + locator = self._divider.new_locator(nx=h_ax_pos[col], + ny=v_ax_pos[self._nrows-1-row]) + self.axes_all[i].set_axes_locator(locator) + + if cb_mode == "each": + if cb_location in ("right", "left"): + locator = self._divider.new_locator( + nx=h_cb_pos[col], ny=v_ax_pos[self._nrows - 1 - row]) + + elif cb_location in ("top", "bottom"): + locator = self._divider.new_locator( + nx=h_ax_pos[col], ny=v_cb_pos[self._nrows - 1 - row]) + + self.cbar_axes[i].set_axes_locator(locator) + elif cb_mode == "edge": + if (cb_location == "left" and col == 0 + or cb_location == "right" and col == self._ncols - 1): + locator = self._divider.new_locator( + nx=h_cb_pos[0], ny=v_ax_pos[self._nrows - 1 - row]) + self.cbar_axes[row].set_axes_locator(locator) + elif (cb_location == "bottom" and row == self._nrows - 1 + or cb_location == "top" and row == 0): + locator = self._divider.new_locator(nx=h_ax_pos[col], + ny=v_cb_pos[0]) + self.cbar_axes[col].set_axes_locator(locator) + + if cb_mode == "single": + if cb_location == "right": + sz = self._nrows * Size.AxesX(self.axes_llc) + h.append(Size.from_any(self._colorbar_pad, sz)) + h.append(Size.from_any(self._colorbar_size, sz)) + locator = self._divider.new_locator(nx=-2, ny=0, ny1=-1) + elif cb_location == "top": + sz = self._ncols * Size.AxesY(self.axes_llc) + v.append(Size.from_any(self._colorbar_pad, sz)) + v.append(Size.from_any(self._colorbar_size, sz)) + locator = self._divider.new_locator(nx=0, nx1=-1, ny=-2) + if cb_location in ("right", "top"): + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(False) + self.cbar_axes[0].set_axes_locator(locator) + self.cbar_axes[0].set_visible(True) + elif cb_mode == "each": + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(True) + elif cb_mode == "edge": + if cb_location in ("right", "left"): + count = self._nrows + else: + count = self._ncols + for i in range(count): + self.cbar_axes[i].set_visible(True) + for j in range(i + 1, self.ngrids): + self.cbar_axes[j].set_visible(False) + else: + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(False) + self.cbar_axes[i].set_position([1., 1., 0.001, 0.001], + which="active") + + self._divider.set_horizontal(h) + self._divider.set_vertical(v) + + +AxesGrid = ImageGrid diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_rgb.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_rgb.py new file mode 100644 index 0000000000..52fd707e87 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_rgb.py @@ -0,0 +1,157 @@ +from types import MethodType + +import numpy as np + +from .axes_divider import make_axes_locatable, Size +from .mpl_axes import Axes, SimpleAxisArtist + + +def make_rgb_axes(ax, pad=0.01, axes_class=None, **kwargs): + """ + Parameters + ---------- + ax : `~matplotlib.axes.Axes` + Axes instance to create the RGB Axes in. + pad : float, optional + Fraction of the Axes height to pad. + axes_class : `matplotlib.axes.Axes` or None, optional + Axes class to use for the R, G, and B Axes. If None, use + the same class as *ax*. + **kwargs + Forwarded to *axes_class* init for the R, G, and B Axes. + """ + + divider = make_axes_locatable(ax) + + pad_size = pad * Size.AxesY(ax) + + xsize = ((1-2*pad)/3) * Size.AxesX(ax) + ysize = ((1-2*pad)/3) * Size.AxesY(ax) + + divider.set_horizontal([Size.AxesX(ax), pad_size, xsize]) + divider.set_vertical([ysize, pad_size, ysize, pad_size, ysize]) + + ax.set_axes_locator(divider.new_locator(0, 0, ny1=-1)) + + ax_rgb = [] + if axes_class is None: + axes_class = type(ax) + + for ny in [4, 2, 0]: + ax1 = axes_class(ax.get_figure(), ax.get_position(original=True), + sharex=ax, sharey=ax, **kwargs) + locator = divider.new_locator(nx=2, ny=ny) + ax1.set_axes_locator(locator) + for t in ax1.yaxis.get_ticklabels() + ax1.xaxis.get_ticklabels(): + t.set_visible(False) + try: + for axis in ax1.axis.values(): + axis.major_ticklabels.set_visible(False) + except AttributeError: + pass + + ax_rgb.append(ax1) + + fig = ax.get_figure() + for ax1 in ax_rgb: + fig.add_axes(ax1) + + return ax_rgb + + +class RGBAxes: + """ + 4-panel `~.Axes.imshow` (RGB, R, G, B). + + Layout:: + + ┌───────────────┬─────┐ + │ │ R │ + │ ├─────┤ + │ RGB │ G │ + │ ├─────┤ + │ │ B │ + └───────────────┴─────┘ + + Subclasses can override the ``_defaultAxesClass`` attribute. + By default RGBAxes uses `.mpl_axes.Axes`. + + Attributes + ---------- + RGB : ``_defaultAxesClass`` + The Axes object for the three-channel `~.Axes.imshow`. + R : ``_defaultAxesClass`` + The Axes object for the red channel `~.Axes.imshow`. + G : ``_defaultAxesClass`` + The Axes object for the green channel `~.Axes.imshow`. + B : ``_defaultAxesClass`` + The Axes object for the blue channel `~.Axes.imshow`. + """ + + _defaultAxesClass = Axes + + def __init__(self, *args, pad=0, **kwargs): + """ + Parameters + ---------- + pad : float, default: 0 + Fraction of the Axes height to put as padding. + axes_class : `~matplotlib.axes.Axes` + Axes class to use. If not provided, ``_defaultAxesClass`` is used. + *args + Forwarded to *axes_class* init for the RGB Axes + **kwargs + Forwarded to *axes_class* init for the RGB, R, G, and B Axes + """ + axes_class = kwargs.pop("axes_class", self._defaultAxesClass) + self.RGB = ax = axes_class(*args, **kwargs) + ax.get_figure().add_axes(ax) + self.R, self.G, self.B = make_rgb_axes( + ax, pad=pad, axes_class=axes_class, **kwargs) + # Set the line color and ticks for the axes. + for ax1 in [self.RGB, self.R, self.G, self.B]: + if isinstance(ax1.axis, MethodType): + ad = Axes.AxisDict(self) + ad.update( + bottom=SimpleAxisArtist(ax1.xaxis, 1, ax1.spines["bottom"]), + top=SimpleAxisArtist(ax1.xaxis, 2, ax1.spines["top"]), + left=SimpleAxisArtist(ax1.yaxis, 1, ax1.spines["left"]), + right=SimpleAxisArtist(ax1.yaxis, 2, ax1.spines["right"])) + else: + ad = ax1.axis + ad[:].line.set_color("w") + ad[:].major_ticks.set_markeredgecolor("w") + + def imshow_rgb(self, r, g, b, **kwargs): + """ + Create the four images {rgb, r, g, b}. + + Parameters + ---------- + r, g, b : array-like + The red, green, and blue arrays. + **kwargs + Forwarded to `~.Axes.imshow` calls for the four images. + + Returns + ------- + rgb : `~matplotlib.image.AxesImage` + r : `~matplotlib.image.AxesImage` + g : `~matplotlib.image.AxesImage` + b : `~matplotlib.image.AxesImage` + """ + if not (r.shape == g.shape == b.shape): + raise ValueError( + f'Input shapes ({r.shape}, {g.shape}, {b.shape}) do not match') + RGB = np.dstack([r, g, b]) + R = np.zeros_like(RGB) + R[:, :, 0] = r + G = np.zeros_like(RGB) + G[:, :, 1] = g + B = np.zeros_like(RGB) + B[:, :, 2] = b + im_rgb = self.RGB.imshow(RGB, **kwargs) + im_r = self.R.imshow(R, **kwargs) + im_g = self.G.imshow(G, **kwargs) + im_b = self.B.imshow(B, **kwargs) + return im_rgb, im_r, im_g, im_b diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_size.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_size.py new file mode 100644 index 0000000000..d251472077 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/axes_size.py @@ -0,0 +1,248 @@ +""" +Provides classes of simple units that will be used with `.AxesDivider` +class (or others) to determine the size of each Axes. The unit +classes define `get_size` method that returns a tuple of two floats, +meaning relative and absolute sizes, respectively. + +Note that this class is nothing more than a simple tuple of two +floats. Take a look at the Divider class to see how these two +values are used. +""" + +from numbers import Real + +from matplotlib import _api +from matplotlib.axes import Axes + + +class _Base: + def __rmul__(self, other): + return Fraction(other, self) + + def __add__(self, other): + if isinstance(other, _Base): + return Add(self, other) + else: + return Add(self, Fixed(other)) + + def get_size(self, renderer): + """ + Return two-float tuple with relative and absolute sizes. + """ + raise NotImplementedError("Subclasses must implement") + + +class Add(_Base): + """ + Sum of two sizes. + """ + + def __init__(self, a, b): + self._a = a + self._b = b + + def get_size(self, renderer): + a_rel_size, a_abs_size = self._a.get_size(renderer) + b_rel_size, b_abs_size = self._b.get_size(renderer) + return a_rel_size + b_rel_size, a_abs_size + b_abs_size + + +class Fixed(_Base): + """ + Simple fixed size with absolute part = *fixed_size* and relative part = 0. + """ + + def __init__(self, fixed_size): + _api.check_isinstance(Real, fixed_size=fixed_size) + self.fixed_size = fixed_size + + def get_size(self, renderer): + rel_size = 0. + abs_size = self.fixed_size + return rel_size, abs_size + + +class Scaled(_Base): + """ + Simple scaled(?) size with absolute part = 0 and + relative part = *scalable_size*. + """ + + def __init__(self, scalable_size): + self._scalable_size = scalable_size + + def get_size(self, renderer): + rel_size = self._scalable_size + abs_size = 0. + return rel_size, abs_size + +Scalable = Scaled + + +def _get_axes_aspect(ax): + aspect = ax.get_aspect() + if aspect == "auto": + aspect = 1. + return aspect + + +class AxesX(_Base): + """ + Scaled size whose relative part corresponds to the data width + of the *axes* multiplied by the *aspect*. + """ + + def __init__(self, axes, aspect=1., ref_ax=None): + self._axes = axes + self._aspect = aspect + if aspect == "axes" and ref_ax is None: + raise ValueError("ref_ax must be set when aspect='axes'") + self._ref_ax = ref_ax + + def get_size(self, renderer): + l1, l2 = self._axes.get_xlim() + if self._aspect == "axes": + ref_aspect = _get_axes_aspect(self._ref_ax) + aspect = ref_aspect / _get_axes_aspect(self._axes) + else: + aspect = self._aspect + + rel_size = abs(l2-l1)*aspect + abs_size = 0. + return rel_size, abs_size + + +class AxesY(_Base): + """ + Scaled size whose relative part corresponds to the data height + of the *axes* multiplied by the *aspect*. + """ + + def __init__(self, axes, aspect=1., ref_ax=None): + self._axes = axes + self._aspect = aspect + if aspect == "axes" and ref_ax is None: + raise ValueError("ref_ax must be set when aspect='axes'") + self._ref_ax = ref_ax + + def get_size(self, renderer): + l1, l2 = self._axes.get_ylim() + + if self._aspect == "axes": + ref_aspect = _get_axes_aspect(self._ref_ax) + aspect = _get_axes_aspect(self._axes) + else: + aspect = self._aspect + + rel_size = abs(l2-l1)*aspect + abs_size = 0. + return rel_size, abs_size + + +class MaxExtent(_Base): + """ + Size whose absolute part is either the largest width or the largest height + of the given *artist_list*. + """ + + def __init__(self, artist_list, w_or_h): + self._artist_list = artist_list + _api.check_in_list(["width", "height"], w_or_h=w_or_h) + self._w_or_h = w_or_h + + def add_artist(self, a): + self._artist_list.append(a) + + def get_size(self, renderer): + rel_size = 0. + extent_list = [ + getattr(a.get_window_extent(renderer), self._w_or_h) / a.figure.dpi + for a in self._artist_list] + abs_size = max(extent_list, default=0) + return rel_size, abs_size + + +class MaxWidth(MaxExtent): + """ + Size whose absolute part is the largest width of the given *artist_list*. + """ + + def __init__(self, artist_list): + super().__init__(artist_list, "width") + + +class MaxHeight(MaxExtent): + """ + Size whose absolute part is the largest height of the given *artist_list*. + """ + + def __init__(self, artist_list): + super().__init__(artist_list, "height") + + +class Fraction(_Base): + """ + An instance whose size is a *fraction* of the *ref_size*. + + >>> s = Fraction(0.3, AxesX(ax)) + """ + + def __init__(self, fraction, ref_size): + _api.check_isinstance(Real, fraction=fraction) + self._fraction_ref = ref_size + self._fraction = fraction + + def get_size(self, renderer): + if self._fraction_ref is None: + return self._fraction, 0. + else: + r, a = self._fraction_ref.get_size(renderer) + rel_size = r*self._fraction + abs_size = a*self._fraction + return rel_size, abs_size + + +def from_any(size, fraction_ref=None): + """ + Create a Fixed unit when the first argument is a float, or a + Fraction unit if that is a string that ends with %. The second + argument is only meaningful when Fraction unit is created. + + >>> from mpl_toolkits.axes_grid1.axes_size import from_any + >>> a = from_any(1.2) # => Fixed(1.2) + >>> from_any("50%", a) # => Fraction(0.5, a) + """ + if isinstance(size, Real): + return Fixed(size) + elif isinstance(size, str): + if size[-1] == "%": + return Fraction(float(size[:-1]) / 100, fraction_ref) + raise ValueError("Unknown format") + + +class _AxesDecorationsSize(_Base): + """ + Fixed size, corresponding to the size of decorations on a given Axes side. + """ + + _get_size_map = { + "left": lambda tight_bb, axes_bb: axes_bb.xmin - tight_bb.xmin, + "right": lambda tight_bb, axes_bb: tight_bb.xmax - axes_bb.xmax, + "bottom": lambda tight_bb, axes_bb: axes_bb.ymin - tight_bb.ymin, + "top": lambda tight_bb, axes_bb: tight_bb.ymax - axes_bb.ymax, + } + + def __init__(self, ax, direction): + self._get_size = _api.check_getitem( + self._get_size_map, direction=direction) + self._ax_list = [ax] if isinstance(ax, Axes) else ax + + def get_size(self, renderer): + sz = max([ + self._get_size(ax.get_tightbbox(renderer, call_axes_locator=False), + ax.bbox) + for ax in self._ax_list]) + dpi = renderer.points_to_pixels(72) + abs_size = sz / dpi + rel_size = 0 + return rel_size, abs_size diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/inset_locator.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/inset_locator.py new file mode 100644 index 0000000000..6d591a4531 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/inset_locator.py @@ -0,0 +1,561 @@ +""" +A collection of functions and objects for creating or placing inset axes. +""" + +from matplotlib import _api, _docstring +from matplotlib.offsetbox import AnchoredOffsetbox +from matplotlib.patches import Patch, Rectangle +from matplotlib.path import Path +from matplotlib.transforms import Bbox, BboxTransformTo +from matplotlib.transforms import IdentityTransform, TransformedBbox + +from . import axes_size as Size +from .parasite_axes import HostAxes + + +@_api.deprecated("3.8", alternative="Axes.inset_axes") +class InsetPosition: + @_docstring.dedent_interpd + def __init__(self, parent, lbwh): + """ + An object for positioning an inset axes. + + This is created by specifying the normalized coordinates in the axes, + instead of the figure. + + Parameters + ---------- + parent : `~matplotlib.axes.Axes` + Axes to use for normalizing coordinates. + + lbwh : iterable of four floats + The left edge, bottom edge, width, and height of the inset axes, in + units of the normalized coordinate of the *parent* axes. + + See Also + -------- + :meth:`matplotlib.axes.Axes.set_axes_locator` + + Examples + -------- + The following bounds the inset axes to a box with 20%% of the parent + axes height and 40%% of the width. The size of the axes specified + ([0, 0, 1, 1]) ensures that the axes completely fills the bounding box: + + >>> parent_axes = plt.gca() + >>> ax_ins = plt.axes([0, 0, 1, 1]) + >>> ip = InsetPosition(parent_axes, [0.5, 0.1, 0.4, 0.2]) + >>> ax_ins.set_axes_locator(ip) + """ + self.parent = parent + self.lbwh = lbwh + + def __call__(self, ax, renderer): + bbox_parent = self.parent.get_position(original=False) + trans = BboxTransformTo(bbox_parent) + bbox_inset = Bbox.from_bounds(*self.lbwh) + bb = TransformedBbox(bbox_inset, trans) + return bb + + +class AnchoredLocatorBase(AnchoredOffsetbox): + def __init__(self, bbox_to_anchor, offsetbox, loc, + borderpad=0.5, bbox_transform=None): + super().__init__( + loc, pad=0., child=None, borderpad=borderpad, + bbox_to_anchor=bbox_to_anchor, bbox_transform=bbox_transform + ) + + def draw(self, renderer): + raise RuntimeError("No draw method should be called") + + def __call__(self, ax, renderer): + if renderer is None: + renderer = ax.figure._get_renderer() + self.axes = ax + bbox = self.get_window_extent(renderer) + px, py = self.get_offset(bbox.width, bbox.height, 0, 0, renderer) + bbox_canvas = Bbox.from_bounds(px, py, bbox.width, bbox.height) + tr = ax.figure.transSubfigure.inverted() + return TransformedBbox(bbox_canvas, tr) + + +class AnchoredSizeLocator(AnchoredLocatorBase): + def __init__(self, bbox_to_anchor, x_size, y_size, loc, + borderpad=0.5, bbox_transform=None): + super().__init__( + bbox_to_anchor, None, loc, + borderpad=borderpad, bbox_transform=bbox_transform + ) + + self.x_size = Size.from_any(x_size) + self.y_size = Size.from_any(y_size) + + def get_bbox(self, renderer): + bbox = self.get_bbox_to_anchor() + dpi = renderer.points_to_pixels(72.) + + r, a = self.x_size.get_size(renderer) + width = bbox.width * r + a * dpi + r, a = self.y_size.get_size(renderer) + height = bbox.height * r + a * dpi + + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + + return Bbox.from_bounds(0, 0, width, height).padded(pad) + + +class AnchoredZoomLocator(AnchoredLocatorBase): + def __init__(self, parent_axes, zoom, loc, + borderpad=0.5, + bbox_to_anchor=None, + bbox_transform=None): + self.parent_axes = parent_axes + self.zoom = zoom + if bbox_to_anchor is None: + bbox_to_anchor = parent_axes.bbox + super().__init__( + bbox_to_anchor, None, loc, borderpad=borderpad, + bbox_transform=bbox_transform) + + def get_bbox(self, renderer): + bb = self.parent_axes.transData.transform_bbox(self.axes.viewLim) + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + return ( + Bbox.from_bounds( + 0, 0, abs(bb.width * self.zoom), abs(bb.height * self.zoom)) + .padded(pad)) + + +class BboxPatch(Patch): + @_docstring.dedent_interpd + def __init__(self, bbox, **kwargs): + """ + Patch showing the shape bounded by a Bbox. + + Parameters + ---------- + bbox : `~matplotlib.transforms.Bbox` + Bbox to use for the extents of this patch. + + **kwargs + Patch properties. Valid arguments include: + + %(Patch:kwdoc)s + """ + if "transform" in kwargs: + raise ValueError("transform should not be set") + + kwargs["transform"] = IdentityTransform() + super().__init__(**kwargs) + self.bbox = bbox + + def get_path(self): + # docstring inherited + x0, y0, x1, y1 = self.bbox.extents + return Path._create_closed([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) + + +class BboxConnector(Patch): + @staticmethod + def get_bbox_edge_pos(bbox, loc): + """ + Return the ``(x, y)`` coordinates of corner *loc* of *bbox*; parameters + behave as documented for the `.BboxConnector` constructor. + """ + x0, y0, x1, y1 = bbox.extents + if loc == 1: + return x1, y1 + elif loc == 2: + return x0, y1 + elif loc == 3: + return x0, y0 + elif loc == 4: + return x1, y0 + + @staticmethod + def connect_bbox(bbox1, bbox2, loc1, loc2=None): + """ + Construct a `.Path` connecting corner *loc1* of *bbox1* to corner + *loc2* of *bbox2*, where parameters behave as documented as for the + `.BboxConnector` constructor. + """ + if isinstance(bbox1, Rectangle): + bbox1 = TransformedBbox(Bbox.unit(), bbox1.get_transform()) + if isinstance(bbox2, Rectangle): + bbox2 = TransformedBbox(Bbox.unit(), bbox2.get_transform()) + if loc2 is None: + loc2 = loc1 + x1, y1 = BboxConnector.get_bbox_edge_pos(bbox1, loc1) + x2, y2 = BboxConnector.get_bbox_edge_pos(bbox2, loc2) + return Path([[x1, y1], [x2, y2]]) + + @_docstring.dedent_interpd + def __init__(self, bbox1, bbox2, loc1, loc2=None, **kwargs): + """ + Connect two bboxes with a straight line. + + Parameters + ---------- + bbox1, bbox2 : `~matplotlib.transforms.Bbox` + Bounding boxes to connect. + + loc1, loc2 : {1, 2, 3, 4} + Corner of *bbox1* and *bbox2* to draw the line. Valid values are:: + + 'upper right' : 1, + 'upper left' : 2, + 'lower left' : 3, + 'lower right' : 4 + + *loc2* is optional and defaults to *loc1*. + + **kwargs + Patch properties for the line drawn. Valid arguments include: + + %(Patch:kwdoc)s + """ + if "transform" in kwargs: + raise ValueError("transform should not be set") + + kwargs["transform"] = IdentityTransform() + kwargs.setdefault( + "fill", bool({'fc', 'facecolor', 'color'}.intersection(kwargs))) + super().__init__(**kwargs) + self.bbox1 = bbox1 + self.bbox2 = bbox2 + self.loc1 = loc1 + self.loc2 = loc2 + + def get_path(self): + # docstring inherited + return self.connect_bbox(self.bbox1, self.bbox2, + self.loc1, self.loc2) + + +class BboxConnectorPatch(BboxConnector): + @_docstring.dedent_interpd + def __init__(self, bbox1, bbox2, loc1a, loc2a, loc1b, loc2b, **kwargs): + """ + Connect two bboxes with a quadrilateral. + + The quadrilateral is specified by two lines that start and end at + corners of the bboxes. The four sides of the quadrilateral are defined + by the two lines given, the line between the two corners specified in + *bbox1* and the line between the two corners specified in *bbox2*. + + Parameters + ---------- + bbox1, bbox2 : `~matplotlib.transforms.Bbox` + Bounding boxes to connect. + + loc1a, loc2a, loc1b, loc2b : {1, 2, 3, 4} + The first line connects corners *loc1a* of *bbox1* and *loc2a* of + *bbox2*; the second line connects corners *loc1b* of *bbox1* and + *loc2b* of *bbox2*. Valid values are:: + + 'upper right' : 1, + 'upper left' : 2, + 'lower left' : 3, + 'lower right' : 4 + + **kwargs + Patch properties for the line drawn: + + %(Patch:kwdoc)s + """ + if "transform" in kwargs: + raise ValueError("transform should not be set") + super().__init__(bbox1, bbox2, loc1a, loc2a, **kwargs) + self.loc1b = loc1b + self.loc2b = loc2b + + def get_path(self): + # docstring inherited + path1 = self.connect_bbox(self.bbox1, self.bbox2, self.loc1, self.loc2) + path2 = self.connect_bbox(self.bbox2, self.bbox1, + self.loc2b, self.loc1b) + path_merged = [*path1.vertices, *path2.vertices, path1.vertices[0]] + return Path(path_merged) + + +def _add_inset_axes(parent_axes, axes_class, axes_kwargs, axes_locator): + """Helper function to add an inset axes and disable navigation in it.""" + if axes_class is None: + axes_class = HostAxes + if axes_kwargs is None: + axes_kwargs = {} + inset_axes = axes_class( + parent_axes.figure, parent_axes.get_position(), + **{"navigate": False, **axes_kwargs, "axes_locator": axes_locator}) + return parent_axes.figure.add_axes(inset_axes) + + +@_docstring.dedent_interpd +def inset_axes(parent_axes, width, height, loc='upper right', + bbox_to_anchor=None, bbox_transform=None, + axes_class=None, axes_kwargs=None, + borderpad=0.5): + """ + Create an inset axes with a given width and height. + + Both sizes used can be specified either in inches or percentage. + For example,:: + + inset_axes(parent_axes, width='40%%', height='30%%', loc='lower left') + + creates in inset axes in the lower left corner of *parent_axes* which spans + over 30%% in height and 40%% in width of the *parent_axes*. Since the usage + of `.inset_axes` may become slightly tricky when exceeding such standard + cases, it is recommended to read :doc:`the examples + </gallery/axes_grid1/inset_locator_demo>`. + + Notes + ----- + The meaning of *bbox_to_anchor* and *bbox_to_transform* is interpreted + differently from that of legend. The value of bbox_to_anchor + (or the return value of its get_points method; the default is + *parent_axes.bbox*) is transformed by the bbox_transform (the default + is Identity transform) and then interpreted as points in the pixel + coordinate (which is dpi dependent). + + Thus, following three calls are identical and creates an inset axes + with respect to the *parent_axes*:: + + axins = inset_axes(parent_axes, "30%%", "40%%") + axins = inset_axes(parent_axes, "30%%", "40%%", + bbox_to_anchor=parent_axes.bbox) + axins = inset_axes(parent_axes, "30%%", "40%%", + bbox_to_anchor=(0, 0, 1, 1), + bbox_transform=parent_axes.transAxes) + + Parameters + ---------- + parent_axes : `matplotlib.axes.Axes` + Axes to place the inset axes. + + width, height : float or str + Size of the inset axes to create. If a float is provided, it is + the size in inches, e.g. *width=1.3*. If a string is provided, it is + the size in relative units, e.g. *width='40%%'*. By default, i.e. if + neither *bbox_to_anchor* nor *bbox_transform* are specified, those + are relative to the parent_axes. Otherwise, they are to be understood + relative to the bounding box provided via *bbox_to_anchor*. + + loc : str, default: 'upper right' + Location to place the inset axes. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + + bbox_to_anchor : tuple or `~matplotlib.transforms.BboxBase`, optional + Bbox that the inset axes will be anchored to. If None, + a tuple of (0, 0, 1, 1) is used if *bbox_transform* is set + to *parent_axes.transAxes* or *parent_axes.figure.transFigure*. + Otherwise, *parent_axes.bbox* is used. If a tuple, can be either + [left, bottom, width, height], or [left, bottom]. + If the kwargs *width* and/or *height* are specified in relative units, + the 2-tuple [left, bottom] cannot be used. Note that, + unless *bbox_transform* is set, the units of the bounding box + are interpreted in the pixel coordinate. When using *bbox_to_anchor* + with tuple, it almost always makes sense to also specify + a *bbox_transform*. This might often be the axes transform + *parent_axes.transAxes*. + + bbox_transform : `~matplotlib.transforms.Transform`, optional + Transformation for the bbox that contains the inset axes. + If None, a `.transforms.IdentityTransform` is used. The value + of *bbox_to_anchor* (or the return value of its get_points method) + is transformed by the *bbox_transform* and then interpreted + as points in the pixel coordinate (which is dpi dependent). + You may provide *bbox_to_anchor* in some normalized coordinate, + and give an appropriate transform (e.g., *parent_axes.transAxes*). + + axes_class : `~matplotlib.axes.Axes` type, default: `.HostAxes` + The type of the newly created inset axes. + + axes_kwargs : dict, optional + Keyword arguments to pass to the constructor of the inset axes. + Valid arguments include: + + %(Axes:kwdoc)s + + borderpad : float, default: 0.5 + Padding between inset axes and the bbox_to_anchor. + The units are axes font size, i.e. for a default font size of 10 points + *borderpad = 0.5* is equivalent to a padding of 5 points. + + Returns + ------- + inset_axes : *axes_class* + Inset axes object created. + """ + + if (bbox_transform in [parent_axes.transAxes, parent_axes.figure.transFigure] + and bbox_to_anchor is None): + _api.warn_external("Using the axes or figure transform requires a " + "bounding box in the respective coordinates. " + "Using bbox_to_anchor=(0, 0, 1, 1) now.") + bbox_to_anchor = (0, 0, 1, 1) + if bbox_to_anchor is None: + bbox_to_anchor = parent_axes.bbox + if (isinstance(bbox_to_anchor, tuple) and + (isinstance(width, str) or isinstance(height, str))): + if len(bbox_to_anchor) != 4: + raise ValueError("Using relative units for width or height " + "requires to provide a 4-tuple or a " + "`Bbox` instance to `bbox_to_anchor.") + return _add_inset_axes( + parent_axes, axes_class, axes_kwargs, + AnchoredSizeLocator( + bbox_to_anchor, width, height, loc=loc, + bbox_transform=bbox_transform, borderpad=borderpad)) + + +@_docstring.dedent_interpd +def zoomed_inset_axes(parent_axes, zoom, loc='upper right', + bbox_to_anchor=None, bbox_transform=None, + axes_class=None, axes_kwargs=None, + borderpad=0.5): + """ + Create an anchored inset axes by scaling a parent axes. For usage, also see + :doc:`the examples </gallery/axes_grid1/inset_locator_demo2>`. + + Parameters + ---------- + parent_axes : `~matplotlib.axes.Axes` + Axes to place the inset axes. + + zoom : float + Scaling factor of the data axes. *zoom* > 1 will enlarge the + coordinates (i.e., "zoomed in"), while *zoom* < 1 will shrink the + coordinates (i.e., "zoomed out"). + + loc : str, default: 'upper right' + Location to place the inset axes. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + + bbox_to_anchor : tuple or `~matplotlib.transforms.BboxBase`, optional + Bbox that the inset axes will be anchored to. If None, + *parent_axes.bbox* is used. If a tuple, can be either + [left, bottom, width, height], or [left, bottom]. + If the kwargs *width* and/or *height* are specified in relative units, + the 2-tuple [left, bottom] cannot be used. Note that + the units of the bounding box are determined through the transform + in use. When using *bbox_to_anchor* it almost always makes sense to + also specify a *bbox_transform*. This might often be the axes transform + *parent_axes.transAxes*. + + bbox_transform : `~matplotlib.transforms.Transform`, optional + Transformation for the bbox that contains the inset axes. + If None, a `.transforms.IdentityTransform` is used (i.e. pixel + coordinates). This is useful when not providing any argument to + *bbox_to_anchor*. When using *bbox_to_anchor* it almost always makes + sense to also specify a *bbox_transform*. This might often be the + axes transform *parent_axes.transAxes*. Inversely, when specifying + the axes- or figure-transform here, be aware that not specifying + *bbox_to_anchor* will use *parent_axes.bbox*, the units of which are + in display (pixel) coordinates. + + axes_class : `~matplotlib.axes.Axes` type, default: `.HostAxes` + The type of the newly created inset axes. + + axes_kwargs : dict, optional + Keyword arguments to pass to the constructor of the inset axes. + Valid arguments include: + + %(Axes:kwdoc)s + + borderpad : float, default: 0.5 + Padding between inset axes and the bbox_to_anchor. + The units are axes font size, i.e. for a default font size of 10 points + *borderpad = 0.5* is equivalent to a padding of 5 points. + + Returns + ------- + inset_axes : *axes_class* + Inset axes object created. + """ + + return _add_inset_axes( + parent_axes, axes_class, axes_kwargs, + AnchoredZoomLocator( + parent_axes, zoom=zoom, loc=loc, + bbox_to_anchor=bbox_to_anchor, bbox_transform=bbox_transform, + borderpad=borderpad)) + + +class _TransformedBboxWithCallback(TransformedBbox): + """ + Variant of `.TransformBbox` which calls *callback* before returning points. + + Used by `.mark_inset` to unstale the parent axes' viewlim as needed. + """ + + def __init__(self, *args, callback, **kwargs): + super().__init__(*args, **kwargs) + self._callback = callback + + def get_points(self): + self._callback() + return super().get_points() + + +@_docstring.dedent_interpd +def mark_inset(parent_axes, inset_axes, loc1, loc2, **kwargs): + """ + Draw a box to mark the location of an area represented by an inset axes. + + This function draws a box in *parent_axes* at the bounding box of + *inset_axes*, and shows a connection with the inset axes by drawing lines + at the corners, giving a "zoomed in" effect. + + Parameters + ---------- + parent_axes : `~matplotlib.axes.Axes` + Axes which contains the area of the inset axes. + + inset_axes : `~matplotlib.axes.Axes` + The inset axes. + + loc1, loc2 : {1, 2, 3, 4} + Corners to use for connecting the inset axes and the area in the + parent axes. + + **kwargs + Patch properties for the lines and box drawn: + + %(Patch:kwdoc)s + + Returns + ------- + pp : `~matplotlib.patches.Patch` + The patch drawn to represent the area of the inset axes. + + p1, p2 : `~matplotlib.patches.Patch` + The patches connecting two corners of the inset axes and its area. + """ + rect = _TransformedBboxWithCallback( + inset_axes.viewLim, parent_axes.transData, + callback=parent_axes._unstale_viewLim) + + kwargs.setdefault("fill", bool({'fc', 'facecolor', 'color'}.intersection(kwargs))) + pp = BboxPatch(rect, **kwargs) + parent_axes.add_patch(pp) + + p1 = BboxConnector(inset_axes.bbox, rect, loc1=loc1, **kwargs) + inset_axes.add_patch(p1) + p1.set_clip_on(False) + p2 = BboxConnector(inset_axes.bbox, rect, loc1=loc2, **kwargs) + inset_axes.add_patch(p2) + p2.set_clip_on(False) + + return pp, p1, p2 diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/mpl_axes.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/mpl_axes.py new file mode 100644 index 0000000000..51c8748758 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/mpl_axes.py @@ -0,0 +1,128 @@ +import matplotlib.axes as maxes +from matplotlib.artist import Artist +from matplotlib.axis import XAxis, YAxis + + +class SimpleChainedObjects: + def __init__(self, objects): + self._objects = objects + + def __getattr__(self, k): + _a = SimpleChainedObjects([getattr(a, k) for a in self._objects]) + return _a + + def __call__(self, *args, **kwargs): + for m in self._objects: + m(*args, **kwargs) + + +class Axes(maxes.Axes): + + class AxisDict(dict): + def __init__(self, axes): + self.axes = axes + super().__init__() + + def __getitem__(self, k): + if isinstance(k, tuple): + r = SimpleChainedObjects( + # super() within a list comprehension needs explicit args. + [super(Axes.AxisDict, self).__getitem__(k1) for k1 in k]) + return r + elif isinstance(k, slice): + if k.start is None and k.stop is None and k.step is None: + return SimpleChainedObjects(list(self.values())) + else: + raise ValueError("Unsupported slice") + else: + return dict.__getitem__(self, k) + + def __call__(self, *v, **kwargs): + return maxes.Axes.axis(self.axes, *v, **kwargs) + + @property + def axis(self): + return self._axislines + + def clear(self): + # docstring inherited + super().clear() + # Init axis artists. + self._axislines = self.AxisDict(self) + self._axislines.update( + bottom=SimpleAxisArtist(self.xaxis, 1, self.spines["bottom"]), + top=SimpleAxisArtist(self.xaxis, 2, self.spines["top"]), + left=SimpleAxisArtist(self.yaxis, 1, self.spines["left"]), + right=SimpleAxisArtist(self.yaxis, 2, self.spines["right"])) + + +class SimpleAxisArtist(Artist): + def __init__(self, axis, axisnum, spine): + self._axis = axis + self._axisnum = axisnum + self.line = spine + + if isinstance(axis, XAxis): + self._axis_direction = ["bottom", "top"][axisnum-1] + elif isinstance(axis, YAxis): + self._axis_direction = ["left", "right"][axisnum-1] + else: + raise ValueError( + f"axis must be instance of XAxis or YAxis, but got {axis}") + super().__init__() + + @property + def major_ticks(self): + tickline = "tick%dline" % self._axisnum + return SimpleChainedObjects([getattr(tick, tickline) + for tick in self._axis.get_major_ticks()]) + + @property + def major_ticklabels(self): + label = "label%d" % self._axisnum + return SimpleChainedObjects([getattr(tick, label) + for tick in self._axis.get_major_ticks()]) + + @property + def label(self): + return self._axis.label + + def set_visible(self, b): + self.toggle(all=b) + self.line.set_visible(b) + self._axis.set_visible(True) + super().set_visible(b) + + def set_label(self, txt): + self._axis.set_label_text(txt) + + def toggle(self, all=None, ticks=None, ticklabels=None, label=None): + + if all: + _ticks, _ticklabels, _label = True, True, True + elif all is not None: + _ticks, _ticklabels, _label = False, False, False + else: + _ticks, _ticklabels, _label = None, None, None + + if ticks is not None: + _ticks = ticks + if ticklabels is not None: + _ticklabels = ticklabels + if label is not None: + _label = label + + if _ticks is not None: + tickparam = {f"tick{self._axisnum}On": _ticks} + self._axis.set_tick_params(**tickparam) + if _ticklabels is not None: + tickparam = {f"label{self._axisnum}On": _ticklabels} + self._axis.set_tick_params(**tickparam) + + if _label is not None: + pos = self._axis.get_label_position() + if (pos == self._axis_direction) and not _label: + self._axis.label.set_visible(False) + elif _label: + self._axis.label.set_visible(True) + self._axis.set_label_position(self._axis_direction) diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/parasite_axes.py b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/parasite_axes.py new file mode 100644 index 0000000000..2a2b5957e8 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axes_grid1/parasite_axes.py @@ -0,0 +1,257 @@ +from matplotlib import _api, cbook +import matplotlib.artist as martist +import matplotlib.transforms as mtransforms +from matplotlib.transforms import Bbox +from .mpl_axes import Axes + + +class ParasiteAxesBase: + + def __init__(self, parent_axes, aux_transform=None, + *, viewlim_mode=None, **kwargs): + self._parent_axes = parent_axes + self.transAux = aux_transform + self.set_viewlim_mode(viewlim_mode) + kwargs["frameon"] = False + super().__init__(parent_axes.figure, parent_axes._position, **kwargs) + + def clear(self): + super().clear() + martist.setp(self.get_children(), visible=False) + self._get_lines = self._parent_axes._get_lines + self._parent_axes.callbacks._connect_picklable( + "xlim_changed", self._sync_lims) + self._parent_axes.callbacks._connect_picklable( + "ylim_changed", self._sync_lims) + + def pick(self, mouseevent): + # This most likely goes to Artist.pick (depending on axes_class given + # to the factory), which only handles pick events registered on the + # axes associated with each child: + super().pick(mouseevent) + # But parasite axes are additionally given pick events from their host + # axes (cf. HostAxesBase.pick), which we handle here: + for a in self.get_children(): + if (hasattr(mouseevent.inaxes, "parasites") + and self in mouseevent.inaxes.parasites): + a.pick(mouseevent) + + # aux_transform support + + def _set_lim_and_transforms(self): + if self.transAux is not None: + self.transAxes = self._parent_axes.transAxes + self.transData = self.transAux + self._parent_axes.transData + self._xaxis_transform = mtransforms.blended_transform_factory( + self.transData, self.transAxes) + self._yaxis_transform = mtransforms.blended_transform_factory( + self.transAxes, self.transData) + else: + super()._set_lim_and_transforms() + + def set_viewlim_mode(self, mode): + _api.check_in_list([None, "equal", "transform"], mode=mode) + self._viewlim_mode = mode + + def get_viewlim_mode(self): + return self._viewlim_mode + + def _sync_lims(self, parent): + viewlim = parent.viewLim.frozen() + mode = self.get_viewlim_mode() + if mode is None: + pass + elif mode == "equal": + self.viewLim.set(viewlim) + elif mode == "transform": + self.viewLim.set(viewlim.transformed(self.transAux.inverted())) + else: + _api.check_in_list([None, "equal", "transform"], mode=mode) + + # end of aux_transform support + + +parasite_axes_class_factory = cbook._make_class_factory( + ParasiteAxesBase, "{}Parasite") +ParasiteAxes = parasite_axes_class_factory(Axes) + + +class HostAxesBase: + def __init__(self, *args, **kwargs): + self.parasites = [] + super().__init__(*args, **kwargs) + + def get_aux_axes( + self, tr=None, viewlim_mode="equal", axes_class=None, **kwargs): + """ + Add a parasite axes to this host. + + Despite this method's name, this should actually be thought of as an + ``add_parasite_axes`` method. + + .. versionchanged:: 3.7 + Defaults to same base axes class as host axes. + + Parameters + ---------- + tr : `~matplotlib.transforms.Transform` or None, default: None + If a `.Transform`, the following relation will hold: + ``parasite.transData = tr + host.transData``. + If None, the parasite's and the host's ``transData`` are unrelated. + viewlim_mode : {"equal", "transform", None}, default: "equal" + How the parasite's view limits are set: directly equal to the + parent axes ("equal"), equal after application of *tr* + ("transform"), or independently (None). + axes_class : subclass type of `~matplotlib.axes.Axes`, optional + The `~.axes.Axes` subclass that is instantiated. If None, the base + class of the host axes is used. + **kwargs + Other parameters are forwarded to the parasite axes constructor. + """ + if axes_class is None: + axes_class = self._base_axes_class + parasite_axes_class = parasite_axes_class_factory(axes_class) + ax2 = parasite_axes_class( + self, tr, viewlim_mode=viewlim_mode, **kwargs) + # note that ax2.transData == tr + ax1.transData + # Anything you draw in ax2 will match the ticks and grids of ax1. + self.parasites.append(ax2) + ax2._remove_method = self.parasites.remove + return ax2 + + def draw(self, renderer): + orig_children_len = len(self._children) + + locator = self.get_axes_locator() + if locator: + pos = locator(self, renderer) + self.set_position(pos, which="active") + self.apply_aspect(pos) + else: + self.apply_aspect() + + rect = self.get_position() + for ax in self.parasites: + ax.apply_aspect(rect) + self._children.extend(ax.get_children()) + + super().draw(renderer) + del self._children[orig_children_len:] + + def clear(self): + super().clear() + for ax in self.parasites: + ax.clear() + + def pick(self, mouseevent): + super().pick(mouseevent) + # Also pass pick events on to parasite axes and, in turn, their + # children (cf. ParasiteAxesBase.pick) + for a in self.parasites: + a.pick(mouseevent) + + def twinx(self, axes_class=None): + """ + Create a twin of Axes with a shared x-axis but independent y-axis. + + The y-axis of self will have ticks on the left and the returned axes + will have ticks on the right. + """ + ax = self._add_twin_axes(axes_class, sharex=self) + self.axis["right"].set_visible(False) + ax.axis["right"].set_visible(True) + ax.axis["left", "top", "bottom"].set_visible(False) + return ax + + def twiny(self, axes_class=None): + """ + Create a twin of Axes with a shared y-axis but independent x-axis. + + The x-axis of self will have ticks on the bottom and the returned axes + will have ticks on the top. + """ + ax = self._add_twin_axes(axes_class, sharey=self) + self.axis["top"].set_visible(False) + ax.axis["top"].set_visible(True) + ax.axis["left", "right", "bottom"].set_visible(False) + return ax + + def twin(self, aux_trans=None, axes_class=None): + """ + Create a twin of Axes with no shared axis. + + While self will have ticks on the left and bottom axis, the returned + axes will have ticks on the top and right axis. + """ + if aux_trans is None: + aux_trans = mtransforms.IdentityTransform() + ax = self._add_twin_axes( + axes_class, aux_transform=aux_trans, viewlim_mode="transform") + self.axis["top", "right"].set_visible(False) + ax.axis["top", "right"].set_visible(True) + ax.axis["left", "bottom"].set_visible(False) + return ax + + def _add_twin_axes(self, axes_class, **kwargs): + """ + Helper for `.twinx`/`.twiny`/`.twin`. + + *kwargs* are forwarded to the parasite axes constructor. + """ + if axes_class is None: + axes_class = self._base_axes_class + ax = parasite_axes_class_factory(axes_class)(self, **kwargs) + self.parasites.append(ax) + ax._remove_method = self._remove_any_twin + return ax + + def _remove_any_twin(self, ax): + self.parasites.remove(ax) + restore = ["top", "right"] + if ax._sharex: + restore.remove("top") + if ax._sharey: + restore.remove("right") + self.axis[tuple(restore)].set_visible(True) + self.axis[tuple(restore)].toggle(ticklabels=False, label=False) + + @_api.make_keyword_only("3.8", "call_axes_locator") + def get_tightbbox(self, renderer=None, call_axes_locator=True, + bbox_extra_artists=None): + bbs = [ + *[ax.get_tightbbox(renderer, call_axes_locator=call_axes_locator) + for ax in self.parasites], + super().get_tightbbox(renderer, + call_axes_locator=call_axes_locator, + bbox_extra_artists=bbox_extra_artists)] + return Bbox.union([b for b in bbs if b.width != 0 or b.height != 0]) + + +host_axes_class_factory = host_subplot_class_factory = \ + cbook._make_class_factory(HostAxesBase, "{}HostAxes", "_base_axes_class") +HostAxes = SubplotHost = host_axes_class_factory(Axes) + + +def host_axes(*args, axes_class=Axes, figure=None, **kwargs): + """ + Create axes that can act as a hosts to parasitic axes. + + Parameters + ---------- + figure : `~matplotlib.figure.Figure` + Figure to which the axes will be added. Defaults to the current figure + `.pyplot.gcf()`. + + *args, **kwargs + Will be passed on to the underlying `~.axes.Axes` object creation. + """ + import matplotlib.pyplot as plt + host_axes_class = host_axes_class_factory(axes_class) + if figure is None: + figure = plt.gcf() + ax = host_axes_class(figure, *args, **kwargs) + figure.add_axes(ax) + return ax + + +host_subplot = host_axes diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/__init__.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/__init__.py new file mode 100644 index 0000000000..47242cf7f0 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/__init__.py @@ -0,0 +1,13 @@ +from .axislines import ( + Axes, AxesZero, AxisArtistHelper, AxisArtistHelperRectlinear, + GridHelperBase, GridHelperRectlinear, Subplot, SubplotZero) +from .axis_artist import AxisArtist, GridlinesCollection +from .grid_helper_curvelinear import GridHelperCurveLinear +from .floating_axes import FloatingAxes, FloatingSubplot +from mpl_toolkits.axes_grid1.parasite_axes import ( + host_axes_class_factory, parasite_axes_class_factory) + + +ParasiteAxes = parasite_axes_class_factory(Axes) +HostAxes = host_axes_class_factory(Axes) +SubplotHost = HostAxes diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/angle_helper.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/angle_helper.py new file mode 100644 index 0000000000..1786cd70bc --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/angle_helper.py @@ -0,0 +1,394 @@ +import numpy as np +import math + +from mpl_toolkits.axisartist.grid_finder import ExtremeFinderSimple + + +def select_step_degree(dv): + + degree_limits_ = [1.5, 3, 7, 13, 20, 40, 70, 120, 270, 520] + degree_steps_ = [1, 2, 5, 10, 15, 30, 45, 90, 180, 360] + degree_factors = [1.] * len(degree_steps_) + + minsec_limits_ = [1.5, 2.5, 3.5, 8, 11, 18, 25, 45] + minsec_steps_ = [1, 2, 3, 5, 10, 15, 20, 30] + + minute_limits_ = np.array(minsec_limits_) / 60 + minute_factors = [60.] * len(minute_limits_) + + second_limits_ = np.array(minsec_limits_) / 3600 + second_factors = [3600.] * len(second_limits_) + + degree_limits = [*second_limits_, *minute_limits_, *degree_limits_] + degree_steps = [*minsec_steps_, *minsec_steps_, *degree_steps_] + degree_factors = [*second_factors, *minute_factors, *degree_factors] + + n = np.searchsorted(degree_limits, dv) + step = degree_steps[n] + factor = degree_factors[n] + + return step, factor + + +def select_step_hour(dv): + + hour_limits_ = [1.5, 2.5, 3.5, 5, 7, 10, 15, 21, 36] + hour_steps_ = [1, 2, 3, 4, 6, 8, 12, 18, 24] + hour_factors = [1.] * len(hour_steps_) + + minsec_limits_ = [1.5, 2.5, 3.5, 4.5, 5.5, 8, 11, 14, 18, 25, 45] + minsec_steps_ = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30] + + minute_limits_ = np.array(minsec_limits_) / 60 + minute_factors = [60.] * len(minute_limits_) + + second_limits_ = np.array(minsec_limits_) / 3600 + second_factors = [3600.] * len(second_limits_) + + hour_limits = [*second_limits_, *minute_limits_, *hour_limits_] + hour_steps = [*minsec_steps_, *minsec_steps_, *hour_steps_] + hour_factors = [*second_factors, *minute_factors, *hour_factors] + + n = np.searchsorted(hour_limits, dv) + step = hour_steps[n] + factor = hour_factors[n] + + return step, factor + + +def select_step_sub(dv): + + # subarcsec or degree + tmp = 10.**(int(math.log10(dv))-1.) + + factor = 1./tmp + + if 1.5*tmp >= dv: + step = 1 + elif 3.*tmp >= dv: + step = 2 + elif 7.*tmp >= dv: + step = 5 + else: + step = 1 + factor = 0.1*factor + + return step, factor + + +def select_step(v1, v2, nv, hour=False, include_last=True, + threshold_factor=3600.): + + if v1 > v2: + v1, v2 = v2, v1 + + dv = (v2 - v1) / nv + + if hour: + _select_step = select_step_hour + cycle = 24. + else: + _select_step = select_step_degree + cycle = 360. + + # for degree + if dv > 1 / threshold_factor: + step, factor = _select_step(dv) + else: + step, factor = select_step_sub(dv*threshold_factor) + + factor = factor * threshold_factor + + levs = np.arange(np.floor(v1 * factor / step), + np.ceil(v2 * factor / step) + 0.5, + dtype=int) * step + + # n : number of valid levels. If there is a cycle, e.g., [0, 90, 180, + # 270, 360], the grid line needs to be extended from 0 to 360, so + # we need to return the whole array. However, the last level (360) + # needs to be ignored often. In this case, so we return n=4. + + n = len(levs) + + # we need to check the range of values + # for example, -90 to 90, 0 to 360, + + if factor == 1. and levs[-1] >= levs[0] + cycle: # check for cycle + nv = int(cycle / step) + if include_last: + levs = levs[0] + np.arange(0, nv+1, 1) * step + else: + levs = levs[0] + np.arange(0, nv, 1) * step + + n = len(levs) + + return np.array(levs), n, factor + + +def select_step24(v1, v2, nv, include_last=True, threshold_factor=3600): + v1, v2 = v1 / 15, v2 / 15 + levs, n, factor = select_step(v1, v2, nv, hour=True, + include_last=include_last, + threshold_factor=threshold_factor) + return levs * 15, n, factor + + +def select_step360(v1, v2, nv, include_last=True, threshold_factor=3600): + return select_step(v1, v2, nv, hour=False, + include_last=include_last, + threshold_factor=threshold_factor) + + +class LocatorBase: + def __init__(self, nbins, include_last=True): + self.nbins = nbins + self._include_last = include_last + + def set_params(self, nbins=None): + if nbins is not None: + self.nbins = int(nbins) + + +class LocatorHMS(LocatorBase): + def __call__(self, v1, v2): + return select_step24(v1, v2, self.nbins, self._include_last) + + +class LocatorHM(LocatorBase): + def __call__(self, v1, v2): + return select_step24(v1, v2, self.nbins, self._include_last, + threshold_factor=60) + + +class LocatorH(LocatorBase): + def __call__(self, v1, v2): + return select_step24(v1, v2, self.nbins, self._include_last, + threshold_factor=1) + + +class LocatorDMS(LocatorBase): + def __call__(self, v1, v2): + return select_step360(v1, v2, self.nbins, self._include_last) + + +class LocatorDM(LocatorBase): + def __call__(self, v1, v2): + return select_step360(v1, v2, self.nbins, self._include_last, + threshold_factor=60) + + +class LocatorD(LocatorBase): + def __call__(self, v1, v2): + return select_step360(v1, v2, self.nbins, self._include_last, + threshold_factor=1) + + +class FormatterDMS: + deg_mark = r"^{\circ}" + min_mark = r"^{\prime}" + sec_mark = r"^{\prime\prime}" + + fmt_d = "$%d" + deg_mark + "$" + fmt_ds = r"$%d.%s" + deg_mark + "$" + + # %s for sign + fmt_d_m = r"$%s%d" + deg_mark + r"\,%02d" + min_mark + "$" + fmt_d_ms = r"$%s%d" + deg_mark + r"\,%02d.%s" + min_mark + "$" + + fmt_d_m_partial = "$%s%d" + deg_mark + r"\,%02d" + min_mark + r"\," + fmt_s_partial = "%02d" + sec_mark + "$" + fmt_ss_partial = "%02d.%s" + sec_mark + "$" + + def _get_number_fraction(self, factor): + ## check for fractional numbers + number_fraction = None + # check for 60 + + for threshold in [1, 60, 3600]: + if factor <= threshold: + break + + d = factor // threshold + int_log_d = int(np.floor(np.log10(d))) + if 10**int_log_d == d and d != 1: + number_fraction = int_log_d + factor = factor // 10**int_log_d + return factor, number_fraction + + return factor, number_fraction + + def __call__(self, direction, factor, values): + if len(values) == 0: + return [] + + ss = np.sign(values) + signs = ["-" if v < 0 else "" for v in values] + + factor, number_fraction = self._get_number_fraction(factor) + + values = np.abs(values) + + if number_fraction is not None: + values, frac_part = divmod(values, 10 ** number_fraction) + frac_fmt = "%%0%dd" % (number_fraction,) + frac_str = [frac_fmt % (f1,) for f1 in frac_part] + + if factor == 1: + if number_fraction is None: + return [self.fmt_d % (s * int(v),) for s, v in zip(ss, values)] + else: + return [self.fmt_ds % (s * int(v), f1) + for s, v, f1 in zip(ss, values, frac_str)] + elif factor == 60: + deg_part, min_part = divmod(values, 60) + if number_fraction is None: + return [self.fmt_d_m % (s1, d1, m1) + for s1, d1, m1 in zip(signs, deg_part, min_part)] + else: + return [self.fmt_d_ms % (s, d1, m1, f1) + for s, d1, m1, f1 + in zip(signs, deg_part, min_part, frac_str)] + + elif factor == 3600: + if ss[-1] == -1: + inverse_order = True + values = values[::-1] + signs = signs[::-1] + else: + inverse_order = False + + l_hm_old = "" + r = [] + + deg_part, min_part_ = divmod(values, 3600) + min_part, sec_part = divmod(min_part_, 60) + + if number_fraction is None: + sec_str = [self.fmt_s_partial % (s1,) for s1 in sec_part] + else: + sec_str = [self.fmt_ss_partial % (s1, f1) + for s1, f1 in zip(sec_part, frac_str)] + + for s, d1, m1, s1 in zip(signs, deg_part, min_part, sec_str): + l_hm = self.fmt_d_m_partial % (s, d1, m1) + if l_hm != l_hm_old: + l_hm_old = l_hm + l = l_hm + s1 + else: + l = "$" + s + s1 + r.append(l) + + if inverse_order: + return r[::-1] + else: + return r + + else: # factor > 3600. + return [r"$%s^{\circ}$" % v for v in ss*values] + + +class FormatterHMS(FormatterDMS): + deg_mark = r"^\mathrm{h}" + min_mark = r"^\mathrm{m}" + sec_mark = r"^\mathrm{s}" + + fmt_d = "$%d" + deg_mark + "$" + fmt_ds = r"$%d.%s" + deg_mark + "$" + + # %s for sign + fmt_d_m = r"$%s%d" + deg_mark + r"\,%02d" + min_mark+"$" + fmt_d_ms = r"$%s%d" + deg_mark + r"\,%02d.%s" + min_mark+"$" + + fmt_d_m_partial = "$%s%d" + deg_mark + r"\,%02d" + min_mark + r"\," + fmt_s_partial = "%02d" + sec_mark + "$" + fmt_ss_partial = "%02d.%s" + sec_mark + "$" + + def __call__(self, direction, factor, values): # hour + return super().__call__(direction, factor, np.asarray(values) / 15) + + +class ExtremeFinderCycle(ExtremeFinderSimple): + # docstring inherited + + def __init__(self, nx, ny, + lon_cycle=360., lat_cycle=None, + lon_minmax=None, lat_minmax=(-90, 90)): + """ + This subclass handles the case where one or both coordinates should be + taken modulo 360, or be restricted to not exceed a specific range. + + Parameters + ---------- + nx, ny : int + The number of samples in each direction. + + lon_cycle, lat_cycle : 360 or None + If not None, values in the corresponding direction are taken modulo + *lon_cycle* or *lat_cycle*; in theory this can be any number but + the implementation actually assumes that it is 360 (if not None); + other values give nonsensical results. + + This is done by "unwrapping" the transformed grid coordinates so + that jumps are less than a half-cycle; then normalizing the span to + no more than a full cycle. + + For example, if values are in the union of the [0, 2] and + [358, 360] intervals (typically, angles measured modulo 360), the + values in the second interval are normalized to [-2, 0] instead so + that the values now cover [-2, 2]. If values are in a range of + [5, 1000], this gets normalized to [5, 365]. + + lon_minmax, lat_minmax : (float, float) or None + If not None, the computed bounding box is clipped to the given + range in the corresponding direction. + """ + self.nx, self.ny = nx, ny + self.lon_cycle, self.lat_cycle = lon_cycle, lat_cycle + self.lon_minmax = lon_minmax + self.lat_minmax = lat_minmax + + def __call__(self, transform_xy, x1, y1, x2, y2): + # docstring inherited + x, y = np.meshgrid( + np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny)) + lon, lat = transform_xy(np.ravel(x), np.ravel(y)) + + # iron out jumps, but algorithm should be improved. + # This is just naive way of doing and my fail for some cases. + # Consider replacing this with numpy.unwrap + # We are ignoring invalid warnings. They are triggered when + # comparing arrays with NaNs using > We are already handling + # that correctly using np.nanmin and np.nanmax + with np.errstate(invalid='ignore'): + if self.lon_cycle is not None: + lon0 = np.nanmin(lon) + lon -= 360. * ((lon - lon0) > 180.) + if self.lat_cycle is not None: + lat0 = np.nanmin(lat) + lat -= 360. * ((lat - lat0) > 180.) + + lon_min, lon_max = np.nanmin(lon), np.nanmax(lon) + lat_min, lat_max = np.nanmin(lat), np.nanmax(lat) + + lon_min, lon_max, lat_min, lat_max = \ + self._add_pad(lon_min, lon_max, lat_min, lat_max) + + # check cycle + if self.lon_cycle: + lon_max = min(lon_max, lon_min + self.lon_cycle) + if self.lat_cycle: + lat_max = min(lat_max, lat_min + self.lat_cycle) + + if self.lon_minmax is not None: + min0 = self.lon_minmax[0] + lon_min = max(min0, lon_min) + max0 = self.lon_minmax[1] + lon_max = min(max0, lon_max) + + if self.lat_minmax is not None: + min0 = self.lat_minmax[0] + lat_min = max(min0, lat_min) + max0 = self.lat_minmax[1] + lat_max = min(max0, lat_max) + + return lon_min, lon_max, lat_min, lat_max diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_divider.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_divider.py new file mode 100644 index 0000000000..a01d4e27df --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_divider.py @@ -0,0 +1,2 @@ +from mpl_toolkits.axes_grid1.axes_divider import ( # noqa + Divider, AxesLocator, SubplotDivider, AxesDivider, make_axes_locatable) diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_grid.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_grid.py new file mode 100644 index 0000000000..ecb3e9d92c --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_grid.py @@ -0,0 +1,23 @@ +from matplotlib import _api + +import mpl_toolkits.axes_grid1.axes_grid as axes_grid_orig +from .axislines import Axes + + +_api.warn_deprecated( + "3.8", name=__name__, obj_type="module", alternative="axes_grid1.axes_grid") + + +@_api.deprecated("3.8", alternative=( + "axes_grid1.axes_grid.Grid(..., axes_class=axislines.Axes")) +class Grid(axes_grid_orig.Grid): + _defaultAxesClass = Axes + + +@_api.deprecated("3.8", alternative=( + "axes_grid1.axes_grid.ImageGrid(..., axes_class=axislines.Axes")) +class ImageGrid(axes_grid_orig.ImageGrid): + _defaultAxesClass = Axes + + +AxesGrid = ImageGrid diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_rgb.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_rgb.py new file mode 100644 index 0000000000..2195747469 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axes_rgb.py @@ -0,0 +1,18 @@ +from matplotlib import _api +from mpl_toolkits.axes_grid1.axes_rgb import ( # noqa + make_rgb_axes, RGBAxes as _RGBAxes) +from .axislines import Axes + + +_api.warn_deprecated( + "3.8", name=__name__, obj_type="module", alternative="axes_grid1.axes_rgb") + + +@_api.deprecated("3.8", alternative=( + "axes_grid1.axes_rgb.RGBAxes(..., axes_class=axislines.Axes")) +class RGBAxes(_RGBAxes): + """ + Subclass of `~.axes_grid1.axes_rgb.RGBAxes` with + ``_defaultAxesClass`` = `.axislines.Axes`. + """ + _defaultAxesClass = Axes diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axis_artist.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axis_artist.py new file mode 100644 index 0000000000..407ad07a3d --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axis_artist.py @@ -0,0 +1,1115 @@ +""" +The :mod:`.axis_artist` module implements custom artists to draw axis elements +(axis lines and labels, tick lines and labels, grid lines). + +Axis lines and labels and tick lines and labels are managed by the `AxisArtist` +class; grid lines are managed by the `GridlinesCollection` class. + +There is one `AxisArtist` per Axis; it can be accessed through +the ``axis`` dictionary of the parent Axes (which should be a +`mpl_toolkits.axislines.Axes`), e.g. ``ax.axis["bottom"]``. + +Children of the AxisArtist are accessed as attributes: ``.line`` and ``.label`` +for the axis line and label, ``.major_ticks``, ``.major_ticklabels``, +``.minor_ticks``, ``.minor_ticklabels`` for the tick lines and labels (e.g. +``ax.axis["bottom"].line``). + +Children properties (colors, fonts, line widths, etc.) can be set using +setters, e.g. :: + + # Make the major ticks of the bottom axis red. + ax.axis["bottom"].major_ticks.set_color("red") + +However, things like the locations of ticks, and their ticklabels need to be +changed from the side of the grid_helper. + +axis_direction +-------------- + +`AxisArtist`, `AxisLabel`, `TickLabels` have an *axis_direction* attribute, +which adjusts the location, angle, etc. The *axis_direction* must be one of +"left", "right", "bottom", "top", and follows the Matplotlib convention for +rectangular axis. + +For example, for the *bottom* axis (the left and right is relative to the +direction of the increasing coordinate), + +* ticklabels and axislabel are on the right +* ticklabels and axislabel have text angle of 0 +* ticklabels are baseline, center-aligned +* axislabel is top, center-aligned + +The text angles are actually relative to (90 + angle of the direction to the +ticklabel), which gives 0 for bottom axis. + +=================== ====== ======== ====== ======== +Property left bottom right top +=================== ====== ======== ====== ======== +ticklabel location left right right left +axislabel location left right right left +ticklabel angle 90 0 -90 180 +axislabel angle 180 0 0 180 +ticklabel va center baseline center baseline +axislabel va center top center bottom +ticklabel ha right center right center +axislabel ha right center right center +=================== ====== ======== ====== ======== + +Ticks are by default direct opposite side of the ticklabels. To make ticks to +the same side of the ticklabels, :: + + ax.axis["bottom"].major_ticks.set_tick_out(True) + +The following attributes can be customized (use the ``set_xxx`` methods): + +* `Ticks`: ticksize, tick_out +* `TickLabels`: pad +* `AxisLabel`: pad +""" + +# FIXME : +# angles are given in data coordinate - need to convert it to canvas coordinate + + +from operator import methodcaller + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook +import matplotlib.artist as martist +import matplotlib.colors as mcolors +import matplotlib.text as mtext +from matplotlib.collections import LineCollection +from matplotlib.lines import Line2D +from matplotlib.patches import PathPatch +from matplotlib.path import Path +from matplotlib.transforms import ( + Affine2D, Bbox, IdentityTransform, ScaledTranslation) + +from .axisline_style import AxislineStyle + + +class AttributeCopier: + def get_ref_artist(self): + """ + Return the underlying artist that actually defines some properties + (e.g., color) of this artist. + """ + raise RuntimeError("get_ref_artist must overridden") + + def get_attribute_from_ref_artist(self, attr_name): + getter = methodcaller("get_" + attr_name) + prop = getter(super()) + return getter(self.get_ref_artist()) if prop == "auto" else prop + + +class Ticks(AttributeCopier, Line2D): + """ + Ticks are derived from `.Line2D`, and note that ticks themselves + are markers. Thus, you should use set_mec, set_mew, etc. + + To change the tick size (length), you need to use + `set_ticksize`. To change the direction of the ticks (ticks are + in opposite direction of ticklabels by default), use + ``set_tick_out(False)`` + """ + + def __init__(self, ticksize, tick_out=False, *, axis=None, **kwargs): + self._ticksize = ticksize + self.locs_angles_labels = [] + + self.set_tick_out(tick_out) + + self._axis = axis + if self._axis is not None: + if "color" not in kwargs: + kwargs["color"] = "auto" + if "mew" not in kwargs and "markeredgewidth" not in kwargs: + kwargs["markeredgewidth"] = "auto" + + Line2D.__init__(self, [0.], [0.], **kwargs) + self.set_snap(True) + + def get_ref_artist(self): + # docstring inherited + return self._axis.majorTicks[0].tick1line + + def set_color(self, color): + # docstring inherited + # Unlike the base Line2D.set_color, this also supports "auto". + if not cbook._str_equal(color, "auto"): + mcolors._check_color_like(color=color) + self._color = color + self.stale = True + + def get_color(self): + return self.get_attribute_from_ref_artist("color") + + def get_markeredgecolor(self): + return self.get_attribute_from_ref_artist("markeredgecolor") + + def get_markeredgewidth(self): + return self.get_attribute_from_ref_artist("markeredgewidth") + + def set_tick_out(self, b): + """Set whether ticks are drawn inside or outside the axes.""" + self._tick_out = b + + def get_tick_out(self): + """Return whether ticks are drawn inside or outside the axes.""" + return self._tick_out + + def set_ticksize(self, ticksize): + """Set length of the ticks in points.""" + self._ticksize = ticksize + + def get_ticksize(self): + """Return length of the ticks in points.""" + return self._ticksize + + def set_locs_angles(self, locs_angles): + self.locs_angles = locs_angles + + _tickvert_path = Path([[0., 0.], [1., 0.]]) + + def draw(self, renderer): + if not self.get_visible(): + return + + gc = renderer.new_gc() + gc.set_foreground(self.get_markeredgecolor()) + gc.set_linewidth(self.get_markeredgewidth()) + gc.set_alpha(self._alpha) + + path_trans = self.get_transform() + marker_transform = (Affine2D() + .scale(renderer.points_to_pixels(self._ticksize))) + if self.get_tick_out(): + marker_transform.rotate_deg(180) + + for loc, angle in self.locs_angles: + locs = path_trans.transform_non_affine(np.array([loc])) + if self.axes and not self.axes.viewLim.contains(*locs[0]): + continue + renderer.draw_markers( + gc, self._tickvert_path, + marker_transform + Affine2D().rotate_deg(angle), + Path(locs), path_trans.get_affine()) + + gc.restore() + + +class LabelBase(mtext.Text): + """ + A base class for `.AxisLabel` and `.TickLabels`. The position and + angle of the text are calculated by the offset_ref_angle, + text_ref_angle, and offset_radius attributes. + """ + + def __init__(self, *args, **kwargs): + self.locs_angles_labels = [] + self._ref_angle = 0 + self._offset_radius = 0. + + super().__init__(*args, **kwargs) + + self.set_rotation_mode("anchor") + self._text_follow_ref_angle = True + + @property + def _text_ref_angle(self): + if self._text_follow_ref_angle: + return self._ref_angle + 90 + else: + return 0 + + @property + def _offset_ref_angle(self): + return self._ref_angle + + _get_opposite_direction = {"left": "right", + "right": "left", + "top": "bottom", + "bottom": "top"}.__getitem__ + + def draw(self, renderer): + if not self.get_visible(): + return + + # save original and adjust some properties + tr = self.get_transform() + angle_orig = self.get_rotation() + theta = np.deg2rad(self._offset_ref_angle) + dd = self._offset_radius + dx, dy = dd * np.cos(theta), dd * np.sin(theta) + + self.set_transform(tr + Affine2D().translate(dx, dy)) + self.set_rotation(self._text_ref_angle + angle_orig) + super().draw(renderer) + # restore original properties + self.set_transform(tr) + self.set_rotation(angle_orig) + + def get_window_extent(self, renderer=None): + if renderer is None: + renderer = self.figure._get_renderer() + + # save original and adjust some properties + tr = self.get_transform() + angle_orig = self.get_rotation() + theta = np.deg2rad(self._offset_ref_angle) + dd = self._offset_radius + dx, dy = dd * np.cos(theta), dd * np.sin(theta) + + self.set_transform(tr + Affine2D().translate(dx, dy)) + self.set_rotation(self._text_ref_angle + angle_orig) + bbox = super().get_window_extent(renderer).frozen() + # restore original properties + self.set_transform(tr) + self.set_rotation(angle_orig) + + return bbox + + +class AxisLabel(AttributeCopier, LabelBase): + """ + Axis label. Derived from `.Text`. The position of the text is updated + in the fly, so changing text position has no effect. Otherwise, the + properties can be changed as a normal `.Text`. + + To change the pad between tick labels and axis label, use `set_pad`. + """ + + def __init__(self, *args, axis_direction="bottom", axis=None, **kwargs): + self._axis = axis + self._pad = 5 + self._external_pad = 0 # in pixels + LabelBase.__init__(self, *args, **kwargs) + self.set_axis_direction(axis_direction) + + def set_pad(self, pad): + """ + Set the internal pad in points. + + The actual pad will be the sum of the internal pad and the + external pad (the latter is set automatically by the `.AxisArtist`). + + Parameters + ---------- + pad : float + The internal pad in points. + """ + self._pad = pad + + def get_pad(self): + """ + Return the internal pad in points. + + See `.set_pad` for more details. + """ + return self._pad + + def get_ref_artist(self): + # docstring inherited + return self._axis.get_label() + + def get_text(self): + # docstring inherited + t = super().get_text() + if t == "__from_axes__": + return self._axis.get_label().get_text() + return self._text + + _default_alignments = dict(left=("bottom", "center"), + right=("top", "center"), + bottom=("top", "center"), + top=("bottom", "center")) + + def set_default_alignment(self, d): + """ + Set the default alignment. See `set_axis_direction` for details. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ + va, ha = _api.check_getitem(self._default_alignments, d=d) + self.set_va(va) + self.set_ha(ha) + + _default_angles = dict(left=180, + right=0, + bottom=0, + top=180) + + def set_default_angle(self, d): + """ + Set the default angle. See `set_axis_direction` for details. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ + self.set_rotation(_api.check_getitem(self._default_angles, d=d)) + + def set_axis_direction(self, d): + """ + Adjust the text angle and text alignment of axis label + according to the matplotlib convention. + + ===================== ========== ========= ========== ========== + Property left bottom right top + ===================== ========== ========= ========== ========== + axislabel angle 180 0 0 180 + axislabel va center top center bottom + axislabel ha right center right center + ===================== ========== ========= ========== ========== + + Note that the text angles are actually relative to (90 + angle + of the direction to the ticklabel), which gives 0 for bottom + axis. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ + self.set_default_alignment(d) + self.set_default_angle(d) + + def get_color(self): + return self.get_attribute_from_ref_artist("color") + + def draw(self, renderer): + if not self.get_visible(): + return + + self._offset_radius = \ + self._external_pad + renderer.points_to_pixels(self.get_pad()) + + super().draw(renderer) + + def get_window_extent(self, renderer=None): + if renderer is None: + renderer = self.figure._get_renderer() + if not self.get_visible(): + return + + r = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + + bb = super().get_window_extent(renderer) + + return bb + + +class TickLabels(AxisLabel): # mtext.Text + """ + Tick labels. While derived from `.Text`, this single artist draws all + ticklabels. As in `.AxisLabel`, the position of the text is updated + in the fly, so changing text position has no effect. Otherwise, + the properties can be changed as a normal `.Text`. Unlike the + ticklabels of the mainline Matplotlib, properties of a single + ticklabel alone cannot be modified. + + To change the pad between ticks and ticklabels, use `~.AxisLabel.set_pad`. + """ + + def __init__(self, *, axis_direction="bottom", **kwargs): + super().__init__(**kwargs) + self.set_axis_direction(axis_direction) + self._axislabel_pad = 0 + + def get_ref_artist(self): + # docstring inherited + return self._axis.get_ticklabels()[0] + + def set_axis_direction(self, label_direction): + """ + Adjust the text angle and text alignment of ticklabels + according to the Matplotlib convention. + + The *label_direction* must be one of [left, right, bottom, top]. + + ===================== ========== ========= ========== ========== + Property left bottom right top + ===================== ========== ========= ========== ========== + ticklabel angle 90 0 -90 180 + ticklabel va center baseline center baseline + ticklabel ha right center right center + ===================== ========== ========= ========== ========== + + Note that the text angles are actually relative to (90 + angle + of the direction to the ticklabel), which gives 0 for bottom + axis. + + Parameters + ---------- + label_direction : {"left", "bottom", "right", "top"} + + """ + self.set_default_alignment(label_direction) + self.set_default_angle(label_direction) + self._axis_direction = label_direction + + def invert_axis_direction(self): + label_direction = self._get_opposite_direction(self._axis_direction) + self.set_axis_direction(label_direction) + + def _get_ticklabels_offsets(self, renderer, label_direction): + """ + Calculate the ticklabel offsets from the tick and their total heights. + + The offset only takes account the offset due to the vertical alignment + of the ticklabels: if axis direction is bottom and va is 'top', it will + return 0; if va is 'baseline', it will return (height-descent). + """ + whd_list = self.get_texts_widths_heights_descents(renderer) + + if not whd_list: + return 0, 0 + + r = 0 + va, ha = self.get_va(), self.get_ha() + + if label_direction == "left": + pad = max(w for w, h, d in whd_list) + if ha == "left": + r = pad + elif ha == "center": + r = .5 * pad + elif label_direction == "right": + pad = max(w for w, h, d in whd_list) + if ha == "right": + r = pad + elif ha == "center": + r = .5 * pad + elif label_direction == "bottom": + pad = max(h for w, h, d in whd_list) + if va == "bottom": + r = pad + elif va == "center": + r = .5 * pad + elif va == "baseline": + max_ascent = max(h - d for w, h, d in whd_list) + max_descent = max(d for w, h, d in whd_list) + r = max_ascent + pad = max_ascent + max_descent + elif label_direction == "top": + pad = max(h for w, h, d in whd_list) + if va == "top": + r = pad + elif va == "center": + r = .5 * pad + elif va == "baseline": + max_ascent = max(h - d for w, h, d in whd_list) + max_descent = max(d for w, h, d in whd_list) + r = max_descent + pad = max_ascent + max_descent + + # r : offset + # pad : total height of the ticklabels. This will be used to + # calculate the pad for the axislabel. + return r, pad + + _default_alignments = dict(left=("center", "right"), + right=("center", "left"), + bottom=("baseline", "center"), + top=("baseline", "center")) + + _default_angles = dict(left=90, + right=-90, + bottom=0, + top=180) + + def draw(self, renderer): + if not self.get_visible(): + self._axislabel_pad = self._external_pad + return + + r, total_width = self._get_ticklabels_offsets(renderer, + self._axis_direction) + + pad = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + pad + + for (x, y), a, l in self._locs_angles_labels: + if not l.strip(): + continue + self._ref_angle = a + self.set_x(x) + self.set_y(y) + self.set_text(l) + LabelBase.draw(self, renderer) + + # the value saved will be used to draw axislabel. + self._axislabel_pad = total_width + pad + + def set_locs_angles_labels(self, locs_angles_labels): + self._locs_angles_labels = locs_angles_labels + + def get_window_extents(self, renderer=None): + if renderer is None: + renderer = self.figure._get_renderer() + + if not self.get_visible(): + self._axislabel_pad = self._external_pad + return [] + + bboxes = [] + + r, total_width = self._get_ticklabels_offsets(renderer, + self._axis_direction) + + pad = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + pad + + for (x, y), a, l in self._locs_angles_labels: + self._ref_angle = a + self.set_x(x) + self.set_y(y) + self.set_text(l) + bb = LabelBase.get_window_extent(self, renderer) + bboxes.append(bb) + + # the value saved will be used to draw axislabel. + self._axislabel_pad = total_width + pad + + return bboxes + + def get_texts_widths_heights_descents(self, renderer): + """ + Return a list of ``(width, height, descent)`` tuples for ticklabels. + + Empty labels are left out. + """ + whd_list = [] + for _loc, _angle, label in self._locs_angles_labels: + if not label.strip(): + continue + clean_line, ismath = self._preprocess_math(label) + whd = renderer.get_text_width_height_descent( + clean_line, self._fontproperties, ismath=ismath) + whd_list.append(whd) + return whd_list + + +class GridlinesCollection(LineCollection): + def __init__(self, *args, which="major", axis="both", **kwargs): + """ + Collection of grid lines. + + Parameters + ---------- + which : {"major", "minor"} + Which grid to consider. + axis : {"both", "x", "y"} + Which axis to consider. + *args, **kwargs + Passed to `.LineCollection`. + """ + self._which = which + self._axis = axis + super().__init__(*args, **kwargs) + self.set_grid_helper(None) + + def set_which(self, which): + """ + Select major or minor grid lines. + + Parameters + ---------- + which : {"major", "minor"} + """ + self._which = which + + def set_axis(self, axis): + """ + Select axis. + + Parameters + ---------- + axis : {"both", "x", "y"} + """ + self._axis = axis + + def set_grid_helper(self, grid_helper): + """ + Set grid helper. + + Parameters + ---------- + grid_helper : `.GridHelperBase` subclass + """ + self._grid_helper = grid_helper + + def draw(self, renderer): + if self._grid_helper is not None: + self._grid_helper.update_lim(self.axes) + gl = self._grid_helper.get_gridlines(self._which, self._axis) + self.set_segments([np.transpose(l) for l in gl]) + super().draw(renderer) + + +class AxisArtist(martist.Artist): + """ + An artist which draws axis (a line along which the n-th axes coord + is constant) line, ticks, tick labels, and axis label. + """ + + zorder = 2.5 + + @property + def LABELPAD(self): + return self.label.get_pad() + + @LABELPAD.setter + def LABELPAD(self, v): + self.label.set_pad(v) + + def __init__(self, axes, + helper, + offset=None, + axis_direction="bottom", + **kwargs): + """ + Parameters + ---------- + axes : `mpl_toolkits.axisartist.axislines.Axes` + helper : `~mpl_toolkits.axisartist.axislines.AxisArtistHelper` + """ + # axes is also used to follow the axis attribute (tick color, etc). + + super().__init__(**kwargs) + + self.axes = axes + + self._axis_artist_helper = helper + + if offset is None: + offset = (0, 0) + self.offset_transform = ScaledTranslation( + *offset, + Affine2D().scale(1 / 72) # points to inches. + + self.axes.figure.dpi_scale_trans) + + if axis_direction in ["left", "right"]: + self.axis = axes.yaxis + else: + self.axis = axes.xaxis + + self._axisline_style = None + self._axis_direction = axis_direction + + self._init_line() + self._init_ticks(**kwargs) + self._init_offsetText(axis_direction) + self._init_label() + + # axis direction + self._ticklabel_add_angle = 0. + self._axislabel_add_angle = 0. + self.set_axis_direction(axis_direction) + + # axis direction + + def set_axis_direction(self, axis_direction): + """ + Adjust the direction, text angle, and text alignment of tick labels + and axis labels following the Matplotlib convention for the rectangle + axes. + + The *axis_direction* must be one of [left, right, bottom, top]. + + ===================== ========== ========= ========== ========== + Property left bottom right top + ===================== ========== ========= ========== ========== + ticklabel direction "-" "+" "+" "-" + axislabel direction "-" "+" "+" "-" + ticklabel angle 90 0 -90 180 + ticklabel va center baseline center baseline + ticklabel ha right center right center + axislabel angle 180 0 0 180 + axislabel va center top center bottom + axislabel ha right center right center + ===================== ========== ========= ========== ========== + + Note that the direction "+" and "-" are relative to the direction of + the increasing coordinate. Also, the text angles are actually + relative to (90 + angle of the direction to the ticklabel), + which gives 0 for bottom axis. + + Parameters + ---------- + axis_direction : {"left", "bottom", "right", "top"} + """ + self.major_ticklabels.set_axis_direction(axis_direction) + self.label.set_axis_direction(axis_direction) + self._axis_direction = axis_direction + if axis_direction in ["left", "top"]: + self.set_ticklabel_direction("-") + self.set_axislabel_direction("-") + else: + self.set_ticklabel_direction("+") + self.set_axislabel_direction("+") + + def set_ticklabel_direction(self, tick_direction): + r""" + Adjust the direction of the tick labels. + + Note that the *tick_direction*\s '+' and '-' are relative to the + direction of the increasing coordinate. + + Parameters + ---------- + tick_direction : {"+", "-"} + """ + self._ticklabel_add_angle = _api.check_getitem( + {"+": 0, "-": 180}, tick_direction=tick_direction) + + def invert_ticklabel_direction(self): + self._ticklabel_add_angle = (self._ticklabel_add_angle + 180) % 360 + self.major_ticklabels.invert_axis_direction() + self.minor_ticklabels.invert_axis_direction() + + def set_axislabel_direction(self, label_direction): + r""" + Adjust the direction of the axis label. + + Note that the *label_direction*\s '+' and '-' are relative to the + direction of the increasing coordinate. + + Parameters + ---------- + label_direction : {"+", "-"} + """ + self._axislabel_add_angle = _api.check_getitem( + {"+": 0, "-": 180}, label_direction=label_direction) + + def get_transform(self): + return self.axes.transAxes + self.offset_transform + + def get_helper(self): + """ + Return axis artist helper instance. + """ + return self._axis_artist_helper + + def set_axisline_style(self, axisline_style=None, **kwargs): + """ + Set the axisline style. + + The new style is completely defined by the passed attributes. Existing + style attributes are forgotten. + + Parameters + ---------- + axisline_style : str or None + The line style, e.g. '->', optionally followed by a comma-separated + list of attributes. Alternatively, the attributes can be provided + as keywords. + + If *None* this returns a string containing the available styles. + + Examples + -------- + The following two commands are equal: + + >>> set_axisline_style("->,size=1.5") + >>> set_axisline_style("->", size=1.5) + """ + if axisline_style is None: + return AxislineStyle.pprint_styles() + + if isinstance(axisline_style, AxislineStyle._Base): + self._axisline_style = axisline_style + else: + self._axisline_style = AxislineStyle(axisline_style, **kwargs) + + self._init_line() + + def get_axisline_style(self): + """Return the current axisline style.""" + return self._axisline_style + + def _init_line(self): + """ + Initialize the *line* artist that is responsible to draw the axis line. + """ + tran = (self._axis_artist_helper.get_line_transform(self.axes) + + self.offset_transform) + + axisline_style = self.get_axisline_style() + if axisline_style is None: + self.line = PathPatch( + self._axis_artist_helper.get_line(self.axes), + color=mpl.rcParams['axes.edgecolor'], + fill=False, + linewidth=mpl.rcParams['axes.linewidth'], + capstyle=mpl.rcParams['lines.solid_capstyle'], + joinstyle=mpl.rcParams['lines.solid_joinstyle'], + transform=tran) + else: + self.line = axisline_style(self, transform=tran) + + def _draw_line(self, renderer): + self.line.set_path(self._axis_artist_helper.get_line(self.axes)) + if self.get_axisline_style() is not None: + self.line.set_line_mutation_scale(self.major_ticklabels.get_size()) + self.line.draw(renderer) + + def _init_ticks(self, **kwargs): + axis_name = self.axis.axis_name + + trans = (self._axis_artist_helper.get_tick_transform(self.axes) + + self.offset_transform) + + self.major_ticks = Ticks( + kwargs.get( + "major_tick_size", + mpl.rcParams[f"{axis_name}tick.major.size"]), + axis=self.axis, transform=trans) + self.minor_ticks = Ticks( + kwargs.get( + "minor_tick_size", + mpl.rcParams[f"{axis_name}tick.minor.size"]), + axis=self.axis, transform=trans) + + size = mpl.rcParams[f"{axis_name}tick.labelsize"] + self.major_ticklabels = TickLabels( + axis=self.axis, + axis_direction=self._axis_direction, + figure=self.axes.figure, + transform=trans, + fontsize=size, + pad=kwargs.get( + "major_tick_pad", mpl.rcParams[f"{axis_name}tick.major.pad"]), + ) + self.minor_ticklabels = TickLabels( + axis=self.axis, + axis_direction=self._axis_direction, + figure=self.axes.figure, + transform=trans, + fontsize=size, + pad=kwargs.get( + "minor_tick_pad", mpl.rcParams[f"{axis_name}tick.minor.pad"]), + ) + + def _get_tick_info(self, tick_iter): + """ + Return a pair of: + + - list of locs and angles for ticks + - list of locs, angles and labels for ticklabels. + """ + ticks_loc_angle = [] + ticklabels_loc_angle_label = [] + + ticklabel_add_angle = self._ticklabel_add_angle + + for loc, angle_normal, angle_tangent, label in tick_iter: + angle_label = angle_tangent - 90 + ticklabel_add_angle + angle_tick = (angle_normal + if 90 <= (angle_label - angle_normal) % 360 <= 270 + else angle_normal + 180) + ticks_loc_angle.append([loc, angle_tick]) + ticklabels_loc_angle_label.append([loc, angle_label, label]) + + return ticks_loc_angle, ticklabels_loc_angle_label + + def _update_ticks(self, renderer=None): + # set extra pad for major and minor ticklabels: use ticksize of + # majorticks even for minor ticks. not clear what is best. + + if renderer is None: + renderer = self.figure._get_renderer() + + dpi_cor = renderer.points_to_pixels(1.) + if self.major_ticks.get_visible() and self.major_ticks.get_tick_out(): + ticklabel_pad = self.major_ticks._ticksize * dpi_cor + self.major_ticklabels._external_pad = ticklabel_pad + self.minor_ticklabels._external_pad = ticklabel_pad + else: + self.major_ticklabels._external_pad = 0 + self.minor_ticklabels._external_pad = 0 + + majortick_iter, minortick_iter = \ + self._axis_artist_helper.get_tick_iterators(self.axes) + + tick_loc_angle, ticklabel_loc_angle_label = \ + self._get_tick_info(majortick_iter) + self.major_ticks.set_locs_angles(tick_loc_angle) + self.major_ticklabels.set_locs_angles_labels(ticklabel_loc_angle_label) + + tick_loc_angle, ticklabel_loc_angle_label = \ + self._get_tick_info(minortick_iter) + self.minor_ticks.set_locs_angles(tick_loc_angle) + self.minor_ticklabels.set_locs_angles_labels(ticklabel_loc_angle_label) + + def _draw_ticks(self, renderer): + self._update_ticks(renderer) + self.major_ticks.draw(renderer) + self.major_ticklabels.draw(renderer) + self.minor_ticks.draw(renderer) + self.minor_ticklabels.draw(renderer) + if (self.major_ticklabels.get_visible() + or self.minor_ticklabels.get_visible()): + self._draw_offsetText(renderer) + + _offsetText_pos = dict(left=(0, 1, "bottom", "right"), + right=(1, 1, "bottom", "left"), + bottom=(1, 0, "top", "right"), + top=(1, 1, "bottom", "right")) + + def _init_offsetText(self, direction): + x, y, va, ha = self._offsetText_pos[direction] + self.offsetText = mtext.Annotation( + "", + xy=(x, y), xycoords="axes fraction", + xytext=(0, 0), textcoords="offset points", + color=mpl.rcParams['xtick.color'], + horizontalalignment=ha, verticalalignment=va, + ) + self.offsetText.set_transform(IdentityTransform()) + self.axes._set_artist_props(self.offsetText) + + def _update_offsetText(self): + self.offsetText.set_text(self.axis.major.formatter.get_offset()) + self.offsetText.set_size(self.major_ticklabels.get_size()) + offset = (self.major_ticklabels.get_pad() + + self.major_ticklabels.get_size() + + 2) + self.offsetText.xyann = (0, offset) + + def _draw_offsetText(self, renderer): + self._update_offsetText() + self.offsetText.draw(renderer) + + def _init_label(self, **kwargs): + tr = (self._axis_artist_helper.get_axislabel_transform(self.axes) + + self.offset_transform) + self.label = AxisLabel( + 0, 0, "__from_axes__", + color="auto", + fontsize=kwargs.get("labelsize", mpl.rcParams['axes.labelsize']), + fontweight=mpl.rcParams['axes.labelweight'], + axis=self.axis, + transform=tr, + axis_direction=self._axis_direction, + ) + self.label.set_figure(self.axes.figure) + labelpad = kwargs.get("labelpad", 5) + self.label.set_pad(labelpad) + + def _update_label(self, renderer): + if not self.label.get_visible(): + return + + if self._ticklabel_add_angle != self._axislabel_add_angle: + if ((self.major_ticks.get_visible() + and not self.major_ticks.get_tick_out()) + or (self.minor_ticks.get_visible() + and not self.major_ticks.get_tick_out())): + axislabel_pad = self.major_ticks._ticksize + else: + axislabel_pad = 0 + else: + axislabel_pad = max(self.major_ticklabels._axislabel_pad, + self.minor_ticklabels._axislabel_pad) + + self.label._external_pad = axislabel_pad + + xy, angle_tangent = \ + self._axis_artist_helper.get_axislabel_pos_angle(self.axes) + if xy is None: + return + + angle_label = angle_tangent - 90 + + x, y = xy + self.label._ref_angle = angle_label + self._axislabel_add_angle + self.label.set(x=x, y=y) + + def _draw_label(self, renderer): + self._update_label(renderer) + self.label.draw(renderer) + + def set_label(self, s): + # docstring inherited + self.label.set_text(s) + + def get_tightbbox(self, renderer=None): + if not self.get_visible(): + return + self._axis_artist_helper.update_lim(self.axes) + self._update_ticks(renderer) + self._update_label(renderer) + + self.line.set_path(self._axis_artist_helper.get_line(self.axes)) + if self.get_axisline_style() is not None: + self.line.set_line_mutation_scale(self.major_ticklabels.get_size()) + + bb = [ + *self.major_ticklabels.get_window_extents(renderer), + *self.minor_ticklabels.get_window_extents(renderer), + self.label.get_window_extent(renderer), + self.offsetText.get_window_extent(renderer), + self.line.get_window_extent(renderer), + ] + bb = [b for b in bb if b and (b.width != 0 or b.height != 0)] + if bb: + _bbox = Bbox.union(bb) + return _bbox + else: + return None + + @martist.allow_rasterization + def draw(self, renderer): + # docstring inherited + if not self.get_visible(): + return + renderer.open_group(__name__, gid=self.get_gid()) + self._axis_artist_helper.update_lim(self.axes) + self._draw_ticks(renderer) + self._draw_line(renderer) + self._draw_label(renderer) + renderer.close_group(__name__) + + def toggle(self, all=None, ticks=None, ticklabels=None, label=None): + """ + Toggle visibility of ticks, ticklabels, and (axis) label. + To turn all off, :: + + axis.toggle(all=False) + + To turn all off but ticks on :: + + axis.toggle(all=False, ticks=True) + + To turn all on but (axis) label off :: + + axis.toggle(all=True, label=False) + + """ + if all: + _ticks, _ticklabels, _label = True, True, True + elif all is not None: + _ticks, _ticklabels, _label = False, False, False + else: + _ticks, _ticklabels, _label = None, None, None + + if ticks is not None: + _ticks = ticks + if ticklabels is not None: + _ticklabels = ticklabels + if label is not None: + _label = label + + if _ticks is not None: + self.major_ticks.set_visible(_ticks) + self.minor_ticks.set_visible(_ticks) + if _ticklabels is not None: + self.major_ticklabels.set_visible(_ticklabels) + self.minor_ticklabels.set_visible(_ticklabels) + if _label is not None: + self.label.set_visible(_label) diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axisline_style.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axisline_style.py new file mode 100644 index 0000000000..5ae188021b --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axisline_style.py @@ -0,0 +1,193 @@ +""" +Provides classes to style the axis lines. +""" +import math + +import numpy as np + +import matplotlib as mpl +from matplotlib.patches import _Style, FancyArrowPatch +from matplotlib.path import Path +from matplotlib.transforms import IdentityTransform + + +class _FancyAxislineStyle: + class SimpleArrow(FancyArrowPatch): + """The artist class that will be returned for SimpleArrow style.""" + _ARROW_STYLE = "->" + + def __init__(self, axis_artist, line_path, transform, + line_mutation_scale): + self._axis_artist = axis_artist + self._line_transform = transform + self._line_path = line_path + self._line_mutation_scale = line_mutation_scale + + FancyArrowPatch.__init__(self, + path=self._line_path, + arrowstyle=self._ARROW_STYLE, + patchA=None, + patchB=None, + shrinkA=0., + shrinkB=0., + mutation_scale=line_mutation_scale, + mutation_aspect=None, + transform=IdentityTransform(), + ) + + def set_line_mutation_scale(self, scale): + self.set_mutation_scale(scale*self._line_mutation_scale) + + def _extend_path(self, path, mutation_size=10): + """ + Extend the path to make a room for drawing arrow. + """ + (x0, y0), (x1, y1) = path.vertices[-2:] + theta = math.atan2(y1 - y0, x1 - x0) + x2 = x1 + math.cos(theta) * mutation_size + y2 = y1 + math.sin(theta) * mutation_size + if path.codes is None: + return Path(np.concatenate([path.vertices, [[x2, y2]]])) + else: + return Path(np.concatenate([path.vertices, [[x2, y2]]]), + np.concatenate([path.codes, [Path.LINETO]])) + + def set_path(self, path): + self._line_path = path + + def draw(self, renderer): + """ + Draw the axis line. + 1) Transform the path to the display coordinate. + 2) Extend the path to make a room for arrow. + 3) Update the path of the FancyArrowPatch. + 4) Draw. + """ + path_in_disp = self._line_transform.transform_path(self._line_path) + mutation_size = self.get_mutation_scale() # line_mutation_scale() + extended_path = self._extend_path(path_in_disp, + mutation_size=mutation_size) + self._path_original = extended_path + FancyArrowPatch.draw(self, renderer) + + def get_window_extent(self, renderer=None): + + path_in_disp = self._line_transform.transform_path(self._line_path) + mutation_size = self.get_mutation_scale() # line_mutation_scale() + extended_path = self._extend_path(path_in_disp, + mutation_size=mutation_size) + self._path_original = extended_path + return FancyArrowPatch.get_window_extent(self, renderer) + + class FilledArrow(SimpleArrow): + """The artist class that will be returned for FilledArrow style.""" + _ARROW_STYLE = "-|>" + + def __init__(self, axis_artist, line_path, transform, + line_mutation_scale, facecolor): + super().__init__(axis_artist, line_path, transform, + line_mutation_scale) + self.set_facecolor(facecolor) + + +class AxislineStyle(_Style): + """ + A container class which defines style classes for AxisArtists. + + An instance of any axisline style class is a callable object, + whose call signature is :: + + __call__(self, axis_artist, path, transform) + + When called, this should return an `.Artist` with the following methods:: + + def set_path(self, path): + # set the path for axisline. + + def set_line_mutation_scale(self, scale): + # set the scale + + def draw(self, renderer): + # draw + """ + + _style_list = {} + + class _Base: + # The derived classes are required to be able to be initialized + # w/o arguments, i.e., all its argument (except self) must have + # the default values. + + def __init__(self): + """ + initialization. + """ + super().__init__() + + def __call__(self, axis_artist, transform): + """ + Given the AxisArtist instance, and transform for the path (set_path + method), return the Matplotlib artist for drawing the axis line. + """ + return self.new_line(axis_artist, transform) + + class SimpleArrow(_Base): + """ + A simple arrow. + """ + + ArrowAxisClass = _FancyAxislineStyle.SimpleArrow + + def __init__(self, size=1): + """ + Parameters + ---------- + size : float + Size of the arrow as a fraction of the ticklabel size. + """ + + self.size = size + super().__init__() + + def new_line(self, axis_artist, transform): + + linepath = Path([(0, 0), (0, 1)]) + axisline = self.ArrowAxisClass(axis_artist, linepath, transform, + line_mutation_scale=self.size) + return axisline + + _style_list["->"] = SimpleArrow + + class FilledArrow(SimpleArrow): + """ + An arrow with a filled head. + """ + + ArrowAxisClass = _FancyAxislineStyle.FilledArrow + + def __init__(self, size=1, facecolor=None): + """ + Parameters + ---------- + size : float + Size of the arrow as a fraction of the ticklabel size. + facecolor : color, default: :rc:`axes.edgecolor` + Fill color. + + .. versionadded:: 3.7 + """ + + if facecolor is None: + facecolor = mpl.rcParams['axes.edgecolor'] + self.size = size + self._facecolor = facecolor + super().__init__(size=size) + + def new_line(self, axis_artist, transform): + linepath = Path([(0, 0), (0, 1)]) + axisline = self.ArrowAxisClass(axis_artist, linepath, transform, + line_mutation_scale=self.size, + facecolor=self._facecolor) + return axisline + + _style_list["-|>"] = FilledArrow diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axislines.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axislines.py new file mode 100644 index 0000000000..35717da8ea --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/axislines.py @@ -0,0 +1,531 @@ +""" +Axislines includes modified implementation of the Axes class. The +biggest difference is that the artists responsible for drawing the axis spine, +ticks, ticklabels and axis labels are separated out from Matplotlib's Axis +class. Originally, this change was motivated to support curvilinear +grid. Here are a few reasons that I came up with a new axes class: + +* "top" and "bottom" x-axis (or "left" and "right" y-axis) can have + different ticks (tick locations and labels). This is not possible + with the current Matplotlib, although some twin axes trick can help. + +* Curvilinear grid. + +* angled ticks. + +In the new axes class, xaxis and yaxis is set to not visible by +default, and new set of artist (AxisArtist) are defined to draw axis +line, ticks, ticklabels and axis label. Axes.axis attribute serves as +a dictionary of these artists, i.e., ax.axis["left"] is a AxisArtist +instance responsible to draw left y-axis. The default Axes.axis contains +"bottom", "left", "top" and "right". + +AxisArtist can be considered as a container artist and has the following +children artists which will draw ticks, labels, etc. + +* line +* major_ticks, major_ticklabels +* minor_ticks, minor_ticklabels +* offsetText +* label + +Note that these are separate artists from `matplotlib.axis.Axis`, thus most +tick-related functions in Matplotlib won't work. For example, color and +markerwidth of the ``ax.axis["bottom"].major_ticks`` will follow those of +Axes.xaxis unless explicitly specified. + +In addition to AxisArtist, the Axes will have *gridlines* attribute, +which obviously draws grid lines. The gridlines needs to be separated +from the axis as some gridlines can never pass any axis. +""" + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api +import matplotlib.axes as maxes +from matplotlib.path import Path +from mpl_toolkits.axes_grid1 import mpl_axes +from .axisline_style import AxislineStyle # noqa +from .axis_artist import AxisArtist, GridlinesCollection + + +class _AxisArtistHelperBase: + """ + Base class for axis helper. + + Subclasses should define the methods listed below. The *axes* + argument will be the ``.axes`` attribute of the caller artist. :: + + # Construct the spine. + + def get_line_transform(self, axes): + return transform + + def get_line(self, axes): + return path + + # Construct the label. + + def get_axislabel_transform(self, axes): + return transform + + def get_axislabel_pos_angle(self, axes): + return (x, y), angle + + # Construct the ticks. + + def get_tick_transform(self, axes): + return transform + + def get_tick_iterators(self, axes): + # A pair of iterables (one for major ticks, one for minor ticks) + # that yield (tick_position, tick_angle, tick_label). + return iter_major, iter_minor + """ + + def update_lim(self, axes): + pass + + def _to_xy(self, values, const): + """ + Create a (*values.shape, 2)-shape array representing (x, y) pairs. + + The other coordinate is filled with the constant *const*. + + Example:: + + >>> self.nth_coord = 0 + >>> self._to_xy([1, 2, 3], const=0) + array([[1, 0], + [2, 0], + [3, 0]]) + """ + if self.nth_coord == 0: + return np.stack(np.broadcast_arrays(values, const), axis=-1) + elif self.nth_coord == 1: + return np.stack(np.broadcast_arrays(const, values), axis=-1) + else: + raise ValueError("Unexpected nth_coord") + + +class _FixedAxisArtistHelperBase(_AxisArtistHelperBase): + """Helper class for a fixed (in the axes coordinate) axis.""" + + passthru_pt = _api.deprecated("3.7")(property( + lambda self: {"left": (0, 0), "right": (1, 0), + "bottom": (0, 0), "top": (0, 1)}[self._loc])) + + def __init__(self, loc, nth_coord=None): + """``nth_coord = 0``: x-axis; ``nth_coord = 1``: y-axis.""" + self.nth_coord = ( + nth_coord if nth_coord is not None else + _api.check_getitem( + {"bottom": 0, "top": 0, "left": 1, "right": 1}, loc=loc)) + if (nth_coord == 0 and loc not in ["left", "right"] + or nth_coord == 1 and loc not in ["bottom", "top"]): + _api.warn_deprecated( + "3.7", message=f"{loc=!r} is incompatible with " + "{nth_coord=}; support is deprecated since %(since)s") + self._loc = loc + self._pos = {"bottom": 0, "top": 1, "left": 0, "right": 1}[loc] + super().__init__() + # axis line in transAxes + self._path = Path(self._to_xy((0, 1), const=self._pos)) + + def get_nth_coord(self): + return self.nth_coord + + # LINE + + def get_line(self, axes): + return self._path + + def get_line_transform(self, axes): + return axes.transAxes + + # LABEL + + def get_axislabel_transform(self, axes): + return axes.transAxes + + def get_axislabel_pos_angle(self, axes): + """ + Return the label reference position in transAxes. + + get_label_transform() returns a transform of (transAxes+offset) + """ + return dict(left=((0., 0.5), 90), # (position, angle_tangent) + right=((1., 0.5), 90), + bottom=((0.5, 0.), 0), + top=((0.5, 1.), 0))[self._loc] + + # TICK + + def get_tick_transform(self, axes): + return [axes.get_xaxis_transform(), + axes.get_yaxis_transform()][self.nth_coord] + + +class _FloatingAxisArtistHelperBase(_AxisArtistHelperBase): + + def __init__(self, nth_coord, value): + self.nth_coord = nth_coord + self._value = value + super().__init__() + + def get_nth_coord(self): + return self.nth_coord + + def get_line(self, axes): + raise RuntimeError( + "get_line method should be defined by the derived class") + + +class FixedAxisArtistHelperRectilinear(_FixedAxisArtistHelperBase): + + def __init__(self, axes, loc, nth_coord=None): + """ + nth_coord = along which coordinate value varies + in 2D, nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + super().__init__(loc, nth_coord) + self.axis = [axes.xaxis, axes.yaxis][self.nth_coord] + + # TICK + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label""" + if self._loc in ["bottom", "top"]: + angle_normal, angle_tangent = 90, 0 + else: # "left", "right" + angle_normal, angle_tangent = 0, 90 + + major = self.axis.major + major_locs = major.locator() + major_labels = major.formatter.format_ticks(major_locs) + + minor = self.axis.minor + minor_locs = minor.locator() + minor_labels = minor.formatter.format_ticks(minor_locs) + + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + + def _f(locs, labels): + for loc, label in zip(locs, labels): + c = self._to_xy(loc, const=self._pos) + # check if the tick point is inside axes + c2 = tick_to_axes.transform(c) + if mpl.transforms._interval_contains_close( + (0, 1), c2[self.nth_coord]): + yield c, angle_normal, angle_tangent, label + + return _f(major_locs, major_labels), _f(minor_locs, minor_labels) + + +class FloatingAxisArtistHelperRectilinear(_FloatingAxisArtistHelperBase): + + def __init__(self, axes, nth_coord, + passingthrough_point, axis_direction="bottom"): + super().__init__(nth_coord, passingthrough_point) + self._axis_direction = axis_direction + self.axis = [axes.xaxis, axes.yaxis][self.nth_coord] + + def get_line(self, axes): + fixed_coord = 1 - self.nth_coord + data_to_axes = axes.transData - axes.transAxes + p = data_to_axes.transform([self._value, self._value]) + return Path(self._to_xy((0, 1), const=p[fixed_coord])) + + def get_line_transform(self, axes): + return axes.transAxes + + def get_axislabel_transform(self, axes): + return axes.transAxes + + def get_axislabel_pos_angle(self, axes): + """ + Return the label reference position in transAxes. + + get_label_transform() returns a transform of (transAxes+offset) + """ + angle = [0, 90][self.nth_coord] + fixed_coord = 1 - self.nth_coord + data_to_axes = axes.transData - axes.transAxes + p = data_to_axes.transform([self._value, self._value]) + verts = self._to_xy(0.5, const=p[fixed_coord]) + if 0 <= verts[fixed_coord] <= 1: + return verts, angle + else: + return None, None + + def get_tick_transform(self, axes): + return axes.transData + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label""" + if self.nth_coord == 0: + angle_normal, angle_tangent = 90, 0 + else: + angle_normal, angle_tangent = 0, 90 + + major = self.axis.major + major_locs = major.locator() + major_labels = major.formatter.format_ticks(major_locs) + + minor = self.axis.minor + minor_locs = minor.locator() + minor_labels = minor.formatter.format_ticks(minor_locs) + + data_to_axes = axes.transData - axes.transAxes + + def _f(locs, labels): + for loc, label in zip(locs, labels): + c = self._to_xy(loc, const=self._value) + c1, c2 = data_to_axes.transform(c) + if 0 <= c1 <= 1 and 0 <= c2 <= 1: + yield c, angle_normal, angle_tangent, label + + return _f(major_locs, major_labels), _f(minor_locs, minor_labels) + + +class AxisArtistHelper: # Backcompat. + Fixed = _FixedAxisArtistHelperBase + Floating = _FloatingAxisArtistHelperBase + + +class AxisArtistHelperRectlinear: # Backcompat. + Fixed = FixedAxisArtistHelperRectilinear + Floating = FloatingAxisArtistHelperRectilinear + + +class GridHelperBase: + + def __init__(self): + self._old_limits = None + super().__init__() + + def update_lim(self, axes): + x1, x2 = axes.get_xlim() + y1, y2 = axes.get_ylim() + if self._old_limits != (x1, x2, y1, y2): + self._update_grid(x1, y1, x2, y2) + self._old_limits = (x1, x2, y1, y2) + + def _update_grid(self, x1, y1, x2, y2): + """Cache relevant computations when the axes limits have changed.""" + + def get_gridlines(self, which, axis): + """ + Return list of grid lines as a list of paths (list of points). + + Parameters + ---------- + which : {"both", "major", "minor"} + axis : {"both", "x", "y"} + """ + return [] + + +class GridHelperRectlinear(GridHelperBase): + + def __init__(self, axes): + super().__init__() + self.axes = axes + + def new_fixed_axis(self, loc, + nth_coord=None, + axis_direction=None, + offset=None, + axes=None, + ): + if axes is None: + _api.warn_external( + "'new_fixed_axis' explicitly requires the axes keyword.") + axes = self.axes + if axis_direction is None: + axis_direction = loc + + helper = FixedAxisArtistHelperRectilinear(axes, loc, nth_coord) + axisline = AxisArtist(axes, helper, offset=offset, + axis_direction=axis_direction) + return axisline + + def new_floating_axis(self, nth_coord, value, + axis_direction="bottom", + axes=None, + ): + if axes is None: + _api.warn_external( + "'new_floating_axis' explicitly requires the axes keyword.") + axes = self.axes + + helper = FloatingAxisArtistHelperRectilinear( + axes, nth_coord, value, axis_direction) + axisline = AxisArtist(axes, helper, axis_direction=axis_direction) + axisline.line.set_clip_on(True) + axisline.line.set_clip_box(axisline.axes.bbox) + return axisline + + def get_gridlines(self, which="major", axis="both"): + """ + Return list of gridline coordinates in data coordinates. + + Parameters + ---------- + which : {"both", "major", "minor"} + axis : {"both", "x", "y"} + """ + _api.check_in_list(["both", "major", "minor"], which=which) + _api.check_in_list(["both", "x", "y"], axis=axis) + gridlines = [] + + if axis in ("both", "x"): + locs = [] + y1, y2 = self.axes.get_ylim() + if which in ("both", "major"): + locs.extend(self.axes.xaxis.major.locator()) + if which in ("both", "minor"): + locs.extend(self.axes.xaxis.minor.locator()) + + for x in locs: + gridlines.append([[x, x], [y1, y2]]) + + if axis in ("both", "y"): + x1, x2 = self.axes.get_xlim() + locs = [] + if self.axes.yaxis._major_tick_kw["gridOn"]: + locs.extend(self.axes.yaxis.major.locator()) + if self.axes.yaxis._minor_tick_kw["gridOn"]: + locs.extend(self.axes.yaxis.minor.locator()) + + for y in locs: + gridlines.append([[x1, x2], [y, y]]) + + return gridlines + + +class Axes(maxes.Axes): + + @_api.deprecated("3.8", alternative="ax.axis") + def __call__(self, *args, **kwargs): + return maxes.Axes.axis(self.axes, *args, **kwargs) + + def __init__(self, *args, grid_helper=None, **kwargs): + self._axisline_on = True + self._grid_helper = (grid_helper if grid_helper + else GridHelperRectlinear(self)) + super().__init__(*args, **kwargs) + self.toggle_axisline(True) + + def toggle_axisline(self, b=None): + if b is None: + b = not self._axisline_on + if b: + self._axisline_on = True + self.spines[:].set_visible(False) + self.xaxis.set_visible(False) + self.yaxis.set_visible(False) + else: + self._axisline_on = False + self.spines[:].set_visible(True) + self.xaxis.set_visible(True) + self.yaxis.set_visible(True) + + @property + def axis(self): + return self._axislines + + def clear(self): + # docstring inherited + + # Init gridlines before clear() as clear() calls grid(). + self.gridlines = gridlines = GridlinesCollection( + [], + colors=mpl.rcParams['grid.color'], + linestyles=mpl.rcParams['grid.linestyle'], + linewidths=mpl.rcParams['grid.linewidth']) + self._set_artist_props(gridlines) + gridlines.set_grid_helper(self.get_grid_helper()) + + super().clear() + + # clip_path is set after Axes.clear(): that's when a patch is created. + gridlines.set_clip_path(self.axes.patch) + + # Init axis artists. + self._axislines = mpl_axes.Axes.AxisDict(self) + new_fixed_axis = self.get_grid_helper().new_fixed_axis + self._axislines.update({ + loc: new_fixed_axis(loc=loc, axes=self, axis_direction=loc) + for loc in ["bottom", "top", "left", "right"]}) + for axisline in [self._axislines["top"], self._axislines["right"]]: + axisline.label.set_visible(False) + axisline.major_ticklabels.set_visible(False) + axisline.minor_ticklabels.set_visible(False) + + def get_grid_helper(self): + return self._grid_helper + + def grid(self, visible=None, which='major', axis="both", **kwargs): + """ + Toggle the gridlines, and optionally set the properties of the lines. + """ + # There are some discrepancies in the behavior of grid() between + # axes_grid and Matplotlib, because axes_grid explicitly sets the + # visibility of the gridlines. + super().grid(visible, which=which, axis=axis, **kwargs) + if not self._axisline_on: + return + if visible is None: + visible = (self.axes.xaxis._minor_tick_kw["gridOn"] + or self.axes.xaxis._major_tick_kw["gridOn"] + or self.axes.yaxis._minor_tick_kw["gridOn"] + or self.axes.yaxis._major_tick_kw["gridOn"]) + self.gridlines.set(which=which, axis=axis, visible=visible) + self.gridlines.set(**kwargs) + + def get_children(self): + if self._axisline_on: + children = [*self._axislines.values(), self.gridlines] + else: + children = [] + children.extend(super().get_children()) + return children + + def new_fixed_axis(self, loc, offset=None): + gh = self.get_grid_helper() + axis = gh.new_fixed_axis(loc, + nth_coord=None, + axis_direction=None, + offset=offset, + axes=self, + ) + return axis + + def new_floating_axis(self, nth_coord, value, axis_direction="bottom"): + gh = self.get_grid_helper() + axis = gh.new_floating_axis(nth_coord, value, + axis_direction=axis_direction, + axes=self) + return axis + + +class AxesZero(Axes): + + def clear(self): + super().clear() + new_floating_axis = self.get_grid_helper().new_floating_axis + self._axislines.update( + xzero=new_floating_axis( + nth_coord=0, value=0., axis_direction="bottom", axes=self), + yzero=new_floating_axis( + nth_coord=1, value=0., axis_direction="left", axes=self), + ) + for k in ["xzero", "yzero"]: + self._axislines[k].line.set_clip_path(self.patch) + self._axislines[k].set_visible(False) + + +Subplot = Axes +SubplotZero = AxesZero diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/floating_axes.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/floating_axes.py new file mode 100644 index 0000000000..97dafe98c6 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/floating_axes.py @@ -0,0 +1,298 @@ +""" +An experimental support for curvilinear grid. +""" + +# TODO : +# see if tick_iterator method can be simplified by reusing the parent method. + +import functools + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook +import matplotlib.patches as mpatches +from matplotlib.path import Path + +from mpl_toolkits.axes_grid1.parasite_axes import host_axes_class_factory + +from . import axislines, grid_helper_curvelinear +from .axis_artist import AxisArtist +from .grid_finder import ExtremeFinderSimple + + +class FloatingAxisArtistHelper( + grid_helper_curvelinear.FloatingAxisArtistHelper): + pass + + +class FixedAxisArtistHelper(grid_helper_curvelinear.FloatingAxisArtistHelper): + + def __init__(self, grid_helper, side, nth_coord_ticks=None): + """ + nth_coord = along which coordinate value varies. + nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + lon1, lon2, lat1, lat2 = grid_helper.grid_finder.extreme_finder(*[None] * 5) + value, nth_coord = _api.check_getitem( + dict(left=(lon1, 0), right=(lon2, 0), bottom=(lat1, 1), top=(lat2, 1)), + side=side) + super().__init__(grid_helper, nth_coord, value, axis_direction=side) + if nth_coord_ticks is None: + nth_coord_ticks = nth_coord + self.nth_coord_ticks = nth_coord_ticks + + self.value = value + self.grid_helper = grid_helper + self._side = side + + def update_lim(self, axes): + self.grid_helper.update_lim(axes) + self._grid_info = self.grid_helper._grid_info + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label, (optionally) tick_label""" + + grid_finder = self.grid_helper.grid_finder + + lat_levs, lat_n, lat_factor = self._grid_info["lat_info"] + yy0 = lat_levs / lat_factor + + lon_levs, lon_n, lon_factor = self._grid_info["lon_info"] + xx0 = lon_levs / lon_factor + + extremes = self.grid_helper.grid_finder.extreme_finder(*[None] * 5) + xmin, xmax = sorted(extremes[:2]) + ymin, ymax = sorted(extremes[2:]) + + def trf_xy(x, y): + trf = grid_finder.get_transform() + axes.transData + return trf.transform(np.column_stack(np.broadcast_arrays(x, y))).T + + if self.nth_coord == 0: + mask = (ymin <= yy0) & (yy0 <= ymax) + (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = \ + grid_helper_curvelinear._value_and_jacobian( + trf_xy, self.value, yy0[mask], (xmin, xmax), (ymin, ymax)) + labels = self._grid_info["lat_labels"] + + elif self.nth_coord == 1: + mask = (xmin <= xx0) & (xx0 <= xmax) + (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = \ + grid_helper_curvelinear._value_and_jacobian( + trf_xy, xx0[mask], self.value, (xmin, xmax), (ymin, ymax)) + labels = self._grid_info["lon_labels"] + + labels = [l for l, m in zip(labels, mask) if m] + + angle_normal = np.arctan2(dyy1, dxx1) + angle_tangent = np.arctan2(dyy2, dxx2) + mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal + angle_normal[mm] = angle_tangent[mm] + np.pi / 2 + + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + in_01 = functools.partial( + mpl.transforms._interval_contains_close, (0, 1)) + + def f1(): + for x, y, normal, tangent, lab \ + in zip(xx1, yy1, angle_normal, angle_tangent, labels): + c2 = tick_to_axes.transform((x, y)) + if in_01(c2[0]) and in_01(c2[1]): + yield [x, y], *np.rad2deg([normal, tangent]), lab + + return f1(), iter([]) + + def get_line(self, axes): + self.update_lim(axes) + k, v = dict(left=("lon_lines0", 0), + right=("lon_lines0", 1), + bottom=("lat_lines0", 0), + top=("lat_lines0", 1))[self._side] + xx, yy = self._grid_info[k][v] + return Path(np.column_stack([xx, yy])) + + +class ExtremeFinderFixed(ExtremeFinderSimple): + # docstring inherited + + def __init__(self, extremes): + """ + This subclass always returns the same bounding box. + + Parameters + ---------- + extremes : (float, float, float, float) + The bounding box that this helper always returns. + """ + self._extremes = extremes + + def __call__(self, transform_xy, x1, y1, x2, y2): + # docstring inherited + return self._extremes + + +class GridHelperCurveLinear(grid_helper_curvelinear.GridHelperCurveLinear): + + def __init__(self, aux_trans, extremes, + grid_locator1=None, + grid_locator2=None, + tick_formatter1=None, + tick_formatter2=None): + # docstring inherited + super().__init__(aux_trans, + extreme_finder=ExtremeFinderFixed(extremes), + grid_locator1=grid_locator1, + grid_locator2=grid_locator2, + tick_formatter1=tick_formatter1, + tick_formatter2=tick_formatter2) + + @_api.deprecated("3.8") + def get_data_boundary(self, side): + """ + Return v=0, nth=1. + """ + lon1, lon2, lat1, lat2 = self.grid_finder.extreme_finder(*[None] * 5) + return dict(left=(lon1, 0), + right=(lon2, 0), + bottom=(lat1, 1), + top=(lat2, 1))[side] + + def new_fixed_axis(self, loc, + nth_coord=None, + axis_direction=None, + offset=None, + axes=None): + if axes is None: + axes = self.axes + if axis_direction is None: + axis_direction = loc + # This is not the same as the FixedAxisArtistHelper class used by + # grid_helper_curvelinear.GridHelperCurveLinear.new_fixed_axis! + helper = FixedAxisArtistHelper( + self, loc, nth_coord_ticks=nth_coord) + axisline = AxisArtist(axes, helper, axis_direction=axis_direction) + # Perhaps should be moved to the base class? + axisline.line.set_clip_on(True) + axisline.line.set_clip_box(axisline.axes.bbox) + return axisline + + # new_floating_axis will inherit the grid_helper's extremes. + + # def new_floating_axis(self, nth_coord, + # value, + # axes=None, + # axis_direction="bottom" + # ): + + # axis = super(GridHelperCurveLinear, + # self).new_floating_axis(nth_coord, + # value, axes=axes, + # axis_direction=axis_direction) + + # # set extreme values of the axis helper + # if nth_coord == 1: + # axis.get_helper().set_extremes(*self._extremes[:2]) + # elif nth_coord == 0: + # axis.get_helper().set_extremes(*self._extremes[2:]) + + # return axis + + def _update_grid(self, x1, y1, x2, y2): + if self._grid_info is None: + self._grid_info = dict() + + grid_info = self._grid_info + + grid_finder = self.grid_finder + extremes = grid_finder.extreme_finder(grid_finder.inv_transform_xy, + x1, y1, x2, y2) + + lon_min, lon_max = sorted(extremes[:2]) + lat_min, lat_max = sorted(extremes[2:]) + grid_info["extremes"] = lon_min, lon_max, lat_min, lat_max # extremes + + lon_levs, lon_n, lon_factor = \ + grid_finder.grid_locator1(lon_min, lon_max) + lon_levs = np.asarray(lon_levs) + lat_levs, lat_n, lat_factor = \ + grid_finder.grid_locator2(lat_min, lat_max) + lat_levs = np.asarray(lat_levs) + + grid_info["lon_info"] = lon_levs, lon_n, lon_factor + grid_info["lat_info"] = lat_levs, lat_n, lat_factor + + grid_info["lon_labels"] = grid_finder.tick_formatter1( + "bottom", lon_factor, lon_levs) + grid_info["lat_labels"] = grid_finder.tick_formatter2( + "bottom", lat_factor, lat_levs) + + lon_values = lon_levs[:lon_n] / lon_factor + lat_values = lat_levs[:lat_n] / lat_factor + + lon_lines, lat_lines = grid_finder._get_raw_grid_lines( + lon_values[(lon_min < lon_values) & (lon_values < lon_max)], + lat_values[(lat_min < lat_values) & (lat_values < lat_max)], + lon_min, lon_max, lat_min, lat_max) + + grid_info["lon_lines"] = lon_lines + grid_info["lat_lines"] = lat_lines + + lon_lines, lat_lines = grid_finder._get_raw_grid_lines( + # lon_min, lon_max, lat_min, lat_max) + extremes[:2], extremes[2:], *extremes) + + grid_info["lon_lines0"] = lon_lines + grid_info["lat_lines0"] = lat_lines + + def get_gridlines(self, which="major", axis="both"): + grid_lines = [] + if axis in ["both", "x"]: + grid_lines.extend(self._grid_info["lon_lines"]) + if axis in ["both", "y"]: + grid_lines.extend(self._grid_info["lat_lines"]) + return grid_lines + + +class FloatingAxesBase: + + def __init__(self, *args, grid_helper, **kwargs): + _api.check_isinstance(GridHelperCurveLinear, grid_helper=grid_helper) + super().__init__(*args, grid_helper=grid_helper, **kwargs) + self.set_aspect(1.) + + def _gen_axes_patch(self): + # docstring inherited + x0, x1, y0, y1 = self.get_grid_helper().grid_finder.extreme_finder(*[None] * 5) + patch = mpatches.Polygon([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) + patch.get_path()._interpolation_steps = 100 + return patch + + def clear(self): + super().clear() + self.patch.set_transform( + self.get_grid_helper().grid_finder.get_transform() + + self.transData) + # The original patch is not in the draw tree; it is only used for + # clipping purposes. + orig_patch = super()._gen_axes_patch() + orig_patch.set_figure(self.figure) + orig_patch.set_transform(self.transAxes) + self.patch.set_clip_path(orig_patch) + self.gridlines.set_clip_path(orig_patch) + self.adjust_axes_lim() + + def adjust_axes_lim(self): + bbox = self.patch.get_path().get_extents( + # First transform to pixel coords, then to parent data coords. + self.patch.get_transform() - self.transData) + bbox = bbox.expanded(1.02, 1.02) + self.set_xlim(bbox.xmin, bbox.xmax) + self.set_ylim(bbox.ymin, bbox.ymax) + + +floatingaxes_class_factory = cbook._make_class_factory( + FloatingAxesBase, "Floating{}") +FloatingAxes = floatingaxes_class_factory( + host_axes_class_factory(axislines.Axes)) +FloatingSubplot = FloatingAxes diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/grid_finder.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/grid_finder.py new file mode 100644 index 0000000000..f969b011c4 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/grid_finder.py @@ -0,0 +1,335 @@ +import numpy as np + +from matplotlib import ticker as mticker +from matplotlib.transforms import Bbox, Transform + + +def _find_line_box_crossings(xys, bbox): + """ + Find the points where a polyline crosses a bbox, and the crossing angles. + + Parameters + ---------- + xys : (N, 2) array + The polyline coordinates. + bbox : `.Bbox` + The bounding box. + + Returns + ------- + list of ((float, float), float) + Four separate lists of crossings, for the left, right, bottom, and top + sides of the bbox, respectively. For each list, the entries are the + ``((x, y), ccw_angle_in_degrees)`` of the crossing, where an angle of 0 + means that the polyline is moving to the right at the crossing point. + + The entries are computed by linearly interpolating at each crossing + between the nearest points on either side of the bbox edges. + """ + crossings = [] + dxys = xys[1:] - xys[:-1] + for sl in [slice(None), slice(None, None, -1)]: + us, vs = xys.T[sl] # "this" coord, "other" coord + dus, dvs = dxys.T[sl] + umin, vmin = bbox.min[sl] + umax, vmax = bbox.max[sl] + for u0, inside in [(umin, us > umin), (umax, us < umax)]: + crossings.append([]) + idxs, = (inside[:-1] ^ inside[1:]).nonzero() + for idx in idxs: + v = vs[idx] + (u0 - us[idx]) * dvs[idx] / dus[idx] + if not vmin <= v <= vmax: + continue + crossing = (u0, v)[sl] + theta = np.degrees(np.arctan2(*dxys[idx][::-1])) + crossings[-1].append((crossing, theta)) + return crossings + + +class ExtremeFinderSimple: + """ + A helper class to figure out the range of grid lines that need to be drawn. + """ + + def __init__(self, nx, ny): + """ + Parameters + ---------- + nx, ny : int + The number of samples in each direction. + """ + self.nx = nx + self.ny = ny + + def __call__(self, transform_xy, x1, y1, x2, y2): + """ + Compute an approximation of the bounding box obtained by applying + *transform_xy* to the box delimited by ``(x1, y1, x2, y2)``. + + The intended use is to have ``(x1, y1, x2, y2)`` in axes coordinates, + and have *transform_xy* be the transform from axes coordinates to data + coordinates; this method then returns the range of data coordinates + that span the actual axes. + + The computation is done by sampling ``nx * ny`` equispaced points in + the ``(x1, y1, x2, y2)`` box and finding the resulting points with + extremal coordinates; then adding some padding to take into account the + finite sampling. + + As each sampling step covers a relative range of *1/nx* or *1/ny*, + the padding is computed by expanding the span covered by the extremal + coordinates by these fractions. + """ + x, y = np.meshgrid( + np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny)) + xt, yt = transform_xy(np.ravel(x), np.ravel(y)) + return self._add_pad(xt.min(), xt.max(), yt.min(), yt.max()) + + def _add_pad(self, x_min, x_max, y_min, y_max): + """Perform the padding mentioned in `__call__`.""" + dx = (x_max - x_min) / self.nx + dy = (y_max - y_min) / self.ny + return x_min - dx, x_max + dx, y_min - dy, y_max + dy + + +class _User2DTransform(Transform): + """A transform defined by two user-set functions.""" + + input_dims = output_dims = 2 + + def __init__(self, forward, backward): + """ + Parameters + ---------- + forward, backward : callable + The forward and backward transforms, taking ``x`` and ``y`` as + separate arguments and returning ``(tr_x, tr_y)``. + """ + # The normal Matplotlib convention would be to take and return an + # (N, 2) array but axisartist uses the transposed version. + super().__init__() + self._forward = forward + self._backward = backward + + def transform_non_affine(self, values): + # docstring inherited + return np.transpose(self._forward(*np.transpose(values))) + + def inverted(self): + # docstring inherited + return type(self)(self._backward, self._forward) + + +class GridFinder: + """ + Internal helper for `~.grid_helper_curvelinear.GridHelperCurveLinear`, with + the same constructor parameters; should not be directly instantiated. + """ + + def __init__(self, + transform, + extreme_finder=None, + grid_locator1=None, + grid_locator2=None, + tick_formatter1=None, + tick_formatter2=None): + if extreme_finder is None: + extreme_finder = ExtremeFinderSimple(20, 20) + if grid_locator1 is None: + grid_locator1 = MaxNLocator() + if grid_locator2 is None: + grid_locator2 = MaxNLocator() + if tick_formatter1 is None: + tick_formatter1 = FormatterPrettyPrint() + if tick_formatter2 is None: + tick_formatter2 = FormatterPrettyPrint() + self.extreme_finder = extreme_finder + self.grid_locator1 = grid_locator1 + self.grid_locator2 = grid_locator2 + self.tick_formatter1 = tick_formatter1 + self.tick_formatter2 = tick_formatter2 + self.set_transform(transform) + + def get_grid_info(self, x1, y1, x2, y2): + """ + lon_values, lat_values : list of grid values. if integer is given, + rough number of grids in each direction. + """ + + extremes = self.extreme_finder(self.inv_transform_xy, x1, y1, x2, y2) + + # min & max rage of lat (or lon) for each grid line will be drawn. + # i.e., gridline of lon=0 will be drawn from lat_min to lat_max. + + lon_min, lon_max, lat_min, lat_max = extremes + lon_levs, lon_n, lon_factor = self.grid_locator1(lon_min, lon_max) + lon_levs = np.asarray(lon_levs) + lat_levs, lat_n, lat_factor = self.grid_locator2(lat_min, lat_max) + lat_levs = np.asarray(lat_levs) + + lon_values = lon_levs[:lon_n] / lon_factor + lat_values = lat_levs[:lat_n] / lat_factor + + lon_lines, lat_lines = self._get_raw_grid_lines(lon_values, + lat_values, + lon_min, lon_max, + lat_min, lat_max) + + ddx = (x2-x1)*1.e-10 + ddy = (y2-y1)*1.e-10 + bb = Bbox.from_extents(x1-ddx, y1-ddy, x2+ddx, y2+ddy) + + grid_info = { + "extremes": extremes, + "lon_lines": lon_lines, + "lat_lines": lat_lines, + "lon": self._clip_grid_lines_and_find_ticks( + lon_lines, lon_values, lon_levs, bb), + "lat": self._clip_grid_lines_and_find_ticks( + lat_lines, lat_values, lat_levs, bb), + } + + tck_labels = grid_info["lon"]["tick_labels"] = {} + for direction in ["left", "bottom", "right", "top"]: + levs = grid_info["lon"]["tick_levels"][direction] + tck_labels[direction] = self.tick_formatter1( + direction, lon_factor, levs) + + tck_labels = grid_info["lat"]["tick_labels"] = {} + for direction in ["left", "bottom", "right", "top"]: + levs = grid_info["lat"]["tick_levels"][direction] + tck_labels[direction] = self.tick_formatter2( + direction, lat_factor, levs) + + return grid_info + + def _get_raw_grid_lines(self, + lon_values, lat_values, + lon_min, lon_max, lat_min, lat_max): + + lons_i = np.linspace(lon_min, lon_max, 100) # for interpolation + lats_i = np.linspace(lat_min, lat_max, 100) + + lon_lines = [self.transform_xy(np.full_like(lats_i, lon), lats_i) + for lon in lon_values] + lat_lines = [self.transform_xy(lons_i, np.full_like(lons_i, lat)) + for lat in lat_values] + + return lon_lines, lat_lines + + def _clip_grid_lines_and_find_ticks(self, lines, values, levs, bb): + gi = { + "values": [], + "levels": [], + "tick_levels": dict(left=[], bottom=[], right=[], top=[]), + "tick_locs": dict(left=[], bottom=[], right=[], top=[]), + "lines": [], + } + + tck_levels = gi["tick_levels"] + tck_locs = gi["tick_locs"] + for (lx, ly), v, lev in zip(lines, values, levs): + tcks = _find_line_box_crossings(np.column_stack([lx, ly]), bb) + gi["levels"].append(v) + gi["lines"].append([(lx, ly)]) + + for tck, direction in zip(tcks, + ["left", "right", "bottom", "top"]): + for t in tck: + tck_levels[direction].append(lev) + tck_locs[direction].append(t) + + return gi + + def set_transform(self, aux_trans): + if isinstance(aux_trans, Transform): + self._aux_transform = aux_trans + elif len(aux_trans) == 2 and all(map(callable, aux_trans)): + self._aux_transform = _User2DTransform(*aux_trans) + else: + raise TypeError("'aux_trans' must be either a Transform " + "instance or a pair of callables") + + def get_transform(self): + return self._aux_transform + + update_transform = set_transform # backcompat alias. + + def transform_xy(self, x, y): + return self._aux_transform.transform(np.column_stack([x, y])).T + + def inv_transform_xy(self, x, y): + return self._aux_transform.inverted().transform( + np.column_stack([x, y])).T + + def update(self, **kwargs): + for k, v in kwargs.items(): + if k in ["extreme_finder", + "grid_locator1", + "grid_locator2", + "tick_formatter1", + "tick_formatter2"]: + setattr(self, k, v) + else: + raise ValueError(f"Unknown update property {k!r}") + + +class MaxNLocator(mticker.MaxNLocator): + def __init__(self, nbins=10, steps=None, + trim=True, + integer=False, + symmetric=False, + prune=None): + # trim argument has no effect. It has been left for API compatibility + super().__init__(nbins, steps=steps, integer=integer, + symmetric=symmetric, prune=prune) + self.create_dummy_axis() + + def __call__(self, v1, v2): + locs = super().tick_values(v1, v2) + return np.array(locs), len(locs), 1 # 1: factor (see angle_helper) + + +class FixedLocator: + def __init__(self, locs): + self._locs = locs + + def __call__(self, v1, v2): + v1, v2 = sorted([v1, v2]) + locs = np.array([l for l in self._locs if v1 <= l <= v2]) + return locs, len(locs), 1 # 1: factor (see angle_helper) + + +# Tick Formatter + +class FormatterPrettyPrint: + def __init__(self, useMathText=True): + self._fmt = mticker.ScalarFormatter( + useMathText=useMathText, useOffset=False) + self._fmt.create_dummy_axis() + + def __call__(self, direction, factor, values): + return self._fmt.format_ticks(values) + + +class DictFormatter: + def __init__(self, format_dict, formatter=None): + """ + format_dict : dictionary for format strings to be used. + formatter : fall-back formatter + """ + super().__init__() + self._format_dict = format_dict + self._fallback_formatter = formatter + + def __call__(self, direction, factor, values): + """ + factor is ignored if value is found in the dictionary + """ + if self._fallback_formatter: + fallback_strings = self._fallback_formatter( + direction, factor, values) + else: + fallback_strings = [""] * len(values) + return [self._format_dict.get(k, v) + for k, v in zip(values, fallback_strings)] diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/grid_helper_curvelinear.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/grid_helper_curvelinear.py new file mode 100644 index 0000000000..ae17452b6c --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/grid_helper_curvelinear.py @@ -0,0 +1,336 @@ +""" +An experimental support for curvilinear grid. +""" + +import functools +from itertools import chain + +import numpy as np + +import matplotlib as mpl +from matplotlib.path import Path +from matplotlib.transforms import Affine2D, IdentityTransform +from .axislines import ( + _FixedAxisArtistHelperBase, _FloatingAxisArtistHelperBase, GridHelperBase) +from .axis_artist import AxisArtist +from .grid_finder import GridFinder + + +def _value_and_jacobian(func, xs, ys, xlims, ylims): + """ + Compute *func* and its derivatives along x and y at positions *xs*, *ys*, + while ensuring that finite difference calculations don't try to evaluate + values outside of *xlims*, *ylims*. + """ + eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime + val = func(xs, ys) + # Take the finite difference step in the direction where the bound is the + # furthest; the step size is min of epsilon and distance to that bound. + xlo, xhi = sorted(xlims) + dxlo = xs - xlo + dxhi = xhi - xs + xeps = (np.take([-1, 1], dxhi >= dxlo) + * np.minimum(eps, np.maximum(dxlo, dxhi))) + val_dx = func(xs + xeps, ys) + ylo, yhi = sorted(ylims) + dylo = ys - ylo + dyhi = yhi - ys + yeps = (np.take([-1, 1], dyhi >= dylo) + * np.minimum(eps, np.maximum(dylo, dyhi))) + val_dy = func(xs, ys + yeps) + return (val, (val_dx - val) / xeps, (val_dy - val) / yeps) + + +class FixedAxisArtistHelper(_FixedAxisArtistHelperBase): + """ + Helper class for a fixed axis. + """ + + def __init__(self, grid_helper, side, nth_coord_ticks=None): + """ + nth_coord = along which coordinate value varies. + nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + + super().__init__(loc=side) + + self.grid_helper = grid_helper + if nth_coord_ticks is None: + nth_coord_ticks = self.nth_coord + self.nth_coord_ticks = nth_coord_ticks + + self.side = side + + def update_lim(self, axes): + self.grid_helper.update_lim(axes) + + def get_tick_transform(self, axes): + return axes.transData + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label""" + v1, v2 = axes.get_ylim() if self.nth_coord == 0 else axes.get_xlim() + if v1 > v2: # Inverted limits. + side = {"left": "right", "right": "left", + "top": "bottom", "bottom": "top"}[self.side] + else: + side = self.side + g = self.grid_helper + ti1 = g.get_tick_iterator(self.nth_coord_ticks, side) + ti2 = g.get_tick_iterator(1-self.nth_coord_ticks, side, minor=True) + return chain(ti1, ti2), iter([]) + + +class FloatingAxisArtistHelper(_FloatingAxisArtistHelperBase): + + def __init__(self, grid_helper, nth_coord, value, axis_direction=None): + """ + nth_coord = along which coordinate value varies. + nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + super().__init__(nth_coord, value) + self.value = value + self.grid_helper = grid_helper + self._extremes = -np.inf, np.inf + self._line_num_points = 100 # number of points to create a line + + def set_extremes(self, e1, e2): + if e1 is None: + e1 = -np.inf + if e2 is None: + e2 = np.inf + self._extremes = e1, e2 + + def update_lim(self, axes): + self.grid_helper.update_lim(axes) + + x1, x2 = axes.get_xlim() + y1, y2 = axes.get_ylim() + grid_finder = self.grid_helper.grid_finder + extremes = grid_finder.extreme_finder(grid_finder.inv_transform_xy, + x1, y1, x2, y2) + + lon_min, lon_max, lat_min, lat_max = extremes + e_min, e_max = self._extremes # ranges of other coordinates + if self.nth_coord == 0: + lat_min = max(e_min, lat_min) + lat_max = min(e_max, lat_max) + elif self.nth_coord == 1: + lon_min = max(e_min, lon_min) + lon_max = min(e_max, lon_max) + + lon_levs, lon_n, lon_factor = \ + grid_finder.grid_locator1(lon_min, lon_max) + lat_levs, lat_n, lat_factor = \ + grid_finder.grid_locator2(lat_min, lat_max) + + if self.nth_coord == 0: + xx0 = np.full(self._line_num_points, self.value) + yy0 = np.linspace(lat_min, lat_max, self._line_num_points) + xx, yy = grid_finder.transform_xy(xx0, yy0) + elif self.nth_coord == 1: + xx0 = np.linspace(lon_min, lon_max, self._line_num_points) + yy0 = np.full(self._line_num_points, self.value) + xx, yy = grid_finder.transform_xy(xx0, yy0) + + self._grid_info = { + "extremes": (lon_min, lon_max, lat_min, lat_max), + "lon_info": (lon_levs, lon_n, np.asarray(lon_factor)), + "lat_info": (lat_levs, lat_n, np.asarray(lat_factor)), + "lon_labels": grid_finder.tick_formatter1( + "bottom", lon_factor, lon_levs), + "lat_labels": grid_finder.tick_formatter2( + "bottom", lat_factor, lat_levs), + "line_xy": (xx, yy), + } + + def get_axislabel_transform(self, axes): + return Affine2D() # axes.transData + + def get_axislabel_pos_angle(self, axes): + def trf_xy(x, y): + trf = self.grid_helper.grid_finder.get_transform() + axes.transData + return trf.transform([x, y]).T + + xmin, xmax, ymin, ymax = self._grid_info["extremes"] + if self.nth_coord == 0: + xx0 = self.value + yy0 = (ymin + ymax) / 2 + elif self.nth_coord == 1: + xx0 = (xmin + xmax) / 2 + yy0 = self.value + xy1, dxy1_dx, dxy1_dy = _value_and_jacobian( + trf_xy, xx0, yy0, (xmin, xmax), (ymin, ymax)) + p = axes.transAxes.inverted().transform(xy1) + if 0 <= p[0] <= 1 and 0 <= p[1] <= 1: + d = [dxy1_dy, dxy1_dx][self.nth_coord] + return xy1, np.rad2deg(np.arctan2(*d[::-1])) + else: + return None, None + + def get_tick_transform(self, axes): + return IdentityTransform() # axes.transData + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label, (optionally) tick_label""" + + lat_levs, lat_n, lat_factor = self._grid_info["lat_info"] + yy0 = lat_levs / lat_factor + + lon_levs, lon_n, lon_factor = self._grid_info["lon_info"] + xx0 = lon_levs / lon_factor + + e0, e1 = self._extremes + + def trf_xy(x, y): + trf = self.grid_helper.grid_finder.get_transform() + axes.transData + return trf.transform(np.column_stack(np.broadcast_arrays(x, y))).T + + # find angles + if self.nth_coord == 0: + mask = (e0 <= yy0) & (yy0 <= e1) + (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = _value_and_jacobian( + trf_xy, self.value, yy0[mask], (-np.inf, np.inf), (e0, e1)) + labels = self._grid_info["lat_labels"] + + elif self.nth_coord == 1: + mask = (e0 <= xx0) & (xx0 <= e1) + (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = _value_and_jacobian( + trf_xy, xx0[mask], self.value, (-np.inf, np.inf), (e0, e1)) + labels = self._grid_info["lon_labels"] + + labels = [l for l, m in zip(labels, mask) if m] + + angle_normal = np.arctan2(dyy1, dxx1) + angle_tangent = np.arctan2(dyy2, dxx2) + mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal + angle_normal[mm] = angle_tangent[mm] + np.pi / 2 + + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + in_01 = functools.partial( + mpl.transforms._interval_contains_close, (0, 1)) + + def f1(): + for x, y, normal, tangent, lab \ + in zip(xx1, yy1, angle_normal, angle_tangent, labels): + c2 = tick_to_axes.transform((x, y)) + if in_01(c2[0]) and in_01(c2[1]): + yield [x, y], *np.rad2deg([normal, tangent]), lab + + return f1(), iter([]) + + def get_line_transform(self, axes): + return axes.transData + + def get_line(self, axes): + self.update_lim(axes) + x, y = self._grid_info["line_xy"] + return Path(np.column_stack([x, y])) + + +class GridHelperCurveLinear(GridHelperBase): + def __init__(self, aux_trans, + extreme_finder=None, + grid_locator1=None, + grid_locator2=None, + tick_formatter1=None, + tick_formatter2=None): + """ + Parameters + ---------- + aux_trans : `.Transform` or tuple[Callable, Callable] + The transform from curved coordinates to rectilinear coordinate: + either a `.Transform` instance (which provides also its inverse), + or a pair of callables ``(trans, inv_trans)`` that define the + transform and its inverse. The callables should have signature:: + + x_rect, y_rect = trans(x_curved, y_curved) + x_curved, y_curved = inv_trans(x_rect, y_rect) + + extreme_finder + + grid_locator1, grid_locator2 + Grid locators for each axis. + + tick_formatter1, tick_formatter2 + Tick formatters for each axis. + """ + super().__init__() + self._grid_info = None + self.grid_finder = GridFinder(aux_trans, + extreme_finder, + grid_locator1, + grid_locator2, + tick_formatter1, + tick_formatter2) + + def update_grid_finder(self, aux_trans=None, **kwargs): + if aux_trans is not None: + self.grid_finder.update_transform(aux_trans) + self.grid_finder.update(**kwargs) + self._old_limits = None # Force revalidation. + + def new_fixed_axis(self, loc, + nth_coord=None, + axis_direction=None, + offset=None, + axes=None): + if axes is None: + axes = self.axes + if axis_direction is None: + axis_direction = loc + helper = FixedAxisArtistHelper(self, loc, nth_coord_ticks=nth_coord) + axisline = AxisArtist(axes, helper, axis_direction=axis_direction) + # Why is clip not set on axisline, unlike in new_floating_axis or in + # the floating_axig.GridHelperCurveLinear subclass? + return axisline + + def new_floating_axis(self, nth_coord, + value, + axes=None, + axis_direction="bottom" + ): + if axes is None: + axes = self.axes + helper = FloatingAxisArtistHelper( + self, nth_coord, value, axis_direction) + axisline = AxisArtist(axes, helper) + axisline.line.set_clip_on(True) + axisline.line.set_clip_box(axisline.axes.bbox) + # axisline.major_ticklabels.set_visible(True) + # axisline.minor_ticklabels.set_visible(False) + return axisline + + def _update_grid(self, x1, y1, x2, y2): + self._grid_info = self.grid_finder.get_grid_info(x1, y1, x2, y2) + + def get_gridlines(self, which="major", axis="both"): + grid_lines = [] + if axis in ["both", "x"]: + for gl in self._grid_info["lon"]["lines"]: + grid_lines.extend(gl) + if axis in ["both", "y"]: + for gl in self._grid_info["lat"]["lines"]: + grid_lines.extend(gl) + return grid_lines + + def get_tick_iterator(self, nth_coord, axis_side, minor=False): + + # axisnr = dict(left=0, bottom=1, right=2, top=3)[axis_side] + angle_tangent = dict(left=90, right=90, bottom=0, top=0)[axis_side] + # angle = [0, 90, 180, 270][axisnr] + lon_or_lat = ["lon", "lat"][nth_coord] + if not minor: # major ticks + for (xy, a), l in zip( + self._grid_info[lon_or_lat]["tick_locs"][axis_side], + self._grid_info[lon_or_lat]["tick_labels"][axis_side]): + angle_normal = a + yield xy, angle_normal, angle_tangent, l + else: + for (xy, a), l in zip( + self._grid_info[lon_or_lat]["tick_locs"][axis_side], + self._grid_info[lon_or_lat]["tick_labels"][axis_side]): + angle_normal = a + yield xy, angle_normal, angle_tangent, "" + # for xy, a, l in self._grid_info[lon_or_lat]["ticks"][axis_side]: + # yield xy, a, "" diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/parasite_axes.py b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/parasite_axes.py new file mode 100644 index 0000000000..4ebd6acc03 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/axisartist/parasite_axes.py @@ -0,0 +1,7 @@ +from mpl_toolkits.axes_grid1.parasite_axes import ( + host_axes_class_factory, parasite_axes_class_factory) +from .axislines import Axes + + +ParasiteAxes = parasite_axes_class_factory(Axes) +HostAxes = SubplotHost = host_axes_class_factory(Axes) diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/__init__.py b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/__init__.py new file mode 100644 index 0000000000..a089fbd6b7 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/__init__.py @@ -0,0 +1,3 @@ +from .axes3d import Axes3D + +__all__ = ['Axes3D'] diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/art3d.py b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/art3d.py new file mode 100644 index 0000000000..4aff115b0c --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/art3d.py @@ -0,0 +1,1252 @@ +# art3d.py, original mplot3d version by John Porter +# Parts rewritten by Reinier Heeres <reinier@heeres.eu> +# Minor additions by Ben Axelrod <baxelrod@coroware.com> + +""" +Module containing 3D artist code and functions to convert 2D +artists into 3D versions which can be added to an Axes3D. +""" + +import math + +import numpy as np + +from contextlib import contextmanager + +from matplotlib import ( + artist, cbook, colors as mcolors, lines, text as mtext, + path as mpath) +from matplotlib.collections import ( + Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) +from matplotlib.colors import Normalize +from matplotlib.patches import Patch +from . import proj3d + + +def _norm_angle(a): + """Return the given angle normalized to -180 < *a* <= 180 degrees.""" + a = (a + 360) % 360 + if a > 180: + a = a - 360 + return a + + +def _norm_text_angle(a): + """Return the given angle normalized to -90 < *a* <= 90 degrees.""" + a = (a + 180) % 180 + if a > 90: + a = a - 180 + return a + + +def get_dir_vector(zdir): + """ + Return a direction vector. + + Parameters + ---------- + zdir : {'x', 'y', 'z', None, 3-tuple} + The direction. Possible values are: + + - 'x': equivalent to (1, 0, 0) + - 'y': equivalent to (0, 1, 0) + - 'z': equivalent to (0, 0, 1) + - *None*: equivalent to (0, 0, 0) + - an iterable (x, y, z) is converted to an array + + Returns + ------- + x, y, z : array + The direction vector. + """ + if zdir == 'x': + return np.array((1, 0, 0)) + elif zdir == 'y': + return np.array((0, 1, 0)) + elif zdir == 'z': + return np.array((0, 0, 1)) + elif zdir is None: + return np.array((0, 0, 0)) + elif np.iterable(zdir) and len(zdir) == 3: + return np.array(zdir) + else: + raise ValueError("'x', 'y', 'z', None or vector of length 3 expected") + + +class Text3D(mtext.Text): + """ + Text object with 3D position and direction. + + Parameters + ---------- + x, y, z : float + The position of the text. + text : str + The text string to display. + zdir : {'x', 'y', 'z', None, 3-tuple} + The direction of the text. See `.get_dir_vector` for a description of + the values. + + Other Parameters + ---------------- + **kwargs + All other parameters are passed on to `~matplotlib.text.Text`. + """ + + def __init__(self, x=0, y=0, z=0, text='', zdir='z', **kwargs): + mtext.Text.__init__(self, x, y, text, **kwargs) + self.set_3d_properties(z, zdir) + + def get_position_3d(self): + """Return the (x, y, z) position of the text.""" + return self._x, self._y, self._z + + def set_position_3d(self, xyz, zdir=None): + """ + Set the (*x*, *y*, *z*) position of the text. + + Parameters + ---------- + xyz : (float, float, float) + The position in 3D space. + zdir : {'x', 'y', 'z', None, 3-tuple} + The direction of the text. If unspecified, the *zdir* will not be + changed. See `.get_dir_vector` for a description of the values. + """ + super().set_position(xyz[:2]) + self.set_z(xyz[2]) + if zdir is not None: + self._dir_vec = get_dir_vector(zdir) + + def set_z(self, z): + """ + Set the *z* position of the text. + + Parameters + ---------- + z : float + """ + self._z = z + self.stale = True + + def set_3d_properties(self, z=0, zdir='z'): + """ + Set the *z* position and direction of the text. + + Parameters + ---------- + z : float + The z-position in 3D space. + zdir : {'x', 'y', 'z', 3-tuple} + The direction of the text. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + self._z = z + self._dir_vec = get_dir_vector(zdir) + self.stale = True + + @artist.allow_rasterization + def draw(self, renderer): + position3d = np.array((self._x, self._y, self._z)) + proj = proj3d._proj_trans_points( + [position3d, position3d + self._dir_vec], self.axes.M) + dx = proj[0][1] - proj[0][0] + dy = proj[1][1] - proj[1][0] + angle = math.degrees(math.atan2(dy, dx)) + with cbook._setattr_cm(self, _x=proj[0][0], _y=proj[1][0], + _rotation=_norm_text_angle(angle)): + mtext.Text.draw(self, renderer) + self.stale = False + + def get_tightbbox(self, renderer=None): + # Overwriting the 2d Text behavior which is not valid for 3d. + # For now, just return None to exclude from layout calculation. + return None + + +def text_2d_to_3d(obj, z=0, zdir='z'): + """ + Convert a `.Text` to a `.Text3D` object. + + Parameters + ---------- + z : float + The z-position in 3D space. + zdir : {'x', 'y', 'z', 3-tuple} + The direction of the text. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + obj.__class__ = Text3D + obj.set_3d_properties(z, zdir) + + +class Line3D(lines.Line2D): + """ + 3D line object. + + .. note:: Use `get_data_3d` to obtain the data associated with the line. + `~.Line2D.get_data`, `~.Line2D.get_xdata`, and `~.Line2D.get_ydata` return + the x- and y-coordinates of the projected 2D-line, not the x- and y-data of + the 3D-line. Similarly, use `set_data_3d` to set the data, not + `~.Line2D.set_data`, `~.Line2D.set_xdata`, and `~.Line2D.set_ydata`. + """ + + def __init__(self, xs, ys, zs, *args, **kwargs): + """ + + Parameters + ---------- + xs : array-like + The x-data to be plotted. + ys : array-like + The y-data to be plotted. + zs : array-like + The z-data to be plotted. + *args, **kwargs + Additional arguments are passed to `~matplotlib.lines.Line2D`. + """ + super().__init__([], [], *args, **kwargs) + self.set_data_3d(xs, ys, zs) + + def set_3d_properties(self, zs=0, zdir='z'): + """ + Set the *z* position and direction of the line. + + Parameters + ---------- + zs : float or array of floats + The location along the *zdir* axis in 3D space to position the + line. + zdir : {'x', 'y', 'z'} + Plane to plot line orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + xs = self.get_xdata() + ys = self.get_ydata() + zs = cbook._to_unmasked_float_array(zs).ravel() + zs = np.broadcast_to(zs, len(xs)) + self._verts3d = juggle_axes(xs, ys, zs, zdir) + self.stale = True + + def set_data_3d(self, *args): + """ + Set the x, y and z data + + Parameters + ---------- + x : array-like + The x-data to be plotted. + y : array-like + The y-data to be plotted. + z : array-like + The z-data to be plotted. + + Notes + ----- + Accepts x, y, z arguments or a single array-like (x, y, z) + """ + if len(args) == 1: + args = args[0] + for name, xyz in zip('xyz', args): + if not np.iterable(xyz): + raise RuntimeError(f'{name} must be a sequence') + self._verts3d = args + self.stale = True + + def get_data_3d(self): + """ + Get the current data + + Returns + ------- + verts3d : length-3 tuple or array-like + The current data as a tuple or array-like. + """ + return self._verts3d + + @artist.allow_rasterization + def draw(self, renderer): + xs3d, ys3d, zs3d = self._verts3d + xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M) + self.set_data(xs, ys) + super().draw(renderer) + self.stale = False + + +def line_2d_to_3d(line, zs=0, zdir='z'): + """ + Convert a `.Line2D` to a `.Line3D` object. + + Parameters + ---------- + zs : float + The location along the *zdir* axis in 3D space to position the line. + zdir : {'x', 'y', 'z'} + Plane to plot line orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + + line.__class__ = Line3D + line.set_3d_properties(zs, zdir) + + +def _path_to_3d_segment(path, zs=0, zdir='z'): + """Convert a path to a 3D segment.""" + + zs = np.broadcast_to(zs, len(path)) + pathsegs = path.iter_segments(simplify=False, curves=False) + seg = [(x, y, z) for (((x, y), code), z) in zip(pathsegs, zs)] + seg3d = [juggle_axes(x, y, z, zdir) for (x, y, z) in seg] + return seg3d + + +def _paths_to_3d_segments(paths, zs=0, zdir='z'): + """Convert paths from a collection object to 3D segments.""" + + if not np.iterable(zs): + zs = np.broadcast_to(zs, len(paths)) + else: + if len(zs) != len(paths): + raise ValueError('Number of z-coordinates does not match paths.') + + segs = [_path_to_3d_segment(path, pathz, zdir) + for path, pathz in zip(paths, zs)] + return segs + + +def _path_to_3d_segment_with_codes(path, zs=0, zdir='z'): + """Convert a path to a 3D segment with path codes.""" + + zs = np.broadcast_to(zs, len(path)) + pathsegs = path.iter_segments(simplify=False, curves=False) + seg_codes = [((x, y, z), code) for ((x, y), code), z in zip(pathsegs, zs)] + if seg_codes: + seg, codes = zip(*seg_codes) + seg3d = [juggle_axes(x, y, z, zdir) for (x, y, z) in seg] + else: + seg3d = [] + codes = [] + return seg3d, list(codes) + + +def _paths_to_3d_segments_with_codes(paths, zs=0, zdir='z'): + """ + Convert paths from a collection object to 3D segments with path codes. + """ + + zs = np.broadcast_to(zs, len(paths)) + segments_codes = [_path_to_3d_segment_with_codes(path, pathz, zdir) + for path, pathz in zip(paths, zs)] + if segments_codes: + segments, codes = zip(*segments_codes) + else: + segments, codes = [], [] + return list(segments), list(codes) + + +class Collection3D(Collection): + """A collection of 3D paths.""" + + def do_3d_projection(self): + """Project the points according to renderer matrix.""" + xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M) + for vs, _ in self._3dverts_codes] + self._paths = [mpath.Path(np.column_stack([xs, ys]), cs) + for (xs, ys, _), (_, cs) in zip(xyzs_list, self._3dverts_codes)] + zs = np.concatenate([zs for _, _, zs in xyzs_list]) + return zs.min() if len(zs) else 1e9 + + +def collection_2d_to_3d(col, zs=0, zdir='z'): + """Convert a `.Collection` to a `.Collection3D` object.""" + zs = np.broadcast_to(zs, len(col.get_paths())) + col._3dverts_codes = [ + (np.column_stack(juggle_axes( + *np.column_stack([p.vertices, np.broadcast_to(z, len(p.vertices))]).T, + zdir)), + p.codes) + for p, z in zip(col.get_paths(), zs)] + col.__class__ = cbook._make_class_factory(Collection3D, "{}3D")(type(col)) + + +class Line3DCollection(LineCollection): + """ + A collection of 3D lines. + """ + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def set_segments(self, segments): + """ + Set 3D segments. + """ + self._segments3d = segments + super().set_segments([]) + + def do_3d_projection(self): + """ + Project the points according to renderer matrix. + """ + xyslist = [proj3d._proj_trans_points(points, self.axes.M) + for points in self._segments3d] + segments_2d = [np.column_stack([xs, ys]) for xs, ys, zs in xyslist] + LineCollection.set_segments(self, segments_2d) + + # FIXME + minz = 1e9 + for xs, ys, zs in xyslist: + minz = min(minz, min(zs)) + return minz + + +def line_collection_2d_to_3d(col, zs=0, zdir='z'): + """Convert a `.LineCollection` to a `.Line3DCollection` object.""" + segments3d = _paths_to_3d_segments(col.get_paths(), zs, zdir) + col.__class__ = Line3DCollection + col.set_segments(segments3d) + + +class Patch3D(Patch): + """ + 3D patch object. + """ + + def __init__(self, *args, zs=(), zdir='z', **kwargs): + """ + Parameters + ---------- + verts : + zs : float + The location along the *zdir* axis in 3D space to position the + patch. + zdir : {'x', 'y', 'z'} + Plane to plot patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + super().__init__(*args, **kwargs) + self.set_3d_properties(zs, zdir) + + def set_3d_properties(self, verts, zs=0, zdir='z'): + """ + Set the *z* position and direction of the patch. + + Parameters + ---------- + verts : + zs : float + The location along the *zdir* axis in 3D space to position the + patch. + zdir : {'x', 'y', 'z'} + Plane to plot patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + zs = np.broadcast_to(zs, len(verts)) + self._segment3d = [juggle_axes(x, y, z, zdir) + for ((x, y), z) in zip(verts, zs)] + + def get_path(self): + return self._path2d + + def do_3d_projection(self): + s = self._segment3d + xs, ys, zs = zip(*s) + vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, + self.axes.M) + self._path2d = mpath.Path(np.column_stack([vxs, vys])) + return min(vzs) + + +class PathPatch3D(Patch3D): + """ + 3D PathPatch object. + """ + + def __init__(self, path, *, zs=(), zdir='z', **kwargs): + """ + Parameters + ---------- + path : + zs : float + The location along the *zdir* axis in 3D space to position the + path patch. + zdir : {'x', 'y', 'z', 3-tuple} + Plane to plot path patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + # Not super().__init__! + Patch.__init__(self, **kwargs) + self.set_3d_properties(path, zs, zdir) + + def set_3d_properties(self, path, zs=0, zdir='z'): + """ + Set the *z* position and direction of the path patch. + + Parameters + ---------- + path : + zs : float + The location along the *zdir* axis in 3D space to position the + path patch. + zdir : {'x', 'y', 'z', 3-tuple} + Plane to plot path patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir) + self._code3d = path.codes + + def do_3d_projection(self): + s = self._segment3d + xs, ys, zs = zip(*s) + vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, + self.axes.M) + self._path2d = mpath.Path(np.column_stack([vxs, vys]), self._code3d) + return min(vzs) + + +def _get_patch_verts(patch): + """Return a list of vertices for the path of a patch.""" + trans = patch.get_patch_transform() + path = patch.get_path() + polygons = path.to_polygons(trans) + return polygons[0] if len(polygons) else np.array([]) + + +def patch_2d_to_3d(patch, z=0, zdir='z'): + """Convert a `.Patch` to a `.Patch3D` object.""" + verts = _get_patch_verts(patch) + patch.__class__ = Patch3D + patch.set_3d_properties(verts, z, zdir) + + +def pathpatch_2d_to_3d(pathpatch, z=0, zdir='z'): + """Convert a `.PathPatch` to a `.PathPatch3D` object.""" + path = pathpatch.get_path() + trans = pathpatch.get_patch_transform() + + mpath = trans.transform_path(path) + pathpatch.__class__ = PathPatch3D + pathpatch.set_3d_properties(mpath, z, zdir) + + +class Patch3DCollection(PatchCollection): + """ + A collection of 3D patches. + """ + + def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): + """ + Create a collection of flat 3D patches with its normal vector + pointed in *zdir* direction, and located at *zs* on the *zdir* + axis. 'zs' can be a scalar or an array-like of the same length as + the number of patches in the collection. + + Constructor arguments are the same as for + :class:`~matplotlib.collections.PatchCollection`. In addition, + keywords *zs=0* and *zdir='z'* are available. + + Also, the keyword argument *depthshade* is available to indicate + whether to shade the patches in order to give the appearance of depth + (default is *True*). This is typically desired in scatter plots. + """ + self._depthshade = depthshade + super().__init__(*args, **kwargs) + self.set_3d_properties(zs, zdir) + + def get_depthshade(self): + return self._depthshade + + def set_depthshade(self, depthshade): + """ + Set whether depth shading is performed on collection members. + + Parameters + ---------- + depthshade : bool + Whether to shade the patches in order to give the appearance of + depth. + """ + self._depthshade = depthshade + self.stale = True + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def set_3d_properties(self, zs, zdir): + """ + Set the *z* positions and direction of the patches. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the patches in the collection + along the *zdir* axis. + zdir : {'x', 'y', 'z'} + Plane to plot patches orthogonal to. + All patches must have the same direction. + See `.get_dir_vector` for a description of the values. + """ + # Force the collection to initialize the face and edgecolors + # just in case it is a scalarmappable with a colormap. + self.update_scalarmappable() + offsets = self.get_offsets() + if len(offsets) > 0: + xs, ys = offsets.T + else: + xs = [] + ys = [] + self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir) + self._z_markers_idx = slice(-1) + self._vzs = None + self.stale = True + + def do_3d_projection(self): + xs, ys, zs = self._offsets3d + vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, + self.axes.M) + self._vzs = vzs + super().set_offsets(np.column_stack([vxs, vys])) + + if vzs.size > 0: + return min(vzs) + else: + return np.nan + + def _maybe_depth_shade_and_sort_colors(self, color_array): + color_array = ( + _zalpha(color_array, self._vzs) + if self._vzs is not None and self._depthshade + else color_array + ) + if len(color_array) > 1: + color_array = color_array[self._z_markers_idx] + return mcolors.to_rgba_array(color_array, self._alpha) + + def get_facecolor(self): + return self._maybe_depth_shade_and_sort_colors(super().get_facecolor()) + + def get_edgecolor(self): + # We need this check here to make sure we do not double-apply the depth + # based alpha shading when the edge color is "face" which means the + # edge colour should be identical to the face colour. + if cbook._str_equal(self._edgecolors, 'face'): + return self.get_facecolor() + return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) + + +class Path3DCollection(PathCollection): + """ + A collection of 3D paths. + """ + + def __init__(self, *args, zs=0, zdir='z', depthshade=True, **kwargs): + """ + Create a collection of flat 3D paths with its normal vector + pointed in *zdir* direction, and located at *zs* on the *zdir* + axis. 'zs' can be a scalar or an array-like of the same length as + the number of paths in the collection. + + Constructor arguments are the same as for + :class:`~matplotlib.collections.PathCollection`. In addition, + keywords *zs=0* and *zdir='z'* are available. + + Also, the keyword argument *depthshade* is available to indicate + whether to shade the patches in order to give the appearance of depth + (default is *True*). This is typically desired in scatter plots. + """ + self._depthshade = depthshade + self._in_draw = False + super().__init__(*args, **kwargs) + self.set_3d_properties(zs, zdir) + self._offset_zordered = None + + def draw(self, renderer): + with self._use_zordered_offset(): + with cbook._setattr_cm(self, _in_draw=True): + super().draw(renderer) + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def set_3d_properties(self, zs, zdir): + """ + Set the *z* positions and direction of the paths. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the paths in the collection + along the *zdir* axis. + zdir : {'x', 'y', 'z'} + Plane to plot paths orthogonal to. + All paths must have the same direction. + See `.get_dir_vector` for a description of the values. + """ + # Force the collection to initialize the face and edgecolors + # just in case it is a scalarmappable with a colormap. + self.update_scalarmappable() + offsets = self.get_offsets() + if len(offsets) > 0: + xs, ys = offsets.T + else: + xs = [] + ys = [] + self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir) + # In the base draw methods we access the attributes directly which + # means we cannot resolve the shuffling in the getter methods like + # we do for the edge and face colors. + # + # This means we need to carry around a cache of the unsorted sizes and + # widths (postfixed with 3d) and in `do_3d_projection` set the + # depth-sorted version of that data into the private state used by the + # base collection class in its draw method. + # + # Grab the current sizes and linewidths to preserve them. + self._sizes3d = self._sizes + self._linewidths3d = np.array(self._linewidths) + xs, ys, zs = self._offsets3d + + # Sort the points based on z coordinates + # Performance optimization: Create a sorted index array and reorder + # points and point properties according to the index array + self._z_markers_idx = slice(-1) + self._vzs = None + self.stale = True + + def set_sizes(self, sizes, dpi=72.0): + super().set_sizes(sizes, dpi) + if not self._in_draw: + self._sizes3d = sizes + + def set_linewidth(self, lw): + super().set_linewidth(lw) + if not self._in_draw: + self._linewidths3d = np.array(self._linewidths) + + def get_depthshade(self): + return self._depthshade + + def set_depthshade(self, depthshade): + """ + Set whether depth shading is performed on collection members. + + Parameters + ---------- + depthshade : bool + Whether to shade the patches in order to give the appearance of + depth. + """ + self._depthshade = depthshade + self.stale = True + + def do_3d_projection(self): + xs, ys, zs = self._offsets3d + vxs, vys, vzs, vis = proj3d.proj_transform_clip(xs, ys, zs, + self.axes.M) + # Sort the points based on z coordinates + # Performance optimization: Create a sorted index array and reorder + # points and point properties according to the index array + z_markers_idx = self._z_markers_idx = np.argsort(vzs)[::-1] + self._vzs = vzs + + # we have to special case the sizes because of code in collections.py + # as the draw method does + # self.set_sizes(self._sizes, self.figure.dpi) + # so we cannot rely on doing the sorting on the way out via get_* + + if len(self._sizes3d) > 1: + self._sizes = self._sizes3d[z_markers_idx] + + if len(self._linewidths3d) > 1: + self._linewidths = self._linewidths3d[z_markers_idx] + + PathCollection.set_offsets(self, np.column_stack((vxs, vys))) + + # Re-order items + vzs = vzs[z_markers_idx] + vxs = vxs[z_markers_idx] + vys = vys[z_markers_idx] + + # Store ordered offset for drawing purpose + self._offset_zordered = np.column_stack((vxs, vys)) + + return np.min(vzs) if vzs.size else np.nan + + @contextmanager + def _use_zordered_offset(self): + if self._offset_zordered is None: + # Do nothing + yield + else: + # Swap offset with z-ordered offset + old_offset = self._offsets + super().set_offsets(self._offset_zordered) + try: + yield + finally: + self._offsets = old_offset + + def _maybe_depth_shade_and_sort_colors(self, color_array): + color_array = ( + _zalpha(color_array, self._vzs) + if self._vzs is not None and self._depthshade + else color_array + ) + if len(color_array) > 1: + color_array = color_array[self._z_markers_idx] + return mcolors.to_rgba_array(color_array, self._alpha) + + def get_facecolor(self): + return self._maybe_depth_shade_and_sort_colors(super().get_facecolor()) + + def get_edgecolor(self): + # We need this check here to make sure we do not double-apply the depth + # based alpha shading when the edge color is "face" which means the + # edge colour should be identical to the face colour. + if cbook._str_equal(self._edgecolors, 'face'): + return self.get_facecolor() + return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) + + +def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True): + """ + Convert a `.PatchCollection` into a `.Patch3DCollection` object + (or a `.PathCollection` into a `.Path3DCollection` object). + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the patches in the collection along + the *zdir* axis. Default: 0. + zdir : {'x', 'y', 'z'} + The axis in which to place the patches. Default: "z". + See `.get_dir_vector` for a description of the values. + depthshade + Whether to shade the patches to give a sense of depth. Default: *True*. + + """ + if isinstance(col, PathCollection): + col.__class__ = Path3DCollection + col._offset_zordered = None + elif isinstance(col, PatchCollection): + col.__class__ = Patch3DCollection + col._depthshade = depthshade + col._in_draw = False + col.set_3d_properties(zs, zdir) + + +class Poly3DCollection(PolyCollection): + """ + A collection of 3D polygons. + + .. note:: + **Filling of 3D polygons** + + There is no simple definition of the enclosed surface of a 3D polygon + unless the polygon is planar. + + In practice, Matplotlib fills the 2D projection of the polygon. This + gives a correct filling appearance only for planar polygons. For all + other polygons, you'll find orientations in which the edges of the + polygon intersect in the projection. This will lead to an incorrect + visualization of the 3D area. + + If you need filled areas, it is recommended to create them via + `~mpl_toolkits.mplot3d.axes3d.Axes3D.plot_trisurf`, which creates a + triangulation and thus generates consistent surfaces. + """ + + def __init__(self, verts, *args, zsort='average', shade=False, + lightsource=None, **kwargs): + """ + Parameters + ---------- + verts : list of (N, 3) array-like + The sequence of polygons [*verts0*, *verts1*, ...] where each + element *verts_i* defines the vertices of polygon *i* as a 2D + array-like of shape (N, 3). + zsort : {'average', 'min', 'max'}, default: 'average' + The calculation method for the z-order. + See `~.Poly3DCollection.set_zsort` for details. + shade : bool, default: False + Whether to shade *facecolors* and *edgecolors*. When activating + *shade*, *facecolors* and/or *edgecolors* must be provided. + + .. versionadded:: 3.7 + + lightsource : `~matplotlib.colors.LightSource`, optional + The lightsource to use when *shade* is True. + + .. versionadded:: 3.7 + + *args, **kwargs + All other parameters are forwarded to `.PolyCollection`. + + Notes + ----- + Note that this class does a bit of magic with the _facecolors + and _edgecolors properties. + """ + if shade: + normals = _generate_normals(verts) + facecolors = kwargs.get('facecolors', None) + if facecolors is not None: + kwargs['facecolors'] = _shade_colors( + facecolors, normals, lightsource + ) + + edgecolors = kwargs.get('edgecolors', None) + if edgecolors is not None: + kwargs['edgecolors'] = _shade_colors( + edgecolors, normals, lightsource + ) + if facecolors is None and edgecolors is None: + raise ValueError( + "You must provide facecolors, edgecolors, or both for " + "shade to work.") + super().__init__(verts, *args, **kwargs) + if isinstance(verts, np.ndarray): + if verts.ndim != 3: + raise ValueError('verts must be a list of (N, 3) array-like') + else: + if any(len(np.shape(vert)) != 2 for vert in verts): + raise ValueError('verts must be a list of (N, 3) array-like') + self.set_zsort(zsort) + self._codes3d = None + + _zsort_functions = { + 'average': np.average, + 'min': np.min, + 'max': np.max, + } + + def set_zsort(self, zsort): + """ + Set the calculation method for the z-order. + + Parameters + ---------- + zsort : {'average', 'min', 'max'} + The function applied on the z-coordinates of the vertices in the + viewer's coordinate system, to determine the z-order. + """ + self._zsortfunc = self._zsort_functions[zsort] + self._sort_zpos = None + self.stale = True + + def get_vector(self, segments3d): + """Optimize points for projection.""" + if len(segments3d): + xs, ys, zs = np.vstack(segments3d).T + else: # vstack can't stack zero arrays. + xs, ys, zs = [], [], [] + ones = np.ones(len(xs)) + self._vec = np.array([xs, ys, zs, ones]) + + indices = [0, *np.cumsum([len(segment) for segment in segments3d])] + self._segslices = [*map(slice, indices[:-1], indices[1:])] + + def set_verts(self, verts, closed=True): + """ + Set 3D vertices. + + Parameters + ---------- + verts : list of (N, 3) array-like + The sequence of polygons [*verts0*, *verts1*, ...] where each + element *verts_i* defines the vertices of polygon *i* as a 2D + array-like of shape (N, 3). + closed : bool, default: True + Whether the polygon should be closed by adding a CLOSEPOLY + connection at the end. + """ + self.get_vector(verts) + # 2D verts will be updated at draw time + super().set_verts([], False) + self._closed = closed + + def set_verts_and_codes(self, verts, codes): + """Set 3D vertices with path codes.""" + # set vertices with closed=False to prevent PolyCollection from + # setting path codes + self.set_verts(verts, closed=False) + # and set our own codes instead. + self._codes3d = codes + + def set_3d_properties(self): + # Force the collection to initialize the face and edgecolors + # just in case it is a scalarmappable with a colormap. + self.update_scalarmappable() + self._sort_zpos = None + self.set_zsort('average') + self._facecolor3d = PolyCollection.get_facecolor(self) + self._edgecolor3d = PolyCollection.get_edgecolor(self) + self._alpha3d = PolyCollection.get_alpha(self) + self.stale = True + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def do_3d_projection(self): + """ + Perform the 3D projection for this object. + """ + if self._A is not None: + # force update of color mapping because we re-order them + # below. If we do not do this here, the 2D draw will call + # this, but we will never port the color mapped values back + # to the 3D versions. + # + # We hold the 3D versions in a fixed order (the order the user + # passed in) and sort the 2D version by view depth. + self.update_scalarmappable() + if self._face_is_mapped: + self._facecolor3d = self._facecolors + if self._edge_is_mapped: + self._edgecolor3d = self._edgecolors + txs, tys, tzs = proj3d._proj_transform_vec(self._vec, self.axes.M) + xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices] + + # This extra fuss is to re-order face / edge colors + cface = self._facecolor3d + cedge = self._edgecolor3d + if len(cface) != len(xyzlist): + cface = cface.repeat(len(xyzlist), axis=0) + if len(cedge) != len(xyzlist): + if len(cedge) == 0: + cedge = cface + else: + cedge = cedge.repeat(len(xyzlist), axis=0) + + if xyzlist: + # sort by depth (furthest drawn first) + z_segments_2d = sorted( + ((self._zsortfunc(zs), np.column_stack([xs, ys]), fc, ec, idx) + for idx, ((xs, ys, zs), fc, ec) + in enumerate(zip(xyzlist, cface, cedge))), + key=lambda x: x[0], reverse=True) + + _, segments_2d, self._facecolors2d, self._edgecolors2d, idxs = \ + zip(*z_segments_2d) + else: + segments_2d = [] + self._facecolors2d = np.empty((0, 4)) + self._edgecolors2d = np.empty((0, 4)) + idxs = [] + + if self._codes3d is not None: + codes = [self._codes3d[idx] for idx in idxs] + PolyCollection.set_verts_and_codes(self, segments_2d, codes) + else: + PolyCollection.set_verts(self, segments_2d, self._closed) + + if len(self._edgecolor3d) != len(cface): + self._edgecolors2d = self._edgecolor3d + + # Return zorder value + if self._sort_zpos is not None: + zvec = np.array([[0], [0], [self._sort_zpos], [1]]) + ztrans = proj3d._proj_transform_vec(zvec, self.axes.M) + return ztrans[2][0] + elif tzs.size > 0: + # FIXME: Some results still don't look quite right. + # In particular, examine contourf3d_demo2.py + # with az = -54 and elev = -45. + return np.min(tzs) + else: + return np.nan + + def set_facecolor(self, colors): + # docstring inherited + super().set_facecolor(colors) + self._facecolor3d = PolyCollection.get_facecolor(self) + + def set_edgecolor(self, colors): + # docstring inherited + super().set_edgecolor(colors) + self._edgecolor3d = PolyCollection.get_edgecolor(self) + + def set_alpha(self, alpha): + # docstring inherited + artist.Artist.set_alpha(self, alpha) + try: + self._facecolor3d = mcolors.to_rgba_array( + self._facecolor3d, self._alpha) + except (AttributeError, TypeError, IndexError): + pass + try: + self._edgecolors = mcolors.to_rgba_array( + self._edgecolor3d, self._alpha) + except (AttributeError, TypeError, IndexError): + pass + self.stale = True + + def get_facecolor(self): + # docstring inherited + # self._facecolors2d is not initialized until do_3d_projection + if not hasattr(self, '_facecolors2d'): + self.axes.M = self.axes.get_proj() + self.do_3d_projection() + return np.asarray(self._facecolors2d) + + def get_edgecolor(self): + # docstring inherited + # self._edgecolors2d is not initialized until do_3d_projection + if not hasattr(self, '_edgecolors2d'): + self.axes.M = self.axes.get_proj() + self.do_3d_projection() + return np.asarray(self._edgecolors2d) + + +def poly_collection_2d_to_3d(col, zs=0, zdir='z'): + """ + Convert a `.PolyCollection` into a `.Poly3DCollection` object. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the polygons in the collection along + the *zdir* axis. Default: 0. + zdir : {'x', 'y', 'z'} + The axis in which to place the patches. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + segments_3d, codes = _paths_to_3d_segments_with_codes( + col.get_paths(), zs, zdir) + col.__class__ = Poly3DCollection + col.set_verts_and_codes(segments_3d, codes) + col.set_3d_properties() + + +def juggle_axes(xs, ys, zs, zdir): + """ + Reorder coordinates so that 2D *xs*, *ys* can be plotted in the plane + orthogonal to *zdir*. *zdir* is normally 'x', 'y' or 'z'. However, if + *zdir* starts with a '-' it is interpreted as a compensation for + `rotate_axes`. + """ + if zdir == 'x': + return zs, xs, ys + elif zdir == 'y': + return xs, zs, ys + elif zdir[0] == '-': + return rotate_axes(xs, ys, zs, zdir) + else: + return xs, ys, zs + + +def rotate_axes(xs, ys, zs, zdir): + """ + Reorder coordinates so that the axes are rotated with *zdir* along + the original z axis. Prepending the axis with a '-' does the + inverse transform, so *zdir* can be 'x', '-x', 'y', '-y', 'z' or '-z'. + """ + if zdir in ('x', '-y'): + return ys, zs, xs + elif zdir in ('-x', 'y'): + return zs, xs, ys + else: + return xs, ys, zs + + +def _zalpha(colors, zs): + """Modify the alphas of the color list according to depth.""" + # FIXME: This only works well if the points for *zs* are well-spaced + # in all three dimensions. Otherwise, at certain orientations, + # the min and max zs are very close together. + # Should really normalize against the viewing depth. + if len(colors) == 0 or len(zs) == 0: + return np.zeros((0, 4)) + norm = Normalize(min(zs), max(zs)) + sats = 1 - norm(zs) * 0.7 + rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) + return np.column_stack([rgba[:, :3], rgba[:, 3] * sats]) + + +def _generate_normals(polygons): + """ + Compute the normals of a list of polygons, one normal per polygon. + + Normals point towards the viewer for a face with its vertices in + counterclockwise order, following the right hand rule. + + Uses three points equally spaced around the polygon. This method assumes + that the points are in a plane. Otherwise, more than one shade is required, + which is not supported. + + Parameters + ---------- + polygons : list of (M_i, 3) array-like, or (..., M, 3) array-like + A sequence of polygons to compute normals for, which can have + varying numbers of vertices. If the polygons all have the same + number of vertices and array is passed, then the operation will + be vectorized. + + Returns + ------- + normals : (..., 3) array + A normal vector estimated for the polygon. + """ + if isinstance(polygons, np.ndarray): + # optimization: polygons all have the same number of points, so can + # vectorize + n = polygons.shape[-2] + i1, i2, i3 = 0, n//3, 2*n//3 + v1 = polygons[..., i1, :] - polygons[..., i2, :] + v2 = polygons[..., i2, :] - polygons[..., i3, :] + else: + # The subtraction doesn't vectorize because polygons is jagged. + v1 = np.empty((len(polygons), 3)) + v2 = np.empty((len(polygons), 3)) + for poly_i, ps in enumerate(polygons): + n = len(ps) + i1, i2, i3 = 0, n//3, 2*n//3 + v1[poly_i, :] = ps[i1, :] - ps[i2, :] + v2[poly_i, :] = ps[i2, :] - ps[i3, :] + return np.cross(v1, v2) + + +def _shade_colors(color, normals, lightsource=None): + """ + Shade *color* using normal vectors given by *normals*, + assuming a *lightsource* (using default position if not given). + *color* can also be an array of the same length as *normals*. + """ + if lightsource is None: + # chosen for backwards-compatibility + lightsource = mcolors.LightSource(azdeg=225, altdeg=19.4712) + + with np.errstate(invalid="ignore"): + shade = ((normals / np.linalg.norm(normals, axis=1, keepdims=True)) + @ lightsource.direction) + mask = ~np.isnan(shade) + + if mask.any(): + # convert dot product to allowed shading fractions + in_norm = mcolors.Normalize(-1, 1) + out_norm = mcolors.Normalize(0.3, 1).inverse + + def norm(x): + return out_norm(in_norm(x)) + + shade[~mask] = 0 + + color = mcolors.to_rgba_array(color) + # shape of color should be (M, 4) (where M is number of faces) + # shape of shade should be (M,) + # colors should have final shape of (M, 4) + alpha = color[:, 3] + colors = norm(shade)[:, np.newaxis] * color + colors[:, 3] = alpha + else: + colors = np.asanyarray(color).copy() + + return colors diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/axes3d.py b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/axes3d.py new file mode 100644 index 0000000000..aeb6a66d2c --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/axes3d.py @@ -0,0 +1,3448 @@ +""" +axes3d.py, original mplot3d version by John Porter +Created: 23 Sep 2005 + +Parts fixed by Reinier Heeres <reinier@heeres.eu> +Minor additions by Ben Axelrod <baxelrod@coroware.com> +Significant updates and revisions by Ben Root <ben.v.root@gmail.com> + +Module containing Axes3D, an object which can plot 3D objects on a +2D matplotlib figure. +""" + +from collections import defaultdict +import functools +import itertools +import math +import textwrap + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook, _docstring, _preprocess_data +import matplotlib.artist as martist +import matplotlib.axes as maxes +import matplotlib.collections as mcoll +import matplotlib.colors as mcolors +import matplotlib.image as mimage +import matplotlib.lines as mlines +import matplotlib.patches as mpatches +import matplotlib.container as mcontainer +import matplotlib.transforms as mtransforms +from matplotlib.axes import Axes +from matplotlib.axes._base import _axis_method_wrapper, _process_plot_format +from matplotlib.transforms import Bbox +from matplotlib.tri._triangulation import Triangulation + +from . import art3d +from . import proj3d +from . import axis3d + + +@_docstring.interpd +@_api.define_aliases({ + "xlim": ["xlim3d"], "ylim": ["ylim3d"], "zlim": ["zlim3d"]}) +class Axes3D(Axes): + """ + 3D Axes object. + + .. note:: + + As a user, you do not instantiate Axes directly, but use Axes creation + methods instead; e.g. from `.pyplot` or `.Figure`: + `~.pyplot.subplots`, `~.pyplot.subplot_mosaic` or `.Figure.add_axes`. + """ + name = '3d' + + _axis_names = ("x", "y", "z") + Axes._shared_axes["z"] = cbook.Grouper() + Axes._shared_axes["view"] = cbook.Grouper() + + vvec = _api.deprecate_privatize_attribute("3.7") + eye = _api.deprecate_privatize_attribute("3.7") + sx = _api.deprecate_privatize_attribute("3.7") + sy = _api.deprecate_privatize_attribute("3.7") + + def __init__( + self, fig, rect=None, *args, + elev=30, azim=-60, roll=0, sharez=None, proj_type='persp', + box_aspect=None, computed_zorder=True, focal_length=None, + shareview=None, + **kwargs): + """ + Parameters + ---------- + fig : Figure + The parent figure. + rect : tuple (left, bottom, width, height), default: None. + The ``(left, bottom, width, height)`` axes position. + elev : float, default: 30 + The elevation angle in degrees rotates the camera above and below + the x-y plane, with a positive angle corresponding to a location + above the plane. + azim : float, default: -60 + The azimuthal angle in degrees rotates the camera about the z axis, + with a positive angle corresponding to a right-handed rotation. In + other words, a positive azimuth rotates the camera about the origin + from its location along the +x axis towards the +y axis. + roll : float, default: 0 + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. + sharez : Axes3D, optional + Other Axes to share z-limits with. + proj_type : {'persp', 'ortho'} + The projection type, default 'persp'. + box_aspect : 3-tuple of floats, default: None + Changes the physical dimensions of the Axes3D, such that the ratio + of the axis lengths in display units is x:y:z. + If None, defaults to 4:4:3 + computed_zorder : bool, default: True + If True, the draw order is computed based on the average position + of the `.Artist`\\s along the view direction. + Set to False if you want to manually control the order in which + Artists are drawn on top of each other using their *zorder* + attribute. This can be used for fine-tuning if the automatic order + does not produce the desired result. Note however, that a manual + zorder will only be correct for a limited view angle. If the figure + is rotated by the user, it will look wrong from certain angles. + focal_length : float, default: None + For a projection type of 'persp', the focal length of the virtual + camera. Must be > 0. If None, defaults to 1. + For a projection type of 'ortho', must be set to either None + or infinity (numpy.inf). If None, defaults to infinity. + The focal length can be computed from a desired Field Of View via + the equation: focal_length = 1/tan(FOV/2) + shareview : Axes3D, optional + Other Axes to share view angles with. + + **kwargs + Other optional keyword arguments: + + %(Axes3D:kwdoc)s + """ + + if rect is None: + rect = [0.0, 0.0, 1.0, 1.0] + + self.initial_azim = azim + self.initial_elev = elev + self.initial_roll = roll + self.set_proj_type(proj_type, focal_length) + self.computed_zorder = computed_zorder + + self.xy_viewLim = Bbox.unit() + self.zz_viewLim = Bbox.unit() + self.xy_dataLim = Bbox.unit() + # z-limits are encoded in the x-component of the Bbox, y is un-used + self.zz_dataLim = Bbox.unit() + + # inhibit autoscale_view until the axes are defined + # they can't be defined until Axes.__init__ has been called + self.view_init(self.initial_elev, self.initial_azim, self.initial_roll) + + self._sharez = sharez + if sharez is not None: + self._shared_axes["z"].join(self, sharez) + self._adjustable = 'datalim' + + self._shareview = shareview + if shareview is not None: + self._shared_axes["view"].join(self, shareview) + + if kwargs.pop('auto_add_to_figure', False): + raise AttributeError( + 'auto_add_to_figure is no longer supported for Axes3D. ' + 'Use fig.add_axes(ax) instead.' + ) + + super().__init__( + fig, rect, frameon=True, box_aspect=box_aspect, *args, **kwargs + ) + # Disable drawing of axes by base class + super().set_axis_off() + # Enable drawing of axes by Axes3D class + self.set_axis_on() + self.M = None + self.invM = None + + # func used to format z -- fall back on major formatters + self.fmt_zdata = None + + self.mouse_init() + self.figure.canvas.callbacks._connect_picklable( + 'motion_notify_event', self._on_move) + self.figure.canvas.callbacks._connect_picklable( + 'button_press_event', self._button_press) + self.figure.canvas.callbacks._connect_picklable( + 'button_release_event', self._button_release) + self.set_top_view() + + self.patch.set_linewidth(0) + # Calculate the pseudo-data width and height + pseudo_bbox = self.transLimits.inverted().transform([(0, 0), (1, 1)]) + self._pseudo_w, self._pseudo_h = pseudo_bbox[1] - pseudo_bbox[0] + + # mplot3d currently manages its own spines and needs these turned off + # for bounding box calculations + self.spines[:].set_visible(False) + + def set_axis_off(self): + self._axis3don = False + self.stale = True + + def set_axis_on(self): + self._axis3don = True + self.stale = True + + def convert_zunits(self, z): + """ + For artists in an Axes, if the zaxis has units support, + convert *z* using zaxis unit type + """ + return self.zaxis.convert_units(z) + + def set_top_view(self): + # this happens to be the right view for the viewing coordinates + # moved up and to the left slightly to fit labels and axes + xdwl = 0.95 / self._dist + xdw = 0.9 / self._dist + ydwl = 0.95 / self._dist + ydw = 0.9 / self._dist + # Set the viewing pane. + self.viewLim.intervalx = (-xdwl, xdw) + self.viewLim.intervaly = (-ydwl, ydw) + self.stale = True + + def _init_axis(self): + """Init 3D axes; overrides creation of regular X/Y axes.""" + self.xaxis = axis3d.XAxis(self) + self.yaxis = axis3d.YAxis(self) + self.zaxis = axis3d.ZAxis(self) + + def get_zaxis(self): + """Return the ``ZAxis`` (`~.axis3d.Axis`) instance.""" + return self.zaxis + + get_zgridlines = _axis_method_wrapper("zaxis", "get_gridlines") + get_zticklines = _axis_method_wrapper("zaxis", "get_ticklines") + + @_api.deprecated("3.7") + def unit_cube(self, vals=None): + return self._unit_cube(vals) + + def _unit_cube(self, vals=None): + minx, maxx, miny, maxy, minz, maxz = vals or self.get_w_lims() + return [(minx, miny, minz), + (maxx, miny, minz), + (maxx, maxy, minz), + (minx, maxy, minz), + (minx, miny, maxz), + (maxx, miny, maxz), + (maxx, maxy, maxz), + (minx, maxy, maxz)] + + @_api.deprecated("3.7") + def tunit_cube(self, vals=None, M=None): + return self._tunit_cube(vals, M) + + def _tunit_cube(self, vals=None, M=None): + if M is None: + M = self.M + xyzs = self._unit_cube(vals) + tcube = proj3d._proj_points(xyzs, M) + return tcube + + @_api.deprecated("3.7") + def tunit_edges(self, vals=None, M=None): + return self._tunit_edges(vals, M) + + def _tunit_edges(self, vals=None, M=None): + tc = self._tunit_cube(vals, M) + edges = [(tc[0], tc[1]), + (tc[1], tc[2]), + (tc[2], tc[3]), + (tc[3], tc[0]), + + (tc[0], tc[4]), + (tc[1], tc[5]), + (tc[2], tc[6]), + (tc[3], tc[7]), + + (tc[4], tc[5]), + (tc[5], tc[6]), + (tc[6], tc[7]), + (tc[7], tc[4])] + return edges + + def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): + """ + Set the aspect ratios. + + Parameters + ---------- + aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} + Possible values: + + ========= ================================================== + value description + ========= ================================================== + 'auto' automatic; fill the position rectangle with data. + 'equal' adapt all the axes to have equal aspect ratios. + 'equalxy' adapt the x and y axes to have equal aspect ratios. + 'equalxz' adapt the x and z axes to have equal aspect ratios. + 'equalyz' adapt the y and z axes to have equal aspect ratios. + ========= ================================================== + + adjustable : None or {'box', 'datalim'}, optional + If not *None*, this defines which parameter will be adjusted to + meet the required aspect. See `.set_adjustable` for further + details. + + anchor : None or str or 2-tuple of float, optional + If not *None*, this defines where the Axes will be drawn if there + is extra space due to aspect constraints. The most common way to + specify the anchor are abbreviations of cardinal directions: + + ===== ===================== + value description + ===== ===================== + 'C' centered + 'SW' lower left corner + 'S' middle of bottom edge + 'SE' lower right corner + etc. + ===== ===================== + + See `~.Axes.set_anchor` for further details. + + share : bool, default: False + If ``True``, apply the settings to all shared Axes. + + See Also + -------- + mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect + """ + _api.check_in_list(('auto', 'equal', 'equalxy', 'equalyz', 'equalxz'), + aspect=aspect) + super().set_aspect( + aspect='auto', adjustable=adjustable, anchor=anchor, share=share) + self._aspect = aspect + + if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + ax_indices = self._equal_aspect_axis_indices(aspect) + + view_intervals = np.array([self.xaxis.get_view_interval(), + self.yaxis.get_view_interval(), + self.zaxis.get_view_interval()]) + ptp = np.ptp(view_intervals, axis=1) + if self._adjustable == 'datalim': + mean = np.mean(view_intervals, axis=1) + scale = max(ptp[ax_indices] / self._box_aspect[ax_indices]) + deltas = scale * self._box_aspect + + for i, set_lim in enumerate((self.set_xlim3d, + self.set_ylim3d, + self.set_zlim3d)): + if i in ax_indices: + set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2.) + else: # 'box' + # Change the box aspect such that the ratio of the length of + # the unmodified axis to the length of the diagonal + # perpendicular to it remains unchanged. + box_aspect = np.array(self._box_aspect) + box_aspect[ax_indices] = ptp[ax_indices] + remaining_ax_indices = {0, 1, 2}.difference(ax_indices) + if remaining_ax_indices: + remaining = remaining_ax_indices.pop() + old_diag = np.linalg.norm(self._box_aspect[ax_indices]) + new_diag = np.linalg.norm(box_aspect[ax_indices]) + box_aspect[remaining] *= new_diag / old_diag + self.set_box_aspect(box_aspect) + + def _equal_aspect_axis_indices(self, aspect): + """ + Get the indices for which of the x, y, z axes are constrained to have + equal aspect ratios. + + Parameters + ---------- + aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} + See descriptions in docstring for `.set_aspect()`. + """ + ax_indices = [] # aspect == 'auto' + if aspect == 'equal': + ax_indices = [0, 1, 2] + elif aspect == 'equalxy': + ax_indices = [0, 1] + elif aspect == 'equalxz': + ax_indices = [0, 2] + elif aspect == 'equalyz': + ax_indices = [1, 2] + return ax_indices + + def set_box_aspect(self, aspect, *, zoom=1): + """ + Set the Axes box aspect. + + The box aspect is the ratio of height to width in display + units for each face of the box when viewed perpendicular to + that face. This is not to be confused with the data aspect (see + `~.Axes3D.set_aspect`). The default ratios are 4:4:3 (x:y:z). + + To simulate having equal aspect in data space, set the box + aspect to match your data range in each dimension. + + *zoom* controls the overall size of the Axes3D in the figure. + + Parameters + ---------- + aspect : 3-tuple of floats or None + Changes the physical dimensions of the Axes3D, such that the ratio + of the axis lengths in display units is x:y:z. + If None, defaults to (4, 4, 3). + + zoom : float, default: 1 + Control overall size of the Axes3D in the figure. Must be > 0. + """ + if zoom <= 0: + raise ValueError(f'Argument zoom = {zoom} must be > 0') + + if aspect is None: + aspect = np.asarray((4, 4, 3), dtype=float) + else: + aspect = np.asarray(aspect, dtype=float) + _api.check_shape((3,), aspect=aspect) + # default scale tuned to match the mpl32 appearance. + aspect *= 1.8294640721620434 * zoom / np.linalg.norm(aspect) + + self._box_aspect = aspect + self.stale = True + + def apply_aspect(self, position=None): + if position is None: + position = self.get_position(original=True) + + # in the superclass, we would go through and actually deal with axis + # scales and box/datalim. Those are all irrelevant - all we need to do + # is make sure our coordinate system is square. + trans = self.get_figure().transSubfigure + bb = mtransforms.Bbox.unit().transformed(trans) + # this is the physical aspect of the panel (or figure): + fig_aspect = bb.height / bb.width + + box_aspect = 1 + pb = position.frozen() + pb1 = pb.shrunk_to_aspect(box_aspect, pb, fig_aspect) + self._set_position(pb1.anchored(self.get_anchor(), pb), 'active') + + @martist.allow_rasterization + def draw(self, renderer): + if not self.get_visible(): + return + self._unstale_viewLim() + + # draw the background patch + self.patch.draw(renderer) + self._frameon = False + + # first, set the aspect + # this is duplicated from `axes._base._AxesBase.draw` + # but must be called before any of the artist are drawn as + # it adjusts the view limits and the size of the bounding box + # of the Axes + locator = self.get_axes_locator() + self.apply_aspect(locator(self, renderer) if locator else None) + + # add the projection matrix to the renderer + self.M = self.get_proj() + self.invM = np.linalg.inv(self.M) + + collections_and_patches = ( + artist for artist in self._children + if isinstance(artist, (mcoll.Collection, mpatches.Patch)) + and artist.get_visible()) + if self.computed_zorder: + # Calculate projection of collections and patches and zorder + # them. Make sure they are drawn above the grids. + zorder_offset = max(axis.get_zorder() + for axis in self._axis_map.values()) + 1 + collection_zorder = patch_zorder = zorder_offset + + for artist in sorted(collections_and_patches, + key=lambda artist: artist.do_3d_projection(), + reverse=True): + if isinstance(artist, mcoll.Collection): + artist.zorder = collection_zorder + collection_zorder += 1 + elif isinstance(artist, mpatches.Patch): + artist.zorder = patch_zorder + patch_zorder += 1 + else: + for artist in collections_and_patches: + artist.do_3d_projection() + + if self._axis3don: + # Draw panes first + for axis in self._axis_map.values(): + axis.draw_pane(renderer) + # Then gridlines + for axis in self._axis_map.values(): + axis.draw_grid(renderer) + # Then axes, labels, text, and ticks + for axis in self._axis_map.values(): + axis.draw(renderer) + + # Then rest + super().draw(renderer) + + def get_axis_position(self): + vals = self.get_w_lims() + tc = self._tunit_cube(vals, self.M) + xhigh = tc[1][2] > tc[2][2] + yhigh = tc[3][2] > tc[2][2] + zhigh = tc[0][2] > tc[2][2] + return xhigh, yhigh, zhigh + + def update_datalim(self, xys, **kwargs): + """ + Not implemented in `~mpl_toolkits.mplot3d.axes3d.Axes3D`. + """ + pass + + get_autoscalez_on = _axis_method_wrapper("zaxis", "_get_autoscale_on") + set_autoscalez_on = _axis_method_wrapper("zaxis", "_set_autoscale_on") + + def set_zmargin(self, m): + """ + Set padding of Z data limits prior to autoscaling. + + *m* times the data interval will be added to each end of that interval + before it is used in autoscaling. If *m* is negative, this will clip + the data range instead of expanding it. + + For example, if your data is in the range [0, 2], a margin of 0.1 will + result in a range [-0.2, 2.2]; a margin of -0.1 will result in a range + of [0.2, 1.8]. + + Parameters + ---------- + m : float greater than -0.5 + """ + if m <= -0.5: + raise ValueError("margin must be greater than -0.5") + self._zmargin = m + self._request_autoscale_view("z") + self.stale = True + + def margins(self, *margins, x=None, y=None, z=None, tight=True): + """ + Set or retrieve autoscaling margins. + + See `.Axes.margins` for full documentation. Because this function + applies to 3D Axes, it also takes a *z* argument, and returns + ``(xmargin, ymargin, zmargin)``. + """ + if margins and (x is not None or y is not None or z is not None): + raise TypeError('Cannot pass both positional and keyword ' + 'arguments for x, y, and/or z.') + elif len(margins) == 1: + x = y = z = margins[0] + elif len(margins) == 3: + x, y, z = margins + elif margins: + raise TypeError('Must pass a single positional argument for all ' + 'margins, or one for each margin (x, y, z).') + + if x is None and y is None and z is None: + if tight is not True: + _api.warn_external(f'ignoring tight={tight!r} in get mode') + return self._xmargin, self._ymargin, self._zmargin + + if x is not None: + self.set_xmargin(x) + if y is not None: + self.set_ymargin(y) + if z is not None: + self.set_zmargin(z) + + self.autoscale_view( + tight=tight, scalex=(x is not None), scaley=(y is not None), + scalez=(z is not None) + ) + + def autoscale(self, enable=True, axis='both', tight=None): + """ + Convenience method for simple axis view autoscaling. + + See `.Axes.autoscale` for full documentation. Because this function + applies to 3D Axes, *axis* can also be set to 'z', and setting *axis* + to 'both' autoscales all three axes. + """ + if enable is None: + scalex = True + scaley = True + scalez = True + else: + if axis in ['x', 'both']: + self.set_autoscalex_on(bool(enable)) + scalex = self.get_autoscalex_on() + else: + scalex = False + if axis in ['y', 'both']: + self.set_autoscaley_on(bool(enable)) + scaley = self.get_autoscaley_on() + else: + scaley = False + if axis in ['z', 'both']: + self.set_autoscalez_on(bool(enable)) + scalez = self.get_autoscalez_on() + else: + scalez = False + if scalex: + self._request_autoscale_view("x", tight=tight) + if scaley: + self._request_autoscale_view("y", tight=tight) + if scalez: + self._request_autoscale_view("z", tight=tight) + + def auto_scale_xyz(self, X, Y, Z=None, had_data=None): + # This updates the bounding boxes as to keep a record as to what the + # minimum sized rectangular volume holds the data. + if np.shape(X) == np.shape(Y): + self.xy_dataLim.update_from_data_xy( + np.column_stack([np.ravel(X), np.ravel(Y)]), not had_data) + else: + self.xy_dataLim.update_from_data_x(X, not had_data) + self.xy_dataLim.update_from_data_y(Y, not had_data) + if Z is not None: + self.zz_dataLim.update_from_data_x(Z, not had_data) + # Let autoscale_view figure out how to use this data. + self.autoscale_view() + + def autoscale_view(self, tight=None, scalex=True, scaley=True, + scalez=True): + """ + Autoscale the view limits using the data limits. + + See `.Axes.autoscale_view` for full documentation. Because this + function applies to 3D Axes, it also takes a *scalez* argument. + """ + # This method looks at the rectangular volume (see above) + # of data and decides how to scale the view portal to fit it. + if tight is None: + _tight = self._tight + if not _tight: + # if image data only just use the datalim + for artist in self._children: + if isinstance(artist, mimage.AxesImage): + _tight = True + elif isinstance(artist, (mlines.Line2D, mpatches.Patch)): + _tight = False + break + else: + _tight = self._tight = bool(tight) + + if scalex and self.get_autoscalex_on(): + x0, x1 = self.xy_dataLim.intervalx + xlocator = self.xaxis.get_major_locator() + x0, x1 = xlocator.nonsingular(x0, x1) + if self._xmargin > 0: + delta = (x1 - x0) * self._xmargin + x0 -= delta + x1 += delta + if not _tight: + x0, x1 = xlocator.view_limits(x0, x1) + self.set_xbound(x0, x1) + + if scaley and self.get_autoscaley_on(): + y0, y1 = self.xy_dataLim.intervaly + ylocator = self.yaxis.get_major_locator() + y0, y1 = ylocator.nonsingular(y0, y1) + if self._ymargin > 0: + delta = (y1 - y0) * self._ymargin + y0 -= delta + y1 += delta + if not _tight: + y0, y1 = ylocator.view_limits(y0, y1) + self.set_ybound(y0, y1) + + if scalez and self.get_autoscalez_on(): + z0, z1 = self.zz_dataLim.intervalx + zlocator = self.zaxis.get_major_locator() + z0, z1 = zlocator.nonsingular(z0, z1) + if self._zmargin > 0: + delta = (z1 - z0) * self._zmargin + z0 -= delta + z1 += delta + if not _tight: + z0, z1 = zlocator.view_limits(z0, z1) + self.set_zbound(z0, z1) + + def get_w_lims(self): + """Get 3D world limits.""" + minx, maxx = self.get_xlim3d() + miny, maxy = self.get_ylim3d() + minz, maxz = self.get_zlim3d() + return minx, maxx, miny, maxy, minz, maxz + + # set_xlim, set_ylim are directly inherited from base Axes. + def set_zlim(self, bottom=None, top=None, *, emit=True, auto=False, + zmin=None, zmax=None): + """ + Set 3D z limits. + + See `.Axes.set_ylim` for full documentation + """ + if top is None and np.iterable(bottom): + bottom, top = bottom + if zmin is not None: + if bottom is not None: + raise TypeError("Cannot pass both 'bottom' and 'zmin'") + bottom = zmin + if zmax is not None: + if top is not None: + raise TypeError("Cannot pass both 'top' and 'zmax'") + top = zmax + return self.zaxis._set_lim(bottom, top, emit=emit, auto=auto) + + set_xlim3d = maxes.Axes.set_xlim + set_ylim3d = maxes.Axes.set_ylim + set_zlim3d = set_zlim + + def get_xlim(self): + # docstring inherited + return tuple(self.xy_viewLim.intervalx) + + def get_ylim(self): + # docstring inherited + return tuple(self.xy_viewLim.intervaly) + + def get_zlim(self): + """ + Return the 3D z-axis view limits. + + Returns + ------- + left, right : (float, float) + The current z-axis limits in data coordinates. + + See Also + -------- + set_zlim + set_zbound, get_zbound + invert_zaxis, zaxis_inverted + + Notes + ----- + The z-axis may be inverted, in which case the *left* value will + be greater than the *right* value. + """ + return tuple(self.zz_viewLim.intervalx) + + get_zscale = _axis_method_wrapper("zaxis", "get_scale") + + # Redefine all three methods to overwrite their docstrings. + set_xscale = _axis_method_wrapper("xaxis", "_set_axes_scale") + set_yscale = _axis_method_wrapper("yaxis", "_set_axes_scale") + set_zscale = _axis_method_wrapper("zaxis", "_set_axes_scale") + set_xscale.__doc__, set_yscale.__doc__, set_zscale.__doc__ = map( + """ + Set the {}-axis scale. + + Parameters + ---------- + value : {{"linear"}} + The axis scale type to apply. 3D axes currently only support + linear scales; other scales yield nonsensical results. + + **kwargs + Keyword arguments are nominally forwarded to the scale class, but + none of them is applicable for linear scales. + """.format, + ["x", "y", "z"]) + + get_zticks = _axis_method_wrapper("zaxis", "get_ticklocs") + set_zticks = _axis_method_wrapper("zaxis", "set_ticks") + get_zmajorticklabels = _axis_method_wrapper("zaxis", "get_majorticklabels") + get_zminorticklabels = _axis_method_wrapper("zaxis", "get_minorticklabels") + get_zticklabels = _axis_method_wrapper("zaxis", "get_ticklabels") + set_zticklabels = _axis_method_wrapper( + "zaxis", "set_ticklabels", + doc_sub={"Axis.set_ticks": "Axes3D.set_zticks"}) + + zaxis_date = _axis_method_wrapper("zaxis", "axis_date") + if zaxis_date.__doc__: + zaxis_date.__doc__ += textwrap.dedent(""" + + Notes + ----- + This function is merely provided for completeness, but 3D axes do not + support dates for ticks, and so this may not work as expected. + """) + + def clabel(self, *args, **kwargs): + """Currently not implemented for 3D axes, and returns *None*.""" + return None + + def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z", + share=False): + """ + Set the elevation and azimuth of the axes in degrees (not radians). + + This can be used to rotate the axes programmatically. + + To look normal to the primary planes, the following elevation and + azimuth angles can be used. A roll angle of 0, 90, 180, or 270 deg + will rotate these views while keeping the axes at right angles. + + ========== ==== ==== + view plane elev azim + ========== ==== ==== + XY 90 -90 + XZ 0 -90 + YZ 0 0 + -XY -90 90 + -XZ 0 90 + -YZ 0 180 + ========== ==== ==== + + Parameters + ---------- + elev : float, default: None + The elevation angle in degrees rotates the camera above the plane + pierced by the vertical axis, with a positive angle corresponding + to a location above that plane. For example, with the default + vertical axis of 'z', the elevation defines the angle of the camera + location above the x-y plane. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + azim : float, default: None + The azimuthal angle in degrees rotates the camera about the + vertical axis, with a positive angle corresponding to a + right-handed rotation. For example, with the default vertical axis + of 'z', a positive azimuth rotates the camera about the origin from + its location along the +x axis towards the +y axis. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + roll : float, default: None + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + vertical_axis : {"z", "x", "y"}, default: "z" + The axis to align vertically. *azim* rotates about this axis. + share : bool, default: False + If ``True``, apply the settings to all Axes with shared views. + """ + + self._dist = 10 # The camera distance from origin. Behaves like zoom + + if elev is None: + elev = self.initial_elev + if azim is None: + azim = self.initial_azim + if roll is None: + roll = self.initial_roll + vertical_axis = _api.check_getitem( + dict(x=0, y=1, z=2), vertical_axis=vertical_axis + ) + + if share: + axes = {sibling for sibling + in self._shared_axes['view'].get_siblings(self)} + else: + axes = [self] + + for ax in axes: + ax.elev = elev + ax.azim = azim + ax.roll = roll + ax._vertical_axis = vertical_axis + + def set_proj_type(self, proj_type, focal_length=None): + """ + Set the projection type. + + Parameters + ---------- + proj_type : {'persp', 'ortho'} + The projection type. + focal_length : float, default: None + For a projection type of 'persp', the focal length of the virtual + camera. Must be > 0. If None, defaults to 1. + The focal length can be computed from a desired Field Of View via + the equation: focal_length = 1/tan(FOV/2) + """ + _api.check_in_list(['persp', 'ortho'], proj_type=proj_type) + if proj_type == 'persp': + if focal_length is None: + focal_length = 1 + elif focal_length <= 0: + raise ValueError(f"focal_length = {focal_length} must be " + "greater than 0") + self._focal_length = focal_length + else: # 'ortho': + if focal_length not in (None, np.inf): + raise ValueError(f"focal_length = {focal_length} must be " + f"None for proj_type = {proj_type}") + self._focal_length = np.inf + + def _roll_to_vertical(self, arr): + """Roll arrays to match the different vertical axis.""" + return np.roll(arr, self._vertical_axis - 2) + + def get_proj(self): + """Create the projection matrix from the current viewing position.""" + + # Transform to uniform world coordinates 0-1, 0-1, 0-1 + box_aspect = self._roll_to_vertical(self._box_aspect) + worldM = proj3d.world_transformation( + *self.get_xlim3d(), + *self.get_ylim3d(), + *self.get_zlim3d(), + pb_aspect=box_aspect, + ) + + # Look into the middle of the world coordinates: + R = 0.5 * box_aspect + + # elev: elevation angle in the z plane. + # azim: azimuth angle in the xy plane. + # Coordinates for a point that rotates around the box of data. + # p0, p1 corresponds to rotating the box only around the vertical axis. + # p2 corresponds to rotating the box only around the horizontal axis. + elev_rad = np.deg2rad(self.elev) + azim_rad = np.deg2rad(self.azim) + p0 = np.cos(elev_rad) * np.cos(azim_rad) + p1 = np.cos(elev_rad) * np.sin(azim_rad) + p2 = np.sin(elev_rad) + + # When changing vertical axis the coordinates changes as well. + # Roll the values to get the same behaviour as the default: + ps = self._roll_to_vertical([p0, p1, p2]) + + # The coordinates for the eye viewing point. The eye is looking + # towards the middle of the box of data from a distance: + eye = R + self._dist * ps + + # vvec, self._vvec and self._eye are unused, remove when deprecated + vvec = R - eye + self._eye = eye + self._vvec = vvec / np.linalg.norm(vvec) + + # Calculate the viewing axes for the eye position + u, v, w = self._calc_view_axes(eye) + self._view_u = u # _view_u is towards the right of the screen + self._view_v = v # _view_v is towards the top of the screen + self._view_w = w # _view_w is out of the screen + + # Generate the view and projection transformation matrices + if self._focal_length == np.inf: + # Orthographic projection + viewM = proj3d._view_transformation_uvw(u, v, w, eye) + projM = proj3d._ortho_transformation(-self._dist, self._dist) + else: + # Perspective projection + # Scale the eye dist to compensate for the focal length zoom effect + eye_focal = R + self._dist * ps * self._focal_length + viewM = proj3d._view_transformation_uvw(u, v, w, eye_focal) + projM = proj3d._persp_transformation(-self._dist, + self._dist, + self._focal_length) + + # Combine all the transformation matrices to get the final projection + M0 = np.dot(viewM, worldM) + M = np.dot(projM, M0) + return M + + def mouse_init(self, rotate_btn=1, pan_btn=2, zoom_btn=3): + """ + Set the mouse buttons for 3D rotation and zooming. + + Parameters + ---------- + rotate_btn : int or list of int, default: 1 + The mouse button or buttons to use for 3D rotation of the axes. + pan_btn : int or list of int, default: 2 + The mouse button or buttons to use to pan the 3D axes. + zoom_btn : int or list of int, default: 3 + The mouse button or buttons to use to zoom the 3D axes. + """ + self.button_pressed = None + # coerce scalars into array-like, then convert into + # a regular list to avoid comparisons against None + # which breaks in recent versions of numpy. + self._rotate_btn = np.atleast_1d(rotate_btn).tolist() + self._pan_btn = np.atleast_1d(pan_btn).tolist() + self._zoom_btn = np.atleast_1d(zoom_btn).tolist() + + def disable_mouse_rotation(self): + """Disable mouse buttons for 3D rotation, panning, and zooming.""" + self.mouse_init(rotate_btn=[], pan_btn=[], zoom_btn=[]) + + def can_zoom(self): + # doc-string inherited + return True + + def can_pan(self): + # doc-string inherited + return True + + def sharez(self, other): + """ + Share the z-axis with *other*. + + This is equivalent to passing ``sharez=other`` when constructing the + Axes, and cannot be used if the z-axis is already being shared with + another Axes. + """ + _api.check_isinstance(Axes3D, other=other) + if self._sharez is not None and other is not self._sharez: + raise ValueError("z-axis is already shared") + self._shared_axes["z"].join(self, other) + self._sharez = other + self.zaxis.major = other.zaxis.major # Ticker instances holding + self.zaxis.minor = other.zaxis.minor # locator and formatter. + z0, z1 = other.get_zlim() + self.set_zlim(z0, z1, emit=False, auto=other.get_autoscalez_on()) + self.zaxis._scale = other.zaxis._scale + + def shareview(self, other): + """ + Share the view angles with *other*. + + This is equivalent to passing ``shareview=other`` when + constructing the Axes, and cannot be used if the view angles are + already being shared with another Axes. + """ + _api.check_isinstance(Axes3D, other=other) + if self._shareview is not None and other is not self._shareview: + raise ValueError("view angles are already shared") + self._shared_axes["view"].join(self, other) + self._shareview = other + vertical_axis = {0: "x", 1: "y", 2: "z"}[other._vertical_axis] + self.view_init(elev=other.elev, azim=other.azim, roll=other.roll, + vertical_axis=vertical_axis, share=True) + + def clear(self): + # docstring inherited. + super().clear() + if self._focal_length == np.inf: + self._zmargin = mpl.rcParams['axes.zmargin'] + else: + self._zmargin = 0. + self.grid(mpl.rcParams['axes3d.grid']) + + def _button_press(self, event): + if event.inaxes == self: + self.button_pressed = event.button + self._sx, self._sy = event.xdata, event.ydata + toolbar = self.figure.canvas.toolbar + if toolbar and toolbar._nav_stack() is None: + toolbar.push_current() + + def _button_release(self, event): + self.button_pressed = None + toolbar = self.figure.canvas.toolbar + # backend_bases.release_zoom and backend_bases.release_pan call + # push_current, so check the navigation mode so we don't call it twice + if toolbar and self.get_navigate_mode() is None: + toolbar.push_current() + + def _get_view(self): + # docstring inherited + return { + "xlim": self.get_xlim(), "autoscalex_on": self.get_autoscalex_on(), + "ylim": self.get_ylim(), "autoscaley_on": self.get_autoscaley_on(), + "zlim": self.get_zlim(), "autoscalez_on": self.get_autoscalez_on(), + }, (self.elev, self.azim, self.roll) + + def _set_view(self, view): + # docstring inherited + props, (elev, azim, roll) = view + self.set(**props) + self.elev = elev + self.azim = azim + self.roll = roll + + def format_zdata(self, z): + """ + Return *z* string formatted. This function will use the + :attr:`fmt_zdata` attribute if it is callable, else will fall + back on the zaxis major formatter + """ + try: + return self.fmt_zdata(z) + except (AttributeError, TypeError): + func = self.zaxis.get_major_formatter().format_data_short + val = func(z) + return val + + def format_coord(self, xv, yv, renderer=None): + """ + Return a string giving the current view rotation angles, or the x, y, z + coordinates of the point on the nearest axis pane underneath the mouse + cursor, depending on the mouse button pressed. + """ + coords = '' + + if self.button_pressed in self._rotate_btn: + # ignore xv and yv and display angles instead + coords = self._rotation_coords() + + elif self.M is not None: + coords = self._location_coords(xv, yv, renderer) + + return coords + + def _rotation_coords(self): + """ + Return the rotation angles as a string. + """ + norm_elev = art3d._norm_angle(self.elev) + norm_azim = art3d._norm_angle(self.azim) + norm_roll = art3d._norm_angle(self.roll) + coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, " + f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, " + f"roll={norm_roll:.0f}\N{DEGREE SIGN}" + ).replace("-", "\N{MINUS SIGN}") + return coords + + def _location_coords(self, xv, yv, renderer): + """ + Return the location on the axis pane underneath the cursor as a string. + """ + p1, pane_idx = self._calc_coord(xv, yv, renderer) + xs = self.format_xdata(p1[0]) + ys = self.format_ydata(p1[1]) + zs = self.format_zdata(p1[2]) + if pane_idx == 0: + coords = f'x pane={xs}, y={ys}, z={zs}' + elif pane_idx == 1: + coords = f'x={xs}, y pane={ys}, z={zs}' + elif pane_idx == 2: + coords = f'x={xs}, y={ys}, z pane={zs}' + return coords + + def _get_camera_loc(self): + """ + Returns the current camera location in data coordinates. + """ + cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges() + c = np.array([cx, cy, cz]) + r = np.array([dx, dy, dz]) + + if self._focal_length == np.inf: # orthographic projection + focal_length = 1e9 # large enough to be effectively infinite + else: # perspective projection + focal_length = self._focal_length + eye = c + self._view_w * self._dist * r / self._box_aspect * focal_length + return eye + + def _calc_coord(self, xv, yv, renderer=None): + """ + Given the 2D view coordinates, find the point on the nearest axis pane + that lies directly below those coordinates. Returns a 3D point in data + coordinates. + """ + if self._focal_length == np.inf: # orthographic projection + zv = 1 + else: # perspective projection + zv = -1 / self._focal_length + + # Convert point on view plane to data coordinates + p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.invM)).ravel() + + # Get the vector from the camera to the point on the view plane + vec = self._get_camera_loc() - p1 + + # Get the pane locations for each of the axes + pane_locs = [] + for axis in self._axis_map.values(): + xys, loc = axis.active_pane(renderer) + pane_locs.append(loc) + + # Find the distance to the nearest pane by projecting the view vector + scales = np.zeros(3) + for i in range(3): + if vec[i] == 0: + scales[i] = np.inf + else: + scales[i] = (p1[i] - pane_locs[i]) / vec[i] + pane_idx = np.argmin(abs(scales)) + scale = scales[pane_idx] + + # Calculate the point on the closest pane + p2 = p1 - scale*vec + return p2, pane_idx + + def _on_move(self, event): + """ + Mouse moving. + + By default, button-1 rotates, button-2 pans, and button-3 zooms; + these buttons can be modified via `mouse_init`. + """ + + if not self.button_pressed: + return + + if self.get_navigate_mode() is not None: + # we don't want to rotate if we are zooming/panning + # from the toolbar + return + + if self.M is None: + return + + x, y = event.xdata, event.ydata + # In case the mouse is out of bounds. + if x is None or event.inaxes != self: + return + + dx, dy = x - self._sx, y - self._sy + w = self._pseudo_w + h = self._pseudo_h + + # Rotation + if self.button_pressed in self._rotate_btn: + # rotate viewing point + # get the x and y pixel coords + if dx == 0 and dy == 0: + return + + roll = np.deg2rad(self.roll) + delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll) + dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll) + elev = self.elev + delev + azim = self.azim + dazim + self.view_init(elev=elev, azim=azim, roll=roll, share=True) + self.stale = True + + # Pan + elif self.button_pressed in self._pan_btn: + # Start the pan event with pixel coordinates + px, py = self.transData.transform([self._sx, self._sy]) + self.start_pan(px, py, 2) + # pan view (takes pixel coordinate input) + self.drag_pan(2, None, event.x, event.y) + self.end_pan() + + # Zoom + elif self.button_pressed in self._zoom_btn: + # zoom view (dragging down zooms in) + scale = h/(h - dy) + self._scale_axis_limits(scale, scale, scale) + + # Store the event coordinates for the next time through. + self._sx, self._sy = x, y + # Always request a draw update at the end of interaction + self.figure.canvas.draw_idle() + + def drag_pan(self, button, key, x, y): + # docstring inherited + + # Get the coordinates from the move event + p = self._pan_start + (xdata, ydata), (xdata_start, ydata_start) = p.trans_inverse.transform( + [(x, y), (p.x, p.y)]) + self._sx, self._sy = xdata, ydata + # Calling start_pan() to set the x/y of this event as the starting + # move location for the next event + self.start_pan(x, y, button) + du, dv = xdata - xdata_start, ydata - ydata_start + dw = 0 + if key == 'x': + dv = 0 + elif key == 'y': + du = 0 + if du == 0 and dv == 0: + return + + # Transform the pan from the view axes to the data axes + R = np.array([self._view_u, self._view_v, self._view_w]) + R = -R / self._box_aspect * self._dist + duvw_projected = R.T @ np.array([du, dv, dw]) + + # Calculate pan distance + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + dx = (maxx - minx) * duvw_projected[0] + dy = (maxy - miny) * duvw_projected[1] + dz = (maxz - minz) * duvw_projected[2] + + # Set the new axis limits + self.set_xlim3d(minx + dx, maxx + dx) + self.set_ylim3d(miny + dy, maxy + dy) + self.set_zlim3d(minz + dz, maxz + dz) + + def _calc_view_axes(self, eye): + """ + Get the unit vectors for the viewing axes in data coordinates. + `u` is towards the right of the screen + `v` is towards the top of the screen + `w` is out of the screen + """ + elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) + roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) + + # Look into the middle of the world coordinates + R = 0.5 * self._roll_to_vertical(self._box_aspect) + + # Define which axis should be vertical. A negative value + # indicates the plot is upside down and therefore the values + # have been reversed: + V = np.zeros(3) + V[self._vertical_axis] = -1 if abs(elev_rad) > np.pi/2 else 1 + + u, v, w = proj3d._view_axes(eye, R, V, roll_rad) + return u, v, w + + def _set_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + """ + Zoom in or out of the bounding box. + + Will center the view in the center of the bounding box, and zoom by + the ratio of the size of the bounding box to the size of the Axes3D. + """ + (start_x, start_y, stop_x, stop_y) = bbox + if mode == 'x': + start_y = self.bbox.min[1] + stop_y = self.bbox.max[1] + elif mode == 'y': + start_x = self.bbox.min[0] + stop_x = self.bbox.max[0] + + # Clip to bounding box limits + start_x, stop_x = np.clip(sorted([start_x, stop_x]), + self.bbox.min[0], self.bbox.max[0]) + start_y, stop_y = np.clip(sorted([start_y, stop_y]), + self.bbox.min[1], self.bbox.max[1]) + + # Move the center of the view to the center of the bbox + zoom_center_x = (start_x + stop_x)/2 + zoom_center_y = (start_y + stop_y)/2 + + ax_center_x = (self.bbox.max[0] + self.bbox.min[0])/2 + ax_center_y = (self.bbox.max[1] + self.bbox.min[1])/2 + + self.start_pan(zoom_center_x, zoom_center_y, 2) + self.drag_pan(2, None, ax_center_x, ax_center_y) + self.end_pan() + + # Calculate zoom level + dx = abs(start_x - stop_x) + dy = abs(start_y - stop_y) + scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) + scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) + + # Keep aspect ratios equal + scale = max(scale_u, scale_v) + + # Zoom out + if direction == 'out': + scale = 1 / scale + + self._zoom_data_limits(scale, scale, scale) + + def _zoom_data_limits(self, scale_u, scale_v, scale_w): + """ + Zoom in or out of a 3D plot. + + Will scale the data limits by the scale factors. These will be + transformed to the x, y, z data axes based on the current view angles. + A scale factor > 1 zooms out and a scale factor < 1 zooms in. + + For an axes that has had its aspect ratio set to 'equal', 'equalxy', + 'equalyz', or 'equalxz', the relevant axes are constrained to zoom + equally. + + Parameters + ---------- + scale_u : float + Scale factor for the u view axis (view screen horizontal). + scale_v : float + Scale factor for the v view axis (view screen vertical). + scale_w : float + Scale factor for the w view axis (view screen depth). + """ + scale = np.array([scale_u, scale_v, scale_w]) + + # Only perform frame conversion if unequal scale factors + if not np.allclose(scale, scale_u): + # Convert the scale factors from the view frame to the data frame + R = np.array([self._view_u, self._view_v, self._view_w]) + S = scale * np.eye(3) + scale = np.linalg.norm(R.T @ S, axis=1) + + # Set the constrained scale factors to the factor closest to 1 + if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + ax_idxs = self._equal_aspect_axis_indices(self._aspect) + min_ax_idxs = np.argmin(np.abs(scale[ax_idxs] - 1)) + scale[ax_idxs] = scale[ax_idxs][min_ax_idxs] + + self._scale_axis_limits(scale[0], scale[1], scale[2]) + + def _scale_axis_limits(self, scale_x, scale_y, scale_z): + """ + Keeping the center of the x, y, and z data axes fixed, scale their + limits by scale factors. A scale factor > 1 zooms out and a scale + factor < 1 zooms in. + + Parameters + ---------- + scale_x : float + Scale factor for the x data axis. + scale_y : float + Scale factor for the y data axis. + scale_z : float + Scale factor for the z data axis. + """ + # Get the axis centers and ranges + cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges() + + # Set the scaled axis limits + self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2) + self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2) + self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2) + + def _get_w_centers_ranges(self): + """Get 3D world centers and axis ranges.""" + # Calculate center of axis limits + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + cx = (maxx + minx)/2 + cy = (maxy + miny)/2 + cz = (maxz + minz)/2 + + # Calculate range of axis limits + dx = (maxx - minx) + dy = (maxy - miny) + dz = (maxz - minz) + return cx, cy, cz, dx, dy, dz + + def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): + """ + Set zlabel. See doc for `.set_ylabel` for description. + """ + if labelpad is not None: + self.zaxis.labelpad = labelpad + return self.zaxis.set_label_text(zlabel, fontdict, **kwargs) + + def get_zlabel(self): + """ + Get the z-label text string. + """ + label = self.zaxis.get_label() + return label.get_text() + + # Axes rectangle characteristics + + # The frame_on methods are not available for 3D axes. + # Python will raise a TypeError if they are called. + get_frame_on = None + set_frame_on = None + + def grid(self, visible=True, **kwargs): + """ + Set / unset 3D grid. + + .. note:: + + Currently, this function does not behave the same as + `.axes.Axes.grid`, but it is intended to eventually support that + behavior. + """ + # TODO: Operate on each axes separately + if len(kwargs): + visible = True + self._draw_grid = visible + self.stale = True + + def tick_params(self, axis='both', **kwargs): + """ + Convenience method for changing the appearance of ticks and + tick labels. + + See `.Axes.tick_params` for full documentation. Because this function + applies to 3D Axes, *axis* can also be set to 'z', and setting *axis* + to 'both' autoscales all three axes. + + Also, because of how Axes3D objects are drawn very differently + from regular 2D axes, some of these settings may have + ambiguous meaning. For simplicity, the 'z' axis will + accept settings as if it was like the 'y' axis. + + .. note:: + Axes3D currently ignores some of these settings. + """ + _api.check_in_list(['x', 'y', 'z', 'both'], axis=axis) + if axis in ['x', 'y', 'both']: + super().tick_params(axis, **kwargs) + if axis in ['z', 'both']: + zkw = dict(kwargs) + zkw.pop('top', None) + zkw.pop('bottom', None) + zkw.pop('labeltop', None) + zkw.pop('labelbottom', None) + self.zaxis.set_tick_params(**zkw) + + # data limits, ticks, tick labels, and formatting + + def invert_zaxis(self): + """ + Invert the z-axis. + + See Also + -------- + zaxis_inverted + get_zlim, set_zlim + get_zbound, set_zbound + """ + bottom, top = self.get_zlim() + self.set_zlim(top, bottom, auto=None) + + zaxis_inverted = _axis_method_wrapper("zaxis", "get_inverted") + + def get_zbound(self): + """ + Return the lower and upper z-axis bounds, in increasing order. + + See Also + -------- + set_zbound + get_zlim, set_zlim + invert_zaxis, zaxis_inverted + """ + bottom, top = self.get_zlim() + if bottom < top: + return bottom, top + else: + return top, bottom + + def set_zbound(self, lower=None, upper=None): + """ + Set the lower and upper numerical bounds of the z-axis. + + This method will honor axes inversion regardless of parameter order. + It will not change the autoscaling setting (`.get_autoscalez_on()`). + + Parameters + ---------- + lower, upper : float or None + The lower and upper bounds. If *None*, the respective axis bound + is not modified. + + See Also + -------- + get_zbound + get_zlim, set_zlim + invert_zaxis, zaxis_inverted + """ + if upper is None and np.iterable(lower): + lower, upper = lower + + old_lower, old_upper = self.get_zbound() + if lower is None: + lower = old_lower + if upper is None: + upper = old_upper + + self.set_zlim(sorted((lower, upper), + reverse=bool(self.zaxis_inverted())), + auto=None) + + def text(self, x, y, z, s, zdir=None, **kwargs): + """ + Add the text *s* to the 3D Axes at location *x*, *y*, *z* in data coordinates. + + Parameters + ---------- + x, y, z : float + The position to place the text. + s : str + The text. + zdir : {'x', 'y', 'z', 3-tuple}, optional + The direction to be used as the z-direction. Default: 'z'. + See `.get_dir_vector` for a description of the values. + **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.text`. + + Returns + ------- + `.Text3D` + The created `.Text3D` instance. + """ + text = super().text(x, y, s, **kwargs) + art3d.text_2d_to_3d(text, z, zdir) + return text + + text3D = text + text2D = Axes.text + + def plot(self, xs, ys, *args, zdir='z', **kwargs): + """ + Plot 2D or 3D data. + + Parameters + ---------- + xs : 1D array-like + x coordinates of vertices. + ys : 1D array-like + y coordinates of vertices. + zs : float or 1D array-like + z coordinates of vertices; either one for all points or one for + each point. + zdir : {'x', 'y', 'z'}, default: 'z' + When plotting 2D data, the direction to use as z. + **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.plot`. + """ + had_data = self.has_data() + + # `zs` can be passed positionally or as keyword; checking whether + # args[0] is a string matches the behavior of 2D `plot` (via + # `_process_plot_var_args`). + if args and not isinstance(args[0], str): + zs, *args = args + if 'zs' in kwargs: + raise TypeError("plot() for multiple values for argument 'z'") + else: + zs = kwargs.pop('zs', 0) + + # Match length + zs = np.broadcast_to(zs, np.shape(xs)) + + lines = super().plot(xs, ys, *args, **kwargs) + for line in lines: + art3d.line_2d_to_3d(line, zs=zs, zdir=zdir) + + xs, ys, zs = art3d.juggle_axes(xs, ys, zs, zdir) + self.auto_scale_xyz(xs, ys, zs, had_data) + return lines + + plot3D = plot + + def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, + vmax=None, lightsource=None, **kwargs): + """ + Create a surface plot. + + By default, it will be colored in shades of a solid color, but it also + supports colormapping by supplying the *cmap* argument. + + .. note:: + + The *rcount* and *ccount* kwargs, which both default to 50, + determine the maximum number of samples used in each direction. If + the input data is larger, it will be downsampled (by slicing) to + these numbers of points. + + .. note:: + + To maximize rendering speed consider setting *rstride* and *cstride* + to divisors of the number of rows minus 1 and columns minus 1 + respectively. For example, given 51 rows rstride can be any of the + divisors of 50. + + Similarly, a setting of *rstride* and *cstride* equal to 1 (or + *rcount* and *ccount* equal the number of rows and columns) can use + the optimized path. + + Parameters + ---------- + X, Y, Z : 2D arrays + Data values. + + rcount, ccount : int + Maximum number of samples used in each direction. If the input + data is larger, it will be downsampled (by slicing) to these + numbers of points. Defaults to 50. + + rstride, cstride : int + Downsampling stride in each direction. These arguments are + mutually exclusive with *rcount* and *ccount*. If only one of + *rstride* or *cstride* is set, the other defaults to 10. + + 'classic' mode uses a default of ``rstride = cstride = 10`` instead + of the new default of ``rcount = ccount = 50``. + + color : color-like + Color of the surface patches. + + cmap : Colormap + Colormap of the surface patches. + + facecolors : array-like of colors. + Colors of each individual patch. + + norm : Normalize + Normalization for the colormap. + + vmin, vmax : float + Bounds for the normalization. + + shade : bool, default: True + Whether to shade the facecolors. Shading is always disabled when + *cmap* is specified. + + lightsource : `~matplotlib.colors.LightSource` + The lightsource to use when *shade* is True. + + **kwargs + Other keyword arguments are forwarded to `.Poly3DCollection`. + """ + + had_data = self.has_data() + + if Z.ndim != 2: + raise ValueError("Argument Z must be 2-dimensional.") + + Z = cbook._to_unmasked_float_array(Z) + X, Y, Z = np.broadcast_arrays(X, Y, Z) + rows, cols = Z.shape + + has_stride = 'rstride' in kwargs or 'cstride' in kwargs + has_count = 'rcount' in kwargs or 'ccount' in kwargs + + if has_stride and has_count: + raise ValueError("Cannot specify both stride and count arguments") + + rstride = kwargs.pop('rstride', 10) + cstride = kwargs.pop('cstride', 10) + rcount = kwargs.pop('rcount', 50) + ccount = kwargs.pop('ccount', 50) + + if mpl.rcParams['_internal.classic_mode']: + # Strides have priority over counts in classic mode. + # So, only compute strides from counts + # if counts were explicitly given + compute_strides = has_count + else: + # If the strides are provided then it has priority. + # Otherwise, compute the strides from the counts. + compute_strides = not has_stride + + if compute_strides: + rstride = int(max(np.ceil(rows / rcount), 1)) + cstride = int(max(np.ceil(cols / ccount), 1)) + + fcolors = kwargs.pop('facecolors', None) + + cmap = kwargs.get('cmap', None) + shade = kwargs.pop('shade', cmap is None) + if shade is None: + raise ValueError("shade cannot be None.") + + colset = [] # the sampled facecolor + if (rows - 1) % rstride == 0 and \ + (cols - 1) % cstride == 0 and \ + fcolors is None: + polys = np.stack( + [cbook._array_patch_perimeters(a, rstride, cstride) + for a in (X, Y, Z)], + axis=-1) + else: + # evenly spaced, and including both endpoints + row_inds = list(range(0, rows-1, rstride)) + [rows-1] + col_inds = list(range(0, cols-1, cstride)) + [cols-1] + + polys = [] + for rs, rs_next in zip(row_inds[:-1], row_inds[1:]): + for cs, cs_next in zip(col_inds[:-1], col_inds[1:]): + ps = [ + # +1 ensures we share edges between polygons + cbook._array_perimeter(a[rs:rs_next+1, cs:cs_next+1]) + for a in (X, Y, Z) + ] + # ps = np.stack(ps, axis=-1) + ps = np.array(ps).T + polys.append(ps) + + if fcolors is not None: + colset.append(fcolors[rs][cs]) + + # In cases where there are non-finite values in the data (possibly NaNs from + # masked arrays), artifacts can be introduced. Here check whether such values + # are present and remove them. + if not isinstance(polys, np.ndarray) or not np.isfinite(polys).all(): + new_polys = [] + new_colset = [] + + # Depending on fcolors, colset is either an empty list or has as + # many elements as polys. In the former case new_colset results in + # a list with None entries, that is discarded later. + for p, col in itertools.zip_longest(polys, colset): + new_poly = np.array(p)[np.isfinite(p).all(axis=1)] + if len(new_poly): + new_polys.append(new_poly) + new_colset.append(col) + + # Replace previous polys and, if fcolors is not None, colset + polys = new_polys + if fcolors is not None: + colset = new_colset + + # note that the striding causes some polygons to have more coordinates + # than others + + if fcolors is not None: + polyc = art3d.Poly3DCollection( + polys, edgecolors=colset, facecolors=colset, shade=shade, + lightsource=lightsource, **kwargs) + elif cmap: + polyc = art3d.Poly3DCollection(polys, **kwargs) + # can't always vectorize, because polys might be jagged + if isinstance(polys, np.ndarray): + avg_z = polys[..., 2].mean(axis=-1) + else: + avg_z = np.array([ps[:, 2].mean() for ps in polys]) + polyc.set_array(avg_z) + if vmin is not None or vmax is not None: + polyc.set_clim(vmin, vmax) + if norm is not None: + polyc.set_norm(norm) + else: + color = kwargs.pop('color', None) + if color is None: + color = self._get_lines.get_next_color() + color = np.array(mcolors.to_rgba(color)) + + polyc = art3d.Poly3DCollection( + polys, facecolors=color, shade=shade, + lightsource=lightsource, **kwargs) + + self.add_collection(polyc) + self.auto_scale_xyz(X, Y, Z, had_data) + + return polyc + + def plot_wireframe(self, X, Y, Z, **kwargs): + """ + Plot a 3D wireframe. + + .. note:: + + The *rcount* and *ccount* kwargs, which both default to 50, + determine the maximum number of samples used in each direction. If + the input data is larger, it will be downsampled (by slicing) to + these numbers of points. + + Parameters + ---------- + X, Y, Z : 2D arrays + Data values. + + rcount, ccount : int + Maximum number of samples used in each direction. If the input + data is larger, it will be downsampled (by slicing) to these + numbers of points. Setting a count to zero causes the data to be + not sampled in the corresponding direction, producing a 3D line + plot rather than a wireframe plot. Defaults to 50. + + rstride, cstride : int + Downsampling stride in each direction. These arguments are + mutually exclusive with *rcount* and *ccount*. If only one of + *rstride* or *cstride* is set, the other defaults to 1. Setting a + stride to zero causes the data to be not sampled in the + corresponding direction, producing a 3D line plot rather than a + wireframe plot. + + 'classic' mode uses a default of ``rstride = cstride = 1`` instead + of the new default of ``rcount = ccount = 50``. + + **kwargs + Other keyword arguments are forwarded to `.Line3DCollection`. + """ + + had_data = self.has_data() + if Z.ndim != 2: + raise ValueError("Argument Z must be 2-dimensional.") + # FIXME: Support masked arrays + X, Y, Z = np.broadcast_arrays(X, Y, Z) + rows, cols = Z.shape + + has_stride = 'rstride' in kwargs or 'cstride' in kwargs + has_count = 'rcount' in kwargs or 'ccount' in kwargs + + if has_stride and has_count: + raise ValueError("Cannot specify both stride and count arguments") + + rstride = kwargs.pop('rstride', 1) + cstride = kwargs.pop('cstride', 1) + rcount = kwargs.pop('rcount', 50) + ccount = kwargs.pop('ccount', 50) + + if mpl.rcParams['_internal.classic_mode']: + # Strides have priority over counts in classic mode. + # So, only compute strides from counts + # if counts were explicitly given + if has_count: + rstride = int(max(np.ceil(rows / rcount), 1)) if rcount else 0 + cstride = int(max(np.ceil(cols / ccount), 1)) if ccount else 0 + else: + # If the strides are provided then it has priority. + # Otherwise, compute the strides from the counts. + if not has_stride: + rstride = int(max(np.ceil(rows / rcount), 1)) if rcount else 0 + cstride = int(max(np.ceil(cols / ccount), 1)) if ccount else 0 + + # We want two sets of lines, one running along the "rows" of + # Z and another set of lines running along the "columns" of Z. + # This transpose will make it easy to obtain the columns. + tX, tY, tZ = np.transpose(X), np.transpose(Y), np.transpose(Z) + + if rstride: + rii = list(range(0, rows, rstride)) + # Add the last index only if needed + if rows > 0 and rii[-1] != (rows - 1): + rii += [rows-1] + else: + rii = [] + if cstride: + cii = list(range(0, cols, cstride)) + # Add the last index only if needed + if cols > 0 and cii[-1] != (cols - 1): + cii += [cols-1] + else: + cii = [] + + if rstride == 0 and cstride == 0: + raise ValueError("Either rstride or cstride must be non zero") + + # If the inputs were empty, then just + # reset everything. + if Z.size == 0: + rii = [] + cii = [] + + xlines = [X[i] for i in rii] + ylines = [Y[i] for i in rii] + zlines = [Z[i] for i in rii] + + txlines = [tX[i] for i in cii] + tylines = [tY[i] for i in cii] + tzlines = [tZ[i] for i in cii] + + lines = ([list(zip(xl, yl, zl)) + for xl, yl, zl in zip(xlines, ylines, zlines)] + + [list(zip(xl, yl, zl)) + for xl, yl, zl in zip(txlines, tylines, tzlines)]) + + linec = art3d.Line3DCollection(lines, **kwargs) + self.add_collection(linec) + self.auto_scale_xyz(X, Y, Z, had_data) + + return linec + + def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, + lightsource=None, **kwargs): + """ + Plot a triangulated surface. + + The (optional) triangulation can be specified in one of two ways; + either:: + + plot_trisurf(triangulation, ...) + + where triangulation is a `~matplotlib.tri.Triangulation` object, or:: + + plot_trisurf(X, Y, ...) + plot_trisurf(X, Y, triangles, ...) + plot_trisurf(X, Y, triangles=triangles, ...) + + in which case a Triangulation object will be created. See + `.Triangulation` for an explanation of these possibilities. + + The remaining arguments are:: + + plot_trisurf(..., Z) + + where *Z* is the array of values to contour, one per point + in the triangulation. + + Parameters + ---------- + X, Y, Z : array-like + Data values as 1D arrays. + color + Color of the surface patches. + cmap + A colormap for the surface patches. + norm : Normalize + An instance of Normalize to map values to colors. + vmin, vmax : float, default: None + Minimum and maximum value to map. + shade : bool, default: True + Whether to shade the facecolors. Shading is always disabled when + *cmap* is specified. + lightsource : `~matplotlib.colors.LightSource` + The lightsource to use when *shade* is True. + **kwargs + All other keyword arguments are passed on to + :class:`~mpl_toolkits.mplot3d.art3d.Poly3DCollection` + + Examples + -------- + .. plot:: gallery/mplot3d/trisurf3d.py + .. plot:: gallery/mplot3d/trisurf3d_2.py + """ + + had_data = self.has_data() + + # TODO: Support custom face colours + if color is None: + color = self._get_lines.get_next_color() + color = np.array(mcolors.to_rgba(color)) + + cmap = kwargs.get('cmap', None) + shade = kwargs.pop('shade', cmap is None) + + tri, args, kwargs = \ + Triangulation.get_from_args_and_kwargs(*args, **kwargs) + try: + z = kwargs.pop('Z') + except KeyError: + # We do this so Z doesn't get passed as an arg to PolyCollection + z, *args = args + z = np.asarray(z) + + triangles = tri.get_masked_triangles() + xt = tri.x[triangles] + yt = tri.y[triangles] + zt = z[triangles] + verts = np.stack((xt, yt, zt), axis=-1) + + if cmap: + polyc = art3d.Poly3DCollection(verts, *args, **kwargs) + # average over the three points of each triangle + avg_z = verts[:, :, 2].mean(axis=1) + polyc.set_array(avg_z) + if vmin is not None or vmax is not None: + polyc.set_clim(vmin, vmax) + if norm is not None: + polyc.set_norm(norm) + else: + polyc = art3d.Poly3DCollection( + verts, *args, shade=shade, lightsource=lightsource, + facecolors=color, **kwargs) + + self.add_collection(polyc) + self.auto_scale_xyz(tri.x, tri.y, z, had_data) + + return polyc + + def _3d_extend_contour(self, cset, stride=5): + """ + Extend a contour in 3D by creating + """ + + dz = (cset.levels[1] - cset.levels[0]) / 2 + polyverts = [] + colors = [] + for idx, level in enumerate(cset.levels): + path = cset.get_paths()[idx] + subpaths = [*path._iter_connected_components()] + color = cset.get_edgecolor()[idx] + top = art3d._paths_to_3d_segments(subpaths, level - dz) + bot = art3d._paths_to_3d_segments(subpaths, level + dz) + if not len(top[0]): + continue + nsteps = max(round(len(top[0]) / stride), 2) + stepsize = (len(top[0]) - 1) / (nsteps - 1) + polyverts.extend([ + (top[0][round(i * stepsize)], top[0][round((i + 1) * stepsize)], + bot[0][round((i + 1) * stepsize)], bot[0][round(i * stepsize)]) + for i in range(round(nsteps) - 1)]) + colors.extend([color] * (round(nsteps) - 1)) + self.add_collection3d(art3d.Poly3DCollection( + np.array(polyverts), # All polygons have 4 vertices, so vectorize. + facecolors=colors, edgecolors=colors, shade=True)) + cset.remove() + + def add_contour_set( + self, cset, extend3d=False, stride=5, zdir='z', offset=None): + zdir = '-' + zdir + if extend3d: + self._3d_extend_contour(cset, stride) + else: + art3d.collection_2d_to_3d( + cset, zs=offset if offset is not None else cset.levels, zdir=zdir) + + def add_contourf_set(self, cset, zdir='z', offset=None): + self._add_contourf_set(cset, zdir=zdir, offset=offset) + + def _add_contourf_set(self, cset, zdir='z', offset=None): + """ + Returns + ------- + levels : `numpy.ndarray` + Levels at which the filled contours are added. + """ + zdir = '-' + zdir + + midpoints = cset.levels[:-1] + np.diff(cset.levels) / 2 + # Linearly interpolate to get levels for any extensions + if cset._extend_min: + min_level = cset.levels[0] - np.diff(cset.levels[:2]) / 2 + midpoints = np.insert(midpoints, 0, min_level) + if cset._extend_max: + max_level = cset.levels[-1] + np.diff(cset.levels[-2:]) / 2 + midpoints = np.append(midpoints, max_level) + + art3d.collection_2d_to_3d( + cset, zs=offset if offset is not None else midpoints, zdir=zdir) + return midpoints + + @_preprocess_data() + def contour(self, X, Y, Z, *args, + extend3d=False, stride=5, zdir='z', offset=None, **kwargs): + """ + Create a 3D contour plot. + + Parameters + ---------- + X, Y, Z : array-like, + Input data. See `.Axes.contour` for supported data shapes. + extend3d : bool, default: False + Whether to extend contour in 3D. + stride : int + Step size for extending contour. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to *zdir*. + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + *args, **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.contour`. + + Returns + ------- + matplotlib.contour.QuadContourSet + """ + had_data = self.has_data() + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + cset = super().contour(jX, jY, jZ, *args, **kwargs) + self.add_contour_set(cset, extend3d, stride, zdir, offset) + + self.auto_scale_xyz(X, Y, Z, had_data) + return cset + + contour3D = contour + + @_preprocess_data() + def tricontour(self, *args, + extend3d=False, stride=5, zdir='z', offset=None, **kwargs): + """ + Create a 3D contour plot. + + .. note:: + This method currently produces incorrect output due to a + longstanding bug in 3D PolyCollection rendering. + + Parameters + ---------- + X, Y, Z : array-like + Input data. See `.Axes.tricontour` for supported data shapes. + extend3d : bool, default: False + Whether to extend contour in 3D. + stride : int + Step size for extending contour. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to *zdir*. + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + *args, **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.tricontour`. + + Returns + ------- + matplotlib.tri._tricontour.TriContourSet + """ + had_data = self.has_data() + + tri, args, kwargs = Triangulation.get_from_args_and_kwargs( + *args, **kwargs) + X = tri.x + Y = tri.y + if 'Z' in kwargs: + Z = kwargs.pop('Z') + else: + # We do this so Z doesn't get passed as an arg to Axes.tricontour + Z, *args = args + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + tri = Triangulation(jX, jY, tri.triangles, tri.mask) + + cset = super().tricontour(tri, jZ, *args, **kwargs) + self.add_contour_set(cset, extend3d, stride, zdir, offset) + + self.auto_scale_xyz(X, Y, Z, had_data) + return cset + + def _auto_scale_contourf(self, X, Y, Z, zdir, levels, had_data): + # Autoscale in the zdir based on the levels added, which are + # different from data range if any contour extensions are present + dim_vals = {'x': X, 'y': Y, 'z': Z, zdir: levels} + # Input data and levels have different sizes, but auto_scale_xyz + # expected same-size input, so manually take min/max limits + limits = [(np.nanmin(dim_vals[dim]), np.nanmax(dim_vals[dim])) + for dim in ['x', 'y', 'z']] + self.auto_scale_xyz(*limits, had_data) + + @_preprocess_data() + def contourf(self, X, Y, Z, *args, zdir='z', offset=None, **kwargs): + """ + Create a 3D filled contour plot. + + Parameters + ---------- + X, Y, Z : array-like + Input data. See `.Axes.contourf` for supported data shapes. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to *zdir*. + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + *args, **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.contourf`. + + Returns + ------- + matplotlib.contour.QuadContourSet + """ + had_data = self.has_data() + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + cset = super().contourf(jX, jY, jZ, *args, **kwargs) + levels = self._add_contourf_set(cset, zdir, offset) + + self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data) + return cset + + contourf3D = contourf + + @_preprocess_data() + def tricontourf(self, *args, zdir='z', offset=None, **kwargs): + """ + Create a 3D filled contour plot. + + .. note:: + This method currently produces incorrect output due to a + longstanding bug in 3D PolyCollection rendering. + + Parameters + ---------- + X, Y, Z : array-like + Input data. See `.Axes.tricontourf` for supported data shapes. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to zdir. + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + *args, **kwargs + Other arguments are forwarded to + `matplotlib.axes.Axes.tricontourf`. + + Returns + ------- + matplotlib.tri._tricontour.TriContourSet + """ + had_data = self.has_data() + + tri, args, kwargs = Triangulation.get_from_args_and_kwargs( + *args, **kwargs) + X = tri.x + Y = tri.y + if 'Z' in kwargs: + Z = kwargs.pop('Z') + else: + # We do this so Z doesn't get passed as an arg to Axes.tricontourf + Z, *args = args + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + tri = Triangulation(jX, jY, tri.triangles, tri.mask) + + cset = super().tricontourf(tri, jZ, *args, **kwargs) + levels = self._add_contourf_set(cset, zdir, offset) + + self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data) + return cset + + def add_collection3d(self, col, zs=0, zdir='z'): + """ + Add a 3D collection object to the plot. + + 2D collection types are converted to a 3D version by + modifying the object and adding z coordinate information. + + Supported are: + + - PolyCollection + - LineCollection + - PatchCollection + """ + zvals = np.atleast_1d(zs) + zsortval = (np.min(zvals) if zvals.size + else 0) # FIXME: arbitrary default + + # FIXME: use issubclass() (although, then a 3D collection + # object would also pass.) Maybe have a collection3d + # abstract class to test for and exclude? + if type(col) is mcoll.PolyCollection: + art3d.poly_collection_2d_to_3d(col, zs=zs, zdir=zdir) + col.set_sort_zpos(zsortval) + elif type(col) is mcoll.LineCollection: + art3d.line_collection_2d_to_3d(col, zs=zs, zdir=zdir) + col.set_sort_zpos(zsortval) + elif type(col) is mcoll.PatchCollection: + art3d.patch_collection_2d_to_3d(col, zs=zs, zdir=zdir) + col.set_sort_zpos(zsortval) + + collection = super().add_collection(col) + return collection + + @_preprocess_data(replace_names=["xs", "ys", "zs", "s", + "edgecolors", "c", "facecolor", + "facecolors", "color"]) + def scatter(self, xs, ys, zs=0, zdir='z', s=20, c=None, depthshade=True, + *args, **kwargs): + """ + Create a scatter plot. + + Parameters + ---------- + xs, ys : array-like + The data positions. + zs : float or array-like, default: 0 + The z-positions. Either an array of the same length as *xs* and + *ys* or a single value to place all points in the same plane. + zdir : {'x', 'y', 'z', '-x', '-y', '-z'}, default: 'z' + The axis direction for the *zs*. This is useful when plotting 2D + data on a 3D Axes. The data must be passed as *xs*, *ys*. Setting + *zdir* to 'y' then plots the data to the x-z-plane. + + See also :doc:`/gallery/mplot3d/2dcollections3d`. + + s : float or array-like, default: 20 + The marker size in points**2. Either an array of the same length + as *xs* and *ys* or a single value to make all markers the same + size. + c : color, sequence, or sequence of colors, optional + The marker color. Possible values: + + - A single color format string. + - A sequence of colors of length n. + - A sequence of n numbers to be mapped to colors using *cmap* and + *norm*. + - A 2D array in which the rows are RGB or RGBA. + + For more details see the *c* argument of `~.axes.Axes.scatter`. + depthshade : bool, default: True + Whether to shade the scatter markers to give the appearance of + depth. Each call to ``scatter()`` will perform its depthshading + independently. + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + **kwargs + All other keyword arguments are passed on to `~.axes.Axes.scatter`. + + Returns + ------- + paths : `~matplotlib.collections.PathCollection` + """ + + had_data = self.has_data() + zs_orig = zs + + xs, ys, zs = np.broadcast_arrays( + *[np.ravel(np.ma.filled(t, np.nan)) for t in [xs, ys, zs]]) + s = np.ma.ravel(s) # This doesn't have to match x, y in size. + + xs, ys, zs, s, c, color = cbook.delete_masked_points( + xs, ys, zs, s, c, kwargs.get('color', None) + ) + if kwargs.get('color', None): + kwargs['color'] = color + + # For xs and ys, 2D scatter() will do the copying. + if np.may_share_memory(zs_orig, zs): # Avoid unnecessary copies. + zs = zs.copy() + + patches = super().scatter(xs, ys, s=s, c=c, *args, **kwargs) + art3d.patch_collection_2d_to_3d(patches, zs=zs, zdir=zdir, + depthshade=depthshade) + + if self._zmargin < 0.05 and xs.size > 0: + self.set_zmargin(0.05) + + self.auto_scale_xyz(xs, ys, zs, had_data) + + return patches + + scatter3D = scatter + + @_preprocess_data() + def bar(self, left, height, zs=0, zdir='z', *args, **kwargs): + """ + Add 2D bar(s). + + Parameters + ---------- + left : 1D array-like + The x coordinates of the left sides of the bars. + height : 1D array-like + The height of the bars. + zs : float or 1D array-like + Z coordinate of bars; if a single value is specified, it will be + used for all bars. + zdir : {'x', 'y', 'z'}, default: 'z' + When plotting 2D data, the direction to use as z ('x', 'y' or 'z'). + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + **kwargs + Other keyword arguments are forwarded to + `matplotlib.axes.Axes.bar`. + + Returns + ------- + mpl_toolkits.mplot3d.art3d.Patch3DCollection + """ + had_data = self.has_data() + + patches = super().bar(left, height, *args, **kwargs) + + zs = np.broadcast_to(zs, len(left)) + + verts = [] + verts_zs = [] + for p, z in zip(patches, zs): + vs = art3d._get_patch_verts(p) + verts += vs.tolist() + verts_zs += [z] * len(vs) + art3d.patch_2d_to_3d(p, z, zdir) + if 'alpha' in kwargs: + p.set_alpha(kwargs['alpha']) + + if len(verts) > 0: + # the following has to be skipped if verts is empty + # NOTE: Bugs could still occur if len(verts) > 0, + # but the "2nd dimension" is empty. + xs, ys = zip(*verts) + else: + xs, ys = [], [] + + xs, ys, verts_zs = art3d.juggle_axes(xs, ys, verts_zs, zdir) + self.auto_scale_xyz(xs, ys, verts_zs, had_data) + + return patches + + @_preprocess_data() + def bar3d(self, x, y, z, dx, dy, dz, color=None, + zsort='average', shade=True, lightsource=None, *args, **kwargs): + """ + Generate a 3D barplot. + + This method creates three-dimensional barplot where the width, + depth, height, and color of the bars can all be uniquely set. + + Parameters + ---------- + x, y, z : array-like + The coordinates of the anchor point of the bars. + + dx, dy, dz : float or array-like + The width, depth, and height of the bars, respectively. + + color : sequence of colors, optional + The color of the bars can be specified globally or + individually. This parameter can be: + + - A single color, to color all bars the same color. + - An array of colors of length N bars, to color each bar + independently. + - An array of colors of length 6, to color the faces of the + bars similarly. + - An array of colors of length 6 * N bars, to color each face + independently. + + When coloring the faces of the boxes specifically, this is + the order of the coloring: + + 1. -Z (bottom of box) + 2. +Z (top of box) + 3. -Y + 4. +Y + 5. -X + 6. +X + + zsort : str, optional + The z-axis sorting scheme passed onto `~.art3d.Poly3DCollection` + + shade : bool, default: True + When true, this shades the dark sides of the bars (relative + to the plot's source of light). + + lightsource : `~matplotlib.colors.LightSource` + The lightsource to use when *shade* is True. + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + Any additional keyword arguments are passed onto + `~.art3d.Poly3DCollection`. + + Returns + ------- + collection : `~.art3d.Poly3DCollection` + A collection of three-dimensional polygons representing the bars. + """ + + had_data = self.has_data() + + x, y, z, dx, dy, dz = np.broadcast_arrays( + np.atleast_1d(x), y, z, dx, dy, dz) + minx = np.min(x) + maxx = np.max(x + dx) + miny = np.min(y) + maxy = np.max(y + dy) + minz = np.min(z) + maxz = np.max(z + dz) + + # shape (6, 4, 3) + # All faces are oriented facing outwards - when viewed from the + # outside, their vertices are in a counterclockwise ordering. + cuboid = np.array([ + # -z + ( + (0, 0, 0), + (0, 1, 0), + (1, 1, 0), + (1, 0, 0), + ), + # +z + ( + (0, 0, 1), + (1, 0, 1), + (1, 1, 1), + (0, 1, 1), + ), + # -y + ( + (0, 0, 0), + (1, 0, 0), + (1, 0, 1), + (0, 0, 1), + ), + # +y + ( + (0, 1, 0), + (0, 1, 1), + (1, 1, 1), + (1, 1, 0), + ), + # -x + ( + (0, 0, 0), + (0, 0, 1), + (0, 1, 1), + (0, 1, 0), + ), + # +x + ( + (1, 0, 0), + (1, 1, 0), + (1, 1, 1), + (1, 0, 1), + ), + ]) + + # indexed by [bar, face, vertex, coord] + polys = np.empty(x.shape + cuboid.shape) + + # handle each coordinate separately + for i, p, dp in [(0, x, dx), (1, y, dy), (2, z, dz)]: + p = p[..., np.newaxis, np.newaxis] + dp = dp[..., np.newaxis, np.newaxis] + polys[..., i] = p + dp * cuboid[..., i] + + # collapse the first two axes + polys = polys.reshape((-1,) + polys.shape[2:]) + + facecolors = [] + if color is None: + color = [self._get_patches_for_fill.get_next_color()] + + color = list(mcolors.to_rgba_array(color)) + + if len(color) == len(x): + # bar colors specified, need to expand to number of faces + for c in color: + facecolors.extend([c] * 6) + else: + # a single color specified, or face colors specified explicitly + facecolors = color + if len(facecolors) < len(x): + facecolors *= (6 * len(x)) + + col = art3d.Poly3DCollection(polys, + zsort=zsort, + facecolors=facecolors, + shade=shade, + lightsource=lightsource, + *args, **kwargs) + self.add_collection(col) + + self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data) + + return col + + def set_title(self, label, fontdict=None, loc='center', **kwargs): + # docstring inherited + ret = super().set_title(label, fontdict=fontdict, loc=loc, **kwargs) + (x, y) = self.title.get_position() + self.title.set_y(0.92 * y) + return ret + + @_preprocess_data() + def quiver(self, X, Y, Z, U, V, W, *, + length=1, arrow_length_ratio=.3, pivot='tail', normalize=False, + **kwargs): + """ + Plot a 3D field of arrows. + + The arguments can be array-like or scalars, so long as they can be + broadcast together. The arguments can also be masked arrays. If an + element in any of argument is masked, then that corresponding quiver + element will not be plotted. + + Parameters + ---------- + X, Y, Z : array-like + The x, y and z coordinates of the arrow locations (default is + tail of arrow; see *pivot* kwarg). + + U, V, W : array-like + The x, y and z components of the arrow vectors. + + length : float, default: 1 + The length of each quiver. + + arrow_length_ratio : float, default: 0.3 + The ratio of the arrow head with respect to the quiver. + + pivot : {'tail', 'middle', 'tip'}, default: 'tail' + The part of the arrow that is at the grid point; the arrow + rotates about this point, hence the name *pivot*. + + normalize : bool, default: False + Whether all arrows are normalized to have the same length, or keep + the lengths defined by *u*, *v*, and *w*. + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + Any additional keyword arguments are delegated to + :class:`.Line3DCollection` + """ + + def calc_arrows(UVW): + # get unit direction vector perpendicular to (u, v, w) + x = UVW[:, 0] + y = UVW[:, 1] + norm = np.linalg.norm(UVW[:, :2], axis=1) + x_p = np.divide(y, norm, where=norm != 0, out=np.zeros_like(x)) + y_p = np.divide(-x, norm, where=norm != 0, out=np.ones_like(x)) + # compute the two arrowhead direction unit vectors + rangle = math.radians(15) + c = math.cos(rangle) + s = math.sin(rangle) + # construct the rotation matrices of shape (3, 3, n) + r13 = y_p * s + r32 = x_p * s + r12 = x_p * y_p * (1 - c) + Rpos = np.array( + [[c + (x_p ** 2) * (1 - c), r12, r13], + [r12, c + (y_p ** 2) * (1 - c), -r32], + [-r13, r32, np.full_like(x_p, c)]]) + # opposite rotation negates all the sin terms + Rneg = Rpos.copy() + Rneg[[0, 1, 2, 2], [2, 2, 0, 1]] *= -1 + # Batch n (3, 3) x (3) matrix multiplications ((3, 3, n) x (n, 3)). + Rpos_vecs = np.einsum("ij...,...j->...i", Rpos, UVW) + Rneg_vecs = np.einsum("ij...,...j->...i", Rneg, UVW) + # Stack into (n, 2, 3) result. + return np.stack([Rpos_vecs, Rneg_vecs], axis=1) + + had_data = self.has_data() + + input_args = [X, Y, Z, U, V, W] + + # extract the masks, if any + masks = [k.mask for k in input_args + if isinstance(k, np.ma.MaskedArray)] + # broadcast to match the shape + bcast = np.broadcast_arrays(*input_args, *masks) + input_args = bcast[:6] + masks = bcast[6:] + if masks: + # combine the masks into one + mask = functools.reduce(np.logical_or, masks) + # put mask on and compress + input_args = [np.ma.array(k, mask=mask).compressed() + for k in input_args] + else: + input_args = [np.ravel(k) for k in input_args] + + if any(len(v) == 0 for v in input_args): + # No quivers, so just make an empty collection and return early + linec = art3d.Line3DCollection([], **kwargs) + self.add_collection(linec) + return linec + + shaft_dt = np.array([0., length], dtype=float) + arrow_dt = shaft_dt * arrow_length_ratio + + _api.check_in_list(['tail', 'middle', 'tip'], pivot=pivot) + if pivot == 'tail': + shaft_dt -= length + elif pivot == 'middle': + shaft_dt -= length / 2 + + XYZ = np.column_stack(input_args[:3]) + UVW = np.column_stack(input_args[3:]).astype(float) + + # Normalize rows of UVW + norm = np.linalg.norm(UVW, axis=1) + + # If any row of UVW is all zeros, don't make a quiver for it + mask = norm > 0 + XYZ = XYZ[mask] + if normalize: + UVW = UVW[mask] / norm[mask].reshape((-1, 1)) + else: + UVW = UVW[mask] + + if len(XYZ) > 0: + # compute the shaft lines all at once with an outer product + shafts = (XYZ - np.multiply.outer(shaft_dt, UVW)).swapaxes(0, 1) + # compute head direction vectors, n heads x 2 sides x 3 dimensions + head_dirs = calc_arrows(UVW) + # compute all head lines at once, starting from the shaft ends + heads = shafts[:, :1] - np.multiply.outer(arrow_dt, head_dirs) + # stack left and right head lines together + heads = heads.reshape((len(arrow_dt), -1, 3)) + # transpose to get a list of lines + heads = heads.swapaxes(0, 1) + + lines = [*shafts, *heads] + else: + lines = [] + + linec = art3d.Line3DCollection(lines, **kwargs) + self.add_collection(linec) + + self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data) + + return linec + + quiver3D = quiver + + def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, + lightsource=None, **kwargs): + """ + ax.voxels([x, y, z,] /, filled, facecolors=None, edgecolors=None, \ +**kwargs) + + Plot a set of filled voxels + + All voxels are plotted as 1x1x1 cubes on the axis, with + ``filled[0, 0, 0]`` placed with its lower corner at the origin. + Occluded faces are not plotted. + + Parameters + ---------- + filled : 3D np.array of bool + A 3D array of values, with truthy values indicating which voxels + to fill + + x, y, z : 3D np.array, optional + The coordinates of the corners of the voxels. This should broadcast + to a shape one larger in every dimension than the shape of + *filled*. These can be used to plot non-cubic voxels. + + If not specified, defaults to increasing integers along each axis, + like those returned by :func:`~numpy.indices`. + As indicated by the ``/`` in the function signature, these + arguments can only be passed positionally. + + facecolors, edgecolors : array-like, optional + The color to draw the faces and edges of the voxels. Can only be + passed as keyword arguments. + These parameters can be: + + - A single color value, to color all voxels the same color. This + can be either a string, or a 1D RGB/RGBA array + - ``None``, the default, to use a single color for the faces, and + the style default for the edges. + - A 3D `~numpy.ndarray` of color names, with each item the color + for the corresponding voxel. The size must match the voxels. + - A 4D `~numpy.ndarray` of RGB/RGBA data, with the components + along the last axis. + + shade : bool, default: True + Whether to shade the facecolors. + + lightsource : `~matplotlib.colors.LightSource` + The lightsource to use when *shade* is True. + + **kwargs + Additional keyword arguments to pass onto + `~mpl_toolkits.mplot3d.art3d.Poly3DCollection`. + + Returns + ------- + faces : dict + A dictionary indexed by coordinate, where ``faces[i, j, k]`` is a + `.Poly3DCollection` of the faces drawn for the voxel + ``filled[i, j, k]``. If no faces were drawn for a given voxel, + either because it was not asked to be drawn, or it is fully + occluded, then ``(i, j, k) not in faces``. + + Examples + -------- + .. plot:: gallery/mplot3d/voxels.py + .. plot:: gallery/mplot3d/voxels_rgb.py + .. plot:: gallery/mplot3d/voxels_torus.py + .. plot:: gallery/mplot3d/voxels_numpy_logo.py + """ + + # work out which signature we should be using, and use it to parse + # the arguments. Name must be voxels for the correct error message + if len(args) >= 3: + # underscores indicate position only + def voxels(__x, __y, __z, filled, **kwargs): + return (__x, __y, __z), filled, kwargs + else: + def voxels(filled, **kwargs): + return None, filled, kwargs + + xyz, filled, kwargs = voxels(*args, **kwargs) + + # check dimensions + if filled.ndim != 3: + raise ValueError("Argument filled must be 3-dimensional") + size = np.array(filled.shape, dtype=np.intp) + + # check xyz coordinates, which are one larger than the filled shape + coord_shape = tuple(size + 1) + if xyz is None: + x, y, z = np.indices(coord_shape) + else: + x, y, z = (np.broadcast_to(c, coord_shape) for c in xyz) + + def _broadcast_color_arg(color, name): + if np.ndim(color) in (0, 1): + # single color, like "red" or [1, 0, 0] + return np.broadcast_to(color, filled.shape + np.shape(color)) + elif np.ndim(color) in (3, 4): + # 3D array of strings, or 4D array with last axis rgb + if np.shape(color)[:3] != filled.shape: + raise ValueError( + f"When multidimensional, {name} must match the shape " + "of filled") + return color + else: + raise ValueError(f"Invalid {name} argument") + + # broadcast and default on facecolors + if facecolors is None: + facecolors = self._get_patches_for_fill.get_next_color() + facecolors = _broadcast_color_arg(facecolors, 'facecolors') + + # broadcast but no default on edgecolors + edgecolors = _broadcast_color_arg(edgecolors, 'edgecolors') + + # scale to the full array, even if the data is only in the center + self.auto_scale_xyz(x, y, z) + + # points lying on corners of a square + square = np.array([ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], dtype=np.intp) + + voxel_faces = defaultdict(list) + + def permutation_matrices(n): + """Generate cyclic permutation matrices.""" + mat = np.eye(n, dtype=np.intp) + for i in range(n): + yield mat + mat = np.roll(mat, 1, axis=0) + + # iterate over each of the YZ, ZX, and XY orientations, finding faces + # to render + for permute in permutation_matrices(3): + # find the set of ranges to iterate over + pc, qc, rc = permute.T.dot(size) + pinds = np.arange(pc) + qinds = np.arange(qc) + rinds = np.arange(rc) + + square_rot_pos = square.dot(permute.T) + square_rot_neg = square_rot_pos[::-1] + + # iterate within the current plane + for p in pinds: + for q in qinds: + # iterate perpendicularly to the current plane, handling + # boundaries. We only draw faces between a voxel and an + # empty space, to avoid drawing internal faces. + + # draw lower faces + p0 = permute.dot([p, q, 0]) + i0 = tuple(p0) + if filled[i0]: + voxel_faces[i0].append(p0 + square_rot_neg) + + # draw middle faces + for r1, r2 in zip(rinds[:-1], rinds[1:]): + p1 = permute.dot([p, q, r1]) + p2 = permute.dot([p, q, r2]) + + i1 = tuple(p1) + i2 = tuple(p2) + + if filled[i1] and not filled[i2]: + voxel_faces[i1].append(p2 + square_rot_pos) + elif not filled[i1] and filled[i2]: + voxel_faces[i2].append(p2 + square_rot_neg) + + # draw upper faces + pk = permute.dot([p, q, rc-1]) + pk2 = permute.dot([p, q, rc]) + ik = tuple(pk) + if filled[ik]: + voxel_faces[ik].append(pk2 + square_rot_pos) + + # iterate over the faces, and generate a Poly3DCollection for each + # voxel + polygons = {} + for coord, faces_inds in voxel_faces.items(): + # convert indices into 3D positions + if xyz is None: + faces = faces_inds + else: + faces = [] + for face_inds in faces_inds: + ind = face_inds[:, 0], face_inds[:, 1], face_inds[:, 2] + face = np.empty(face_inds.shape) + face[:, 0] = x[ind] + face[:, 1] = y[ind] + face[:, 2] = z[ind] + faces.append(face) + + # shade the faces + facecolor = facecolors[coord] + edgecolor = edgecolors[coord] + + poly = art3d.Poly3DCollection( + faces, facecolors=facecolor, edgecolors=edgecolor, + shade=shade, lightsource=lightsource, **kwargs) + self.add_collection3d(poly) + polygons[coord] = poly + + return polygons + + @_preprocess_data(replace_names=["x", "y", "z", "xerr", "yerr", "zerr"]) + def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', + barsabove=False, errorevery=1, ecolor=None, elinewidth=None, + capsize=None, capthick=None, xlolims=False, xuplims=False, + ylolims=False, yuplims=False, zlolims=False, zuplims=False, + **kwargs): + """ + Plot lines and/or markers with errorbars around them. + + *x*/*y*/*z* define the data locations, and *xerr*/*yerr*/*zerr* define + the errorbar sizes. By default, this draws the data markers/lines as + well the errorbars. Use fmt='none' to draw errorbars only. + + Parameters + ---------- + x, y, z : float or array-like + The data positions. + + xerr, yerr, zerr : float or array-like, shape (N,) or (2, N), optional + The errorbar sizes: + + - scalar: Symmetric +/- values for all data points. + - shape(N,): Symmetric +/-values for each data point. + - shape(2, N): Separate - and + values for each bar. First row + contains the lower errors, the second row contains the upper + errors. + - *None*: No errorbar. + + Note that all error arrays should have *positive* values. + + fmt : str, default: '' + The format for the data points / data lines. See `.plot` for + details. + + Use 'none' (case-insensitive) to plot errorbars without any data + markers. + + ecolor : color, default: None + The color of the errorbar lines. If None, use the color of the + line connecting the markers. + + elinewidth : float, default: None + The linewidth of the errorbar lines. If None, the linewidth of + the current style is used. + + capsize : float, default: :rc:`errorbar.capsize` + The length of the error bar caps in points. + + capthick : float, default: None + An alias to the keyword argument *markeredgewidth* (a.k.a. *mew*). + This setting is a more sensible name for the property that + controls the thickness of the error bar cap in points. For + backwards compatibility, if *mew* or *markeredgewidth* are given, + then they will over-ride *capthick*. This may change in future + releases. + + barsabove : bool, default: False + If True, will plot the errorbars above the plot + symbols. Default is below. + + xlolims, ylolims, zlolims : bool, default: False + These arguments can be used to indicate that a value gives only + lower limits. In that case a caret symbol is used to indicate + this. *lims*-arguments may be scalars, or array-likes of the same + length as the errors. To use limits with inverted axes, + `~.Axes.set_xlim` or `~.Axes.set_ylim` must be called before + `errorbar`. Note the tricky parameter names: setting e.g. + *ylolims* to True means that the y-value is a *lower* limit of the + True value, so, only an *upward*-pointing arrow will be drawn! + + xuplims, yuplims, zuplims : bool, default: False + Same as above, but for controlling the upper limits. + + errorevery : int or (int, int), default: 1 + draws error bars on a subset of the data. *errorevery* =N draws + error bars on the points (x[::N], y[::N], z[::N]). + *errorevery* =(start, N) draws error bars on the points + (x[start::N], y[start::N], z[start::N]). e.g. *errorevery* =(6, 3) + adds error bars to the data at (x[6], x[9], x[12], x[15], ...). + Used to avoid overlapping error bars when two series share x-axis + values. + + Returns + ------- + errlines : list + List of `~mpl_toolkits.mplot3d.art3d.Line3DCollection` instances + each containing an errorbar line. + caplines : list + List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each + containing a capline object. + limmarks : list + List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each + containing a marker with an upper or lower limit. + + Other Parameters + ---------------- + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + All other keyword arguments for styling errorbar lines are passed + `~mpl_toolkits.mplot3d.art3d.Line3DCollection`. + + Examples + -------- + .. plot:: gallery/mplot3d/errorbar3d.py + """ + had_data = self.has_data() + + kwargs = cbook.normalize_kwargs(kwargs, mlines.Line2D) + # Drop anything that comes in as None to use the default instead. + kwargs = {k: v for k, v in kwargs.items() if v is not None} + kwargs.setdefault('zorder', 2) + + self._process_unit_info([("x", x), ("y", y), ("z", z)], kwargs, + convert=False) + + # make sure all the args are iterable; use lists not arrays to + # preserve units + x = x if np.iterable(x) else [x] + y = y if np.iterable(y) else [y] + z = z if np.iterable(z) else [z] + + if not len(x) == len(y) == len(z): + raise ValueError("'x', 'y', and 'z' must have the same size") + + everymask = self._errorevery_to_mask(x, errorevery) + + label = kwargs.pop("label", None) + kwargs['label'] = '_nolegend_' + + # Create the main line and determine overall kwargs for child artists. + # We avoid calling self.plot() directly, or self._get_lines(), because + # that would call self._process_unit_info again, and do other indirect + # data processing. + (data_line, base_style), = self._get_lines._plot_args( + self, (x, y) if fmt == '' else (x, y, fmt), kwargs, return_kwargs=True) + art3d.line_2d_to_3d(data_line, zs=z) + + # Do this after creating `data_line` to avoid modifying `base_style`. + if barsabove: + data_line.set_zorder(kwargs['zorder'] - .1) + else: + data_line.set_zorder(kwargs['zorder'] + .1) + + # Add line to plot, or throw it away and use it to determine kwargs. + if fmt.lower() != 'none': + self.add_line(data_line) + else: + data_line = None + # Remove alpha=0 color that _process_plot_format returns. + base_style.pop('color') + + if 'color' not in base_style: + base_style['color'] = 'C0' + if ecolor is None: + ecolor = base_style['color'] + + # Eject any line-specific information from format string, as it's not + # needed for bars or caps. + for key in ['marker', 'markersize', 'markerfacecolor', + 'markeredgewidth', 'markeredgecolor', 'markevery', + 'linestyle', 'fillstyle', 'drawstyle', 'dash_capstyle', + 'dash_joinstyle', 'solid_capstyle', 'solid_joinstyle']: + base_style.pop(key, None) + + # Make the style dict for the line collections (the bars). + eb_lines_style = {**base_style, 'color': ecolor} + + if elinewidth: + eb_lines_style['linewidth'] = elinewidth + elif 'linewidth' in kwargs: + eb_lines_style['linewidth'] = kwargs['linewidth'] + + for key in ('transform', 'alpha', 'zorder', 'rasterized'): + if key in kwargs: + eb_lines_style[key] = kwargs[key] + + # Make the style dict for caps (the "hats"). + eb_cap_style = {**base_style, 'linestyle': 'None'} + if capsize is None: + capsize = mpl.rcParams["errorbar.capsize"] + if capsize > 0: + eb_cap_style['markersize'] = 2. * capsize + if capthick is not None: + eb_cap_style['markeredgewidth'] = capthick + eb_cap_style['color'] = ecolor + + def _apply_mask(arrays, mask): + # Return, for each array in *arrays*, the elements for which *mask* + # is True, without using fancy indexing. + return [[*itertools.compress(array, mask)] for array in arrays] + + def _extract_errs(err, data, lomask, himask): + # For separate +/- error values we need to unpack err + if len(err.shape) == 2: + low_err, high_err = err + else: + low_err, high_err = err, err + + lows = np.where(lomask | ~everymask, data, data - low_err) + highs = np.where(himask | ~everymask, data, data + high_err) + + return lows, highs + + # collect drawn items while looping over the three coordinates + errlines, caplines, limmarks = [], [], [] + + # list of endpoint coordinates, used for auto-scaling + coorderrs = [] + + # define the markers used for errorbar caps and limits below + # the dictionary key is mapped by the `i_xyz` helper dictionary + capmarker = {0: '|', 1: '|', 2: '_'} + i_xyz = {'x': 0, 'y': 1, 'z': 2} + + # Calculate marker size from points to quiver length. Because these are + # not markers, and 3D Axes do not use the normal transform stack, this + # is a bit involved. Since the quiver arrows will change size as the + # scene is rotated, they are given a standard size based on viewing + # them directly in planar form. + quiversize = eb_cap_style.get('markersize', + mpl.rcParams['lines.markersize']) ** 2 + quiversize *= self.figure.dpi / 72 + quiversize = self.transAxes.inverted().transform([ + (0, 0), (quiversize, quiversize)]) + quiversize = np.mean(np.diff(quiversize, axis=0)) + # quiversize is now in Axes coordinates, and to convert back to data + # coordinates, we need to run it through the inverse 3D transform. For + # consistency, this uses a fixed elevation, azimuth, and roll. + with cbook._setattr_cm(self, elev=0, azim=0, roll=0): + invM = np.linalg.inv(self.get_proj()) + # elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is + # 'y' in 3D, hence the 1 index. + quiversize = np.dot(invM, [quiversize, 0, 0, 0])[1] + # Quivers use a fixed 15-degree arrow head, so scale up the length so + # that the size corresponds to the base. In other words, this constant + # corresponds to the equation tan(15) = (base / 2) / (arrow length). + quiversize *= 1.8660254037844388 + eb_quiver_style = {**eb_cap_style, + 'length': quiversize, 'arrow_length_ratio': 1} + eb_quiver_style.pop('markersize', None) + + # loop over x-, y-, and z-direction and draw relevant elements + for zdir, data, err, lolims, uplims in zip( + ['x', 'y', 'z'], [x, y, z], [xerr, yerr, zerr], + [xlolims, ylolims, zlolims], [xuplims, yuplims, zuplims]): + + dir_vector = art3d.get_dir_vector(zdir) + i_zdir = i_xyz[zdir] + + if err is None: + continue + + if not np.iterable(err): + err = [err] * len(data) + + err = np.atleast_1d(err) + + # arrays fine here, they are booleans and hence not units + lolims = np.broadcast_to(lolims, len(data)).astype(bool) + uplims = np.broadcast_to(uplims, len(data)).astype(bool) + + # a nested list structure that expands to (xl,xh),(yl,yh),(zl,zh), + # where x/y/z and l/h correspond to dimensions and low/high + # positions of errorbars in a dimension we're looping over + coorderr = [ + _extract_errs(err * dir_vector[i], coord, lolims, uplims) + for i, coord in enumerate([x, y, z])] + (xl, xh), (yl, yh), (zl, zh) = coorderr + + # draws capmarkers - flat caps orthogonal to the error bars + nolims = ~(lolims | uplims) + if nolims.any() and capsize > 0: + lo_caps_xyz = _apply_mask([xl, yl, zl], nolims & everymask) + hi_caps_xyz = _apply_mask([xh, yh, zh], nolims & everymask) + + # setting '_' for z-caps and '|' for x- and y-caps; + # these markers will rotate as the viewing angle changes + cap_lo = art3d.Line3D(*lo_caps_xyz, ls='', + marker=capmarker[i_zdir], + **eb_cap_style) + cap_hi = art3d.Line3D(*hi_caps_xyz, ls='', + marker=capmarker[i_zdir], + **eb_cap_style) + self.add_line(cap_lo) + self.add_line(cap_hi) + caplines.append(cap_lo) + caplines.append(cap_hi) + + if lolims.any(): + xh0, yh0, zh0 = _apply_mask([xh, yh, zh], lolims & everymask) + self.quiver(xh0, yh0, zh0, *dir_vector, **eb_quiver_style) + if uplims.any(): + xl0, yl0, zl0 = _apply_mask([xl, yl, zl], uplims & everymask) + self.quiver(xl0, yl0, zl0, *-dir_vector, **eb_quiver_style) + + errline = art3d.Line3DCollection(np.array(coorderr).T, + **eb_lines_style) + self.add_collection(errline) + errlines.append(errline) + coorderrs.append(coorderr) + + coorderrs = np.array(coorderrs) + + def _digout_minmax(err_arr, coord_label): + return (np.nanmin(err_arr[:, i_xyz[coord_label], :, :]), + np.nanmax(err_arr[:, i_xyz[coord_label], :, :])) + + minx, maxx = _digout_minmax(coorderrs, 'x') + miny, maxy = _digout_minmax(coorderrs, 'y') + minz, maxz = _digout_minmax(coorderrs, 'z') + self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data) + + # Adapting errorbar containers for 3d case, assuming z-axis points "up" + errorbar_container = mcontainer.ErrorbarContainer( + (data_line, tuple(caplines), tuple(errlines)), + has_xerr=(xerr is not None or yerr is not None), + has_yerr=(zerr is not None), + label=label) + self.containers.append(errorbar_container) + + return errlines, caplines, limmarks + + @_api.make_keyword_only("3.8", "call_axes_locator") + def get_tightbbox(self, renderer=None, call_axes_locator=True, + bbox_extra_artists=None, *, for_layout_only=False): + ret = super().get_tightbbox(renderer, + call_axes_locator=call_axes_locator, + bbox_extra_artists=bbox_extra_artists, + for_layout_only=for_layout_only) + batch = [ret] + if self._axis3don: + for axis in self._axis_map.values(): + if axis.get_visible(): + axis_bb = martist._get_tightbbox_for_layout_only( + axis, renderer) + if axis_bb: + batch.append(axis_bb) + return mtransforms.Bbox.union(batch) + + @_preprocess_data() + def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', + bottom=0, label=None, orientation='z'): + """ + Create a 3D stem plot. + + A stem plot draws lines perpendicular to a baseline, and places markers + at the heads. By default, the baseline is defined by *x* and *y*, and + stems are drawn vertically from *bottom* to *z*. + + Parameters + ---------- + x, y, z : array-like + The positions of the heads of the stems. The stems are drawn along + the *orientation*-direction from the baseline at *bottom* (in the + *orientation*-coordinate) to the heads. By default, the *x* and *y* + positions are used for the baseline and *z* for the head position, + but this can be changed by *orientation*. + + linefmt : str, default: 'C0-' + A string defining the properties of the vertical lines. Usually, + this will be a color or a color and a linestyle: + + ========= ============= + Character Line Style + ========= ============= + ``'-'`` solid line + ``'--'`` dashed line + ``'-.'`` dash-dot line + ``':'`` dotted line + ========= ============= + + Note: While it is technically possible to specify valid formats + other than color or color and linestyle (e.g. 'rx' or '-.'), this + is beyond the intention of the method and will most likely not + result in a reasonable plot. + + markerfmt : str, default: 'C0o' + A string defining the properties of the markers at the stem heads. + + basefmt : str, default: 'C3-' + A format string defining the properties of the baseline. + + bottom : float, default: 0 + The position of the baseline, in *orientation*-coordinates. + + label : str, default: None + The label to use for the stems in legends. + + orientation : {'x', 'y', 'z'}, default: 'z' + The direction along which stems are drawn. + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + Returns + ------- + `.StemContainer` + The container may be treated like a tuple + (*markerline*, *stemlines*, *baseline*) + + Examples + -------- + .. plot:: gallery/mplot3d/stem3d_demo.py + """ + + from matplotlib.container import StemContainer + + had_data = self.has_data() + + _api.check_in_list(['x', 'y', 'z'], orientation=orientation) + + xlim = (np.min(x), np.max(x)) + ylim = (np.min(y), np.max(y)) + zlim = (np.min(z), np.max(z)) + + # Determine the appropriate plane for the baseline and the direction of + # stemlines based on the value of orientation. + if orientation == 'x': + basex, basexlim = y, ylim + basey, baseylim = z, zlim + lines = [[(bottom, thisy, thisz), (thisx, thisy, thisz)] + for thisx, thisy, thisz in zip(x, y, z)] + elif orientation == 'y': + basex, basexlim = x, xlim + basey, baseylim = z, zlim + lines = [[(thisx, bottom, thisz), (thisx, thisy, thisz)] + for thisx, thisy, thisz in zip(x, y, z)] + else: + basex, basexlim = x, xlim + basey, baseylim = y, ylim + lines = [[(thisx, thisy, bottom), (thisx, thisy, thisz)] + for thisx, thisy, thisz in zip(x, y, z)] + + # Determine style for stem lines. + linestyle, linemarker, linecolor = _process_plot_format(linefmt) + if linestyle is None: + linestyle = mpl.rcParams['lines.linestyle'] + + # Plot everything in required order. + baseline, = self.plot(basex, basey, basefmt, zs=bottom, + zdir=orientation, label='_nolegend_') + stemlines = art3d.Line3DCollection( + lines, linestyles=linestyle, colors=linecolor, label='_nolegend_') + self.add_collection(stemlines) + markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_') + + stem_container = StemContainer((markerline, stemlines, baseline), + label=label) + self.add_container(stem_container) + + jx, jy, jz = art3d.juggle_axes(basexlim, baseylim, [bottom, bottom], + orientation) + self.auto_scale_xyz([*jx, *xlim], [*jy, *ylim], [*jz, *zlim], had_data) + + return stem_container + + stem3D = stem + + +def get_test_data(delta=0.05): + """Return a tuple X, Y, Z with a test data set.""" + x = y = np.arange(-3.0, 3.0, delta) + X, Y = np.meshgrid(x, y) + + Z1 = np.exp(-(X**2 + Y**2) / 2) / (2 * np.pi) + Z2 = (np.exp(-(((X - 1) / 1.5)**2 + ((Y - 1) / 0.5)**2) / 2) / + (2 * np.pi * 0.5 * 1.5)) + Z = Z2 - Z1 + + X = X * 10 + Y = Y * 10 + Z = Z * 500 + return X, Y, Z diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/axis3d.py b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/axis3d.py new file mode 100644 index 0000000000..4c5fa8a9c9 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/axis3d.py @@ -0,0 +1,753 @@ +# axis3d.py, original mplot3d version by John Porter +# Created: 23 Sep 2005 +# Parts rewritten by Reinier Heeres <reinier@heeres.eu> + +import inspect + +import numpy as np + +import matplotlib as mpl +from matplotlib import ( + _api, artist, lines as mlines, axis as maxis, patches as mpatches, + transforms as mtransforms, colors as mcolors) +from . import art3d, proj3d + + +def _move_from_center(coord, centers, deltas, axmask=(True, True, True)): + """ + For each coordinate where *axmask* is True, move *coord* away from + *centers* by *deltas*. + """ + coord = np.asarray(coord) + return coord + axmask * np.copysign(1, coord - centers) * deltas + + +def _tick_update_position(tick, tickxs, tickys, labelpos): + """Update tick line and label position and style.""" + + tick.label1.set_position(labelpos) + tick.label2.set_position(labelpos) + tick.tick1line.set_visible(True) + tick.tick2line.set_visible(False) + tick.tick1line.set_linestyle('-') + tick.tick1line.set_marker('') + tick.tick1line.set_data(tickxs, tickys) + tick.gridline.set_data([0], [0]) + + +class Axis(maxis.XAxis): + """An Axis class for the 3D plots.""" + # These points from the unit cube make up the x, y and z-planes + _PLANES = ( + (0, 3, 7, 4), (1, 2, 6, 5), # yz planes + (0, 1, 5, 4), (3, 2, 6, 7), # xz planes + (0, 1, 2, 3), (4, 5, 6, 7), # xy planes + ) + + # Some properties for the axes + _AXINFO = { + 'x': {'i': 0, 'tickdir': 1, 'juggled': (1, 0, 2)}, + 'y': {'i': 1, 'tickdir': 0, 'juggled': (0, 1, 2)}, + 'z': {'i': 2, 'tickdir': 0, 'juggled': (0, 2, 1)}, + } + + def _old_init(self, adir, v_intervalx, d_intervalx, axes, *args, + rotate_label=None, **kwargs): + return locals() + + def _new_init(self, axes, *, rotate_label=None, **kwargs): + return locals() + + def __init__(self, *args, **kwargs): + params = _api.select_matching_signature( + [self._old_init, self._new_init], *args, **kwargs) + if "adir" in params: + _api.warn_deprecated( + "3.6", message=f"The signature of 3D Axis constructors has " + f"changed in %(since)s; the new signature is " + f"{inspect.signature(type(self).__init__)}", pending=True) + if params["adir"] != self.axis_name: + raise ValueError(f"Cannot instantiate {type(self).__name__} " + f"with adir={params['adir']!r}") + axes = params["axes"] + rotate_label = params["rotate_label"] + args = params.get("args", ()) + kwargs = params["kwargs"] + + name = self.axis_name + + self._label_position = 'default' + self._tick_position = 'default' + + # This is a temporary member variable. + # Do not depend on this existing in future releases! + self._axinfo = self._AXINFO[name].copy() + # Common parts + self._axinfo.update({ + 'label': {'va': 'center', 'ha': 'center', + 'rotation_mode': 'anchor'}, + 'color': mpl.rcParams[f'axes3d.{name}axis.panecolor'], + 'tick': { + 'inward_factor': 0.2, + 'outward_factor': 0.1, + }, + }) + + if mpl.rcParams['_internal.classic_mode']: + self._axinfo.update({ + 'axisline': {'linewidth': 0.75, 'color': (0, 0, 0, 1)}, + 'grid': { + 'color': (0.9, 0.9, 0.9, 1), + 'linewidth': 1.0, + 'linestyle': '-', + }, + }) + self._axinfo['tick'].update({ + 'linewidth': { + True: mpl.rcParams['lines.linewidth'], # major + False: mpl.rcParams['lines.linewidth'], # minor + } + }) + else: + self._axinfo.update({ + 'axisline': { + 'linewidth': mpl.rcParams['axes.linewidth'], + 'color': mpl.rcParams['axes.edgecolor'], + }, + 'grid': { + 'color': mpl.rcParams['grid.color'], + 'linewidth': mpl.rcParams['grid.linewidth'], + 'linestyle': mpl.rcParams['grid.linestyle'], + }, + }) + self._axinfo['tick'].update({ + 'linewidth': { + True: ( # major + mpl.rcParams['xtick.major.width'] if name in 'xz' + else mpl.rcParams['ytick.major.width']), + False: ( # minor + mpl.rcParams['xtick.minor.width'] if name in 'xz' + else mpl.rcParams['ytick.minor.width']), + } + }) + + super().__init__(axes, *args, **kwargs) + + # data and viewing intervals for this direction + if "d_intervalx" in params: + self.set_data_interval(*params["d_intervalx"]) + if "v_intervalx" in params: + self.set_view_interval(*params["v_intervalx"]) + self.set_rotate_label(rotate_label) + self._init3d() # Inline after init3d deprecation elapses. + + __init__.__signature__ = inspect.signature(_new_init) + adir = _api.deprecated("3.6", pending=True)( + property(lambda self: self.axis_name)) + + def _init3d(self): + self.line = mlines.Line2D( + xdata=(0, 0), ydata=(0, 0), + linewidth=self._axinfo['axisline']['linewidth'], + color=self._axinfo['axisline']['color'], + antialiased=True) + + # Store dummy data in Polygon object + self.pane = mpatches.Polygon([[0, 0], [0, 1]], closed=False) + self.set_pane_color(self._axinfo['color']) + + self.axes._set_artist_props(self.line) + self.axes._set_artist_props(self.pane) + self.gridlines = art3d.Line3DCollection([]) + self.axes._set_artist_props(self.gridlines) + self.axes._set_artist_props(self.label) + self.axes._set_artist_props(self.offsetText) + # Need to be able to place the label at the correct location + self.label._transform = self.axes.transData + self.offsetText._transform = self.axes.transData + + @_api.deprecated("3.6", pending=True) + def init3d(self): # After deprecation elapses, inline _init3d to __init__. + self._init3d() + + def get_major_ticks(self, numticks=None): + ticks = super().get_major_ticks(numticks) + for t in ticks: + for obj in [ + t.tick1line, t.tick2line, t.gridline, t.label1, t.label2]: + obj.set_transform(self.axes.transData) + return ticks + + def get_minor_ticks(self, numticks=None): + ticks = super().get_minor_ticks(numticks) + for t in ticks: + for obj in [ + t.tick1line, t.tick2line, t.gridline, t.label1, t.label2]: + obj.set_transform(self.axes.transData) + return ticks + + def set_ticks_position(self, position): + """ + Set the ticks position. + + Parameters + ---------- + position : {'lower', 'upper', 'both', 'default', 'none'} + The position of the bolded axis lines, ticks, and tick labels. + """ + if position in ['top', 'bottom']: + _api.warn_deprecated('3.8', name=f'{position=}', + obj_type='argument value', + alternative="'upper' or 'lower'") + return + _api.check_in_list(['lower', 'upper', 'both', 'default', 'none'], + position=position) + self._tick_position = position + + def get_ticks_position(self): + """ + Get the ticks position. + + Returns + ------- + str : {'lower', 'upper', 'both', 'default', 'none'} + The position of the bolded axis lines, ticks, and tick labels. + """ + return self._tick_position + + def set_label_position(self, position): + """ + Set the label position. + + Parameters + ---------- + position : {'lower', 'upper', 'both', 'default', 'none'} + The position of the axis label. + """ + if position in ['top', 'bottom']: + _api.warn_deprecated('3.8', name=f'{position=}', + obj_type='argument value', + alternative="'upper' or 'lower'") + return + _api.check_in_list(['lower', 'upper', 'both', 'default', 'none'], + position=position) + self._label_position = position + + def get_label_position(self): + """ + Get the label position. + + Returns + ------- + str : {'lower', 'upper', 'both', 'default', 'none'} + The position of the axis label. + """ + return self._label_position + + def set_pane_color(self, color, alpha=None): + """ + Set pane color. + + Parameters + ---------- + color : color + Color for axis pane. + alpha : float, optional + Alpha value for axis pane. If None, base it on *color*. + """ + color = mcolors.to_rgba(color, alpha) + self._axinfo['color'] = color + self.pane.set_edgecolor(color) + self.pane.set_facecolor(color) + self.pane.set_alpha(color[-1]) + self.stale = True + + def set_rotate_label(self, val): + """ + Whether to rotate the axis label: True, False or None. + If set to None the label will be rotated if longer than 4 chars. + """ + self._rotate_label = val + self.stale = True + + def get_rotate_label(self, text): + if self._rotate_label is not None: + return self._rotate_label + else: + return len(text) > 4 + + def _get_coord_info(self, renderer): + mins, maxs = np.array([ + self.axes.get_xbound(), + self.axes.get_ybound(), + self.axes.get_zbound(), + ]).T + + # Get the mean value for each bound: + centers = 0.5 * (maxs + mins) + + # Add a small offset between min/max point and the edge of the plot: + deltas = (maxs - mins) / 12 + mins -= 0.25 * deltas + maxs += 0.25 * deltas + + # Project the bounds along the current position of the cube: + bounds = mins[0], maxs[0], mins[1], maxs[1], mins[2], maxs[2] + bounds_proj = self.axes._tunit_cube(bounds, self.axes.M) + + # Determine which one of the parallel planes are higher up: + means_z0 = np.zeros(3) + means_z1 = np.zeros(3) + for i in range(3): + means_z0[i] = np.mean(bounds_proj[self._PLANES[2 * i], 2]) + means_z1[i] = np.mean(bounds_proj[self._PLANES[2 * i + 1], 2]) + highs = means_z0 < means_z1 + + # Special handling for edge-on views + equals = np.abs(means_z0 - means_z1) <= np.finfo(float).eps + if np.sum(equals) == 2: + vertical = np.where(~equals)[0][0] + if vertical == 2: # looking at XY plane + highs = np.array([True, True, highs[2]]) + elif vertical == 1: # looking at XZ plane + highs = np.array([True, highs[1], False]) + elif vertical == 0: # looking at YZ plane + highs = np.array([highs[0], False, False]) + + return mins, maxs, centers, deltas, bounds_proj, highs + + def _get_axis_line_edge_points(self, minmax, maxmin, position=None): + """Get the edge points for the black bolded axis line.""" + # When changing vertical axis some of the axes has to be + # moved to the other plane so it looks the same as if the z-axis + # was the vertical axis. + mb = [minmax, maxmin] # line from origin to nearest corner to camera + mb_rev = mb[::-1] + mm = [[mb, mb_rev, mb_rev], [mb_rev, mb_rev, mb], [mb, mb, mb]] + mm = mm[self.axes._vertical_axis][self._axinfo["i"]] + + juggled = self._axinfo["juggled"] + edge_point_0 = mm[0].copy() # origin point + + if ((position == 'lower' and mm[1][juggled[-1]] < mm[0][juggled[-1]]) or + (position == 'upper' and mm[1][juggled[-1]] > mm[0][juggled[-1]])): + edge_point_0[juggled[-1]] = mm[1][juggled[-1]] + else: + edge_point_0[juggled[0]] = mm[1][juggled[0]] + + edge_point_1 = edge_point_0.copy() + edge_point_1[juggled[1]] = mm[1][juggled[1]] + + return edge_point_0, edge_point_1 + + def _get_all_axis_line_edge_points(self, minmax, maxmin, axis_position=None): + # Determine edge points for the axis lines + edgep1s = [] + edgep2s = [] + position = [] + if axis_position in (None, 'default'): + edgep1, edgep2 = self._get_axis_line_edge_points(minmax, maxmin) + edgep1s = [edgep1] + edgep2s = [edgep2] + position = ['default'] + else: + edgep1_l, edgep2_l = self._get_axis_line_edge_points(minmax, maxmin, + position='lower') + edgep1_u, edgep2_u = self._get_axis_line_edge_points(minmax, maxmin, + position='upper') + if axis_position in ('lower', 'both'): + edgep1s.append(edgep1_l) + edgep2s.append(edgep2_l) + position.append('lower') + if axis_position in ('upper', 'both'): + edgep1s.append(edgep1_u) + edgep2s.append(edgep2_u) + position.append('upper') + return edgep1s, edgep2s, position + + def _get_tickdir(self, position): + """ + Get the direction of the tick. + + Parameters + ---------- + position : str, optional : {'upper', 'lower', 'default'} + The position of the axis. + + Returns + ------- + tickdir : int + Index which indicates which coordinate the tick line will + align with. + """ + _api.check_in_list(('upper', 'lower', 'default'), position=position) + + # TODO: Move somewhere else where it's triggered less: + tickdirs_base = [v["tickdir"] for v in self._AXINFO.values()] # default + elev_mod = np.mod(self.axes.elev + 180, 360) - 180 + azim_mod = np.mod(self.axes.azim, 360) + if position == 'upper': + if elev_mod >= 0: + tickdirs_base = [2, 2, 0] + else: + tickdirs_base = [1, 0, 0] + if 0 <= azim_mod < 180: + tickdirs_base[2] = 1 + elif position == 'lower': + if elev_mod >= 0: + tickdirs_base = [1, 0, 1] + else: + tickdirs_base = [2, 2, 1] + if 0 <= azim_mod < 180: + tickdirs_base[2] = 0 + info_i = [v["i"] for v in self._AXINFO.values()] + + i = self._axinfo["i"] + vert_ax = self.axes._vertical_axis + j = vert_ax - 2 + # default: tickdir = [[1, 2, 1], [2, 2, 0], [1, 0, 0]][vert_ax][i] + tickdir = np.roll(info_i, -j)[np.roll(tickdirs_base, j)][i] + return tickdir + + def active_pane(self, renderer): + mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer) + info = self._axinfo + index = info['i'] + if not highs[index]: + loc = mins[index] + plane = self._PLANES[2 * index] + else: + loc = maxs[index] + plane = self._PLANES[2 * index + 1] + xys = np.array([tc[p] for p in plane]) + return xys, loc + + def draw_pane(self, renderer): + """ + Draw pane. + + Parameters + ---------- + renderer : `~matplotlib.backend_bases.RendererBase` subclass + """ + renderer.open_group('pane3d', gid=self.get_gid()) + xys, loc = self.active_pane(renderer) + self.pane.xy = xys[:, :2] + self.pane.draw(renderer) + renderer.close_group('pane3d') + + def _axmask(self): + axmask = [True, True, True] + axmask[self._axinfo["i"]] = False + return axmask + + def _draw_ticks(self, renderer, edgep1, centers, deltas, highs, + deltas_per_point, pos): + ticks = self._update_ticks() + info = self._axinfo + index = info["i"] + + # Draw ticks: + tickdir = self._get_tickdir(pos) + tickdelta = deltas[tickdir] if highs[tickdir] else -deltas[tickdir] + + tick_info = info['tick'] + tick_out = tick_info['outward_factor'] * tickdelta + tick_in = tick_info['inward_factor'] * tickdelta + tick_lw = tick_info['linewidth'] + edgep1_tickdir = edgep1[tickdir] + out_tickdir = edgep1_tickdir + tick_out + in_tickdir = edgep1_tickdir - tick_in + + default_label_offset = 8. # A rough estimate + points = deltas_per_point * deltas + for tick in ticks: + # Get tick line positions + pos = edgep1.copy() + pos[index] = tick.get_loc() + pos[tickdir] = out_tickdir + x1, y1, z1 = proj3d.proj_transform(*pos, self.axes.M) + pos[tickdir] = in_tickdir + x2, y2, z2 = proj3d.proj_transform(*pos, self.axes.M) + + # Get position of label + labeldeltas = (tick.get_pad() + default_label_offset) * points + + pos[tickdir] = edgep1_tickdir + pos = _move_from_center(pos, centers, labeldeltas, self._axmask()) + lx, ly, lz = proj3d.proj_transform(*pos, self.axes.M) + + _tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly)) + tick.tick1line.set_linewidth(tick_lw[tick._major]) + tick.draw(renderer) + + def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers, + highs, pep, dx, dy): + # Get general axis information: + info = self._axinfo + index = info["i"] + juggled = info["juggled"] + tickdir = info["tickdir"] + + # Which of the two edge points do we want to + # use for locating the offset text? + if juggled[2] == 2: + outeredgep = edgep1 + outerindex = 0 + else: + outeredgep = edgep2 + outerindex = 1 + + pos = _move_from_center(outeredgep, centers, labeldeltas, + self._axmask()) + olx, oly, olz = proj3d.proj_transform(*pos, self.axes.M) + self.offsetText.set_text(self.major.formatter.get_offset()) + self.offsetText.set_position((olx, oly)) + angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx))) + self.offsetText.set_rotation(angle) + # Must set rotation mode to "anchor" so that + # the alignment point is used as the "fulcrum" for rotation. + self.offsetText.set_rotation_mode('anchor') + + # ---------------------------------------------------------------------- + # Note: the following statement for determining the proper alignment of + # the offset text. This was determined entirely by trial-and-error + # and should not be in any way considered as "the way". There are + # still some edge cases where alignment is not quite right, but this + # seems to be more of a geometry issue (in other words, I might be + # using the wrong reference points). + # + # (TT, FF, TF, FT) are the shorthand for the tuple of + # (centpt[tickdir] <= pep[tickdir, outerindex], + # centpt[index] <= pep[index, outerindex]) + # + # Three-letters (e.g., TFT, FTT) are short-hand for the array of bools + # from the variable 'highs'. + # --------------------------------------------------------------------- + centpt = proj3d.proj_transform(*centers, self.axes.M) + if centpt[tickdir] > pep[tickdir, outerindex]: + # if FT and if highs has an even number of Trues + if (centpt[index] <= pep[index, outerindex] + and np.count_nonzero(highs) % 2 == 0): + # Usually, this means align right, except for the FTT case, + # in which offset for axis 1 and 2 are aligned left. + if highs.tolist() == [False, True, True] and index in (1, 2): + align = 'left' + else: + align = 'right' + else: + # The FF case + align = 'left' + else: + # if TF and if highs has an even number of Trues + if (centpt[index] > pep[index, outerindex] + and np.count_nonzero(highs) % 2 == 0): + # Usually mean align left, except if it is axis 2 + align = 'right' if index == 2 else 'left' + else: + # The TT case + align = 'right' + + self.offsetText.set_va('center') + self.offsetText.set_ha(align) + self.offsetText.draw(renderer) + + def _draw_labels(self, renderer, edgep1, edgep2, labeldeltas, centers, dx, dy): + label = self._axinfo["label"] + + # Draw labels + lxyz = 0.5 * (edgep1 + edgep2) + lxyz = _move_from_center(lxyz, centers, labeldeltas, self._axmask()) + tlx, tly, tlz = proj3d.proj_transform(*lxyz, self.axes.M) + self.label.set_position((tlx, tly)) + if self.get_rotate_label(self.label.get_text()): + angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx))) + self.label.set_rotation(angle) + self.label.set_va(label['va']) + self.label.set_ha(label['ha']) + self.label.set_rotation_mode(label['rotation_mode']) + self.label.draw(renderer) + + @artist.allow_rasterization + def draw(self, renderer): + self.label._transform = self.axes.transData + self.offsetText._transform = self.axes.transData + renderer.open_group("axis3d", gid=self.get_gid()) + + # Get general axis information: + mins, maxs, centers, deltas, tc, highs = self._get_coord_info(renderer) + + # Calculate offset distances + # A rough estimate; points are ambiguous since 3D plots rotate + reltoinches = self.figure.dpi_scale_trans.inverted() + ax_inches = reltoinches.transform(self.axes.bbox.size) + ax_points_estimate = sum(72. * ax_inches) + deltas_per_point = 48 / ax_points_estimate + default_offset = 21. + labeldeltas = (self.labelpad + default_offset) * deltas_per_point * deltas + + # Determine edge points for the axis lines + minmax = np.where(highs, maxs, mins) # "origin" point + maxmin = np.where(~highs, maxs, mins) # "opposite" corner near camera + + for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points( + minmax, maxmin, self._tick_position)): + # Project the edge points along the current position + pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M) + pep = np.asarray(pep) + + # The transAxes transform is used because the Text object + # rotates the text relative to the display coordinate system. + # Therefore, if we want the labels to remain parallel to the + # axis regardless of the aspect ratio, we need to convert the + # edge points of the plane to display coordinates and calculate + # an angle from that. + # TODO: Maybe Text objects should handle this themselves? + dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) - + self.axes.transAxes.transform([pep[0:2, 0]]))[0] + + # Draw the lines + self.line.set_data(pep[0], pep[1]) + self.line.draw(renderer) + + # Draw ticks + self._draw_ticks(renderer, edgep1, centers, deltas, highs, + deltas_per_point, pos) + + # Draw Offset text + self._draw_offset_text(renderer, edgep1, edgep2, labeldeltas, + centers, highs, pep, dx, dy) + + for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points( + minmax, maxmin, self._label_position)): + # See comments above + pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M) + pep = np.asarray(pep) + dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) - + self.axes.transAxes.transform([pep[0:2, 0]]))[0] + + # Draw labels + self._draw_labels(renderer, edgep1, edgep2, labeldeltas, centers, dx, dy) + + renderer.close_group('axis3d') + self.stale = False + + @artist.allow_rasterization + def draw_grid(self, renderer): + if not self.axes._draw_grid: + return + + renderer.open_group("grid3d", gid=self.get_gid()) + + ticks = self._update_ticks() + if len(ticks): + # Get general axis information: + info = self._axinfo + index = info["i"] + + mins, maxs, _, _, _, highs = self._get_coord_info(renderer) + + minmax = np.where(highs, maxs, mins) + maxmin = np.where(~highs, maxs, mins) + + # Grid points where the planes meet + xyz0 = np.tile(minmax, (len(ticks), 1)) + xyz0[:, index] = [tick.get_loc() for tick in ticks] + + # Grid lines go from the end of one plane through the plane + # intersection (at xyz0) to the end of the other plane. The first + # point (0) differs along dimension index-2 and the last (2) along + # dimension index-1. + lines = np.stack([xyz0, xyz0, xyz0], axis=1) + lines[:, 0, index - 2] = maxmin[index - 2] + lines[:, 2, index - 1] = maxmin[index - 1] + self.gridlines.set_segments(lines) + gridinfo = info['grid'] + self.gridlines.set_color(gridinfo['color']) + self.gridlines.set_linewidth(gridinfo['linewidth']) + self.gridlines.set_linestyle(gridinfo['linestyle']) + self.gridlines.do_3d_projection() + self.gridlines.draw(renderer) + + renderer.close_group('grid3d') + + # TODO: Get this to work (more) properly when mplot3d supports the + # transforms framework. + def get_tightbbox(self, renderer=None, *, for_layout_only=False): + # docstring inherited + if not self.get_visible(): + return + # We have to directly access the internal data structures + # (and hope they are up to date) because at draw time we + # shift the ticks and their labels around in (x, y) space + # based on the projection, the current view port, and their + # position in 3D space. If we extend the transforms framework + # into 3D we would not need to do this different book keeping + # than we do in the normal axis + major_locs = self.get_majorticklocs() + minor_locs = self.get_minorticklocs() + + ticks = [*self.get_minor_ticks(len(minor_locs)), + *self.get_major_ticks(len(major_locs))] + view_low, view_high = self.get_view_interval() + if view_low > view_high: + view_low, view_high = view_high, view_low + interval_t = self.get_transform().transform([view_low, view_high]) + + ticks_to_draw = [] + for tick in ticks: + try: + loc_t = self.get_transform().transform(tick.get_loc()) + except AssertionError: + # Transform.transform doesn't allow masked values but + # some scales might make them, so we need this try/except. + pass + else: + if mtransforms._interval_contains_close(interval_t, loc_t): + ticks_to_draw.append(tick) + + ticks = ticks_to_draw + + bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer) + other = [] + + if self.line.get_visible(): + other.append(self.line.get_window_extent(renderer)) + if (self.label.get_visible() and not for_layout_only and + self.label.get_text()): + other.append(self.label.get_window_extent(renderer)) + + return mtransforms.Bbox.union([*bb_1, *bb_2, *other]) + + d_interval = _api.deprecated( + "3.6", alternative="get_data_interval", pending=True)( + property(lambda self: self.get_data_interval(), + lambda self, minmax: self.set_data_interval(*minmax))) + v_interval = _api.deprecated( + "3.6", alternative="get_view_interval", pending=True)( + property(lambda self: self.get_view_interval(), + lambda self, minmax: self.set_view_interval(*minmax))) + + +class XAxis(Axis): + axis_name = "x" + get_view_interval, set_view_interval = maxis._make_getset_interval( + "view", "xy_viewLim", "intervalx") + get_data_interval, set_data_interval = maxis._make_getset_interval( + "data", "xy_dataLim", "intervalx") + + +class YAxis(Axis): + axis_name = "y" + get_view_interval, set_view_interval = maxis._make_getset_interval( + "view", "xy_viewLim", "intervaly") + get_data_interval, set_data_interval = maxis._make_getset_interval( + "data", "xy_dataLim", "intervaly") + + +class ZAxis(Axis): + axis_name = "z" + get_view_interval, set_view_interval = maxis._make_getset_interval( + "view", "zz_viewLim", "intervalx") + get_data_interval, set_data_interval = maxis._make_getset_interval( + "data", "zz_dataLim", "intervalx") diff --git a/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/proj3d.py b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/proj3d.py new file mode 100644 index 0000000000..098a7b6f66 --- /dev/null +++ b/contrib/python/matplotlib/py3/mpl_toolkits/mplot3d/proj3d.py @@ -0,0 +1,259 @@ +""" +Various transforms used for by the 3D code +""" + +import numpy as np + +from matplotlib import _api + + +def world_transformation(xmin, xmax, + ymin, ymax, + zmin, zmax, pb_aspect=None): + """ + Produce a matrix that scales homogeneous coords in the specified ranges + to [0, 1], or [0, pb_aspect[i]] if the plotbox aspect ratio is specified. + """ + dx = xmax - xmin + dy = ymax - ymin + dz = zmax - zmin + if pb_aspect is not None: + ax, ay, az = pb_aspect + dx /= ax + dy /= ay + dz /= az + + return np.array([[1/dx, 0, 0, -xmin/dx], + [0, 1/dy, 0, -ymin/dy], + [0, 0, 1/dz, -zmin/dz], + [0, 0, 0, 1]]) + + +@_api.deprecated("3.8") +def rotation_about_vector(v, angle): + """ + Produce a rotation matrix for an angle in radians about a vector. + """ + return _rotation_about_vector(v, angle) + + +def _rotation_about_vector(v, angle): + """ + Produce a rotation matrix for an angle in radians about a vector. + """ + vx, vy, vz = v / np.linalg.norm(v) + s = np.sin(angle) + c = np.cos(angle) + t = 2*np.sin(angle/2)**2 # more numerically stable than t = 1-c + + R = np.array([ + [t*vx*vx + c, t*vx*vy - vz*s, t*vx*vz + vy*s], + [t*vy*vx + vz*s, t*vy*vy + c, t*vy*vz - vx*s], + [t*vz*vx - vy*s, t*vz*vy + vx*s, t*vz*vz + c]]) + + return R + + +def _view_axes(E, R, V, roll): + """ + Get the unit viewing axes in data coordinates. + + Parameters + ---------- + E : 3-element numpy array + The coordinates of the eye/camera. + R : 3-element numpy array + The coordinates of the center of the view box. + V : 3-element numpy array + Unit vector in the direction of the vertical axis. + roll : float + The roll angle in radians. + + Returns + ------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + """ + w = (E - R) + w = w/np.linalg.norm(w) + u = np.cross(V, w) + u = u/np.linalg.norm(u) + v = np.cross(w, u) # Will be a unit vector + + # Save some computation for the default roll=0 + if roll != 0: + # A positive rotation of the camera is a negative rotation of the world + Rroll = _rotation_about_vector(w, -roll) + u = np.dot(Rroll, u) + v = np.dot(Rroll, v) + return u, v, w + + +def _view_transformation_uvw(u, v, w, E): + """ + Return the view transformation matrix. + + Parameters + ---------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + E : 3-element numpy array + The coordinates of the eye/camera. + """ + Mr = np.eye(4) + Mt = np.eye(4) + Mr[:3, :3] = [u, v, w] + Mt[:3, -1] = -E + M = np.dot(Mr, Mt) + return M + + +@_api.deprecated("3.8") +def view_transformation(E, R, V, roll): + """ + Return the view transformation matrix. + + Parameters + ---------- + E : 3-element numpy array + The coordinates of the eye/camera. + R : 3-element numpy array + The coordinates of the center of the view box. + V : 3-element numpy array + Unit vector in the direction of the vertical axis. + roll : float + The roll angle in radians. + """ + u, v, w = _view_axes(E, R, V, roll) + M = _view_transformation_uvw(u, v, w, E) + return M + + +@_api.deprecated("3.8") +def persp_transformation(zfront, zback, focal_length): + return _persp_transformation(zfront, zback, focal_length) + + +def _persp_transformation(zfront, zback, focal_length): + e = focal_length + a = 1 # aspect ratio + b = (zfront+zback)/(zfront-zback) + c = -2*(zfront*zback)/(zfront-zback) + proj_matrix = np.array([[e, 0, 0, 0], + [0, e/a, 0, 0], + [0, 0, b, c], + [0, 0, -1, 0]]) + return proj_matrix + + +@_api.deprecated("3.8") +def ortho_transformation(zfront, zback): + return _ortho_transformation(zfront, zback) + + +def _ortho_transformation(zfront, zback): + # note: w component in the resulting vector will be (zback-zfront), not 1 + a = -(zfront + zback) + b = -(zfront - zback) + proj_matrix = np.array([[2, 0, 0, 0], + [0, 2, 0, 0], + [0, 0, -2, 0], + [0, 0, a, b]]) + return proj_matrix + + +def _proj_transform_vec(vec, M): + vecw = np.dot(M, vec) + w = vecw[3] + # clip here.. + txs, tys, tzs = vecw[0]/w, vecw[1]/w, vecw[2]/w + return txs, tys, tzs + + +def _proj_transform_vec_clip(vec, M): + vecw = np.dot(M, vec) + w = vecw[3] + # clip here. + txs, tys, tzs = vecw[0] / w, vecw[1] / w, vecw[2] / w + tis = (0 <= vecw[0]) & (vecw[0] <= 1) & (0 <= vecw[1]) & (vecw[1] <= 1) + if np.any(tis): + tis = vecw[1] < 1 + return txs, tys, tzs, tis + + +def inv_transform(xs, ys, zs, invM): + """ + Transform the points by the inverse of the projection matrix, *invM*. + """ + vec = _vec_pad_ones(xs, ys, zs) + vecr = np.dot(invM, vec) + if vecr.shape == (4,): + vecr = vecr.reshape((4, 1)) + for i in range(vecr.shape[1]): + if vecr[3][i] != 0: + vecr[:, i] = vecr[:, i] / vecr[3][i] + return vecr[0], vecr[1], vecr[2] + + +def _vec_pad_ones(xs, ys, zs): + return np.array([xs, ys, zs, np.ones_like(xs)]) + + +def proj_transform(xs, ys, zs, M): + """ + Transform the points by the projection matrix *M*. + """ + vec = _vec_pad_ones(xs, ys, zs) + return _proj_transform_vec(vec, M) + + +transform = _api.deprecated( + "3.8", obj_type="function", name="transform", + alternative="proj_transform")(proj_transform) + + +def proj_transform_clip(xs, ys, zs, M): + """ + Transform the points by the projection matrix + and return the clipping result + returns txs, tys, tzs, tis + """ + vec = _vec_pad_ones(xs, ys, zs) + return _proj_transform_vec_clip(vec, M) + + +@_api.deprecated("3.8") +def proj_points(points, M): + return _proj_points(points, M) + + +def _proj_points(points, M): + return np.column_stack(_proj_trans_points(points, M)) + + +@_api.deprecated("3.8") +def proj_trans_points(points, M): + return _proj_trans_points(points, M) + + +def _proj_trans_points(points, M): + xs, ys, zs = zip(*points) + return proj_transform(xs, ys, zs, M) + + +@_api.deprecated("3.8") +def rot_x(V, alpha): + cosa, sina = np.cos(alpha), np.sin(alpha) + M1 = np.array([[1, 0, 0, 0], + [0, cosa, -sina, 0], + [0, sina, cosa, 0], + [0, 0, 0, 1]]) + return np.dot(M1, V) |