From 0cd51bec31bbfc2667bc6f91fb64e68785e3470b Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Sun, 14 Feb 2021 20:38:48 -0700 Subject: [PATCH 1/7] ENH: Adding zoom and pan to colorbar The zoom and pan funcitons change the vmin/vmax of the norm attached to the colorbar. The colorbar is rendered as an inset axis, but the event handler is implemented on the parent axis. --- .../next_api_changes/behavior/19515-GL.rst | 6 ++++ lib/matplotlib/colorbar.py | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 doc/api/next_api_changes/behavior/19515-GL.rst diff --git a/doc/api/next_api_changes/behavior/19515-GL.rst b/doc/api/next_api_changes/behavior/19515-GL.rst new file mode 100644 index 000000000000..2b5c1bb88e2a --- /dev/null +++ b/doc/api/next_api_changes/behavior/19515-GL.rst @@ -0,0 +1,6 @@ +Colorbars now have pan and zoom functionality +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Interactive plots with colorbars can now be zoomed and panned on +the colorbar axis. This adjusts the *vmin* and *vmax* of the +``ScalarMappable`` associated with the colorbar. diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index d826649af167..bf335e20560a 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -1274,6 +1274,36 @@ def _short_axis(self): return self.ax.xaxis return self.ax.yaxis + def _get_view(self): + # docstring inherited + # An interactive view for a colorbar is the norm's vmin/vmax + return self.norm.vmin, self.norm.vmax + + def _set_view(self, view): + # docstring inherited + # An interactive view for a colorbar is the norm's vmin/vmax + self.norm.vmin, self.norm.vmax = view + + def _set_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + # docstring inherited + # For colorbars, we use the zoom bbox to scale the norm's vmin/vmax + new_xbound, new_ybound = self.ax._prepare_view_from_bbox( + bbox, direction=direction, mode=mode, twinx=twinx, twiny=twiny) + if self.orientation == 'horizontal': + self.norm.vmin, self.norm.vmax = new_xbound + elif self.orientation == 'vertical': + self.norm.vmin, self.norm.vmax = new_ybound + + def drag_pan(self, button, key, x, y): + # docstring inherited + points = self.ax._get_pan_points(button, key, x, y) + if points is not None: + if self.orientation == 'horizontal': + self.norm.vmin, self.norm.vmax = points[:, 0] + elif self.orientation == 'vertical': + self.norm.vmin, self.norm.vmax = points[:, 1] + ColorbarBase = Colorbar # Backcompat API From dc0fd659c4b1b6c13f31505a85b9360b4ca7db7f Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Mon, 31 May 2021 13:40:47 -0600 Subject: [PATCH 2/7] MAINT: Refactor zoom/pan code to use helpers This helps for subclasses in finding the zoom/pan locations by not having to duplicate the code used to determine the x/y locations of the zoom or pan. --- lib/matplotlib/axes/_base.py | 124 +++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index bcac6bbbf331..458a1e273c1e 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -4255,41 +4255,14 @@ def _set_view(self, view): self.set_xlim((xmin, xmax)) self.set_ylim((ymin, ymax)) - def _set_view_from_bbox(self, bbox, direction='in', - mode=None, twinx=False, twiny=False): + def _prepare_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): """ - Update view from a selection bbox. - - .. note:: - - Intended to be overridden by new projection types, but if not, the - default implementation sets the view limits to the bbox directly. - - Parameters - ---------- - bbox : 4-tuple or 3 tuple - * If bbox is a 4 tuple, it is the selected bounding box limits, - in *display* coordinates. - * If bbox is a 3 tuple, it is an (xp, yp, scl) triple, where - (xp, yp) is the center of zooming and scl the scale factor to - zoom by. - - direction : str - The direction to apply the bounding box. - * `'in'` - The bounding box describes the view directly, i.e., - it zooms in. - * `'out'` - The bounding box describes the size to make the - existing view, i.e., it zooms out. - - mode : str or None - The selection mode, whether to apply the bounding box in only the - `'x'` direction, `'y'` direction or both (`None`). + Helper function to prepare the new bounds from a bbox. - twinx : bool - Whether this axis is twinned in the *x*-direction. - - twiny : bool - Whether this axis is twinned in the *y*-direction. + This helper function returns the new x and y bounds from the zoom + bbox. This a convenience method to abstract the bbox logic + out of the base setter. """ if len(bbox) == 3: xp, yp, scl = bbox # Zooming code @@ -4360,6 +4333,46 @@ def _set_view_from_bbox(self, bbox, direction='in', symax1 = symax0 + factor * (symax0 - symax) new_ybound = y_trf.inverted().transform([symin1, symax1]) + return new_xbound, new_ybound + + def _set_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + """ + Update view from a selection bbox. + + .. note:: + + Intended to be overridden by new projection types, but if not, the + default implementation sets the view limits to the bbox directly. + + Parameters + ---------- + bbox : 4-tuple or 3 tuple + * If bbox is a 4 tuple, it is the selected bounding box limits, + in *display* coordinates. + * If bbox is a 3 tuple, it is an (xp, yp, scl) triple, where + (xp, yp) is the center of zooming and scl the scale factor to + zoom by. + + direction : str + The direction to apply the bounding box. + * `'in'` - The bounding box describes the view directly, i.e., + it zooms in. + * `'out'` - The bounding box describes the size to make the + existing view, i.e., it zooms out. + + mode : str or None + The selection mode, whether to apply the bounding box in only the + `'x'` direction, `'y'` direction or both (`None`). + + twinx : bool + Whether this axis is twinned in the *x*-direction. + + twiny : bool + Whether this axis is twinned in the *y*-direction. + """ + new_xbound, new_ybound = self._prepare_view_from_bbox( + bbox, direction=direction, mode=mode, twinx=twinx, twiny=twiny) if not twinx and mode != "y": self.set_xbound(new_xbound) self.set_autoscalex_on(False) @@ -4400,22 +4413,13 @@ def end_pan(self): """ del self._pan_start - def drag_pan(self, button, key, x, y): + def _get_pan_points(self, button, key, x, y): """ - Called when the mouse moves during a pan operation. + Helper function to return the new points after a pan. - Parameters - ---------- - button : `.MouseButton` - The pressed mouse button. - key : str or None - The pressed key, if any. - x, y : float - The mouse coordinates in display coords. - - Notes - ----- - This is intended to be overridden by new projection types. + This helper function returns the points on the axis after a pan has + occurred. This a convenience method to abstract the pan logic + out of the base setter. """ def format_deltas(key, dx, dy): if key == 'control': @@ -4469,8 +4473,30 @@ def format_deltas(key, dx, dy): points = result.get_points().astype(object) # Just ignore invalid limits (typically, underflow in log-scale). points[~valid] = None - self.set_xlim(points[:, 0]) - self.set_ylim(points[:, 1]) + return points + + + def drag_pan(self, button, key, x, y): + """ + Called when the mouse moves during a pan operation. + + Parameters + ---------- + button : `.MouseButton` + The pressed mouse button. + key : str or None + The pressed key, if any. + x, y : float + The mouse coordinates in display coords. + + Notes + ----- + This is intended to be overridden by new projection types. + """ + points = self._get_pan_points(button, key, x, y) + if points is not None: + self.set_xlim(points[:, 0]) + self.set_ylim(points[:, 1]) def get_children(self): # docstring inherited. From 48d06632cfa9cf6fbb1a48c05d71009f412433bf Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 8 Jul 2021 20:02:41 -0600 Subject: [PATCH 3/7] MNT: Updating Colorbar zoom rectangle and selections Setting the zoom selector rectangle to the vertical/horizontal limits of the colorbar, depending on the orientation. Remove some mappable types from colorbar navigation. Certain mappables, like categoricals and contours shouldn't be mapped by default due to the limits of an axis carrying certain meaning. So, turn that off for now and potentially revisit in the future. --- lib/matplotlib/axes/_base.py | 3 +-- lib/matplotlib/backend_bases.py | 9 +++++++++ lib/matplotlib/colorbar.py | 11 ++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 458a1e273c1e..ec4dc2c58e6f 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -4418,7 +4418,7 @@ def _get_pan_points(self, button, key, x, y): Helper function to return the new points after a pan. This helper function returns the points on the axis after a pan has - occurred. This a convenience method to abstract the pan logic + occurred. This is a convenience method to abstract the pan logic out of the base setter. """ def format_deltas(key, dx, dy): @@ -4475,7 +4475,6 @@ def format_deltas(key, dx, dy): points[~valid] = None return points - def drag_pan(self, button, key, x, y): """ Called when the mouse moves during a pan operation. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index fda7bd1c9613..c5f11d9674bf 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3166,6 +3166,15 @@ def drag_zoom(self, event): y1, y2 = ax.bbox.intervaly elif event.key == "y": x1, x2 = ax.bbox.intervalx + + # A colorbar is one-dimensional, so we extend the zoom rectangle out + # to the edge of the axes bbox in the other dimension + if hasattr(ax, "_colorbar"): + if ax._colorbar.orientation == 'horizontal': + y1, y2 = ax.bbox.intervaly + else: + x1, x2 = ax.bbox.intervalx + self.draw_rubberband(event, x1, y1, x2, y2) def release_zoom(self, event): diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index bf335e20560a..bd86968d2e9e 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -422,7 +422,6 @@ def __init__(self, ax, mappable=None, *, cmap=None, self.ax = ax self.ax._axes_locator = _ColorbarAxesLocator(self) - ax.set(navigate=False) if extend is None: if (not isinstance(mappable, contour.ContourSet) @@ -496,6 +495,16 @@ def __init__(self, ax, mappable=None, *, cmap=None, if isinstance(mappable, contour.ContourSet) and not mappable.filled: self.add_lines(mappable) + # Link the Axes and Colorbar for interactive use + self.ax._colorbar = self + for x in ["_get_view", "_set_view", "_set_view_from_bbox", + "drag_pan", "start_pan", "end_pan"]: + setattr(self.ax, x, getattr(self, x)) + # Don't navigate on any of these types of mappables + if (isinstance(self.norm, (colors.BoundaryNorm, colors.NoNorm)) or + isinstance(self.mappable, contour.ContourSet)): + self.ax.set_navigate(False) + # Also remove ._patch after deprecation elapses. patch = _api.deprecate_privatize_attribute("3.5", alternative="ax") From 011551f007bffcb16eb6a4fb4eac6665260f9ea4 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 9 Jul 2021 13:59:49 -0600 Subject: [PATCH 4/7] TST: Adding interactive colorbar tests Adding tests for vertical and horizontal placements, zoom in, zoom out, and pan. Also verifying that a colorbar on a Contourset is not able to be interacted with. --- lib/matplotlib/tests/test_backend_bases.py | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 1550d3256c04..04e2f5223c27 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -181,6 +181,63 @@ def test_interactive_zoom(): assert not ax.get_autoscalex_on() and not ax.get_autoscaley_on() +@pytest.mark.parametrize("plot_func", ["imshow", "contourf"]) +@pytest.mark.parametrize("orientation", ["vertical", "horizontal"]) +@pytest.mark.parametrize("tool,button,expected", + [("zoom", MouseButton.LEFT, (4, 6)), # zoom in + ("zoom", MouseButton.RIGHT, (-20, 30)), # zoom out + ("pan", MouseButton.LEFT, (-2, 8))]) +def test_interactive_colorbar(plot_func, orientation, tool, button, expected): + fig, ax = plt.subplots() + data = np.arange(12).reshape((4, 3)) + vmin0, vmax0 = 0, 10 + coll = getattr(ax, plot_func)(data, vmin=vmin0, vmax=vmax0) + + cb = fig.colorbar(coll, ax=ax, orientation=orientation) + if plot_func == "contourf": + # Just determine we can't navigate and exit out of the test + assert not cb.ax.get_navigate() + return + + assert cb.ax.get_navigate() + + # Mouse from 4 to 6 (data coordinates, "d"). + # The y coordinate doesn't matter, just needs to be between 0 and 1 + vmin, vmax = 4, 6 + d0 = (vmin, 0.1) + d1 = (vmax, 0.9) + # Swap them if the orientation is vertical + if orientation == "vertical": + d0 = d0[::-1] + d1 = d1[::-1] + # Convert to screen coordinates ("s"). Events are defined only with pixel + # precision, so round the pixel values, and below, check against the + # corresponding xdata/ydata, which are close but not equal to d0/d1. + s0 = cb.ax.transData.transform(d0).astype(int) + s1 = cb.ax.transData.transform(d1).astype(int) + + # Set up the mouse movements + start_event = MouseEvent( + "button_press_event", fig.canvas, *s0, button) + stop_event = MouseEvent( + "button_release_event", fig.canvas, *s1, button) + + tb = NavigationToolbar2(fig.canvas) + if tool == "zoom": + tb.zoom() + tb.press_zoom(start_event) + tb.drag_zoom(stop_event) + tb.release_zoom(stop_event) + else: + tb.pan() + tb.press_pan(start_event) + tb.drag_pan(stop_event) + tb.release_pan(stop_event) + + # Should be close, but won't be exact due to screen integer resolution + assert (cb.vmin, cb.vmax) == pytest.approx(expected, abs=0.15) + + def test_toolbar_zoompan(): expected_warning_regex = ( r"Treat the new Tool classes introduced in " From b4ddd97c65b077aebc7e11569e284a37edffd367 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 16 Sep 2021 17:33:58 -0600 Subject: [PATCH 5/7] DOC: Updating API release note for colorbar interactivity --- doc/api/next_api_changes/behavior/19515-GL.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/api/next_api_changes/behavior/19515-GL.rst b/doc/api/next_api_changes/behavior/19515-GL.rst index 2b5c1bb88e2a..cb6d925b797c 100644 --- a/doc/api/next_api_changes/behavior/19515-GL.rst +++ b/doc/api/next_api_changes/behavior/19515-GL.rst @@ -3,4 +3,8 @@ Colorbars now have pan and zoom functionality Interactive plots with colorbars can now be zoomed and panned on the colorbar axis. This adjusts the *vmin* and *vmax* of the -``ScalarMappable`` associated with the colorbar. +``ScalarMappable`` associated with the colorbar. This is currently +only enabled for continuous norms. Norms used with contourf and +categoricals, such as ``BoundaryNorm`` and ``NoNorm``, have the +interactive capability disabled by default. ``cb.ax.set_navigate()`` +can be used to set whether a colorbar axes is interactive or not. From e1f9cc914a74e1a1aef0bf4f8f32e9e00d4896cb Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 16 Sep 2021 17:34:33 -0600 Subject: [PATCH 6/7] FIX: Add cla() function to colorbar interactivity This adds logic to remove the colorbar interactivity and replace the axes it is drawn in with the original interactive routines. --- lib/matplotlib/colorbar.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index bd86968d2e9e..a7b59294221b 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -497,14 +497,27 @@ def __init__(self, ax, mappable=None, *, cmap=None, # Link the Axes and Colorbar for interactive use self.ax._colorbar = self - for x in ["_get_view", "_set_view", "_set_view_from_bbox", - "drag_pan", "start_pan", "end_pan"]: - setattr(self.ax, x, getattr(self, x)) # Don't navigate on any of these types of mappables if (isinstance(self.norm, (colors.BoundaryNorm, colors.NoNorm)) or isinstance(self.mappable, contour.ContourSet)): self.ax.set_navigate(False) + # These are the functions that set up interactivity on this colorbar + 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)) + # Set the cla function to the cbar's method to override it + self.ax.cla = self._cbar_cla + + 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() + # Also remove ._patch after deprecation elapses. patch = _api.deprecate_privatize_attribute("3.5", alternative="ax") From 21fc347b98a2a323d789a4411525a132b21e1765 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Sat, 18 Sep 2021 16:53:14 -0600 Subject: [PATCH 7/7] FIX: Allow colorbar zoom when the short-axis values are close Colorbars are one-dimensional, so we don't want to cancel the zoom based on the short-axis. This also updates the test to account for this case. --- lib/matplotlib/backend_bases.py | 42 ++++++++++++++-------- lib/matplotlib/tests/test_backend_bases.py | 9 +++-- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index c5f11d9674bf..487ab96851da 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3137,7 +3137,7 @@ def zoom(self, *args): a.set_navigate_mode(self.mode._navigate_mode) self.set_message(self.mode) - _ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid") + _ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid cbar") def press_zoom(self, event): """Callback for mouse button press in zoom to rect mode.""" @@ -3152,9 +3152,16 @@ def press_zoom(self, event): self.push_current() # set the home button to this view id_zoom = self.canvas.mpl_connect( "motion_notify_event", self.drag_zoom) + # A colorbar is one-dimensional, so we extend the zoom rectangle out + # to the edge of the axes bbox in the other dimension. To do that we + # store the orientation of the colorbar for later. + if hasattr(axes[0], "_colorbar"): + cbar = axes[0]._colorbar.orientation + else: + cbar = None self._zoom_info = self._ZoomInfo( direction="in" if event.button == 1 else "out", - start_xy=(event.x, event.y), axes=axes, cid=id_zoom) + start_xy=(event.x, event.y), axes=axes, cid=id_zoom, cbar=cbar) def drag_zoom(self, event): """Callback for dragging in zoom mode.""" @@ -3162,19 +3169,17 @@ def drag_zoom(self, event): ax = self._zoom_info.axes[0] (x1, y1), (x2, y2) = np.clip( [start_xy, [event.x, event.y]], ax.bbox.min, ax.bbox.max) - if event.key == "x": + key = event.key + # Force the key on colorbars to extend the short-axis bbox + if self._zoom_info.cbar == "horizontal": + key = "x" + elif self._zoom_info.cbar == "vertical": + key = "y" + if key == "x": y1, y2 = ax.bbox.intervaly - elif event.key == "y": + elif key == "y": x1, x2 = ax.bbox.intervalx - # A colorbar is one-dimensional, so we extend the zoom rectangle out - # to the edge of the axes bbox in the other dimension - if hasattr(ax, "_colorbar"): - if ax._colorbar.orientation == 'horizontal': - y1, y2 = ax.bbox.intervaly - else: - x1, x2 = ax.bbox.intervalx - self.draw_rubberband(event, x1, y1, x2, y2) def release_zoom(self, event): @@ -3188,10 +3193,17 @@ def release_zoom(self, event): self.remove_rubberband() start_x, start_y = self._zoom_info.start_xy + key = event.key + # Force the key on colorbars to ignore the zoom-cancel on the + # short-axis side + if self._zoom_info.cbar == "horizontal": + key = "x" + elif self._zoom_info.cbar == "vertical": + key = "y" # Ignore single clicks: 5 pixels is a threshold that allows the user to # "cancel" a zoom action by zooming by less than 5 pixels. - if ((abs(event.x - start_x) < 5 and event.key != "y") - or (abs(event.y - start_y) < 5 and event.key != "x")): + if ((abs(event.x - start_x) < 5 and key != "y") or + (abs(event.y - start_y) < 5 and key != "x")): self.canvas.draw_idle() self._zoom_info = None return @@ -3205,7 +3217,7 @@ def release_zoom(self, event): for prev in self._zoom_info.axes[:i]) ax._set_view_from_bbox( (start_x, start_y, event.x, event.y), - self._zoom_info.direction, event.key, twinx, twiny) + self._zoom_info.direction, key, twinx, twiny) self.canvas.draw_idle() self._zoom_info = None diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 04e2f5223c27..4abaf1a78ed5 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -202,10 +202,13 @@ def test_interactive_colorbar(plot_func, orientation, tool, button, expected): assert cb.ax.get_navigate() # Mouse from 4 to 6 (data coordinates, "d"). - # The y coordinate doesn't matter, just needs to be between 0 and 1 vmin, vmax = 4, 6 - d0 = (vmin, 0.1) - d1 = (vmax, 0.9) + # The y coordinate doesn't matter, it just needs to be between 0 and 1 + # However, we will set d0/d1 to the same y coordinate to test that small + # pixel changes in that coordinate doesn't cancel the zoom like a normal + # axes would. + d0 = (vmin, 0.5) + d1 = (vmax, 0.5) # Swap them if the orientation is vertical if orientation == "vertical": d0 = d0[::-1]