diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index 18e7c43932a9..147762d0152b 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -55,6 +55,7 @@ Multivariate Colormaps BivarColormap SegmentedBivarColormap BivarColormapFromImage + MultivarColormap Other classes ------------- diff --git a/doc/api/figure_api.rst b/doc/api/figure_api.rst index 5dd3adbfec9f..9d00bad65d93 100644 --- a/doc/api/figure_api.rst +++ b/doc/api/figure_api.rst @@ -60,6 +60,8 @@ Annotating :nosignatures: Figure.colorbar + Figure.colorbar_bivar + Figure.colorbar_multivar Figure.legend Figure.text Figure.suptitle @@ -254,6 +256,8 @@ Annotating :nosignatures: SubFigure.colorbar + SubFigure.colorbar_bivar + SubFigure.colorbar_multivar SubFigure.legend SubFigure.text SubFigure.suptitle diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index 97d9c576cc86..5885e747bed2 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -263,6 +263,8 @@ Colormapping clim colorbar + colorbar_bivar + colorbar_multivar gci sci get_cmap diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index 33ec8ef985e7..8cb5d64c1e75 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -398,12 +398,18 @@ def make_layout_margins(layoutgrids, fig, renderer, *, w_pad=0, h_pad=0, # make margin for colorbars. These margins go in the # padding margin, versus the margin for Axes decorators. for cbax in ax._colorbars: + if cbax._colorbar_info["type"] == "MultivarColorbar": + # a matplotlib.colorbar.MultivarColorbar object + fig = cbax.axes[0].get_figure(root=False) + tightbbox = cbax.get_tightbbox(renderer, for_layout_only=True) + cbbbox = tightbbox.transformed(fig.transFigure.inverted()) + else: + cbpos, cbbbox = get_pos_and_bbox(cbax, renderer) # note pad is a fraction of the parent width... pad = colorbar_get_pad(layoutgrids, cbax) # colorbars can be child of more than one subplot spec: cbp_rspan, cbp_cspan = get_cb_parent_spans(cbax) loc = cbax._colorbar_info['location'] - cbpos, cbbbox = get_pos_and_bbox(cbax, renderer) if loc == 'right': if cbp_cspan.stop == ss.colspan.stop: # only increase if the colorbar is on the right edge @@ -700,7 +706,7 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=Fa Parameters ---------- layoutgrids : dict - cbax : `~matplotlib.axes.Axes` + cbax : `~matplotlib.colorbar.MultiColorbars` or `~matplotlib.axes.Axes` Axes for the colorbar. renderer : `~matplotlib.backend_bases.RendererBase` subclass. The renderer to use. @@ -710,10 +716,18 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=Fa compress : bool Whether we're in compressed layout mode. """ + cb_info = cbax._colorbar_info + if cb_info["type"] == "MultivarColorbar": + multi_cbar = cbax + cbaxes = multi_cbar.axes + multi = True + else: + multi = False + cbaxes = [cbax] - parents = cbax._colorbar_info['parents'] + parents = cb_info['parents'] gs = parents[0].get_gridspec() - fig = cbax.get_figure(root=False) + fig = cbaxes[0].get_figure(root=False) trans_fig_to_subfig = fig.transFigure - fig.transSubfigure cb_rspans, cb_cspans = get_cb_parent_spans(cbax) @@ -721,11 +735,11 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=Fa cols=cb_cspans) pb = layoutgrids[gs].get_inner_bbox(rows=cb_rspans, cols=cb_cspans) - location = cbax._colorbar_info['location'] - anchor = cbax._colorbar_info['anchor'] - fraction = cbax._colorbar_info['fraction'] - aspect = cbax._colorbar_info['aspect'] - shrink = cbax._colorbar_info['shrink'] + location = cb_info['location'] + anchor = cb_info['anchor'] + fraction = cb_info['fraction'] + aspect = cb_info['aspect'] + shrink = cb_info['shrink'] # For colorbars with a single parent in compressed layout, # use the actual visual size of the parent axis after apply_aspect() @@ -745,14 +759,20 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=Fa # Keep the pb x-coordinates but use actual y-coordinates pb = Bbox.from_extents(pb.x0, actual_pos_fig.y0, pb.x1, actual_pos_fig.y1) - elif location in ('top', 'bottom'): + else: # location in ('top', 'bottom'): # For horizontal colorbars, use the actual parent bbox width # for colorbar sizing # Keep the pb y-coordinates but use actual x-coordinates pb = Bbox.from_extents(actual_pos_fig.x0, pb.y0, actual_pos_fig.x1, pb.y1) - cbpos, cbbbox = get_pos_and_bbox(cbax, renderer) + if multi: + tightbbox = martist._get_tightbbox_for_layout_only(multi_cbar, renderer) + cbbbox = tightbbox.transformed(fig.transFigure.inverted()) + cbpos = multi_cbar._get_original_position() + cbpos = cbpos.transformed(fig.transSubfigure - fig.transFigure) + else: + cbpos, cbbbox = get_pos_and_bbox(cbax, renderer) # Colorbar gets put at extreme edge of outer bbox of the subplotspec # It needs to be moved in by: 1) a pad 2) its "margin" 3) by @@ -763,6 +783,8 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=Fa pbcb = pb.shrunk(fraction, shrink).anchored(anchor, pb) # The colorbar is at the left side of the parent. Need # to translate to right (or left) + if multi: + pbcb.x1 = pbcb.x0 + cbbbox.width if location == 'right': lmargin = cbpos.x0 - cbbbox.x0 dx = bboxparent.x1 - pbcb.x0 + offset['right'] @@ -777,6 +799,8 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=Fa pbcb = pbcb.translated(dx, 0) else: # horizontal axes: pbcb = pb.shrunk(shrink, fraction).anchored(anchor, pb) + if multi: + pbcb.y1 = pbcb.y0 + cbbbox.height if location == 'top': bmargin = cbpos.y0 - cbbbox.y0 dy = bboxparent.y1 - pbcb.y0 + offset['top'] @@ -790,14 +814,19 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=Fa offset['bottom'] += cbbbox.height + cbpad pbcb = pbcb.translated(0, dy) - pbcb = trans_fig_to_subfig.transform_bbox(pbcb) - cbax.set_transform(fig.transSubfigure) - cbax._set_position(pbcb) - cbax.set_anchor(anchor) if location in ['bottom', 'top']: aspect = 1 / aspect - cbax.set_box_aspect(aspect) - cbax.set_aspect('auto') + if multi: + new_bboxs = multi_cbar._get_tight_packing_inside(pbcb) + else: + new_bboxs = [pbcb] + for cbax, new_bbox in zip(cbaxes, new_bboxs): + transformed_bbox = trans_fig_to_subfig.transform_bbox(new_bbox) + cbax.set_transform(fig.transSubfigure) + cbax._set_position(transformed_bbox) + cbax.set_anchor(anchor) + cbax.set_box_aspect(aspect) + cbax.set_aspect('auto') return offset diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 66770e426386..bedb88df21fa 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -6175,6 +6175,7 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, - (M, N): an image with scalar data. The values are mapped to colors using normalization and a colormap. See parameters *norm*, *cmap*, *vmin*, *vmax*. + - (K, M, N): if coupled with a cmap that supports K scalars - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), i.e. including transparency. @@ -6184,15 +6185,16 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, Out-of-range RGB(A) values are clipped. - %(cmap_doc)s + + %(multi_cmap_doc)s This parameter is ignored if *X* is RGB(A). - %(norm_doc)s + %(multi_norm_doc)s This parameter is ignored if *X* is RGB(A). - %(vmin_vmax_doc)s + %(multi_vmin_vmax_doc)s This parameter is ignored if *X* is RGB(A). @@ -6271,6 +6273,10 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, See :doc:`/gallery/images_contours_and_fields/image_antialiasing` for a discussion of image antialiasing. + When using a `~matplotlib.colors.BivarColormap` or + `~matplotlib.colors.MultivarColormap`, 'data' is the only valid + interpolation_stage. + alpha : float or array-like, optional The alpha blending value, between 0 (transparent) and 1 (opaque). If *alpha* is an array, the alpha blending values are applied pixel @@ -6376,6 +6382,7 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, if aspect is not None: self.set_aspect(aspect) + X = mcolorizer._ensure_multivariate_data(X, im.norm.n_components) im.set_data(X) im.set_alpha(alpha) if im.get_clip_path() is None: @@ -6531,9 +6538,10 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, Parameters ---------- - C : 2D array-like + C : 2D (M, N) or 3D (K, M, N) array-like The color-mapped values. Color-mapping is controlled by *cmap*, - *norm*, *vmin*, and *vmax*. + *norm*, *vmin*, and *vmax*. 3D arrays are supported only if the + cmap supports K channels. X, Y : array-like, optional The coordinates of the corners of quadrilaterals of a pcolormesh:: @@ -6578,11 +6586,11 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, See :doc:`/gallery/images_contours_and_fields/pcolormesh_grids` for more description. - %(cmap_doc)s + %(multi_cmap_doc)s - %(norm_doc)s + %(multi_norm_doc)s - %(vmin_vmax_doc)s + %(multi_vmin_vmax_doc)s %(colorizer_doc)s @@ -6657,8 +6665,19 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, if shading is None: shading = mpl.rcParams['pcolor.shading'] shading = shading.lower() - X, Y, C, shading = self._pcolorargs('pcolor', *args, shading=shading, - kwargs=kwargs) + + mcolorizer.ColorizingArtist._check_exclusionary_keywords(colorizer, + vmin=vmin, vmax=vmax, + norm=norm, cmap=cmap) + if colorizer is None: + colorizer = mcolorizer.Colorizer(cmap=cmap, norm=norm) + + C = mcolorizer._ensure_multivariate_data(args[-1], + colorizer.cmap.n_variates) + + X, Y, C, shading = self._pcolorargs('pcolor', *args[:-1], C, + shading=shading, kwargs=kwargs) + linewidths = (0.25,) if 'linewidth' in kwargs: kwargs['linewidths'] = kwargs.pop('linewidth') @@ -6693,9 +6712,7 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, coords = stack([X, Y], axis=-1) collection = mcoll.PolyQuadMesh( - coords, array=C, cmap=cmap, norm=norm, colorizer=colorizer, - alpha=alpha, **kwargs) - collection._check_exclusionary_keywords(colorizer, vmin=vmin, vmax=vmax) + coords, array=C, colorizer=colorizer, alpha=alpha, **kwargs) collection._scale_norm(norm, vmin, vmax) coords = coords.reshape(-1, 2) # flatten the grid structure; keep x, y @@ -6733,6 +6750,7 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, - (M, N) or M*N: a mesh with scalar data. The values are mapped to colors using normalization and a colormap. See parameters *norm*, *cmap*, *vmin*, *vmax*. + - (K, M, N): if coupled with a cmap that supports K scalars - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), i.e. including transparency. @@ -6767,11 +6785,11 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, expanded as needed into the appropriate 2D arrays, making a rectangular grid. - %(cmap_doc)s + %(multi_cmap_doc)s - %(norm_doc)s + %(multi_norm_doc)s - %(vmin_vmax_doc)s + %(multi_vmin_vmax_doc)s %(colorizer_doc)s @@ -6897,7 +6915,16 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, shading = mpl._val_or_rc(shading, 'pcolor.shading').lower() kwargs.setdefault('edgecolors', 'none') - X, Y, C, shading = self._pcolorargs('pcolormesh', *args, + mcolorizer.ColorizingArtist._check_exclusionary_keywords(colorizer, + vmin=vmin, vmax=vmax, + norm=norm, cmap=cmap) + if colorizer is None: + colorizer = mcolorizer.Colorizer(cmap=cmap, norm=norm) + + C = mcolorizer._ensure_multivariate_data(args[-1], + colorizer.cmap.n_variates) + + X, Y, C, shading = self._pcolorargs('pcolormesh', *args[:-1], C, shading=shading, kwargs=kwargs) coords = np.stack([X, Y], axis=-1) @@ -6905,8 +6932,7 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, collection = mcoll.QuadMesh( coords, antialiased=antialiased, shading=shading, - array=C, cmap=cmap, norm=norm, colorizer=colorizer, alpha=alpha, **kwargs) - collection._check_exclusionary_keywords(colorizer, vmin=vmin, vmax=vmax) + array=C, colorizer=colorizer, alpha=alpha, **kwargs) collection._scale_norm(norm, vmin, vmax) coords = coords.reshape(-1, 2) # flatten the grid structure; keep x, y @@ -8848,6 +8874,9 @@ def matshow(self, Z, **kwargs): """ Z = np.asanyarray(Z) + if Z.ndim != 2: + if Z.ndim != 3 or Z.shape[2] not in (1, 3, 4): + raise TypeError(f"Invalid shape {Z.shape} for image data") kw = {'origin': 'upper', 'interpolation': 'nearest', 'aspect': 'equal', # (already the imshow default) diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 1c3e1e560d07..880523112041 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -12,7 +12,13 @@ from matplotlib.collections import ( QuadMesh, ) from matplotlib.colorizer import Colorizer -from matplotlib.colors import Colormap, Normalize +from matplotlib.colors import ( + Colormap, + BivarColormap, + MultivarColormap, + Norm, + Normalize, +) from matplotlib.container import ( BarContainer, PieContainer, ErrorbarContainer, StemContainer) from matplotlib.contour import ContourSet, QuadContourSet @@ -500,14 +506,14 @@ class Axes(_AxesBase): def imshow( self, X: ArrayLike | PIL.Image.Image, - cmap: str | Colormap | None = ..., - norm: str | Normalize | None = ..., + cmap: str | Colormap | BivarColormap | MultivarColormap | None = ..., + norm: str | Norm | None = ..., *, aspect: Literal["equal", "auto"] | float | None = ..., interpolation: str | None = ..., alpha: float | ArrayLike | None = ..., - vmin: float | None = ..., - vmax: float | None = ..., + vmin: float | tuple[float, ...] | None = ..., + vmax: float | tuple[float, ...] | None = ..., colorizer: Colorizer | None = ..., origin: Literal["upper", "lower"] | None = ..., extent: tuple[float, float, float, float] | None = ..., @@ -524,10 +530,10 @@ class Axes(_AxesBase): *args: ArrayLike, shading: Literal["flat", "nearest", "auto"] | None = ..., alpha: float | None = ..., - norm: str | Normalize | None = ..., - cmap: str | Colormap | None = ..., - vmin: float | None = ..., - vmax: float | None = ..., + norm: str | Norm | None = ..., + cmap: str | Colormap | BivarColormap | MultivarColormap | None = ..., + vmin: float | tuple[float, ...] | None = ..., + vmax: float | tuple[float, ...] | None = ..., colorizer: Colorizer | None = ..., data=..., **kwargs @@ -536,10 +542,10 @@ class Axes(_AxesBase): self, *args: ArrayLike, alpha: float | None = ..., - norm: str | Normalize | None = ..., - cmap: str | Colormap | None = ..., - vmin: float | None = ..., - vmax: float | None = ..., + norm: str | Norm | None = ..., + cmap: str | Colormap | BivarColormap | MultivarColormap | None = ..., + vmin: float | tuple[float, ...] | None = ..., + vmax: float | tuple[float, ...] | None = ..., colorizer: Colorizer | None = ..., shading: Literal["flat", "nearest", "gouraud", "auto"] | None = ..., antialiased: bool = ..., diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 508b744ca04d..3e7dbcf01ef0 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3267,7 +3267,8 @@ def press_zoom(self, event): # to the edge of the Axes bbox in the other dimension. To do that we # store the orientation of the colorbar for later. parent_ax = axes[0] - if hasattr(parent_ax, "_colorbar"): + if hasattr(parent_ax, "_colorbar") and hasattr(parent_ax._colorbar, + "orientation"): cbar = parent_ax._colorbar.orientation else: cbar = None diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index c9e04a70b356..95150115fc56 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -2386,6 +2386,8 @@ def set_array(self, A): h, w = height, width ok_shapes = [(h, w, 3), (h, w, 4), (h, w), (h * w,)] if A is not None: + if hasattr(self, 'norm'): + A = mcolorizer._ensure_multivariate_data(A, self.norm.n_components) shape = np.shape(A) if shape not in ok_shapes: raise ValueError( @@ -2643,7 +2645,7 @@ def _get_unmasked_polys(self): mask = (mask[0:-1, 0:-1] | mask[1:, 1:] | mask[0:-1, 1:] | mask[1:, 0:-1]) arr = self.get_array() if arr is not None: - arr = np.ma.getmaskarray(arr) + arr = self._getmaskarray(arr) if arr.ndim == 3: # RGB(A) case mask |= np.any(arr, axis=-1) diff --git a/lib/matplotlib/collections.pyi b/lib/matplotlib/collections.pyi index ecd969cfacc6..e30ed77cafc9 100644 --- a/lib/matplotlib/collections.pyi +++ b/lib/matplotlib/collections.pyi @@ -7,7 +7,12 @@ from numpy.typing import ArrayLike, NDArray from . import colorizer, transforms from .backend_bases import MouseEvent from .artist import Artist -from .colors import Normalize, Colormap +from .colors import ( + Colormap, + BivarColormap, + MultivarColormap, + Norm, +) from .lines import Line2D from .path import Path from .patches import Patch @@ -29,8 +34,8 @@ class Collection(colorizer.ColorizingArtist): antialiaseds: bool | Sequence[bool] | None = ..., offsets: tuple[float, float] | Sequence[tuple[float, float]] | None = ..., offset_transform: transforms.Transform | None = ..., - norm: Normalize | None = ..., - cmap: Colormap | None = ..., + norm: Norm | None = ..., + cmap: Colormap | BivarColormap | MultivarColormap | None = ..., colorizer: colorizer.Colorizer | None = ..., pickradius: float = ..., hatch: str | None = ..., diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index 0a89865d7837..b0fce56ed56b 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -14,6 +14,7 @@ import logging import numpy as np +from collections.abc import Sequence import matplotlib as mpl from matplotlib import _api, cbook, collections, colors, contour, ticker @@ -27,6 +28,8 @@ _log = logging.getLogger(__name__) + + _docstring.interpd.register( _make_axes_kw_doc=""" location : None or {'left', 'right', 'top', 'bottom'} @@ -113,7 +116,79 @@ spacing : {'uniform', 'proportional'} For discrete colorbars (`.BoundaryNorm` or contours), 'uniform' gives each color the same space; 'proportional' makes the space proportional to the - data interval.""") + data interval.""", + _make_bivar_axes_kw_doc=""" +location : None or {'left', 'right', 'top', 'bottom'} + The location, relative to the parent Axes, where the colorbar Axes + is created. Also determines the position of the ticks and labels, + which will favour being away from the parent axes. + +fraction : float, default: 0.15 + Fraction of original Axes to use for colorbar. + +shrink : float, default: 1.0 + Fraction by which to multiply the size of the colorbar. + +pad : float, default: 0.05 if left or right, 0.15 if top or bottom + Fraction of original Axes between colorbar and new image Axes. + +anchor : (float, float), optional + The anchor point of the colorbar Axes. + Defaults to (0.0, 0.5) if left or right; (0.5, 1.0) if top or bottom. + +panchor : (float, float), or *False*, optional + The anchor point of the colorbar parent Axes. If *False*, the parent + axes' anchor will be unchanged. + Defaults to (1.0, 0.5) if left or right; (0.5, 0.0) if top or bottom. +""", + _bivar_colormap_kw_doc=""" +ticklocations : tuple describing the ticklocation of the y and x axis + The first element must be {'auto', 'left', 'right'} + The second element must be {'auto', 'top', 'bottom''}. + If 'auto', the ticklocations are determined by the *location*. + +""", + _make_multivar_axes_kw_doc=""" +location : None or {'left', 'right', 'top', 'bottom'} + The location, relative to the parent Axes, where the colorbar Axes + is created. It also determines the *orientation* of the colorbar + (colorbars on the left and right are vertical, colorbars at the top + and bottom are horizontal). If None, the location will come from the + *orientation* if it is set (vertical colorbars on the right, horizontal + ones at the bottom), or default to 'right' if *orientation* is unset. + +orientation : None or {'vertical', 'horizontal'} + The orientation of the colorbars. It is preferable to set the *location* + of the colorbar, as that also determines the *orientation*; passing + incompatible values for *location* and *orientation* raises an exception. + +fraction : float, default: 0.15 + Fraction of original Axes to use for colorbars. + +shrink : float, default: 1.0 + Fraction by which to multiply the size. + +aspect : float, default: 20 + Ratio of long to short dimensions. + +pad : float, default: 0.05 if vertical, 0.15 if horizontal + Fraction of original Axes between colorbars and new image Axes. + +anchor : (float, float), optional + The anchor point of the colorbars Axes. + Defaults to (0.0, 0.5) if vertical; (0.5, 1.0) if horizontal. + +panchor : (float, float), or *False*, optional + The anchor point of the colorbars parent Axes. If *False*, the parent + axes' anchor will be unchanged. + Defaults to (1.0, 0.5) if vertical; (0.5, 0.0) if horizontal. + +major_pad : float + Spacing between colorbars along the long axis + +major_pad : float + Spacing between colorbars along the short axis +""") def _set_ticks_on_axis_warn(*args, **kwargs): @@ -511,7 +586,6 @@ def update_normal(self, mappable=None): if self.mappable.norm != self.norm: self.norm = self.mappable.norm self._reset_locator_formatter_scale() - self._draw_all() if isinstance(self.mappable, contour.ContourSet): CS = self.mappable @@ -1043,6 +1117,8 @@ def remove(self): try: ax = self.mappable.axes + if ax is None: + return except AttributeError: return try: @@ -1337,9 +1413,584 @@ def drag_pan(self, button, key, x, y): ColorbarBase = Colorbar # Backcompat API +class BivarColorbar: + r""" + Draw a bivariate colorbar in an existing Axes. + + Typically, bivariate colorbars are created using `.Figure.colorbar_bivar` + and associated with `.ColorizingArtist`\s (such as an + `.AxesImage` generated via `~.axes.Axes.imshow`). + + Unlike `Colorbar`, `BivarColorbar` does not support + customizing the ticks, and ticks must be customized on the axes instead. + """ + + n_rasterize = 256 # rasterize solids if number of colors >= n_rasterize + + def __init__( + self, ax, mappable, + *, + alpha=None, + location=None, + ticklocations=('auto', 'auto'), + aspect=1.0, + ): + """ + Parameters + ---------- + ax : `~matplotlib.axes.Axes` + The `~.axes.Axes` instance in which the colorbar is drawn. + + mappable : `.ColorizingArtist` + The mappable whose colormap and norm will be used. + + alpha : float + The colorbars transparency between 0 (transparent) and 1 (opaque). + + location : None or {'left', 'right', 'top', 'bottom'} + Set the bivariate colorbars's location + + Other Parameters + ---------------- + ticklocations : tuple describing the ticklocation of the y and x axis + The first element must be {'auto', 'left', 'right'} + The second element must be {'auto', 'top', 'bottom''}. + If 'auto', the ticklocations are determined by the *location*. + """ + + self.ax = ax + self.location = location + if isinstance(mappable, mpl.colorizer.Colorizer): + mappable = mcolorizer.ColorizingArtist(mappable) + + self.mappable = mappable + self.aspect = aspect + self.colorizer = mappable.colorizer + + mappable.colorbar = self + mappable.colorbar_cid = mappable.callbacks.connect( + 'changed', self.update_normals) + + ticklocations = list(ticklocations) + if len(ticklocations) != 2: + raise ValueError("ticklocations must be a tuple of length 2") + _api.check_in_list(['auto', 'left', 'right'], + ticklocation=ticklocations[0]) + _api.check_in_list(['auto', 'top', 'bottom'], + ticklocation=ticklocations[1]) + + location_ticklocs = _get_bivar_ticklocations_from_location(location) + for i in range(2): + if ticklocations[i] == 'auto': + ticklocations[i] = location_ticklocs[i] + self.ticklocations = ticklocations + self.ax.yaxis.set(label_position=self.ticklocations[0], + ticks_position=self.ticklocations[0]) + self.ax.xaxis.set(label_position=self.ticklocations[1], + ticks_position=self.ticklocations[1]) + + self._image = None + self.alpha = None + # Call set_alpha to handle array-like alphas properly + self.set_alpha(alpha) + + self.update_normals() # also calls _draw_all() + self.ax._colorbar = self + self._interactive_funcs = ["_get_view", "_set_view", + "_set_view_from_bbox", "drag_pan"] + for x in self._interactive_funcs: + setattr(self.ax, x, getattr(self, x)) + self.ax.cla = self._cbar_cla + + """self._colorbar_xax_cid = ax.callbacks.connect( + 'xlim_changed', self._x_limits_changed) + self._colorbar_yax_cid = ax.callbacks.connect( + 'ylim_changed', self._y_limits_changed) + """ + + @property + def aspect(self): + return self._aspect + + @aspect.setter + def aspect(self, aspect): + aspect = float(aspect) + self._aspect = aspect + self.ax.set_box_aspect(aspect) + if hasattr(self.ax, "_colorbar_info"): + self.ax._colorbar_info["aspect"] = aspect + + def _draw_all(self): + """ + Calculate any free parameters based on the current cmap and norm, + and do all the drawing. + """ + + # transform from 0-1 to vmin-vmax: + if self.mappable.get_array() is not None: + self.mappable.autoscale_None() + if not self.colorizer.norm.scaled(): + # If we still aren't scaled after autoscaling, use 0, 1 as default + self._set_view([0, 1, 0, 1]) + n, m = self.colorizer.cmap.N, self.colorizer.cmap.M + x = self.colorizer.norm.norms[1].inverse(np.linspace(0, 1, m + 1)) + y = self.colorizer.norm.norms[0].inverse(np.linspace(0, 1, n + 1)) + X, Y = np.meshgrid(x, y) + + if self.alpha is None: + lut = self.colorizer.cmap.lut + else: + lut = np.copy(self.colorizer.cmap.lut) + lut[:, :, 3] *= self.alpha + + if n * m > self.n_rasterize: + rasterized = True + else: + rasterized = False + + if self._image is not None: + self._image.remove() + self._image = self.ax.pcolormesh( + X, Y, lut, + alpha=self.alpha, + rasterized=rasterized, + edgecolors='none', shading='flat') + # Apply norm scaling (supports LogNorm etc.) + if getattr(self.colorizer.norm.norms[0], '_scale', None): + # use the norm's scale (if it exists and is not None): + self.ax.set_yscale(self.colorizer.norm.norms[0]._scale) + else: + # fallback for custom norms, or NoNorm() + self.ax.set_yscale( + 'function', + functions=( + self.colorizer.norm.norms[0], + self.colorizer.norm.norms[0].inverse + ) + ) + + if getattr(self.colorizer.norm.norms[1], '_scale', None): + # use the norm's scale (if it exists and is not None): + self.ax.set_xscale(self.colorizer.norm.norms[1]._scale) + else: + # fallback for custom norms, or NoNorm() + self.ax.set_xscale( + 'function', + functions=( + self.colorizer.norm.norms[1], + self.colorizer.norm.norms[1].inverse + ) + ) + # Manually set limits (image is in Axes coordinates) + extent = [ + self.colorizer.norm.norms[1].vmin, + self.colorizer.norm.norms[1].vmax, + self.colorizer.norm.norms[0].vmin, + self.colorizer.norm.norms[0].vmax, + ] + self.ax.set_ylim(extent[2:4]) + self.ax.set_xlim(extent[0:2]) + + def set_xlabel(self, label): + self.ax.set_xlabel(label) + + def set_ylabel(self, label): + self.ax.set_ylabel(label) + + @property + def xaxis(self): + return self.ax.xaxis + + @property + def yaxis(self): + return self.ax.yaxis + + def update_normals(self, mappable=None): + self.set_alpha(self.mappable.get_alpha()) + #self._draw_all() + + def set_alpha(self, alpha): + """ + Set the transparency between 0 (transparent) and 1 (opaque). + + If an array is provided, *alpha* will be set to None to use the + transparency values associated with the colormap. + """ + self.alpha = None if isinstance(alpha, np.ndarray) else alpha + self._draw_all() + + def remove(self): + """ + Remove this colorbar from the figure. + + If the colorbar was created with ``use_gridspec=True`` the previous + gridspec is restored. + """ + if hasattr(self.ax, '_colorbar_info'): + parents = self.ax._colorbar_info['parents'] + for a in parents: + if self.ax in a._colorbars: + a._colorbars.remove(self.ax) + self.ax.remove() + self.mappable.callbacks.disconnect(self.mappable.colorbar_cid) + self.mappable.colorbar = None + self.mappable.colorbar_cid = None + # self.ax.callbacks.disconnect(self._colorbar_xax_cid) + # self.ax.callbacks.disconnect(self._colorbar_yax_cid) + + ax = self.mappable.axes + if ax is None: + return + try: + subplotspec = self.ax.get_subplotspec().get_gridspec()._subplot_spec + except AttributeError: # use_gridspec was False + pos = ax.get_position(original=True) + ax._set_position(pos) + else: # use_gridspec was True + ax.set_subplotspec(subplotspec) + + def _get_view(self): + ynorm, xnorm = self.colorizer.norm.norms + return ynorm.vmin, ynorm.vmax, xnorm.vmin, xnorm.vmax + + def _set_view(self, view): + ynorm, xnorm = self.colorizer.norm.norms + if (view[0] != ynorm.vmin + or view[1] != ynorm.vmax + or view[2] != xnorm.vmin + or view[3] != xnorm.vmax): + with self.colorizer.norm.callbacks.blocked(): + ynorm.vmin, ynorm.vmax, xnorm.vmin, xnorm.vmax = view + self.colorizer.norm._changed() + + def _set_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + new_xbound, new_ybound = self.ax._prepare_view_from_bbox( + bbox, direction=direction, mode=mode, twinx=twinx, twiny=twiny) + ynorm, xnorm = self.colorizer.norm.norms + ynorm.vmin, ynorm.vmax = new_ybound + xnorm.vmin, xnorm.vmax = new_xbound + + def drag_pan(self, button, key, x, y): + points = self.ax._get_pan_points(button, key, x, y) + if points is not None: + ynorm, xnorm = self.colorizer.norm.norms + xnorm.vmin, xnorm.vmax = points[:, 0] + ynorm.vmin, ynorm.vmax = points[:, 1] + + def _cbar_cla(self): + """Function to clear the interactive colorbar state.""" + for x in self._interactive_funcs: + delattr(self.ax, x) + # We now restore the old cla() back and can call it directly + del self.ax.cla + self.ax.cla() + + +class MultivarColorbar(Sequence): + r""" + Draw a multivariate colorbar in existing Axes. + + Typically, multivariate colorbars are created using `.Figure.colorbar_multivar` + and associated with `.ColorizingArtist`\s (such as an + `.AxesImage` generated via `~.axes.Axes.imshow`). + + MultivarColorbar is iterable, and the constituent Colorbar objects can be accessed + by index. + """ + + def __init__(self, axes, mappable=None, **kwargs): + """ + Parameters + ---------- + axes : list of `~matplotlib.axes.Axes` + The `~.axes.Axes` instances in which the colorbars are drawn. + + mappable : `.ColorizingArtist` + The mappable whose colormap and norm will be used. + + alpha : float + The colorbars transparency between 0 (transparent) and 1 (opaque). + + location : None or {'left', 'right', 'top', 'bottom'} + Set the multivariate colorbars's location + + Other Parameters + ---------------- + orientation : None or {'vertical', 'horizontal'} + If None, use the value determined by *location*. If both + *orientation* and *location* are None then defaults to 'vertical'. + + ticklocation : {'auto', 'left', 'right', 'top', 'bottom'} + The location of the colorbar ticks. The *ticklocation* must match + *orientation*. For example, a horizontal colorbar can only have ticks + at the top or the bottom. If 'auto', the ticks will be the same as + *location*, so a colorbar to the left will have ticks to the left. If + *location* is None, the ticks will be at the bottom for a horizontal + colorbar and at the right for a vertical. + """ + + if isinstance(mappable, mpl.colorizer.Colorizer): + mappable = mcolorizer.ColorizingArtist(mappable) + + self.mappable = mappable + self.colorizer = mappable.colorizer + cmap = self.colorizer.cmap + norm = self.colorizer.norm + n = cmap.n_variates + + self._colorbars = [Colorbar(axes[i], + norm=norm.norms[i], + cmap=cmap[i], + **kwargs, + ) for i in range(n)] + + mappable.colorbar = self + mappable.colorbar_cid = mappable.callbacks.connect( + 'changed', self.update_normals) + + self.axes = axes + + def _set_colorbar_info(self, colorbar_info): + self._colorbar_info = colorbar_info + if colorbar_info is not None: + parents = colorbar_info['parents'] + for a in parents: + a._colorbars.append(self) + + def update_normals(self, mappable=None): + [c.update_normal() for c in self._colorbars] + + def remove(self): + if hasattr(self, '_colorbar_info'): + parents = self._colorbar_info['parents'] + for a in parents: + if self in a._colorbars: + a._colorbars.remove(self) + + for ax in self.axes: + ax.remove() + + self.mappable.callbacks.disconnect(self.mappable.colorbar_cid) + self.mappable.colorbar = None + self.mappable.colorbar_cid = None + + try: + ax = self.mappable.axes + if ax is None: + return + except AttributeError: + return + try: + subplotspec = self.ax.get_subplotspec().get_gridspec()._subplot_spec + except AttributeError: # use_gridspec was False + pos = ax.get_position(original=True) + ax._set_position(pos) + else: # use_gridspec was True + ax.set_subplotspec(subplotspec) + + def __getitem__(self, index): + return self._colorbars[index] + + def __len__(self): + return len(self._colorbars) + + def get_tightbbox(self, renderer=None, for_layout_only=False): + if for_layout_only and hasattr(self, '_colorbar_info'): + # figure out the maximum size of the tight boxes + # then multiply that up to the correct size + bounds = self.axes[0].get_tightbbox(renderer=renderer, + for_layout_only=True).bounds + x0, y0, width, height = bounds + for ax in self.axes[1:]: + bb = ax.get_tightbbox(renderer=renderer, + for_layout_only=True).bounds + x0 = min(x0, bb[0]) + y0 = min(y0, bb[1]) + width = max(width, bb[2]) + height = max(height, bb[3]) + n_major = self._colorbar_info["n_major"] + n_minor = self._colorbar_info["n_minor"] + + maj_p = self._colorbar_info["major_pad"] + min_p = self._colorbar_info["minor_pad"] + + if self._colorbar_info["orientation"] == 'vertical': + # height *= n_major + width *= n_minor + min_p * (n_minor - 1) + height *= n_major + maj_p * (n_major - 1) + else: + width *= n_major + maj_p * (n_major - 1) + height *= n_minor + min_p * (n_minor - 1) + + bbox = mtransforms.Bbox.from_bounds(x0, y0, width, height) + else: + # calculate the minimum size of the bbox that + # fits the current distribution of colormaps + # colorbars in the required grid + bbox = self.axes[0].get_tightbbox(renderer=renderer, + for_layout_only=for_layout_only) + for ax in self.axes[1:]: + bb = ax.get_tightbbox(renderer=renderer, + for_layout_only=for_layout_only) + if bb.x0 < bbox.x0: + bbox.x0 = bb.x0 + if bb.y0 < bbox.y0: + bbox.y0 = bb.y0 + if bb.x1 > bbox.x1: + bbox.x1 = bb.x1 + if bb.y1 > bbox.y1: + bbox.y1 = bb.y1 + return bbox + + def _get_tight_packing_inside(self, bbox): + """ + Positions the colorbars in a grid contained in bbox + + This function relates to get_tightbbox(for_layout_only=True) + It requires that self._colorbar_info exists + + It will position colorbars so that the bboxes + of each cmponent spans the assigned bbox + + The procedure for vertical colorbars is as follows: + 1. Calculate the height of each colorbar, based on n_minor and the padding + 2. From the height calculate the width using the aspect + 3. With both the width and height known, position the colorbars equispaced. + + Horizontal colorbars follow the same procedure but the height and width + are swapped. + """ + x_b, y_b, width_b, height_b = bbox.bounds # b for box + if hasattr(self, '_colorbar_info'): + n_major = self._colorbar_info["n_major"] + n_minor = self._colorbar_info["n_minor"] + aspect = self._colorbar_info["aspect"] + major_pad = self._colorbar_info["major_pad"] + # pad_minor = 0.6 + if self._colorbar_info["orientation"] == 'vertical': + if n_major > 1: + bar_height = height_b * (1 - major_pad) / n_major + y_spacing = (height_b - bar_height * n_major)/(n_major-1) + else: + bar_height = height_b + y_spacing = 0 + bar_width = bar_height / aspect + if n_minor > 1: + x_spacing = (width_b - bar_width * n_minor)/(n_minor) + else: + x_spacing = 0 + else: + if n_major > 1: + bar_width = width_b * (1 - major_pad) / n_major + x_spacing = (width_b - bar_width * n_major)/(n_major-1) + else: + bar_width = width_b + x_spacing = 0 + bar_height = bar_width / aspect + if n_minor > 1: + y_spacing = (height_b - bar_height * n_minor)/(n_minor) + else: + y_spacing = 0 + + x_step = bar_width + x_spacing + y_step = bar_height + y_spacing + bboxes = [] + if self._colorbar_info["orientation"] == 'vertical': + for i in range(n_minor): + for j in range(n_major): + bboxes.append(mtransforms.Bbox([[x_b + i * x_step, + y_b + j * y_step], + [x_b + i * x_step + bar_width, + y_b + j * y_step + bar_height], + ])) + else: + for i in range(n_minor): + for j in range(n_major): + bboxes.append(mtransforms.Bbox([[x_b + j * x_step, + y_b + i * y_step], + [x_b + j * x_step + bar_width, + y_b + i * y_step + bar_height], + ])) + return bboxes + else: + raise ValueError("_set_tight_packing cannot be called " + "unless the MultivarColorbar was created " + "by fig.multicolorbar(mappable).") + + def _get_original_position(self): + # comparable to axes.get_position(original=True) + bbox = self.axes[0].get_position(original=True) + for ax in self.axes[1:]: + bb = ax.get_position(original=True) + if bb.x0 < bbox.x0: + bbox.x0 = bb.x0 + if bb.y0 < bbox.y0: + bbox.y0 = bb.y0 + if bb.x1 > bbox.x1: + bbox.x1 = bb.x1 + if bb.y1 > bbox.y1: + bbox.y1 = bb.y1 + return bbox + + @staticmethod + def _subdivide_bbox(bbox, + n_major, + n_minor, + orientation, + major_pad=0.1, + minor_pad=0.6, + ): + major_width = (1-major_pad) / n_major + if n_major > 1: + major_space = major_pad / (n_major - 1) + else: + major_space = 0 + major_split = np.empty(2 * (n_major - 1)) + major_split[0::2] = major_width + major_split[1::2] = major_space + major_split = np.cumsum(major_split) + + minor_width = (1-minor_pad) / n_minor + if n_minor > 1: + minor_space = minor_pad / (n_minor - 1) + else: + minor_space = 0 + minor_split = np.empty(2 * (n_minor - 1)) + minor_split[0::2] = minor_width + minor_split[1::2] = minor_space + minor_split = np.cumsum(minor_split) + + # make the colorbar bboxes + sub_bboxs = [] + v = orientation == "vertical" + bboxs = bbox.splitx(*minor_split) if v else bbox.splity(*minor_split)[::-1] + for i, bboxs_i in enumerate(bboxs): + if i % 2 == 1: + continue + bboxs_j = bboxs_i.splity(*major_split + )[::-1] if v else bboxs_i.splitx(*major_split) + for j, sub_bbox in enumerate(bboxs_j): + if j % 2 == 1: + continue + sub_bboxs.append(sub_bbox) + return sub_bboxs + + def _normalize_location_orientation(location, orientation): if location is None: location = _get_ticklocation_from_orientation(orientation) + loc_settings = _normalize_location(location) + loc_settings["orientation"] = _get_orientation_from_location(location) + if orientation is not None and orientation != loc_settings["orientation"]: + # Allow the user to pass both if they are consistent. + raise TypeError("location and orientation are mutually exclusive") + return loc_settings + + +def _normalize_location(location): + if location is None: + location = 'right' loc_settings = _api.getitem_checked({ "left": {"location": "left", "anchor": (1.0, 0.5), "panchor": (0.0, 0.5), "pad": 0.10}, @@ -1350,10 +2001,6 @@ def _normalize_location_orientation(location, orientation): "bottom": {"location": "bottom", "anchor": (0.5, 1.0), "panchor": (0.5, 0.0), "pad": 0.15}, }, location=location) - loc_settings["orientation"] = _get_orientation_from_location(location) - if orientation is not None and orientation != loc_settings["orientation"]: - # Allow the user to pass both if they are consistent. - raise TypeError("location and orientation are mutually exclusive") return loc_settings @@ -1369,39 +2016,18 @@ def _get_ticklocation_from_orientation(orientation): orientation=orientation) -@_docstring.interpd -def make_axes(parents, location=None, orientation=None, fraction=0.15, - shrink=1.0, aspect=20, **kwargs): - """ - Create an `~.axes.Axes` suitable for a colorbar. +def _get_bivar_ticklocations_from_location(location): + loc_0 = _api.getitem_checked( + {None: "right", "left": "left", "right": "right", + "top": "left", "bottom": "left"}, location=location) + loc_1 = _api.getitem_checked( + {None: "bottom", "left": "bottom", "right": "bottom", + "top": "top", "bottom": "bottom"}, location=location) + return (loc_0, loc_1) - The Axes is placed in the figure of the *parents* Axes, by resizing and - repositioning *parents*. - Parameters - ---------- - parents : `~matplotlib.axes.Axes` or iterable or `numpy.ndarray` of `~.axes.Axes` - The Axes to use as parents for placing the colorbar. - %(_make_axes_kw_doc)s - - Returns - ------- - cax : `~matplotlib.axes.Axes` - The child Axes. - kwargs : dict - The reduced keyword dictionary to be passed when creating the colorbar - instance. - """ - loc_settings = _normalize_location_orientation(location, orientation) - # put appropriate values into the kwargs dict for passing back to - # the Colorbar class - kwargs['orientation'] = loc_settings['orientation'] - location = kwargs['ticklocation'] = loc_settings['location'] - - anchor = kwargs.pop('anchor', loc_settings['anchor']) - panchor = kwargs.pop('panchor', loc_settings['panchor']) - aspect0 = aspect - # turn parents into a list if it is not already. Note we cannot +def _normalize_parents(parents): + # Turn parents into a list if it is not already. Note we cannot # use .flatten or .ravel as these copy the references rather than # reuse them, leading to a memory leak if isinstance(parents, np.ndarray): @@ -1413,18 +2039,20 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, fig = parents[0].get_figure() - pad0 = 0.05 if fig.get_constrained_layout() else loc_settings['pad'] - pad = kwargs.pop('pad', pad0) - if not all(fig is ax.get_figure() for ax in parents): raise ValueError('Unable to create a colorbar Axes as not all ' 'parents share the same figure.') + return parents, fig + +def _get_bbox_shrink_parents(parents, location, fraction, + pad, shrink, anchor, panchor): + """Shrink parents and get the bbox for the colorbar.""" # take a bounding box around all of the given Axes - parents_bbox = mtransforms.Bbox.union( + pb = mtransforms.Bbox.union( [ax.get_position(original=True).frozen() for ax in parents]) - pb = parents_bbox + # calculate the new bounding boxes if location in ('left', 'right'): if location == 'left': pbcb, _, pb1 = pb.splitx(fraction, fraction + pad) @@ -1438,64 +2066,164 @@ def make_axes(parents, location=None, orientation=None, fraction=0.15, pb1, _, pbcb = pb.splity(1 - fraction - pad, 1 - fraction) pbcb = pbcb.shrunk(shrink, 1.0).anchored(anchor, pbcb) - # define the aspect ratio in terms of y's per x rather than x's per y - aspect = 1.0 / aspect - # define a transform which takes us from old axes coordinates to # new axes coordinates - shrinking_trans = mtransforms.BboxTransform(parents_bbox, pb1) + shrinking_trans = mtransforms.BboxTransform(pb, pb1) - # transform each of the Axes in parents using the new transform for ax in parents: new_posn = shrinking_trans.transform(ax.get_position(original=True)) new_posn = mtransforms.Bbox(new_posn) ax._set_position(new_posn) if panchor is not False: ax.set_anchor(panchor) + return pbcb - cax = fig.add_axes(pbcb, label="") - for a in parents: - a._colorbars.append(cax) # tell the parent it has a colorbar - cax._colorbar_info = dict( + +def _make_axes_get_pbcb(loc_settings, fraction, shrink, aspect, kwargs, parents): + + location = loc_settings['location'] + kwargs['location'] = location + + anchor = kwargs.pop('anchor', loc_settings['anchor']) + panchor = kwargs.pop('panchor', loc_settings['panchor']) + parents, fig = _normalize_parents(parents) + + pad0 = 0.05 if fig.get_constrained_layout() else loc_settings['pad'] + pad = kwargs.pop('pad', pad0) + + # shrink the parents and get the bbox for the colorbar + pbcb = _get_bbox_shrink_parents(parents, location, fraction, + pad, shrink, anchor, panchor) + colorbar_info = dict( parents=parents, location=location, shrink=shrink, anchor=anchor, panchor=panchor, fraction=fraction, - aspect=aspect0, + aspect=aspect, pad=pad) - # and we need to set the aspect ratio by hand... - cax.set_anchor(anchor) + + return fig, pbcb, colorbar_info + + +def _make_axes_helper(loc_settings, fraction, shrink, aspect, kwargs, parents): + """ + Help function for `make_axes` and `make_bivar_axes`. + + `make_axes` and `make_bivar_axes` are identical + except for the fact that `make_axes` also deals with: + 1. the aspect + 2. orientation. + + `make_bivar_axes` on the other hand has no concept of orientation + and the aspect is handled by the `BivarColormap` instance, not during + creation of the axes. + """ + fig, pbcb, colorbar_info = _make_axes_get_pbcb(loc_settings, fraction, shrink, + aspect, kwargs, parents) + cax = fig.add_axes(pbcb, label="") + for a in colorbar_info["parents"]: + a._colorbars.append(cax) # tell the parent it has a colorbar + + cax._colorbar_info = colorbar_info + cax.set_anchor(colorbar_info["anchor"]) + return cax + + +@_docstring.interpd +def make_axes(parents, location=None, orientation=None, fraction=0.15, + shrink=1.0, aspect=20, **kwargs): + """ + Create an `~.axes.Axes` suitable for a colorbar. + + The Axes is placed in the figure of the *parents* Axes, by resizing and + repositioning *parents*. + + Parameters + ---------- + parents : `~matplotlib.axes.Axes` or iterable or `numpy.ndarray` of `~.axes.Axes` + The Axes to use as parents for placing the colorbar. + %(_make_axes_kw_doc)s + + Returns + ------- + cax : `~matplotlib.axes.Axes` + The child Axes. + kwargs : dict + The reduced keyword dictionary to be passed when creating the colorbar + instance. + """ + loc_settings = _normalize_location_orientation(location, orientation) + # put appropriate values into the kwargs dict for passing back to + # the Colorbar class + kwargs['orientation'] = loc_settings['orientation'] + kwargs['ticklocation'] = loc_settings['location'] + + cax = _make_axes_helper(loc_settings, fraction, shrink, aspect, kwargs, parents) + cax._colorbar_info["type"] = 'Colorbar' + if loc_settings["location"] in ('top', 'bottom'): + aspect = 1.0 / aspect cax.set_box_aspect(aspect) cax.set_aspect('auto') + # and we need to set the aspect ratio by hand... return cax, kwargs @_docstring.interpd -def make_axes_gridspec(parent, *, location=None, orientation=None, - fraction=0.15, shrink=1.0, aspect=20, **kwargs): +def make_bivar_axes(parents, location=None, fraction=0.15, + shrink=1.0, aspect=1.0, **kwargs): """ - Create an `~.axes.Axes` suitable for a colorbar. + Create an `~.axes.Axes` suitable for a bivariate colorbar. - The Axes is placed in the figure of the *parent* Axes, by resizing and - repositioning *parent*. + The Axes is placed in the figure of the *parents* Axes, by resizing and + repositioning *parents*. - This function is similar to `.make_axes` and mostly compatible with it. - Primary differences are + Parameters + ---------- + parents : `~matplotlib.axes.Axes` or iterable or `numpy.ndarray` of `~.axes.Axes` + The Axes to use as parents for placing the colorbar. + %(_make_bivar_axes_kw_doc)s - - `.make_axes_gridspec` requires the *parent* to have a subplotspec. - - `.make_axes` positions the Axes in figure coordinates; - `.make_axes_gridspec` positions it using a subplotspec. - - `.make_axes` updates the position of the parent. `.make_axes_gridspec` - replaces the parent gridspec with a new one. + Returns + ------- + cax : `~matplotlib.axes.Axes` + The child Axes. + kwargs : dict + The reduced keyword dictionary to be passed when creating the colorbar + instance. + """ + loc_settings = _normalize_location(location) + cax = _make_axes_helper(loc_settings, fraction, shrink, aspect, kwargs, parents) + cax._colorbar_info["type"] = 'BivarColorbar' + # need to add aspect to kwargs so it propagates to the BivarColorbar + kwargs["aspect"] = aspect + + return cax, kwargs + + +@_docstring.interpd +def make_multivar_axes(parents, n_variates, n_major, location=None, orientation=None, + fraction=0.15, shrink=1.0, aspect=20, major_pad=0.2, + minor_pad=0.6, **kwargs): + """ + Create an `~.axes.Axes` suitable for a mulitvariate colorbar. + + The Axes is placed in the figure of the *parents* Axes, by resizing and + repositioning *parents*. Parameters ---------- - parent : `~matplotlib.axes.Axes` - The Axes to use as parent for placing the colorbar. - %(_make_axes_kw_doc)s + parents : `~matplotlib.axes.Axes` or iterable or `numpy.ndarray` of `~.axes.Axes` + The Axes to use as parents for placing the colorbar. + + n_variates : int + The number of colorbars to be made + + n_major : int + Number of colorbars along the long axis of the colorbars + %(_make_multivar_axes_kw_doc)s Returns ------- @@ -1505,12 +2233,88 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, The reduced keyword dictionary to be passed when creating the colorbar instance. """ + if n_major == -1: + n_major = n_variates + n_minor = 1 + else: + if n_major == 0: + raise ValueError("n_major cannot be zero") + n_minor = n_variates // n_major + if n_major * n_minor < n_variates: + n_minor += 1 + + aspect = aspect / n_major + fraction = fraction * n_minor loc_settings = _normalize_location_orientation(location, orientation) - kwargs['orientation'] = loc_settings['orientation'] - location = kwargs['ticklocation'] = loc_settings['location'] - aspect0 = aspect + # put appropriate values into the kwargs dict for passing back to + # the Colorbar class + orientation = loc_settings['orientation'] + kwargs['orientation'] = orientation + kwargs['ticklocation'] = loc_settings['location'] + + # get the shape of the grid of new colorbars + + if n_minor > 1: + location = loc_settings["location"] + if location == 'left': + loc_settings["anchor"] = (1, 0.5) + elif location == 'right': + loc_settings["anchor"] = (0, 0.5) + elif location == 'top': + loc_settings["anchor"] = (0.5, 0) + else: + loc_settings["anchor"] = (0.5, 1) + + fig, pbcb, colorbar_info = _make_axes_get_pbcb(loc_settings, fraction, shrink, + aspect, kwargs, parents) + colorbar_info["type"] = 'MultivarColorbar' + + # split pbcb into the required parts + sub_bboxes = MultivarColorbar._subdivide_bbox(pbcb, + n_major, + n_minor, + orientation, + major_pad=major_pad, + minor_pad=minor_pad, + )[:n_variates] + caxes = [fig.add_axes(sub_bbox, label="") + for sub_bbox in sub_bboxes] + + # adjust aspect + if loc_settings["location"] in ('top', 'bottom'): + aspect = 1.0 / aspect + + for cax in caxes: + cax.set_anchor(colorbar_info["anchor"]) + cax.set_box_aspect(aspect) + cax.set_aspect('auto') + + colorbar_info["n_major"] = n_major + colorbar_info["n_minor"] = n_minor + colorbar_info["major_pad"] = major_pad + colorbar_info["minor_pad"] = minor_pad + colorbar_info["orientation"] = orientation + return caxes, kwargs, colorbar_info + + +def _make_axes_gridspec_helper(loc_settings, fraction, shrink, aspect, kwargs, parent): + """ + Help function for `make_axes_gridspec` and `make_bivar_axes_gridspec`. + + `make_axes_gridspec` and `make_bivar_axes_gridspec` are identical + except for the fact that `make_axes_gridspec` also deals with: + 1. the aspect + 2. orientation. + + `make_bivar_axes_gridspec` on the other hand has no concept of orientation + and the aspect is handled by the `BivarColormap` instance, not during creation + of the axes. + """ + location = loc_settings['location'] + kwargs['location'] = location + anchor = kwargs.pop('anchor', loc_settings['anchor']) panchor = kwargs.pop('panchor', loc_settings['panchor']) pad = kwargs.pop('pad', loc_settings["pad"]) @@ -1540,7 +2344,6 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, gs.set_height_ratios([1 - fraction - pad, fraction]) ss_main = gs[0, :] ss_cb = gs[1, 1] - aspect = 1 / aspect parent.set_subplotspec(ss_main) if panchor is not False: @@ -1550,8 +2353,7 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, cax = fig.add_subplot(ss_cb, label="") parent._colorbars.append(cax) # tell the parent it has a colorbar cax.set_anchor(anchor) - cax.set_box_aspect(aspect) - cax.set_aspect('auto') + cax._colorbar_info = dict( location=location, parents=[parent], @@ -1559,7 +2361,98 @@ def make_axes_gridspec(parent, *, location=None, orientation=None, anchor=anchor, panchor=panchor, fraction=fraction, - aspect=aspect0, + aspect=aspect, pad=pad) + return cax + + +@_docstring.interpd +def make_axes_gridspec(parent, *, location=None, orientation=None, + fraction=0.15, shrink=1.0, aspect=20, **kwargs): + """ + Create an `~.axes.Axes` suitable for a colorbar. + + The Axes is placed in the figure of the *parent* Axes, by resizing and + repositioning *parent*. + + This function is similar to `.make_axes` and mostly compatible with it. + Primary differences are + + - `.make_axes_gridspec` requires the *parent* to have a subplotspec. + - `.make_axes` positions the Axes in figure coordinates; + `.make_axes_gridspec` positions it using a subplotspec. + - `.make_axes` updates the position of the parent. `.make_axes_gridspec` + replaces the parent gridspec with a new one. + + Parameters + ---------- + parent : `~matplotlib.axes.Axes` + The Axes to use as parent for placing the colorbar. + %(_make_axes_kw_doc)s + + Returns + ------- + cax : `~matplotlib.axes.Axes` + The child Axes. + kwargs : dict + The reduced keyword dictionary to be passed when creating the colorbar + instance. + """ + loc_settings = _normalize_location_orientation(location, orientation) + kwargs['orientation'] = loc_settings['orientation'] + kwargs['ticklocation'] = loc_settings['location'] + + cax = _make_axes_gridspec_helper(loc_settings, fraction, shrink, + aspect, kwargs, parent) + cax._colorbar_info["type"] = 'Colorbar' + if loc_settings["location"] in ('top', 'bottom'): + aspect = 1 / aspect + cax.set_box_aspect(aspect) + cax.set_aspect('auto') + + return cax, kwargs + + +@_docstring.interpd +def make_bivar_axes_gridspec(parent, *, location=None, + fraction=0.15, shrink=1.0, aspect=1.0, **kwargs): + """ + Create an `~.axes.Axes` suitable for a bivariate colorbar. + + The Axes is placed in the figure of the *parent* Axes, by resizing and + repositioning *parent*. + + This function is similar to `.make_axes` and mostly compatible with it. + Primary differences are + + - `.make_axes_gridspec` requires the *parent* to have a subplotspec. + - `.make_axes` positions the Axes in figure coordinates; + `.make_axes_gridspec` positions it using a subplotspec. + - `.make_axes` updates the position of the parent. `.make_axes_gridspec` + replaces the parent gridspec with a new one. + + Parameters + ---------- + parent : `~matplotlib.axes.Axes` + The Axes to use as parent for placing the colorbar. + %(_make_bivar_axes_kw_doc)s + + Returns + ------- + cax : `~matplotlib.axes.Axes` + The child Axes. + kwargs : dict + The reduced keyword dictionary to be passed when creating the colorbar + instance. + """ + loc_settings = _normalize_location(location) + + cax = _make_axes_gridspec_helper(loc_settings, fraction, shrink, + aspect, kwargs, parent) + cax._colorbar_info["type"] = 'BivarColorbar' + + # need to add aspect to kwargs so it propagates to the BivarColorbar + kwargs["aspect"] = aspect + return cax, kwargs diff --git a/lib/matplotlib/colorbar.pyi b/lib/matplotlib/colorbar.pyi index 07467ca74f3d..4c55bf7f9fd6 100644 --- a/lib/matplotlib/colorbar.pyi +++ b/lib/matplotlib/colorbar.pyi @@ -1,5 +1,5 @@ import matplotlib.spines as mspines -from matplotlib import cm, collections, colors, contour, colorizer +from matplotlib import cm, collections, colors, contour, colorizer as mcolorizer from matplotlib.axes import Axes from matplotlib.axis import Axis from matplotlib.backend_bases import RendererBase @@ -22,7 +22,7 @@ class _ColorbarSpine(mspines.Spines): class Colorbar: n_rasterize: int - mappable: cm.ScalarMappable | colorizer.ColorizingArtist + mappable: cm.ScalarMappable | mcolorizer.ColorizingArtist ax: Axes alpha: float | None cmap: colors.Colormap @@ -44,7 +44,7 @@ class Colorbar: def __init__( self, ax: Axes, - mappable: cm.ScalarMappable | colorizer.ColorizingArtist | None = ..., + mappable: cm.ScalarMappable | mcolorizer.ColorizingArtist | None = ..., *, cmap: str | colors.Colormap | None = ..., norm: colors.Normalize | None = ..., @@ -116,8 +116,62 @@ class Colorbar: def remove(self) -> None: ... def drag_pan(self, button: Any, key: Any, x: float, y: float) -> None: ... + ColorbarBase = Colorbar +class BivarColorbar: + n_rasterize: int + mappable: mcolorizer.ColorizingArtist + ax: Axes + alpha: float | None + colorizer: mcolorizer.Colorizer + ticklocations: tuple[Literal["auto", "left", "right"], Literal["auto", "top", "bottom"]] + def __init__( + self, + ax: Axes, + mappable: mcolorizer.ColorizingArtist | mcolorizer.Colorizer, + *, + alpha: float | None = ..., + location: Literal["left", "right", "top", "bottom"] | None = ..., + ticklocations: tuple[Literal["auto", "left", "right"], Literal["auto", "top", "bottom"]] = ..., + aspect: float = ..., + ) -> None: ... + @property + def aspect(self) -> float: ... + @aspect.setter + def aspect(self, aspect: float) -> None: ... + def set_xlabel(self, label: str) -> None: ... + def set_ylabel(self, label: str) -> None: ... + @property + def xaxis(self) -> Axis: ... + @property + def yaxis(self) -> Axis: ... + def update_normals(self, mappable: mcolorizer.ColorizingArtist | None = ...) -> None: ... + def set_alpha(self, alpha: float | None) -> None: ... + def remove(self) -> None: ... + def drag_pan(self, button: Any, key: Any, x: float, y: float) -> None: ... + +class MultivarColorbar(Sequence[Colorbar]): + mappable: mcolorizer.ColorizingArtist + colorizer: mcolorizer.Colorizer + axes: Sequence[Axes] + _colorbars: list[Colorbar] + def __init__( + self, + axes: Sequence[Axes], + mappable: mcolorizer.ColorizingArtist | mcolorizer.Colorizer | None = ..., + **kwargs: Any, + ) -> None: ... + + def update_normals(self, mappable: mcolorizer.ColorizingArtist | None = ...) -> None: ... + def remove(self) -> None: ... + @overload + def __getitem__(self, index: int, /) -> Colorbar: ... + @overload + def __getitem__(self, index: slice[int | None, int | None, int | None], /) -> Sequence[Colorbar]: ... + def __len__(self) -> int: ... + def get_tightbbox(self, renderer: RendererBase | None = ..., for_layout_only: bool = ...) -> Bbox: ... + def make_axes( parents: Axes | list[Axes] | np.ndarray, location: Literal["left", "right", "top", "bottom"] | None = ..., @@ -127,6 +181,27 @@ def make_axes( aspect: float = ..., **kwargs ) -> tuple[Axes, dict[str, Any]]: ... +def make_bivar_axes( + parents: Axes | list[Axes] | np.ndarray, + location: Literal["left", "right", "top", "bottom"] | None = ..., + fraction: float = ..., + shrink: float = ..., + aspect: float = ..., + **kwargs +) -> tuple[Axes, dict[str, Any]]: ... +def make_multivar_axes( + parents: Axes | list[Axes] | np.ndarray, + n_variates: int, + n_major: int, + location: Literal["left", "right", "top", "bottom"] | None = ..., + orientation: Literal["vertical", "horizontal"] | None = ..., + fraction: float = ..., + shrink: float = ..., + aspect: float = ..., + major_pad: float = ..., + minor_pad: float = ..., + **kwargs +) -> tuple[Axes, dict[str, Any], dict[str, Any]]: ... def make_axes_gridspec( parent: Axes, *, @@ -137,3 +212,12 @@ def make_axes_gridspec( aspect: float = ..., **kwargs ) -> tuple[Axes, dict[str, Any]]: ... +def make_bivar_axes_gridspec( + parent: Axes | list[Axes] | np.ndarray, + *, + location: Literal["left", "right", "top", "bottom"] | None = ..., + fraction: float = ..., + shrink: float = ..., + aspect: float = ..., + **kwargs +) -> tuple[Axes, dict[str, Any]]: ... diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index 095b93ccfe85..92f13a514f69 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -74,7 +74,7 @@ def _scale_norm(self, norm, vmin, vmax, A): """ if vmin is not None or vmax is not None: self.set_clim(vmin, vmax) - if isinstance(norm, colors.Normalize): + if isinstance(norm, colors.Norm): raise ValueError( "Passing a Normalize instance simultaneously with " "vmin/vmax is not supported. Please pass vmin/vmax " @@ -277,8 +277,16 @@ def set_clim(self, vmin=None, vmax=None): def get_clim(self): """ Return the values (min, max) that are mapped to the colormap limits. + + This function always returns min and max as tuples to ensure type consistency + when working with both scalar and multivariate color mapping. + See also `.ColorizingArtist.get_clim()` which returns scalars but is unavailable + for multivariate color mapping. """ - return self.norm.vmin, self.norm.vmax + if self.norm.n_components == 1: + return (self.norm.vmin, ), (self.norm.vmax, ) + else: + return self.norm.vmin, self.norm.vmax def changed(self): """ @@ -306,7 +314,10 @@ def vmax(self, vmax): @property def clip(self): - return self.norm.clip + if self.norm.n_components == 1: + return (self.norm.clip, ) + else: + return self.norm.clip @clip.setter def clip(self, clip): @@ -360,8 +371,14 @@ def to_rgba(self, x, alpha=None, bytes=False, norm=True): def get_clim(self): """ Return the values (min, max) that are mapped to the colormap limits. + + This function is not available for multivariate data. + Use `.Colorizer.get_clim` via the .colorizer property instead. """ - return self._colorizer.get_clim() + if self._colorizer.norm.n_components > 1: + raise AttributeError("get_clim() cannot be used with a multi-component " + "colormap. Use .colorizer.get_clim() instead") + return self.colorizer.norm.vmin, self.colorizer.norm.vmax def set_clim(self, vmin=None, vmax=None): """ @@ -376,9 +393,14 @@ def set_clim(self, vmin=None, vmax=None): tuple (*vmin*, *vmax*) as a single positional argument. .. ACCEPTS: (vmin: float, vmax: float) + + This function is not available for multivariate data. """ # If the norm's limits are updated self.changed() will be called # through the callbacks attached to the norm + if self._colorizer.norm.n_components > 1: + raise AttributeError("set_clim() cannot be used with a multi-component " + "colormap. Use .colorizer.set_clim() instead") self._colorizer.set_clim(vmin, vmax) def get_alpha(self): @@ -600,6 +622,18 @@ def get_array(self): """ return self._A + def _getmaskarray(self, A): + """ + Similar to np.ma.getmaskarray but also handles the case where + the data has multiple fields. + + The return array always has the same shape as the input, and dtype bool + """ + mask = np.ma.getmaskarray(A) + if isinstance(self.norm, colors.MultiNorm): + mask = np.any(mask.view('bool').reshape((*A.shape, -1)), axis=-1) + return mask + def changed(self): """ Call this whenever the mappable is changed to notify all the @@ -771,7 +805,8 @@ def _ensure_norm(norm, n_components=1): _api.check_isinstance((colors.MultiNorm, None, tuple), norm=norm) if norm is None: norm = colors.MultiNorm(['linear']*n_components) - else: # iterable, i.e. multiple strings or Normalize objects + elif not isinstance(norm, colors.MultiNorm): + # iterable, i.e. multiple strings or Normalize objects norm = colors.MultiNorm(norm) if isinstance(norm, colors.MultiNorm) and norm.n_components == n_components: return norm diff --git a/lib/matplotlib/colorizer.pyi b/lib/matplotlib/colorizer.pyi index 9a5a73415d83..3350709faf01 100644 --- a/lib/matplotlib/colorizer.pyi +++ b/lib/matplotlib/colorizer.pyi @@ -9,13 +9,13 @@ class Colorizer: callbacks: cbook.CallbackRegistry def __init__( self, - cmap: str | colors.Colormap | None = ..., + cmap: str | colors.Colormap | colors.BivarColormap | colors.MultivarColormap | None = ..., norm: str | colors.Norm | None = ..., ) -> None: ... @property def norm(self) -> colors.Norm: ... @norm.setter - def norm(self, norm: colors.Norm | str | None) -> None: ... + def norm(self, norm: colors.Norm | str | tuple[str, ...] | None) -> None: ... def to_rgba( self, x: np.ndarray, @@ -26,28 +26,28 @@ class Colorizer: def autoscale(self, A: ArrayLike) -> None: ... def autoscale_None(self, A: ArrayLike) -> None: ... @property - def cmap(self) -> colors.Colormap: ... + def cmap(self) -> colors.Colormap | colors.BivarColormap | colors.MultivarColormap: ... @cmap.setter - def cmap(self, cmap: colors.Colormap | str | None) -> None: ... - def get_clim(self) -> tuple[float, float]: ... - def set_clim(self, vmin: float | tuple[float, float] | None = ..., vmax: float | None = ...) -> None: ... + def cmap(self, cmap: colors.Colormap | colors.BivarColormap | colors.MultivarColormap | str | None) -> None: ... + def get_clim(self) -> tuple[tuple[float | None, ...], tuple[float | None, ...]]: ... + def set_clim(self, vmin: float | tuple[float | None, ...] | None = ..., vmax: float | tuple[float | None, ...] | None = ...) -> None: ... def changed(self) -> None: ... @property - def vmin(self) -> float | None: ... + def vmin(self) -> tuple[float | None, ...] | None: ... @vmin.setter - def vmin(self, value: float | None) -> None: ... + def vmin(self, value: tuple[float | None, ...] | None) -> None: ... @property - def vmax(self) -> float | None: ... + def vmax(self) -> tuple[float | None, ...] | None: ... @vmax.setter - def vmax(self, value: float | None) -> None: ... + def vmax(self, value: tuple[float | None, ...] | None) -> None: ... @property - def clip(self) -> bool: ... + def clip(self) -> tuple[bool, ...]: ... @clip.setter - def clip(self, value: bool) -> None: ... + def clip(self, value: ArrayLike | bool | tuple[bool, ...]) -> None: ... class _ColorizerInterface: - cmap: colors.Colormap + cmap: colors.Colormap | colors.BivarColormap | colors.MultivarColormap colorbar: colorbar.Colorbar | None callbacks: cbook.CallbackRegistry def to_rgba( @@ -57,11 +57,11 @@ class _ColorizerInterface: bytes: bool = ..., norm: bool = ..., ) -> np.ndarray: ... - def get_clim(self) -> tuple[float, float]: ... - def set_clim(self, vmin: float | tuple[float, float] | None = ..., vmax: float | None = ...) -> None: ... + def get_clim(self) -> tuple[float, float] | tuple[tuple[float, ...], tuple[float, ...]]: ... + def set_clim(self, vmin: float | tuple[float, float] | tuple[float | None, ...] | None = ..., vmax: float | tuple[float | None, ...] | None = ...) -> None: ... def get_alpha(self) -> float | None: ... - def get_cmap(self) -> colors.Colormap: ... - def set_cmap(self, cmap: str | colors.Colormap) -> None: ... + def get_cmap(self) -> colors.Colormap | colors.BivarColormap | colors.MultivarColormap: ... + def set_cmap(self, cmap: str | colors.Colormap | colors.BivarColormap | colors.MultivarColormap) -> None: ... @property def norm(self) -> colors.Norm: ... @norm.setter @@ -75,7 +75,7 @@ class _ScalarMappable(_ColorizerInterface): def __init__( self, norm: colors.Norm | None = ..., - cmap: str | colors.Colormap | None = ..., + cmap: str | colors.Colormap | colors.BivarColormap | colors.MultivarColormap | None = ..., *, colorizer: Colorizer | None = ..., **kwargs diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 02ea1d3723e9..0d689ebad9f1 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1432,9 +1432,9 @@ def __init__(self, colormaps, combination_mode, name='multivariate colormap'): Describe how colormaps are combined in sRGB space - If 'sRGB_add' -> Mixing produces brighter colors - `sRGB = sum(colors)` + ``sRGB = sum(colors)`` - If 'sRGB_sub' -> Mixing produces darker colors - `sRGB = 1 - sum(1 - colors)` + ``sRGB = 1 - sum(1 - colors)`` name : str, optional The name of the colormap family. """ @@ -1606,15 +1606,15 @@ def with_extremes(self, *, bad=None, under=None, over=None): Parameters ---------- - bad: :mpltype:`color`, default: None + bad : :mpltype:`color`, default: None If Matplotlib color, the bad value is set accordingly in the copy - under tuple of :mpltype:`color`, default: None - If tuple, the `under` value of each component is set with the values + under : tuple of :mpltype:`color`, default: None + If tuple, the 'under' value of each component is set with the values from the tuple. - over tuple of :mpltype:`color`, default: None - If tuple, the `over` value of each component is set with the values + over : tuple of :mpltype:`color`, default: None + If tuple, the 'over' value of each component is set with the values from the tuple. Returns @@ -3561,11 +3561,16 @@ def autoscale_None(self, A): - If structured array, must have `n_components` fields. Each field is used for the limits of one constituent norm. """ + changed = False with self.callbacks.blocked(): A = self._iterable_components_in_data(A, self.n_components) for n, a in zip(self.norms, A): + vmin, vmax = n.vmin, n.vmax n.autoscale_None(a) - self._changed() + if vmin != n.vmin or vmax != n.vmax: + changed = True + if changed: + self._changed() def scaled(self): """Return whether both *vmin* and *vmax* are set on all constituent norms.""" diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index d7fbbf181272..ddeb19975cf9 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -255,13 +255,13 @@ class Norm(ABC): def __init__(self) -> None: ... @property @abstractmethod - def vmin(self) -> float | tuple[float] | None: ... + def vmin(self) -> float | tuple[float | None, ...] | None: ... @property @abstractmethod - def vmax(self) -> float | tuple[float] | None: ... + def vmax(self) -> float | tuple[float | None, ...] | None: ... @property @abstractmethod - def clip(self) -> bool | tuple[bool]: ... + def clip(self) -> bool | tuple[bool, ...]: ... @abstractmethod def __call__(self, value: np.ndarray, clip: bool | None = ...) -> ArrayLike: ... @abstractmethod diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index ad0206e0db5c..275196aad079 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -45,6 +45,8 @@ import matplotlib._api as _api import matplotlib.cbook as cbook import matplotlib.colorbar as cbar +import matplotlib.colors as mcolors +import matplotlib.colorizer as mcolorizer import matplotlib.image as mimage from matplotlib.axes import Axes @@ -1273,6 +1275,13 @@ def colorbar( therefore, this workaround is not used by default (see issue #1188). """ + if isinstance(mappable.cmap, mcolors.BivarColormap): + raise ValueError("`Figure.colorbar` can only be together with a " + "scalar colormap, please use `Figure.colorbar_bivar` " + "when working with a bivariate colormap") + if isinstance(mappable.cmap, mcolors.MultivarColormap): + raise ValueError("colorbar can only be together with a" + "scalar colormap") if ax is None: ax = getattr(mappable, "axes", None) @@ -1319,6 +1328,188 @@ def colorbar( cax.get_figure(root=False).stale = True return cb + @_docstring.interpd + def colorbar_bivar( + self, mappable, *, cax=None, ax=None, use_gridspec=True, **kwargs): + """ + Add a bivariate colorbar to a plot. + + Parameters + ---------- + mappable + The `matplotlib.colorizer.ColorizingArtist` (i.e., `.AxesImage` + etc.) described by this bivariate colorbar. + This argument is mandatory for the `.Figure.colorbar_bivar` method + but optional for the`.pyplot.colorbar_bivar` function, which sets + the default to the current image. + + cax : `~matplotlib.axes.Axes`, optional + Axes into which the colorbar will be drawn. If `None`, then a new + Axes is created and the space for it will be stolen from the Axes(s) + specified in *ax*. + + ax : `~matplotlib.axes.Axes` or iterable or `numpy.ndarray` of Axes, optional + The one or more parent Axes from which space for a new colorbar Axes + will be stolen. This parameter is only used if *cax* is not set. + + Defaults to the Axes that contains the mappable used to create the + colorbar. + + use_gridspec : bool, optional + If *cax* is ``None``, a new *cax* is created as an instance of + Axes. If *ax* is positioned with a subplotspec and *use_gridspec* + is ``True``, then *cax* is also positioned with a subplotspec. + + Returns + ------- + bivariate_colorbar : `~matplotlib.colorbar.BivarColorbar` + + Other Parameters + ---------------- + %(_make_bivar_axes_kw_doc)s + %(_bivar_colormap_kw_doc)s + + """ + + if isinstance(mappable, mpl.colorizer.Colorizer): + mappable = mcolorizer.ColorizingArtist(mappable) + if not isinstance(mappable.colorizer.cmap, mcolors.BivarColormap): + raise ValueError("A bivariate colorbar can only be used together with a " + f"bivariate colormap, not {type(mappable.colorizer.cmap)}") + if ax is None: + ax = getattr(mappable, "axes", None) + + if cax is None: + if ax is None: + raise ValueError( + 'Unable to determine Axes to steal space for Colorbar. ' + 'Either provide the *cax* argument to use as the Axes for ' + 'the Colorbar, provide the *ax* argument to steal space ' + 'from it, or add *mappable* to an Axes.') + fig = ( # Figure of first Axes; logic copied from make_axes. + [*ax.flat] if isinstance(ax, np.ndarray) + else [*ax] if np.iterable(ax) + else [ax])[0].get_figure(root=False) + current_ax = fig.gca() + if (fig.get_layout_engine() is not None and + not fig.get_layout_engine().colorbar_gridspec): + use_gridspec = False + if (use_gridspec + and isinstance(ax, mpl.axes._base._AxesBase) + and ax.get_subplotspec()): + cax, kwargs = cbar.make_bivar_axes_gridspec(ax, **kwargs) + else: + cax, kwargs = cbar.make_bivar_axes(ax, **kwargs) + # make_axes calls add_{axes,subplot} which changes gca; undo that. + fig.sca(current_ax) + cax.grid(visible=False, which='both', axis='both') + + if (hasattr(mappable, "get_figure") and + (mappable_host_fig := mappable.get_figure(root=True)) is not None): + # Warn in case of mismatch + if mappable_host_fig is not self._root_figure: + _api.warn_external( + f'Adding colorbar to a different Figure ' + f'{repr(mappable_host_fig)} than ' + f'{repr(self._root_figure)} which ' + f'fig.colorbar is called on.') + NON_COLORBAR_KEYS = [ # remove kws that cannot be passed to Colorbar + 'fraction', 'pad', 'shrink', 'anchor', 'panchor'] + cb = cbar.BivarColorbar(cax, mappable, **{ + k: v for k, v in kwargs.items() if k not in NON_COLORBAR_KEYS}) + cax.get_figure(root=False).stale = True + return cb + + @_docstring.interpd + def colorbar_multivar( + self, mappable, *, caxes=None, ax=None, + n_major=-1, **kwargs): + """ + Add a bivariate colorbar to a plot. + + Parameters + ---------- + mappable + The `matplotlib.colorizer.ColorizingArtist` (i.e., `.AxesImage` + etc.) described by this multivariate colorbar. + This argument is mandatory for the `.Figure.colorbar_multivar` method + but optional for the`.pyplot.colorbar_multivar` function, which sets + the default to the current image. + + caxes : `~matplotlib.axes.Axes`, optional + Axes into which the colorbar will be drawn. If `None`, then new + Axes are created and the space for it will be stolen from the Axes(s) + specified in *ax*. + + ax : `~matplotlib.axes.Axes` or iterable or `numpy.ndarray` of Axes, optional + The one or more parent Axes from which space for a new colorbar Axes + will be stolen. This parameter is only used if *cax* is not set. + + Defaults to the Axes that contains the mappable used to create the + colorbar. + + Returns + ------- + multivariate_colorbar : `~matplotlib.colorbar.MultivarColorbar` + + Other Parameters + ---------------- + %(_make_multivar_axes_kw_doc)s + + """ + + if isinstance(mappable, mpl.colorizer.Colorizer): + mappable = mcolorizer.ColorizingArtist(mappable) + if not isinstance(mappable.colorizer.cmap, mcolors.MultivarColormap): + raise ValueError("A multivariate colorbar can only be used together " + "with a multivariate colormap, not " + f"{type(mappable.colorizer.cmap)}") + + n_variates = mappable.colorizer.cmap.n_variates + + if ax is None: + ax = getattr(mappable, "axes", None) + + cbar_info = None + if caxes is None: + if ax is None: + raise ValueError( + 'Unable to determine Axes to steal space for Colorbar. ' + 'Either provide the *cax* argument to use as the Axes for ' + 'the Colorbar, provide the *ax* argument to steal space ' + 'from it, or add *mappable* to an Axes.') + fig = ( # Figure of first Axes; logic copied from make_axes. + [*ax.flat] if isinstance(ax, np.ndarray) + else [*ax] if np.iterable(ax) + else [ax])[0].get_figure(root=False) + current_ax = fig.gca() + caxes, kwargs, cbar_info = cbar.make_multivar_axes(ax, n_variates, + n_major, **kwargs) + # make_axes calls add_{axes,subplot} which changes gca; undo that. + fig.sca(current_ax) + for cax in caxes: + cax.grid(visible=False, which='both', axis='both') + + if (hasattr(mappable, "get_figure") and + (mappable_host_fig := mappable.get_figure(root=True)) is not None): + # Warn in case of mismatch + if mappable_host_fig is not self._root_figure: + _api.warn_external( + f'Adding colorbar to a different Figure ' + f'{repr(mappable_host_fig)} than ' + f'{repr(self._root_figure)} which ' + f'fig.colorbar is called on.') + NON_COLORBAR_KEYS = [ # remove kws that cannot be passed to Colorbar + 'fraction', 'pad', 'shrink', 'anchor', 'panchor'] + + cb = cbar.MultivarColorbar(caxes, mappable, **{ + k: v for k, v in kwargs.items() if k not in NON_COLORBAR_KEYS}) + cb._set_colorbar_info(cbar_info) + + for cax in caxes: + cax.get_figure(root=False).stale = True + return cb + def subplots_adjust(self, left=None, bottom=None, right=None, top=None, wspace=None, hspace=None): """ diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi index 59d276362dc5..048633aee334 100644 --- a/lib/matplotlib/figure.pyi +++ b/lib/matplotlib/figure.pyi @@ -14,7 +14,7 @@ from matplotlib.backend_bases import ( RendererBase, ) from matplotlib.colors import Colormap, Normalize -from matplotlib.colorbar import Colorbar +from matplotlib.colorbar import Colorbar, BivarColorbar, MultivarColorbar from matplotlib.colorizer import ColorizingArtist, Colorizer from matplotlib.cm import ScalarMappable from matplotlib.gridspec import GridSpec, SubplotSpec, SubplotParams as SubplotParams @@ -179,6 +179,24 @@ class FigureBase(Artist): use_gridspec: bool = ..., **kwargs ) -> Colorbar: ... + def colorbar_bivar( + self, + mappable: ColorizingArtist, + *, + cax: Axes | None = ..., + ax: Axes | Iterable[Axes] | None = ..., + use_gridspec: bool = ..., + **kwargs + ) -> BivarColorbar: ... + def colorbar_multivar( + self, + mappable: ColorizingArtist, + *, + caxes: Iterable[Axes] | None = ..., + ax: Axes | Iterable[Axes] | None = ..., + n_major: int = ..., + **kwargs + ) -> MultivarColorbar: ... def subplots_adjust( self, left: float | None = ..., diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index 25e6a3bd5ee8..100a0920e2e5 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -281,7 +281,14 @@ def __init__(self, ax, self.set_filternorm(filternorm) self.set_filterrad(filterrad) self.set_interpolation(interpolation) - self.set_interpolation_stage(interpolation_stage) + if isinstance(self.norm, mcolors.MultiNorm): + if interpolation_stage not in [None, 'data', 'auto']: + raise ValueError("when using multivariate color mapping 'data' " + "is the only valid interpolation_stage, but got " + f"{interpolation_stage}") + self.set_interpolation_stage('data') + else: + self.set_interpolation_stage(interpolation_stage) self.set_resample(resample) self.axes = ax @@ -471,30 +478,45 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, # input data is not going to match the size on the screen so we # have to resample to the correct number of pixels - if A.dtype.kind == 'f': # Float dtype: scale to same dtype. - scaled_dtype = np.dtype("f8" if A.dtype.itemsize > 4 else "f4") - if scaled_dtype.itemsize < A.dtype.itemsize: - _api.warn_external(f"Casting input data from {A.dtype}" - f" to {scaled_dtype} for imshow.") - else: # Int dtype, likely. - # TODO slice input array first - # Scale to appropriately sized float: use float32 if the - # dynamic range is small, to limit the memory footprint. - da = A.max().astype("f8") - A.min().astype("f8") - scaled_dtype = "f8" if da > 1e8 else "f4" - - # resample the input data to the correct resolution and shape - A_resampled = _resample(self, A.astype(scaled_dtype), out_shape, t) + if A.dtype.fields is None: # scalar data and colormap + arrs, norms, dtypes = [A], [self.norm], [A.dtype] + else: # using a multivariate colormap + arrs = [A[f] for f in A.dtype.fields] + norms = self.norm.norms + dtypes = [A.dtype.fields[f][0] for f in A.dtype.fields] + + def get_scaled_dtype(A): + # gets the scaled dtype + if A.dtype.kind == 'f': # Float dtype: scale to same dtype. + scaled_dtype = np.dtype('f8' if A.dtype.itemsize > 4 else 'f4') + if scaled_dtype.itemsize < A.dtype.itemsize: + _api.warn_external(f"Casting input data from {A.dtype}" + f" to {scaled_dtype} for imshow.") + else: # Int dtype, likely. + # TODO slice input array first + # Scale to appropriately sized float: use float32 if the + # dynamic range is small, to limit the memory footprint. + da = A.max().astype("f8") - A.min().astype("f8") + scaled_dtype = "f8" if da > 1e8 else "f4" + + return scaled_dtype + + A_resampled = [_resample(self, + a.astype(get_scaled_dtype(a)), + out_shape, t) + for a in arrs] # if using NoNorm, cast back to the original datatype - if isinstance(self.norm, mcolors.NoNorm): - A_resampled = A_resampled.astype(A.dtype) + for i, n in enumerate(norms): + if isinstance(n, mcolors.NoNorm): + A_resampled[i] = A_resampled[i].astype(dtypes[i]) # Compute out_mask (what screen pixels include "bad" data # pixels) and out_alpha (to what extent screen pixels are # covered by data pixels: 0 outside the data extent, 1 inside # (even for bad data), and intermediate values at the edges). - mask = (np.where(A.mask, np.float32(np.nan), np.float32(1)) + mask = (np.where(self._getmaskarray(A), + np.float32(np.nan), np.float32(1)) if A.mask.shape == A.shape # nontrivial mask else np.ones_like(A, np.float32)) # we always have to interpolate the mask to account for @@ -507,8 +529,13 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, alpha = self.get_alpha() if alpha is not None and np.ndim(alpha) > 0: out_alpha *= _resample(self, alpha, out_shape, t, resample=True) - # mask and run through the norm - resampled_masked = np.ma.masked_array(A_resampled, out_mask) + + # mask + resampled_masked = [np.ma.masked_array(r, out_mask) + for r in A_resampled] + + if A.dtype.fields is None: + resampled_masked = resampled_masked[0] res = self.norm(resampled_masked) else: if A.ndim == 2: # interpolation_stage = 'rgba' @@ -669,8 +696,15 @@ def _normalize_image_array(A): """ A = cbook.safe_masked_invalid(A, copy=True) if A.dtype != np.uint8 and not np.can_cast(A.dtype, float, "same_kind"): - raise TypeError(f"Image data of dtype {A.dtype} cannot be " - f"converted to float") + if A.dtype.fields is None: + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to float") + else: + for key in A.dtype.fields: + if not np.can_cast(A[key].dtype, float, "same_kind"): + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to a sequence of floats") + if A.ndim == 3 and A.shape[-1] == 1: A = A.squeeze(-1) # If just (M, N, 1), assume scalar and apply colormap. if not (A.ndim == 2 or A.ndim == 3 and A.shape[-1] in [3, 4]): diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index dd80da45e332..f8b028a98b8f 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -83,7 +83,11 @@ from matplotlib.scale import get_scale_names # noqa: F401 from matplotlib.cm import _colormaps -from matplotlib.colors import _color_sequences, Colormap +from matplotlib.colors import (_color_sequences, + Colormap, + BivarColormap, + MultivarColormap, + ) import numpy as np @@ -122,7 +126,11 @@ EventCollection, QuadMesh, ) - from matplotlib.colorbar import Colorbar + from matplotlib.colorbar import ( + Colorbar, + BivarColorbar, + MultivarColorbar, + ) from matplotlib.container import ( BarContainer, ErrorbarContainer, @@ -160,7 +168,7 @@ # We may not need the following imports here: -from matplotlib.colors import Normalize +from matplotlib.colors import Norm, Normalize from matplotlib.lines import Line2D, AxLine from matplotlib.text import Text, Annotation from matplotlib.patches import Arrow, Circle, Rectangle # noqa: F401 @@ -2680,6 +2688,42 @@ def colorbar( return ret +@_copy_docstring_and_deprecators(Figure.colorbar_bivar) +def colorbar_bivar( + mappable: ColorizingArtist | None = None, + cax: matplotlib.axes.Axes | None = None, + ax: matplotlib.axes.Axes | Iterable[matplotlib.axes.Axes] | None = None, + **kwargs +) -> BivarColorbar: + if mappable is None: + mappable = gci() + if mappable is None: + raise RuntimeError('No mappable was found to use for colorbar ' + 'creation. First define a mappable such as ' + 'an image (with imshow) or a contour set (' + 'with contourf).') + ret = gcf().colorbar_bivar(mappable, cax=cax, ax=ax, **kwargs) + return ret + + +@_copy_docstring_and_deprecators(Figure.colorbar_multivar) +def colorbar_multivar( + mappable: ColorizingArtist | None = None, + caxes: Iterable[matplotlib.axes.Axes] | None = None, + ax: matplotlib.axes.Axes | Iterable[matplotlib.axes.Axes] | None = None, + **kwargs +) -> MultivarColorbar: + if mappable is None: + mappable = gci() + if mappable is None: + raise RuntimeError('No mappable was found to use for colorbar ' + 'creation. First define a mappable such as ' + 'an image (with imshow) or a contour set (' + 'with contourf).') + ret = gcf().colorbar_multivar(mappable, caxes=caxes, ax=ax, **kwargs) + return ret + + def clim(vmin: float | None = None, vmax: float | None = None) -> None: """ Set the color limits of the current image. @@ -3759,14 +3803,14 @@ def hlines( @_copy_docstring_and_deprecators(Axes.imshow) def imshow( X: ArrayLike | PIL.Image.Image, - cmap: str | Colormap | None = None, - norm: str | Normalize | None = None, + cmap: str | Colormap | BivarColormap | MultivarColormap | None = None, + norm: str | Norm | None = None, *, aspect: Literal["equal", "auto"] | float | None = None, interpolation: str | None = None, alpha: float | ArrayLike | None = None, - vmin: float | None = None, - vmax: float | None = None, + vmin: float | tuple[float, ...] | None = None, + vmax: float | tuple[float, ...] | None = None, colorizer: Colorizer | None = None, origin: Literal["upper", "lower"] | None = None, extent: tuple[float, float, float, float] | None = None, @@ -3878,10 +3922,10 @@ def pcolor( *args: ArrayLike, shading: Literal["flat", "nearest", "auto"] | None = None, alpha: float | None = None, - norm: str | Normalize | None = None, - cmap: str | Colormap | None = None, - vmin: float | None = None, - vmax: float | None = None, + norm: str | Norm | None = None, + cmap: str | Colormap | BivarColormap | MultivarColormap | None = None, + vmin: float | tuple[float, ...] | None = None, + vmax: float | tuple[float, ...] | None = None, colorizer: Colorizer | None = None, data=None, **kwargs, @@ -3907,10 +3951,10 @@ def pcolor( def pcolormesh( *args: ArrayLike, alpha: float | None = None, - norm: str | Normalize | None = None, - cmap: str | Colormap | None = None, - vmin: float | None = None, - vmax: float | None = None, + norm: str | Norm | None = None, + cmap: str | Colormap | BivarColormap | MultivarColormap | None = None, + vmin: float | tuple[float, ...] | None = None, + vmax: float | tuple[float, ...] | None = None, colorizer: Colorizer | None = None, shading: Literal["flat", "nearest", "gouraud", "auto"] | None = None, antialiased: bool = False, diff --git a/lib/matplotlib/tests/baseline_images/test_axes/bivariate_cmap_shapes.png b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_cmap_shapes.png new file mode 100644 index 000000000000..a3339afa7545 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_cmap_shapes.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/bivariate_visualizations.png b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_visualizations.png new file mode 100644 index 000000000000..501a87436e0d Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_visualizations.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_alpha.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_alpha.png new file mode 100644 index 000000000000..03420bfb2473 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_alpha.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_norm.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_norm.png new file mode 100644 index 000000000000..134c73f1ca85 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_norm.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_alpha.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_alpha.png new file mode 100644 index 000000000000..a5748573dbe2 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_alpha.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_norm.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_norm.png new file mode 100644 index 000000000000..e85e27fd183d Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_norm.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_visualizations.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_visualizations.png new file mode 100644 index 000000000000..16843b0dd775 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_visualizations.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing.png b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing.png new file mode 100644 index 000000000000..a81d0fb1d4ee Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_constrained-failed-diff.png b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_constrained-failed-diff.png new file mode 100644 index 000000000000..a2686355d238 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_constrained-failed-diff.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_constrained.png b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_constrained.png new file mode 100644 index 000000000000..d412a50f4fcc Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_constrained.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_gridspec.png b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_gridspec.png new file mode 100644 index 000000000000..5abdeceae8a9 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_locationing_gridspec.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_not_rasterized-failed-diff.png b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_not_rasterized-failed-diff.png new file mode 100644 index 000000000000..9b926d41b687 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_not_rasterized-failed-diff.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_not_rasterized.png b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_not_rasterized.png new file mode 100644 index 000000000000..55436d3f07ce Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_not_rasterized.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_sharing.png b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_sharing.png new file mode 100644 index 000000000000..243a5a944351 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/bivar_cbar_sharing.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing.png new file mode 100644 index 000000000000..7600a8ab99d0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing_constrained-failed-diff.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing_constrained-failed-diff.png new file mode 100644 index 000000000000..9db981d6facf Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing_constrained-failed-diff.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing_constrained.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing_constrained.png new file mode 100644 index 000000000000..57b300920c0f Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_locationing_constrained.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major-expected.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major-expected.png new file mode 120000 index 000000000000..f700252594d1 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major-expected.png @@ -0,0 +1 @@ +/home/trygvrad/matplotlib/matplotlib/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major.png \ No newline at end of file diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major-failed-diff.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major-failed-diff.png new file mode 100644 index 000000000000..8887cf9acc22 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major-failed-diff.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major.png new file mode 100644 index 000000000000..8c9569bd79b8 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_n_major.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing-expected.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing-expected.png new file mode 120000 index 000000000000..fdd398076209 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing-expected.png @@ -0,0 +1 @@ +/home/trygvrad/matplotlib/matplotlib/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing.png \ No newline at end of file diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing-failed-diff.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing-failed-diff.png new file mode 100644 index 000000000000..3e04a67ca830 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing-failed-diff.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing.png b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing.png new file mode 100644 index 000000000000..6423085fb9e0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colorbar/multivar_cbar_sharing.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 6751666360b1..ba91ec377884 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -10366,3 +10366,248 @@ def test_errorbar_uses_rcparams(): assert_allclose([cap.get_markeredgewidth() for cap in caplines], 2.5) for barcol in barlinecols: assert_allclose(barcol.get_linewidths(), 1.75) + + +def test_matshow_not_multivariate(): + """ + matshow() currently does not support multivariate/bivariate colormaps. + This test is to ensure coverage for the if-statement that checks for this. + + This test should be removed if matshow() is updated to support + multivariate/bivariate colormaps. + """ + fig, axes = plt.subplots() + arr = np.arange(24).reshape((-1, 4, 2)) + with pytest.raises(TypeError, match="Invalid shape"): + axes.matshow(arr) + + +@image_comparison(["bivariate_visualizations.png"], style='mpl20') +def test_bivariate_visualizations(): + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + + fig, axes = plt.subplots(1, 5, figsize=(8, 2)) + + axes[0].imshow((x_0, x_1), cmap='BiPeak', interpolation='nearest') + axes[1].pcolor((x_0, x_1), cmap='BiPeak') + axes[2].pcolormesh((x_0, x_1), cmap='BiPeak') + + x = np.arange(5) + y = np.arange(5) + X, Y = np.meshgrid(x, y) + axes[3].pcolormesh(X, Y, (x_0, x_1), cmap='BiPeak') + + patches = [ + mpl.patches.Wedge((.3, .7), .1, 0, 360), # Full circle + mpl.patches.Wedge((.7, .8), .2, 0, 360, width=0.05), # Full ring + mpl.patches.Wedge((.8, .3), .2, 0, 45), # Full sector + mpl.patches.Wedge((.8, .3), .2, 22.5, 90, width=0.10), # Ring sector + ] + colors_0 = np.arange(len(patches)) // 2 + colors_1 = np.arange(len(patches)) % 2 + p = mpl.collections.PatchCollection(patches, cmap='BiPeak', alpha=0.5) + p.set_array((colors_0, colors_1)) + axes[4].add_collection(p) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_visualizations.png"], style='mpl20') +def test_multivariate_visualizations(): + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(1, 5, figsize=(8, 2)) + + axes[0].imshow((x_0, x_1, x_2), cmap='3VarAddA') + axes[1].pcolor((x_0, x_1, x_2), cmap='3VarAddA') + axes[2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA') + + x = np.arange(5) + y = np.arange(5) + X, Y = np.meshgrid(x, y) + axes[3].pcolormesh(X, Y, (x_0, x_1, x_2), cmap='3VarAddA') + + patches = [ + mpl.patches.Wedge((.3, .7), .1, 0, 360), # Full circle + mpl.patches.Wedge((.7, .8), .2, 0, 360, width=0.05), # Full ring + mpl.patches.Wedge((.8, .3), .2, 0, 45), # Full sector + mpl.patches.Wedge((.8, .3), .2, 22.5, 90, width=0.10), # Ring sector + ] + colors_0 = np.arange(len(patches)) // 2 + colors_1 = np.arange(len(patches)) % 2 + colors_2 = np.arange(len(patches)) % 3 + p = mpl.collections.PatchCollection(patches, cmap='3VarAddA', alpha=0.5) + p.set_array((colors_0, colors_1, colors_2)) + axes[4].add_collection(p) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_pcolormesh_alpha.png"], style='mpl20') +def test_multivariate_pcolormesh_alpha(): + """ + Check that the the alpha keyword works for pcolormesh + This test covers all plotting modes that use the same pipeline + (inherit from Collection). + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(2, 3) + + axes[0, 0].pcolormesh(x_1, alpha=0.5) + axes[0, 1].pcolormesh((x_0, x_1), cmap='BiPeak', alpha=0.5) + axes[0, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', alpha=0.5) + + al = np.arange(25, dtype='float32').reshape(5, 5)[::-1].T % 6 / 5 + + axes[1, 0].pcolormesh(x_1, alpha=al) + axes[1, 1].pcolormesh((x_0, x_1), cmap='BiPeak', alpha=al) + axes[1, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', alpha=al) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_imshow_alpha.png"], style='mpl20') +def test_multivariate_imshow_alpha(): + """ + Check that the the alpha keyword works for imshow. + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(2, 3) + + # interpolation='nearest' to reduce size of baseline image + axes[0, 0].imshow(x_1, interpolation='nearest', alpha=0.5) + axes[0, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', alpha=0.5) + axes[0, 2].imshow((x_0, x_1, x_2), interpolation='nearest', + cmap='3VarAddA', alpha=0.5) + + al = np.arange(25, dtype='float32').reshape(5, 5)[::-1].T % 6 / 5 + + axes[1, 0].imshow(x_1, interpolation='nearest', alpha=al) + axes[1, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', alpha=al) + axes[1, 2].imshow((x_0, x_1, x_2), interpolation='nearest', + cmap='3VarAddA', alpha=al) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_pcolormesh_norm.png"], style='mpl20') +def test_multivariate_pcolormesh_norm(): + """ + Test vmin, vmax and norm + Norm is checked via a LogNorm, as this converts the input to a masked array, + masking for X <= 0. By using a LogNorm, this functionality is also tested. + This test covers all plotting modes that use the same pipeline + (inherit from Collection). + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(3, 5) + + axes[0, 0].pcolormesh(x_1) + axes[0, 1].pcolormesh((x_0, x_1), cmap='BiPeak') + axes[0, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA') + axes[0, 3].pcolormesh((x_0, x_1), cmap='BiPeak') # repeated for visual consistency + axes[0, 4].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA') # repeated + + vmin = 1 + vmax = 3 + axes[1, 0].pcolormesh(x_1, vmin=vmin, vmax=vmax) + axes[1, 1].pcolormesh((x_0, x_1), cmap='BiPeak', vmin=[vmin]*2, vmax=[vmax]*2) + axes[1, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', + vmin=[vmin]*3, vmax=[vmax]*3) + axes[1, 3].pcolormesh((x_0, x_1), cmap='BiPeak', + vmin=(None, vmin), vmax=(None, vmax)) + axes[1, 4].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', + vmin=(None, vmin, None), vmax=(None, vmax, None)) + + norm = mcolors.LogNorm(vmin=1, vmax=5) + axes[2, 0].pcolormesh(x_1, norm=norm) + axes[2, 1].pcolormesh((x_0, x_1), cmap='BiPeak', norm=(norm, norm)) + axes[2, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', norm=(norm, norm, norm)) + axes[2, 3].pcolormesh((x_0, x_1), cmap='BiPeak', norm=('linear', norm)) + axes[2, 4].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', + norm=('linear', norm, 'linear')) + + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_imshow_norm.png"], style='mpl20') +def test_multivariate_imshow_norm(): + """ + Test vmin, vmax and norm + Norm is checked via a LogNorm. + A LogNorm converts the input to a masked array, masking for X <= 0 + By using a LogNorm, this functionality is also tested. + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(3, 5) + + # interpolation='nearest' to reduce size of baseline image and + # removes ambiguity when using masked array (from LogNorm) + axes[0, 0].imshow(x_1, interpolation='nearest') + axes[0, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak') + axes[0, 2].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA') + axes[0, 3].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak') + axes[0, 4].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA') + + vmin = 1 + vmax = 3 + axes[1, 0].imshow(x_1, interpolation='nearest', vmin=vmin, vmax=vmax) + axes[1, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', + vmin=[vmin]*2, vmax=[vmax]*2) + axes[1, 2].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + vmin=[vmin]*3, vmax=[vmax]*3) + axes[1, 3].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', + vmin=(None, vmin), vmax=(None, vmax)) + axes[1, 4].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + vmin=(None, vmin, None), vmax=(None, vmax, None)) + + n = mcolors.LogNorm(vmin=1, vmax=5) + axes[2, 0].imshow(x_1, interpolation='nearest', norm=n) + axes[2, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', norm=(n, n)) + axes[2, 2].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + norm=(n, n, n)) + axes[2, 3].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', + norm=('linear', n)) + axes[2, 4].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + norm=('linear', n, 'linear')) + + remove_ticks_and_titles(fig) + + +@image_comparison(["bivariate_cmap_shapes.png"], style='mpl20') +def test_bivariate_cmap_shapes(): + x_0 = np.arange(100, dtype='float32').reshape(10, 10) % 10 + x_1 = np.arange(100, dtype='float32').reshape(10, 10).T % 10 + + fig, axes = plt.subplots(1, 4, figsize=(10, 2)) + + # shape = square + axes[0].imshow((x_0, x_1), cmap='BiPeak', vmin=(1, 1), vmax=(8, 8), + interpolation='nearest') + # shape = cone + axes[1].imshow((x_0, x_1), cmap='BiCone', vmin=(0.5, 0.5), vmax=(8.5, 8.5), + interpolation='nearest') + + # shape = ignore + cmap = mpl.bivar_colormaps['BiPeak'] + cmap = cmap.with_extremes(shape='ignore') + axes[2].imshow((x_0, x_1), cmap=cmap, vmin=(1, 1), vmax=(8, 8), + interpolation='nearest') + + # shape = circleignore + cmap = mpl.bivar_colormaps['BiCone'] + cmap = cmap.with_extremes(shape='circleignore') + axes[3].imshow((x_0, x_1), cmap=cmap, vmin=(0.5, 0.5), vmax=(8.5, 8.5), + interpolation='nearest') + remove_ticks_and_titles(fig) diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 22c3c818ae92..0f5e5af6e594 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -1241,3 +1241,510 @@ def test_colorbar_format_string_and_old(): plt.imshow([[0, 1]]) cb = plt.colorbar(format="{x}%") assert isinstance(cb._formatter, StrMethodFormatter) + + +@pytest.mark.parametrize('use_gridspec', [True, False]) +@image_comparison(['bivar_cbar_locationing.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_colorbar_bivar_location(use_gridspec): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3) + # ------------------- + locations = ['left', 'right', 'top', 'bottom'] + fig, axes = plt.subplots(2, 2) + for i, ax in enumerate(axes.ravel()): + mim = ax.imshow(data, cmap='BiOrangeBlue') + fig.colorbar_bivar(mim, location=locations[i], use_gridspec=use_gridspec) + + +@image_comparison(['bivar_cbar_locationing_constrained.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_colorbar_bivar_location_constrained(): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3) + # ------------------- + locations = ['left', 'right', 'top', 'bottom'] + fig, axes = plt.subplots(2, 2, constrained_layout='constrained') + for i, ax in enumerate(axes.ravel()): + mim = ax.imshow(data, cmap='BiOrangeBlue') + fig.colorbar_bivar(mim, location=locations[i]) + + +@image_comparison(['multivar_cbar_locationing.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_colorbar_multivar_location(): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3) + # ------------------- + locations = ['left', 'right', 'top', 'bottom'] + fig, axes = plt.subplots(2, 2) + for i, ax in enumerate(axes.ravel()): + mim = ax.imshow(data, cmap='2VarAddA') + fig.colorbar_multivar(mim, location=locations[i]) + + +@image_comparison(['multivar_cbar_locationing_constrained.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_colorbar_multivar_location_constrained(): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3) + # ------------------- + locations = ['left', 'right', 'top', 'bottom'] + fig, axes = plt.subplots(2, 2, constrained_layout='constrained') + for i, ax in enumerate(axes.ravel()): + mim = ax.imshow(data, cmap='2VarAddA') + fig.colorbar_multivar(mim, location=locations[i]) + + +@pytest.mark.parametrize('use_gridspec', [True, False]) +@image_comparison(['bivar_cbar_sharing.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_colorbar_bivar_sharing(use_gridspec): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3) + # ------------------- + plt.figure() + ax1 = plt.subplot(211, anchor='NE', aspect='equal') + plt.imshow(data, cmap='BiOrangeBlue') + ax2 = plt.subplot(223) + plt.imshow(data, cmap='BiOrangeBlue') + ax3 = plt.subplot(224) + plt.imshow(data, cmap='BiOrangeBlue') + + plt.colorbar_bivar(ax=[ax2, ax3, ax1], location='right', pad=0.0, shrink=0.5, + panchor=False, use_gridspec=use_gridspec) + plt.colorbar_bivar(ax=[ax2, ax3, ax1], location='left', shrink=0.5, + panchor=False, use_gridspec=use_gridspec) + plt.colorbar_bivar(ax=[ax1], location='bottom', panchor=False, + anchor=(0.8, 0.5), shrink=0.6, use_gridspec=use_gridspec) + + +@image_comparison(['multivar_cbar_sharing.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_colorbar_multivar_sharing(): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3, + np.arange(12).reshape((3, 4)) % 5) + # ------------------- + plt.figure() + ax1 = plt.subplot(211, anchor='NE', aspect='equal') + plt.imshow(data, cmap='3VarAddA') + ax2 = plt.subplot(223) + plt.imshow(data, cmap='3VarAddA') + ax3 = plt.subplot(224) + plt.imshow(data, cmap='3VarAddA') + + plt.colorbar_multivar(ax=[ax2, ax3, ax1], location='right') + plt.colorbar_multivar(ax=[ax2, ax3, ax1], location='left') + plt.colorbar_multivar(ax=[ax1], location='bottom') + + +@pytest.mark.parametrize('constrained', [False, True], + ids=['standard', 'constrained']) +def test_bivar_cbar_single_ax_panchor_east(constrained): + fig, ax = plt.subplots(constrained_layout=constrained) + ax.set_anchor('N') + assert ax.get_anchor() == 'N' + mp = ax.imshow([[[0, 1], [1, 2]], [[2, 1], [0, 1]]], cmap='BiOrangeBlue') + fig.colorbar_bivar(mp, panchor='E') + assert ax.get_anchor() == 'E' + + +def test_bivar_cbar_set_xylabel(): + fig, axes = plt.subplots(1, 2) + mp = axes[0].imshow([[[0, 1], [1, 2]], [[2, 1], [0, 1]]], cmap='BiOrangeBlue') + cb = fig.colorbar_bivar(mp, cax=axes[1]) + cb.set_ylabel('y') + cb.set_xlabel('x') + assert axes[1].get_ylabel() == 'y' + assert axes[1].get_xlabel() == 'x' + assert cb.xaxis is axes[1].xaxis + assert cb.yaxis is axes[1].yaxis + + +def test_bivar_cbar_log_no_scale(): + fig, axes = plt.subplots(1, 2) + mp = axes[0].imshow([[[100, 1], [10, 1]], [[0.5, 0], [0.3, 1]]], + cmap='BiOrangeBlue', + norm=['log', mcolors.NoNorm()], + ) + assert axes[1].get_yscale() == 'linear' + assert axes[1].get_xscale() == 'linear' + fig.colorbar_bivar(mp, cax=axes[1]) + assert axes[1].get_yscale() == 'log' + assert axes[1].get_xscale() == 'function' + + +def test_bivar_cbar_change_vmin_vmax(): + fig, axes = plt.subplots(1, 2) + mp = axes[0].imshow([[[100, 1], [10, 1]], [[0.5, 0], [0.3, 1]]], + cmap='BiOrangeBlue', + ) + fig.colorbar_bivar(mp, cax=axes[1]) + assert np.all(axes[1].get_ylim() == np.array([1, 100])) + assert np.all(axes[1].get_xlim() == np.array([0, 1])) + mp.colorizer.norm.vmin = [-1, -2] + mp.colorizer.norm.vmax = [3, 5] + assert np.all(axes[1].get_ylim() == np.array([-1, 3])) + assert np.all(axes[1].get_xlim() == np.array([-2, 5])) + + +def test_bivar_cbar_change_norms(): + fig, axes = plt.subplots(1, 2) + mp = axes[0].imshow([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='BiOrangeBlue', + ) + fig.colorbar_bivar(mp, cax=axes[1]) + assert axes[1].get_yscale() == 'linear' + assert axes[1].get_xscale() == 'linear' + mp.colorizer.norm = ['log', mcolors.NoNorm()] + assert axes[1].get_yscale() == 'log' + assert axes[1].get_xscale() == 'function' + + +def test_bivar_cbar_anchor(): + # right + fig, ax = plt.subplots(figsize=(6, 2)) + mp = ax.imshow([[[0, 1], [1, 2]], [[2, 1], [0, 1]]], cmap='BiOrangeBlue') + anchor = (1, 0.3) + shrink = 0.3 + cbar = fig.colorbar_bivar(mp, anchor=anchor, shrink=shrink) + + x0, y0, x1, y1 = ax.get_position().extents + cx0, cy0, cx1, cy1 = cbar.ax.get_position().extents + p0 = (y1 - y0) * anchor[1] + y0 + np.testing.assert_allclose( + [cy1, cy0], + [y1 * shrink + (1 - shrink) * p0, p0 * (1 - shrink) + y0 * shrink]) + + # left + fig, ax = plt.subplots(figsize=(6, 2)) + mp = ax.imshow([[[0, 1], [1, 2]], [[2, 1], [0, 1]]], cmap='BiOrangeBlue') + anchor = (1, 0.7) + shrink = 0.3 + cbar = fig.colorbar_bivar(mp, anchor=anchor, shrink=shrink, location='left') + + x0, y0, x1, y1 = ax.get_position().extents + cx0, cy0, cx1, cy1 = cbar.ax.get_position().extents + p0 = (y1 - y0) * anchor[1] + y0 + np.testing.assert_allclose( + [cy1, cy0], + [y1 * shrink + (1 - shrink) * p0, p0 * (1 - shrink) + y0 * shrink]) + + # top + fig, ax = plt.subplots(figsize=(2, 6)) + mp = ax.imshow([[[0, 1], [1, 2]], [[2, 1], [0, 1]]], cmap='BiOrangeBlue') + anchor = (0.3, 1) + shrink = 0.3 + cbar = fig.colorbar_bivar(mp, anchor=anchor, shrink=shrink, location='top') + + x0, y0, x1, y1 = ax.get_position().extents + cx0, cy0, cx1, cy1 = cbar.ax.get_position().extents + p0 = (x1 - x0) * anchor[0] + x0 + np.testing.assert_allclose( + [cx1, cx0], + [x1 * shrink + (1 - shrink) * p0, p0 * (1 - shrink) + x0 * shrink]) + + # bottom + fig, ax = plt.subplots(figsize=(2, 6)) + mp = ax.imshow([[[0, 1], [1, 2]], [[2, 1], [0, 1]]], cmap='BiOrangeBlue') + anchor = (0.3, 1) + shrink = 0.3 + cbar = fig.colorbar_bivar(mp, anchor=anchor, shrink=shrink, location='bottom') + + x0, y0, x1, y1 = ax.get_position().extents + cx0, cy0, cx1, cy1 = cbar.ax.get_position().extents + p0 = (x1 - x0) * anchor[0] + x0 + np.testing.assert_allclose( + [cx1, cx0], + [x1 * shrink + (1 - shrink) * p0, p0 * (1 - shrink) + x0 * shrink]) + + +def test_bivar_cbar_aspect(): + fig, ax = plt.subplots(1, 1) + mp = ax.imshow([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='BiOrangeBlue', + ) + cbar = fig.colorbar_bivar(mp, aspect=0.3) + assert cbar.ax.get_box_aspect() == 0.3 + assert cbar.ax._colorbar_info["aspect"] == 0.3 + assert cbar.aspect == 0.3 + cbar.aspect = 4 + assert cbar.ax.get_box_aspect() == 4 + assert cbar.ax._colorbar_info["aspect"] == 4 + assert cbar.aspect == 4 + + +def test_bivar_cbar_alpha(): + fig, ax = plt.subplots(1, 1) + mp = ax.imshow([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='BiOrangeBlue', + alpha=0.5, + ) + cbar = fig.colorbar_bivar(mp) + assert cbar._image._alpha == 0.5 + + +# If we decide in the future to disallow calling colorbar() on the "wrong" figure, +# just delete this test. +def test_bivar_cbar_wrong_figure(): + fig0, ax0 = plt.subplots() + fig1, ax1 = plt.subplots() + mp = ax0.imshow([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='BiOrangeBlue', + ) + with pytest.warns(UserWarning, match="different Figure"): + fig1.colorbar_bivar(mp) + + +@pytest.mark.parametrize('use_gridspec', [True, False]) +@pytest.mark.parametrize('nested_gridspecs', [True, False]) +def test_bivar_cbar_remove_from_figure(nested_gridspecs, use_gridspec): + """Test `remove` with the specified ``use_gridspec`` setting.""" + fig = plt.figure() + if nested_gridspecs: + gs = fig.add_gridspec(2, 2)[1, 1].subgridspec(2, 2) + ax = fig.add_subplot(gs[1, 1]) + else: + ax = fig.add_subplot() + mp = ax.pcolormesh([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='BiOrangeBlue', + ) + pre_position = ax.get_position() + cb = fig.colorbar_bivar(mp, use_gridspec=use_gridspec) + fig.subplots_adjust() + cb.remove() + fig.subplots_adjust() + post_position = ax.get_position() + assert (pre_position.get_points() == post_position.get_points()).all() + + +def test_multivar_cbar_remove_from_figure(): + """Test `remove` with the specified ``use_gridspec`` setting.""" + fig = plt.figure() + ax = fig.add_subplot() + mp = ax.pcolormesh([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='2VarAddA', + ) + pre_position = ax.get_position() + cb = fig.colorbar_multivar(mp) + fig.subplots_adjust() + cb.remove() + fig.subplots_adjust() + post_position = ax.get_position() + assert (pre_position.get_points() == post_position.get_points()).all() + + +def test_bivar_cbar_remove_with_no_mappable(): + fig, ax = plt.subplots() + norm = mpl.colors.MultiNorm(['linear', 'linear']) + ca = mpl.colorizer.Colorizer('BiOrangeBlue', norm) + cb = mpl.colorbar.BivarColorbar(ax, ca) + cb.remove() + + +def test_multivar_cbar_from_colorizer(): + fig, ax = plt.subplots() + norm = mpl.colors.MultiNorm(['linear', 'linear']) + ca = mpl.colorizer.Colorizer('2VarAddA', norm) + cb = fig.colorbar_multivar(ca, ax=ax) + + +def test_multivar_cbar_from_colorizer_cax(): + fig, axes = plt.subplots(1, 3) + norm = mpl.colors.MultiNorm(['linear', 'linear', 'linear']) + ca = mpl.colorizer.Colorizer('3VarAddA', norm) + cb = fig.colorbar_multivar(ca, caxes=axes) + + +def test_bivar_cbar_ticklocations(): + norm = mpl.colors.MultiNorm(['linear', 'linear']) + ca = mpl.colorizer.Colorizer('BiOrangeBlue', norm) + fig, ax = plt.subplots() + with pytest.raises(ValueError, match='ticklocations must be a tuple of'): + cbar = fig.colorbar_bivar(ca, cax=ax, ticklocations=['left']) + cbar = fig.colorbar_bivar(ca, cax=ax, ticklocations=['left', 'top']) + cbar.ax.yaxis.get_label_position() == 'left' + cbar.ax.xaxis.get_label_position() == 'top' + + +def test_wrong_kind_colorbar(): + fig, ax = plt.subplots(1, 1) + mp = ax.imshow([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='BiOrangeBlue', + ) + with pytest.raises(ValueError, match='can only be together with a scalar colormap'): + fig.colorbar(mp, ax=ax) + + fig, ax = plt.subplots(1, 1) + mp = ax.imshow([[100, 1], [10, 1]]) + with pytest.raises(ValueError, match='bivariate colorbar can only be used '): + fig.colorbar_bivar(mp, ax=ax) + + +def test_colorbar_bivar_set_get_view(): + # maybe not the best way to test this, this is normally used + # interacitively ._get_view() and ._set_view() are normally + # used in interactive mode + fig, axes = plt.subplots(1, 1) + mp = axes.imshow([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap='BiOrangeBlue', + ) + cb = fig.colorbar_bivar(mp, ax=axes) + # limits are x: (0.2, 1), y: (1, 100) + view = cb._get_view() + view = np.array(view) + 1 + cb._set_view(view) + # limits are x: (1.2, 2), y: (2, 101) + assert mp.colorizer.norm.norms[1].vmin == 1.2 + assert mp.colorizer.norm.norms[1].vmax == 2 + assert mp.colorizer.norm.norms[0].vmin == 2 + assert mp.colorizer.norm.norms[0].vmax == 101 + + +def test_colorbar_bivar_no_ax(): + norm = mpl.colors.MultiNorm(['linear', 'linear']) + ca = mpl.colorizer.Colorizer('BiOrangeBlue', norm) + fig, ax = plt.subplots() + with pytest.raises(ValueError, match='Unable to determine Axes'): + fig.colorbar_bivar(ca) + + +def test_colorbar_bivar_custom_norm(): + """ + This tests setting the correct scale for norms that do not have a + ._scale property. + + In the normal case, colorbar_bivar uses the ._scale property + of each norm to set the transform on each axis. + """ + class CustomHalfNorm(mcolors.Normalize): + def __init__(self): + super().__init__() + + @property + def vmin(self): + return 0 + + @vmin.setter + def vmin(self, val): + ... + + @property + def vmax(self): + return 1 + + @vmax.setter + def vmax(self, val): + ... + + @property + def clip(self): + return False + + @clip.setter + def clip(self, val): + ... + + def __call__(self, value, clip=None): + return value / 2 + + def inverse(self, value): + return 2 * value + + def autoscale(self, A): + pass + + def autoscale_None(self, A): + pass + + def scaled(self): + return True + + @property + def n_components(self): + return 1 + + norm = mpl.colors.MultiNorm([CustomHalfNorm(), CustomHalfNorm()]) + ca = mpl.colorizer.Colorizer('BiOrangeBlue', norm) + fig, ax = plt.subplots() + fig.colorbar_bivar(ca, cax=ax) + assert ax.get_yscale() == 'function' + assert ax.get_xscale() == 'function' + + +def test_colorbar_bivar_not_via_fig(): + # This test makes a colorbar without calling + # fig.colorbar_bivar() and without a ColorizingArtist + norm = mpl.colors.MultiNorm(['linear', 'linear']) + ca = mpl.colorizer.Colorizer('BiOrangeBlue', norm) + fig, ax = plt.subplots() + cb = mpl.colorbar.BivarColorbar(ax, ca) + assert cb.colorizer is ca + + +def test_remove_colorbar_with_no_mappable(): + fig, ax = plt.subplots() + cb = mpl.colorbar.Colorbar(ax) + cb.remove() + + +@image_comparison(['bivar_cbar_not_rasterized.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_bivar_cbar_not_rasterized(): + cm = mpl.bivar_colormaps['BiOrangeBlue'].resampled((5,3)) + fig, axes = plt.subplots(1, 1) + mp = axes.imshow([[[100, 1], [10, 1]], [[0.5, 0.2], [0.3, 1]]], + cmap=cm, + interpolation='nearest' + ) + fig.colorbar_bivar(mp) + + +@image_comparison(['multivar_cbar_n_major.png', + ], style='mpl20', + remove_text=True, savefig_kwarg={'dpi': 40}, tol=0.05) +def test_colorbar_multivar_n_major(): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3, + np.arange(12).reshape((3, 4)) % 5) + # ------------------- + fig, axes = plt.subplots(4, 3, figsize=(8, 8), constrained_layout='constrained') + locations = ['left', 'right', 'top', 'bottom'] + for i, axs in enumerate(axes): + for j, ax in enumerate(axs): + mim = ax.imshow(data, cmap='3VarAddA') + fig.colorbar_multivar(mim, n_major=j + 1, location=locations[i]) + + with pytest.raises(ValueError, match="cannot be zero"): + fig.colorbar_multivar(mim, n_major=0) + + +def test_cbar_wrong_figures(): + fig0, ax0 = plt.subplots() + fig1, ax1 = plt.subplots() + im0 = ax0.imshow([[0, 1], [2, 3]]) + im1 = ax1.imshow([[0, 1], [2, 3]]) + with pytest.raises(ValueError, match="not all parents share"): + fig0.colorbar(im0, ax=[im0, im1]) + + +def test_multivar_cbar_set_label_limits(): + data = (np.arange(12).reshape((3, 4)) % 4, + np.arange(12).reshape((3, 4)) % 3, + np.arange(12).reshape((3, 4)) % 5) + # ------------------- + fig, ax = plt.subplots(1, 1) + mim = ax.imshow(data, cmap='3VarAddA') + cbs = fig.colorbar_multivar(mim) + cbs[0].set_label('A') + assert len(cbs) == 3 + mim.norm.vmin = (-1, -1, -1) + mim.norm.vmax = (1, 2, 3) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 5bc1f8aea973..a17b2bd26df5 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1785,16 +1785,30 @@ def test_is_color_like(input, expected): assert is_color_like(input) is expected -def test_colorizer_vmin_vmax(): +def test_colorizer_vmin_vmax_clip(): ca = mcolorizer.Colorizer() - assert ca.vmin is None - assert ca.vmax is None + assert len(ca.vmin) == 1 + assert len(ca.vmax) == 1 + assert ca.vmin[0] is None + assert ca.vmax[0] is None ca.vmin = 1 ca.vmax = 3 - assert ca.vmin == 1.0 - assert ca.vmax == 3.0 + assert ca.vmin == (1.0, ) + assert ca.vmax == (3.0, ) assert ca.norm.vmin == 1.0 assert ca.norm.vmax == 3.0 + assert ca.clip == (False, ) + + ca = mcolorizer.Colorizer('BiOrangeBlue') + assert len(ca.vmin) == 2 + assert len(ca.vmax) == 2 + ca.vmin = (1, 2) + ca.vmax = (3, 4) + assert ca.vmin == (1.0, 2.0) + assert ca.vmax == (3.0, 4.0) + assert ca.norm.vmin == (1.0, 2.0) + assert ca.norm.vmax == (3.0, 4.0) + assert ca.clip == (False, False) def test_LinearSegmentedColormap_from_list_color_alpha_tuple(): diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index e193e0d9fd3e..20a7ca7b042c 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -2055,3 +2055,12 @@ def test_affine_fill_to_edges(): axs[i, j].vlines([-0.5, N - 0.5], N - 3, N, lw=0.5, color='red') axs[i, j].hlines([-0.5, N - 0.5], -1, 2, lw=0.5, color='red') axs[i, j].hlines([-0.5, N - 0.5], N - 3, N, lw=0.5, color='red') + + +def test_invalid_interpolation_stage_multinorm(): + fig, ax = plt.subplots() + data = np.arange(24).reshape((2, 3, 4)) + + with pytest.raises(ValueError, + match="'data' is the only valid interpolation_stage"): + ax.imshow(data, cmap='2VarAddA', interpolation_stage='rgba')