From b84775ccee0ecc62cce9d541acbc4901a486d553 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Thu, 3 Jun 2021 16:05:18 +0200 Subject: [PATCH] Merge Colorbar and ColorbarBase. ColorbarBase differs from Colorbar in that it is not associated with a ScalarMappable (but constructed from explicit cmap/norm), but we already document in Figure.colorbar that the preferred way to draw colorbars not associated with an existing artist is to create an empty ScalarMappable to provide `norm` and `cmap`. Hence, likewise merge Colorbar and ColorbarBase (creating the ScalarMappable on-the-fly if no mappable is passed to the constructor), which should make the APIs clearer. (Note that we are already discouraging users to directly call either class' constructors, anyways). We could deprecate the backcompat APIs (i.e., the `ColorbarBase` alias, the `cmap` and `norm` kwargs, and the different semantics of `add_lines`), but that can be done later; this PR should be entirely backcompatible. --- doc/users/prev_whats_new/whats_new_3.0.rst | 4 +- doc/users/prev_whats_new/whats_new_3.3.0.rst | 2 +- examples/color/colorbar_basics.py | 4 +- .../image_masked.py | 2 +- lib/matplotlib/colorbar.py | 321 ++++++++---------- lib/matplotlib/tests/test_colorbar.py | 7 + 6 files changed, 162 insertions(+), 178 deletions(-) diff --git a/doc/users/prev_whats_new/whats_new_3.0.rst b/doc/users/prev_whats_new/whats_new_3.0.rst index 3ca126eeddf6..8aaf4a2d1770 100644 --- a/doc/users/prev_whats_new/whats_new_3.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.0.rst @@ -60,11 +60,11 @@ frame. Padding and separation parameters can be adjusted. Add ``minorticks_on()/off()`` methods for colorbar -------------------------------------------------- -A new method `~.colorbar.ColorbarBase.minorticks_on` has been added to +A new method ``ColorbarBase.minorticks_on`` has been added to correctly display minor ticks on a colorbar. This method doesn't allow the minor ticks to extend into the regions beyond vmin and vmax when the *extend* keyword argument (used while creating the colorbar) is set to 'both', 'max' or -'min'. A complementary method `~.colorbar.ColorbarBase.minorticks_off` has +'min'. A complementary method ``ColorbarBase.minorticks_off`` has also been added to remove the minor ticks on the colorbar. diff --git a/doc/users/prev_whats_new/whats_new_3.3.0.rst b/doc/users/prev_whats_new/whats_new_3.3.0.rst index 04ca9d923f76..18c22e2cb3cb 100644 --- a/doc/users/prev_whats_new/whats_new_3.3.0.rst +++ b/doc/users/prev_whats_new/whats_new_3.3.0.rst @@ -285,7 +285,7 @@ Align labels to Axes edges -------------------------- `~.axes.Axes.set_xlabel`, `~.axes.Axes.set_ylabel` and -`.ColorbarBase.set_label` support a parameter ``loc`` for simplified +``ColorbarBase.set_label`` support a parameter ``loc`` for simplified positioning. For the xlabel, the supported values are 'left', 'center', or 'right'. For the ylabel, the supported values are 'bottom', 'center', or 'top'. diff --git a/examples/color/colorbar_basics.py b/examples/color/colorbar_basics.py index adc7e6d9c7fe..a80b7caf77df 100644 --- a/examples/color/colorbar_basics.py +++ b/examples/color/colorbar_basics.py @@ -54,5 +54,5 @@ # # - `matplotlib.axes.Axes.imshow` / `matplotlib.pyplot.imshow` # - `matplotlib.figure.Figure.colorbar` / `matplotlib.pyplot.colorbar` -# - `matplotlib.colorbar.ColorbarBase.minorticks_on` -# - `matplotlib.colorbar.ColorbarBase.minorticks_off` +# - `matplotlib.colorbar.Colorbar.minorticks_on` +# - `matplotlib.colorbar.Colorbar.minorticks_off` diff --git a/examples/images_contours_and_fields/image_masked.py b/examples/images_contours_and_fields/image_masked.py index 2aa88f4f6b64..b6dacd7114e4 100644 --- a/examples/images_contours_and_fields/image_masked.py +++ b/examples/images_contours_and_fields/image_masked.py @@ -79,4 +79,4 @@ # - `matplotlib.axes.Axes.imshow` / `matplotlib.pyplot.imshow` # - `matplotlib.figure.Figure.colorbar` / `matplotlib.pyplot.colorbar` # - `matplotlib.colors.BoundaryNorm` -# - `matplotlib.colorbar.ColorbarBase.set_label` +# - `matplotlib.colorbar.Colorbar.set_label` diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index 3a8bd2cdd0f1..0d20a59d67c3 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -4,28 +4,11 @@ .. note:: Colorbars are typically created through `.Figure.colorbar` or its pyplot - wrapper `.pyplot.colorbar`, which use `.make_axes` and `.Colorbar` - internally. + wrapper `.pyplot.colorbar`, which internally use `.Colorbar` together with + `.make_axes_gridspec` (for `.GridSpec`-positioned axes) or `.make_axes` (for + non-`.GridSpec`-positioned axes). - As an end-user, you most likely won't have to call the methods or - instantiate the classes in this module explicitly. - -:class:`ColorbarBase` - The base class with full colorbar drawing functionality. - It can be used as-is to make a colorbar for a given colormap; - a mappable object (e.g., image) is not needed. - -:class:`Colorbar` - On top of `.ColorbarBase` this connects the colorbar with a - `.ScalarMappable` such as an image or contour plot. - -:func:`make_axes` - Create an `~.axes.Axes` suitable for a colorbar. This functions can be - used with figures containing a single axes or with freely placed axes. - -:func:`make_axes_gridspec` - Create a `.SubplotBase` suitable for a colorbar. This function should - be used for adding a colorbar to a `.GridSpec`. + End-users most likely won't need to directly use this module's API. """ import copy @@ -173,7 +156,6 @@ Returns ------- colorbar : `~matplotlib.colorbar.Colorbar` - See also its base class, `~matplotlib.colorbar.ColorbarBase`. Notes ----- @@ -220,7 +202,7 @@ def _set_ticks_on_axis_warn(*args, **kw): # a top level function which gets put in at the axes' - # set_xticks and set_yticks by ColorbarBase.__init__. + # set_xticks and set_yticks by Colorbar.__init__. _api.warn_external("Use the colorbar set_ticks() method instead.") @@ -312,30 +294,18 @@ def draw(self, renderer): return ret -class ColorbarBase: +class Colorbar: r""" Draw a colorbar in an existing axes. - There are only some rare cases in which you would work directly with a - `.ColorbarBase` as an end-user. Typically, colorbars are used - with `.ScalarMappable`\s such as an `.AxesImage` generated via - `~.axes.Axes.imshow`. For these cases you will use `.Colorbar` and - likely create it via `.pyplot.colorbar` or `.Figure.colorbar`. - - The main application of using a `.ColorbarBase` explicitly is drawing - colorbars that are not associated with other elements in the figure, e.g. - when showing a colormap by itself. - - If the *cmap* kwarg is given but *boundaries* and *values* are left as - None, then the colormap will be displayed on a 0-1 scale. To show the - under- and over-value colors, specify the *norm* as:: + Typically, colorbars are created using `.Figure.colorbar` or + `.pyplot.colorbar` and associated with `.ScalarMappable`\s (such as an + `.AxesImage` generated via `~.axes.Axes.imshow`). - norm=colors.Normalize(clip=False) - - To show the colors versus index instead of on the 0-1 scale, - use:: - - norm=colors.NoNorm() + In order to draw a colorbar not associated with other elements in the + figure, e.g. when showing a colormap by itself, one can create an empty + `.ScalarMappable`, or directly pass *cmap* and *norm* instead of *mappable* + to `Colorbar`. Useful public methods are :meth:`set_label` and :meth:`add_lines`. @@ -352,16 +322,32 @@ class ColorbarBase: ---------- ax : `~matplotlib.axes.Axes` The `~.axes.Axes` instance in which the colorbar is drawn. + + mappable : `.ScalarMappable` + The mappable whose colormap and norm will be used. + + To show the under- and over- value colors, the mappable's norm should + be specified as :: + + norm = colors.Normalize(clip=False) + + To show the colors versus index instead of on a 0-1 scale, use:: + + norm=colors.NoNorm() + cmap : `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` - The colormap to use. + The colormap to use. This parameter is ignored, unless *mappable* is + None. + norm : `~matplotlib.colors.Normalize` + The normalization to use. This parameter is ignored, unless *mappable* + is None. alpha : float The colorbar transparency between 0 (transparent) and 1 (opaque). - values - - boundaries + values, boundaries + If unset, the colormap will be displayed on a 0-1 scale. orientation : {'vertical', 'horizontal'} @@ -391,7 +377,7 @@ class ColorbarBase: n_rasterize = 50 # rasterize solids if number of colors >= n_rasterize - def __init__(self, ax, *, cmap=None, + def __init__(self, ax, mappable=None, *, cmap=None, norm=None, alpha=None, values=None, @@ -409,7 +395,35 @@ def __init__(self, ax, *, cmap=None, label='', userax=False, ): - _api.check_isinstance([colors.Colormap, None], cmap=cmap) + + if mappable is None: + mappable = cm.ScalarMappable(norm=norm, cmap=cmap) + + # Ensure the given mappable's norm has appropriate vmin and vmax + # set even if mappable.draw has not yet been called. + if mappable.get_array() is not None: + mappable.autoscale_None() + + self.mappable = mappable + cmap = mappable.cmap + norm = mappable.norm + + if isinstance(mappable, contour.ContourSet): + cs = mappable + alpha = cs.get_alpha() + boundaries = cs._levels + values = cs.cvalues + extend = cs.extend + filled = cs.filled + if ticks is None: + ticks = ticker.FixedLocator(cs.levels, nbins=10) + elif isinstance(mappable, martist.Artist): + alpha = mappable.get_alpha() + + mappable.colorbar = self + mappable.colorbar_cid = mappable.callbacksSM.connect( + 'changed', self.update_normal) + _api.check_in_list( ['vertical', 'horizontal'], orientation=orientation) _api.check_in_list( @@ -423,12 +437,11 @@ def __init__(self, ax, *, cmap=None, self.ax = ax ax.set(navigate=False) - if cmap is None: - cmap = cm.get_cmap() - if norm is None: - norm = colors.Normalize() if extend is None: - if hasattr(norm, 'extend'): + if (not isinstance(mappable, contour.ContourSet) + and getattr(cmap, 'colorbar_extend', False) is not False): + extend = cmap.colorbar_extend + elif hasattr(norm, 'extend'): extend = norm.extend else: extend = 'neither' @@ -491,6 +504,37 @@ def __init__(self, ax, *, cmap=None, self.formatter = format # Assume it is a Formatter or None self.draw_all() + if isinstance(mappable, contour.ContourSet) and not mappable.filled: + self.add_lines(mappable) + + def update_normal(self, mappable): + """ + Update solid patches, lines, etc. + + This is meant to be called when the norm of the image or contour plot + to which this colorbar belongs changes. + + If the norm on the mappable is different than before, this resets the + locator and formatter for the axis, so if these have been customized, + they will need to be customized again. However, if the norm only + changes values of *vmin*, *vmax* or *cmap* then the old formatter + and locator will be preserved. + """ + _log.debug('colorbar update normal %r %r', mappable.norm, self.norm) + self.mappable = mappable + self.set_alpha(mappable.get_alpha()) + self.cmap = mappable.cmap + if mappable.norm != self.norm: + self.norm = mappable.norm + self._reset_locator_formatter_scale() + + self.draw_all() + if isinstance(self.mappable, contour.ContourSet): + CS = self.mappable + if not CS.filled: + self.add_lines(CS) + self.stale = True + def draw_all(self): """ Calculate any free parameters based on the current cmap and norm, @@ -657,7 +701,7 @@ def _do_extends(self, extendlen): self.ax.outer_ax.add_patch(patch) return - def add_lines(self, levels, colors, linewidths, erase=True): + def add_lines(self, *args, **kwargs): """ Draw lines on the colorbar. @@ -675,7 +719,31 @@ def add_lines(self, levels, colors, linewidths, erase=True): for each line. erase : bool, default: True Whether to remove any previously added lines. + + Notes + ----- + Alternatively, this method can also be called with the signature + ``colorbar.add_lines(contour_set, erase=True)``, in which case + *levels*, *colors*, and *linewidths* are taken from *contour_set*. """ + params = _api.select_matching_signature( + [lambda self, CS, erase=True: locals(), + lambda self, levels, colors, linewidths, erase=True: locals()], + self, *args, **kwargs) + if "CS" in params: + self, CS, erase = params.values() + if not isinstance(CS, contour.ContourSet) or CS.filled: + raise ValueError("If a single artist is passed to add_lines, " + "it must be a ContourSet of lines") + # TODO: Make colorbar lines auto-follow changes in contour lines. + return self.add_lines( + CS.levels, + [c[0] for c in CS.tcolors], + [t[0] for t in CS.tlinewidths], + erase=erase) + else: + self, levels, colors, linewidths, erase = params.values() + y = self._locate(levels) rtol = (self._y[-1] - self._y[0]) * 1e-10 igood = (y < self._y[-1] + rtol) & (y > self._y[0] - rtol) @@ -875,10 +943,34 @@ def set_alpha(self, alpha): self.alpha = alpha def remove(self): - """Remove this colorbar from the figure.""" + """ + Remove this colorbar from the figure. + + If the colorbar was created with ``use_gridspec=True`` the previous + gridspec is restored. + """ self.ax.inner_ax.remove() self.ax.outer_ax.remove() + self.mappable.callbacksSM.disconnect(self.mappable.colorbar_cid) + self.mappable.colorbar = None + self.mappable.colorbar_cid = None + + try: + ax = self.mappable.axes + except AttributeError: + return + try: + gs = ax.get_subplotspec().get_gridspec() + subplotspec = gs.get_topmost_subplotspec() + 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 _ticker(self, locator, formatter): """ Return the sequence of ticks (colorbar data locations), @@ -1152,122 +1244,7 @@ def _short_axis(self): return self.ax.yaxis -class Colorbar(ColorbarBase): - """ - This class connects a `ColorbarBase` to a `~.cm.ScalarMappable` - such as an `~.image.AxesImage` generated via `~.axes.Axes.imshow`. - - .. note:: - This class is not intended to be instantiated directly; instead, use - `.Figure.colorbar` or `.pyplot.colorbar` to create a colorbar. - """ - - def __init__(self, ax, mappable, **kwargs): - # Ensure the given mappable's norm has appropriate vmin and vmax set - # even if mappable.draw has not yet been called. - if mappable.get_array() is not None: - mappable.autoscale_None() - - self.mappable = mappable - kwargs.update({"cmap": mappable.cmap, "norm": mappable.norm}) - - if isinstance(mappable, contour.ContourSet): - cs = mappable - kwargs.update({"alpha": cs.get_alpha(), - "boundaries": cs._levels, - "values": cs.cvalues, - "extend": cs.extend, - "filled": cs.filled}) - kwargs.setdefault( - 'ticks', ticker.FixedLocator(cs.levels, nbins=10)) - super().__init__(ax, **kwargs) - if not cs.filled: - self.add_lines(cs) - else: - if getattr(mappable.cmap, 'colorbar_extend', False) is not False: - kwargs.setdefault('extend', mappable.cmap.colorbar_extend) - if isinstance(mappable, martist.Artist): - kwargs.update({"alpha": mappable.get_alpha()}) - super().__init__(ax, **kwargs) - - mappable.colorbar = self - mappable.colorbar_cid = mappable.callbacksSM.connect( - 'changed', self.update_normal) - - def add_lines(self, CS, erase=True): - """ - Add the lines from a non-filled `~.contour.ContourSet` to the colorbar. - - Parameters - ---------- - CS : `~.contour.ContourSet` - The line positions are taken from the ContourSet levels. The - ContourSet must not be filled. - erase : bool, default: True - Whether to remove any previously added lines. - """ - if not isinstance(CS, contour.ContourSet) or CS.filled: - raise ValueError('add_lines is only for a ContourSet of lines') - tcolors = [c[0] for c in CS.tcolors] - tlinewidths = [t[0] for t in CS.tlinewidths] - # Wishlist: Make colorbar lines auto-follow changes in contour lines. - super().add_lines(CS.levels, tcolors, tlinewidths, erase=erase) - - def update_normal(self, mappable): - """ - Update solid patches, lines, etc. - - This is meant to be called when the norm of the image or contour plot - to which this colorbar belongs changes. - - If the norm on the mappable is different than before, this resets the - locator and formatter for the axis, so if these have been customized, - they will need to be customized again. However, if the norm only - changes values of *vmin*, *vmax* or *cmap* then the old formatter - and locator will be preserved. - """ - _log.debug('colorbar update normal %r %r', mappable.norm, self.norm) - self.mappable = mappable - self.set_alpha(mappable.get_alpha()) - self.cmap = mappable.cmap - if mappable.norm != self.norm: - self.norm = mappable.norm - self._reset_locator_formatter_scale() - - self.draw_all() - if isinstance(self.mappable, contour.ContourSet): - CS = self.mappable - if not CS.filled: - self.add_lines(CS) - self.stale = True - - def remove(self): - """ - Remove this colorbar from the figure. - - If the colorbar was created with ``use_gridspec=True`` the previous - gridspec is restored. - """ - super().remove() - self.mappable.callbacksSM.disconnect(self.mappable.colorbar_cid) - self.mappable.colorbar = None - self.mappable.colorbar_cid = None - - try: - ax = self.mappable.axes - except AttributeError: - return - - try: - gs = ax.get_subplotspec().get_gridspec() - subplotspec = gs.get_topmost_subplotspec() - 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) +ColorbarBase = Colorbar # Backcompat API def _normalize_location_orientation(location, orientation): diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index 6167618575dd..bb4781f131ed 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -785,3 +785,10 @@ def test_twoslope_colorbar(): np.arange(100).reshape(10, 10), norm=norm, cmap='RdBu_r') fig.colorbar(pc) + + +@check_figures_equal(extensions=["png"]) +def test_remove_cb_whose_mappable_has_no_figure(fig_ref, fig_test): + ax = fig_test.add_subplot() + cb = fig_test.colorbar(cm.ScalarMappable(), cax=ax) + cb.remove()