Skip to content

Commit b9193ba

Browse files
authored
Merge pull request #19515 from greglucas/colorbar-axes-zoom
Colorbar axis zoom and pan
2 parents be70fcf + 23c118b commit b9193ba

File tree

5 files changed

+225
-57
lines changed

5 files changed

+225
-57
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Colorbars now have pan and zoom functionality
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
Interactive plots with colorbars can now be zoomed and panned on
5+
the colorbar axis. This adjusts the *vmin* and *vmax* of the
6+
``ScalarMappable`` associated with the colorbar. This is currently
7+
only enabled for continuous norms. Norms used with contourf and
8+
categoricals, such as ``BoundaryNorm`` and ``NoNorm``, have the
9+
interactive capability disabled by default. ``cb.ax.set_navigate()``
10+
can be used to set whether a colorbar axes is interactive or not.

lib/matplotlib/axes/_base.py

+74-49
Original file line numberDiff line numberDiff line change
@@ -4255,41 +4255,14 @@ def _set_view(self, view):
42554255
self.set_xlim((xmin, xmax))
42564256
self.set_ylim((ymin, ymax))
42574257

4258-
def _set_view_from_bbox(self, bbox, direction='in',
4259-
mode=None, twinx=False, twiny=False):
4258+
def _prepare_view_from_bbox(self, bbox, direction='in',
4259+
mode=None, twinx=False, twiny=False):
42604260
"""
4261-
Update view from a selection bbox.
4262-
4263-
.. note::
4264-
4265-
Intended to be overridden by new projection types, but if not, the
4266-
default implementation sets the view limits to the bbox directly.
4267-
4268-
Parameters
4269-
----------
4270-
bbox : 4-tuple or 3 tuple
4271-
* If bbox is a 4 tuple, it is the selected bounding box limits,
4272-
in *display* coordinates.
4273-
* If bbox is a 3 tuple, it is an (xp, yp, scl) triple, where
4274-
(xp, yp) is the center of zooming and scl the scale factor to
4275-
zoom by.
4261+
Helper function to prepare the new bounds from a bbox.
42764262
4277-
direction : str
4278-
The direction to apply the bounding box.
4279-
* `'in'` - The bounding box describes the view directly, i.e.,
4280-
it zooms in.
4281-
* `'out'` - The bounding box describes the size to make the
4282-
existing view, i.e., it zooms out.
4283-
4284-
mode : str or None
4285-
The selection mode, whether to apply the bounding box in only the
4286-
`'x'` direction, `'y'` direction or both (`None`).
4287-
4288-
twinx : bool
4289-
Whether this axis is twinned in the *x*-direction.
4290-
4291-
twiny : bool
4292-
Whether this axis is twinned in the *y*-direction.
4263+
This helper function returns the new x and y bounds from the zoom
4264+
bbox. This a convenience method to abstract the bbox logic
4265+
out of the base setter.
42934266
"""
42944267
if len(bbox) == 3:
42954268
xp, yp, scl = bbox # Zooming code
@@ -4360,6 +4333,46 @@ def _set_view_from_bbox(self, bbox, direction='in',
43604333
symax1 = symax0 + factor * (symax0 - symax)
43614334
new_ybound = y_trf.inverted().transform([symin1, symax1])
43624335

4336+
return new_xbound, new_ybound
4337+
4338+
def _set_view_from_bbox(self, bbox, direction='in',
4339+
mode=None, twinx=False, twiny=False):
4340+
"""
4341+
Update view from a selection bbox.
4342+
4343+
.. note::
4344+
4345+
Intended to be overridden by new projection types, but if not, the
4346+
default implementation sets the view limits to the bbox directly.
4347+
4348+
Parameters
4349+
----------
4350+
bbox : 4-tuple or 3 tuple
4351+
* If bbox is a 4 tuple, it is the selected bounding box limits,
4352+
in *display* coordinates.
4353+
* If bbox is a 3 tuple, it is an (xp, yp, scl) triple, where
4354+
(xp, yp) is the center of zooming and scl the scale factor to
4355+
zoom by.
4356+
4357+
direction : str
4358+
The direction to apply the bounding box.
4359+
* `'in'` - The bounding box describes the view directly, i.e.,
4360+
it zooms in.
4361+
* `'out'` - The bounding box describes the size to make the
4362+
existing view, i.e., it zooms out.
4363+
4364+
mode : str or None
4365+
The selection mode, whether to apply the bounding box in only the
4366+
`'x'` direction, `'y'` direction or both (`None`).
4367+
4368+
twinx : bool
4369+
Whether this axis is twinned in the *x*-direction.
4370+
4371+
twiny : bool
4372+
Whether this axis is twinned in the *y*-direction.
4373+
"""
4374+
new_xbound, new_ybound = self._prepare_view_from_bbox(
4375+
bbox, direction=direction, mode=mode, twinx=twinx, twiny=twiny)
43634376
if not twinx and mode != "y":
43644377
self.set_xbound(new_xbound)
43654378
self.set_autoscalex_on(False)
@@ -4400,22 +4413,13 @@ def end_pan(self):
44004413
"""
44014414
del self._pan_start
44024415

4403-
def drag_pan(self, button, key, x, y):
4416+
def _get_pan_points(self, button, key, x, y):
44044417
"""
4405-
Called when the mouse moves during a pan operation.
4418+
Helper function to return the new points after a pan.
44064419
4407-
Parameters
4408-
----------
4409-
button : `.MouseButton`
4410-
The pressed mouse button.
4411-
key : str or None
4412-
The pressed key, if any.
4413-
x, y : float
4414-
The mouse coordinates in display coords.
4415-
4416-
Notes
4417-
-----
4418-
This is intended to be overridden by new projection types.
4420+
This helper function returns the points on the axis after a pan has
4421+
occurred. This is a convenience method to abstract the pan logic
4422+
out of the base setter.
44194423
"""
44204424
def format_deltas(key, dx, dy):
44214425
if key == 'control':
@@ -4469,8 +4473,29 @@ def format_deltas(key, dx, dy):
44694473
points = result.get_points().astype(object)
44704474
# Just ignore invalid limits (typically, underflow in log-scale).
44714475
points[~valid] = None
4472-
self.set_xlim(points[:, 0])
4473-
self.set_ylim(points[:, 1])
4476+
return points
4477+
4478+
def drag_pan(self, button, key, x, y):
4479+
"""
4480+
Called when the mouse moves during a pan operation.
4481+
4482+
Parameters
4483+
----------
4484+
button : `.MouseButton`
4485+
The pressed mouse button.
4486+
key : str or None
4487+
The pressed key, if any.
4488+
x, y : float
4489+
The mouse coordinates in display coords.
4490+
4491+
Notes
4492+
-----
4493+
This is intended to be overridden by new projection types.
4494+
"""
4495+
points = self._get_pan_points(button, key, x, y)
4496+
if points is not None:
4497+
self.set_xlim(points[:, 0])
4498+
self.set_ylim(points[:, 1])
44744499

44754500
def get_children(self):
44764501
# docstring inherited.

lib/matplotlib/backend_bases.py

+28-7
Original file line numberDiff line numberDiff line change
@@ -3136,7 +3136,7 @@ def zoom(self, *args):
31363136
a.set_navigate_mode(self.mode._navigate_mode)
31373137
self.set_message(self.mode)
31383138

3139-
_ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid")
3139+
_ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid cbar")
31403140

31413141
def press_zoom(self, event):
31423142
"""Callback for mouse button press in zoom to rect mode."""
@@ -3151,20 +3151,34 @@ def press_zoom(self, event):
31513151
self.push_current() # set the home button to this view
31523152
id_zoom = self.canvas.mpl_connect(
31533153
"motion_notify_event", self.drag_zoom)
3154+
# A colorbar is one-dimensional, so we extend the zoom rectangle out
3155+
# to the edge of the axes bbox in the other dimension. To do that we
3156+
# store the orientation of the colorbar for later.
3157+
if hasattr(axes[0], "_colorbar"):
3158+
cbar = axes[0]._colorbar.orientation
3159+
else:
3160+
cbar = None
31543161
self._zoom_info = self._ZoomInfo(
31553162
direction="in" if event.button == 1 else "out",
3156-
start_xy=(event.x, event.y), axes=axes, cid=id_zoom)
3163+
start_xy=(event.x, event.y), axes=axes, cid=id_zoom, cbar=cbar)
31573164

31583165
def drag_zoom(self, event):
31593166
"""Callback for dragging in zoom mode."""
31603167
start_xy = self._zoom_info.start_xy
31613168
ax = self._zoom_info.axes[0]
31623169
(x1, y1), (x2, y2) = np.clip(
31633170
[start_xy, [event.x, event.y]], ax.bbox.min, ax.bbox.max)
3164-
if event.key == "x":
3171+
key = event.key
3172+
# Force the key on colorbars to extend the short-axis bbox
3173+
if self._zoom_info.cbar == "horizontal":
3174+
key = "x"
3175+
elif self._zoom_info.cbar == "vertical":
3176+
key = "y"
3177+
if key == "x":
31653178
y1, y2 = ax.bbox.intervaly
3166-
elif event.key == "y":
3179+
elif key == "y":
31673180
x1, x2 = ax.bbox.intervalx
3181+
31683182
self.draw_rubberband(event, x1, y1, x2, y2)
31693183

31703184
def release_zoom(self, event):
@@ -3178,10 +3192,17 @@ def release_zoom(self, event):
31783192
self.remove_rubberband()
31793193

31803194
start_x, start_y = self._zoom_info.start_xy
3195+
key = event.key
3196+
# Force the key on colorbars to ignore the zoom-cancel on the
3197+
# short-axis side
3198+
if self._zoom_info.cbar == "horizontal":
3199+
key = "x"
3200+
elif self._zoom_info.cbar == "vertical":
3201+
key = "y"
31813202
# Ignore single clicks: 5 pixels is a threshold that allows the user to
31823203
# "cancel" a zoom action by zooming by less than 5 pixels.
3183-
if ((abs(event.x - start_x) < 5 and event.key != "y")
3184-
or (abs(event.y - start_y) < 5 and event.key != "x")):
3204+
if ((abs(event.x - start_x) < 5 and key != "y") or
3205+
(abs(event.y - start_y) < 5 and key != "x")):
31853206
self.canvas.draw_idle()
31863207
self._zoom_info = None
31873208
return
@@ -3195,7 +3216,7 @@ def release_zoom(self, event):
31953216
for prev in self._zoom_info.axes[:i])
31963217
ax._set_view_from_bbox(
31973218
(start_x, start_y, event.x, event.y),
3198-
self._zoom_info.direction, event.key, twinx, twiny)
3219+
self._zoom_info.direction, key, twinx, twiny)
31993220

32003221
self.canvas.draw_idle()
32013222
self._zoom_info = None

lib/matplotlib/colorbar.py

+53-1
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,6 @@ def __init__(self, ax, mappable=None, *, cmap=None,
422422

423423
self.ax = ax
424424
self.ax._axes_locator = _ColorbarAxesLocator(self)
425-
ax.set(navigate=False)
426425

427426
if extend is None:
428427
if (not isinstance(mappable, contour.ContourSet)
@@ -496,6 +495,29 @@ def __init__(self, ax, mappable=None, *, cmap=None,
496495
if isinstance(mappable, contour.ContourSet) and not mappable.filled:
497496
self.add_lines(mappable)
498497

498+
# Link the Axes and Colorbar for interactive use
499+
self.ax._colorbar = self
500+
# Don't navigate on any of these types of mappables
501+
if (isinstance(self.norm, (colors.BoundaryNorm, colors.NoNorm)) or
502+
isinstance(self.mappable, contour.ContourSet)):
503+
self.ax.set_navigate(False)
504+
505+
# These are the functions that set up interactivity on this colorbar
506+
self._interactive_funcs = ["_get_view", "_set_view",
507+
"_set_view_from_bbox", "drag_pan"]
508+
for x in self._interactive_funcs:
509+
setattr(self.ax, x, getattr(self, x))
510+
# Set the cla function to the cbar's method to override it
511+
self.ax.cla = self._cbar_cla
512+
513+
def _cbar_cla(self):
514+
"""Function to clear the interactive colorbar state."""
515+
for x in self._interactive_funcs:
516+
delattr(self.ax, x)
517+
# We now restore the old cla() back and can call it directly
518+
del self.ax.cla
519+
self.ax.cla()
520+
499521
# Also remove ._patch after deprecation elapses.
500522
patch = _api.deprecate_privatize_attribute("3.5", alternative="ax")
501523

@@ -1276,6 +1298,36 @@ def _short_axis(self):
12761298
return self.ax.xaxis
12771299
return self.ax.yaxis
12781300

1301+
def _get_view(self):
1302+
# docstring inherited
1303+
# An interactive view for a colorbar is the norm's vmin/vmax
1304+
return self.norm.vmin, self.norm.vmax
1305+
1306+
def _set_view(self, view):
1307+
# docstring inherited
1308+
# An interactive view for a colorbar is the norm's vmin/vmax
1309+
self.norm.vmin, self.norm.vmax = view
1310+
1311+
def _set_view_from_bbox(self, bbox, direction='in',
1312+
mode=None, twinx=False, twiny=False):
1313+
# docstring inherited
1314+
# For colorbars, we use the zoom bbox to scale the norm's vmin/vmax
1315+
new_xbound, new_ybound = self.ax._prepare_view_from_bbox(
1316+
bbox, direction=direction, mode=mode, twinx=twinx, twiny=twiny)
1317+
if self.orientation == 'horizontal':
1318+
self.norm.vmin, self.norm.vmax = new_xbound
1319+
elif self.orientation == 'vertical':
1320+
self.norm.vmin, self.norm.vmax = new_ybound
1321+
1322+
def drag_pan(self, button, key, x, y):
1323+
# docstring inherited
1324+
points = self.ax._get_pan_points(button, key, x, y)
1325+
if points is not None:
1326+
if self.orientation == 'horizontal':
1327+
self.norm.vmin, self.norm.vmax = points[:, 0]
1328+
elif self.orientation == 'vertical':
1329+
self.norm.vmin, self.norm.vmax = points[:, 1]
1330+
12791331

12801332
ColorbarBase = Colorbar # Backcompat API
12811333

lib/matplotlib/tests/test_backend_bases.py

+60
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,66 @@ def test_interactive_zoom():
181181
assert not ax.get_autoscalex_on() and not ax.get_autoscaley_on()
182182

183183

184+
@pytest.mark.parametrize("plot_func", ["imshow", "contourf"])
185+
@pytest.mark.parametrize("orientation", ["vertical", "horizontal"])
186+
@pytest.mark.parametrize("tool,button,expected",
187+
[("zoom", MouseButton.LEFT, (4, 6)), # zoom in
188+
("zoom", MouseButton.RIGHT, (-20, 30)), # zoom out
189+
("pan", MouseButton.LEFT, (-2, 8))])
190+
def test_interactive_colorbar(plot_func, orientation, tool, button, expected):
191+
fig, ax = plt.subplots()
192+
data = np.arange(12).reshape((4, 3))
193+
vmin0, vmax0 = 0, 10
194+
coll = getattr(ax, plot_func)(data, vmin=vmin0, vmax=vmax0)
195+
196+
cb = fig.colorbar(coll, ax=ax, orientation=orientation)
197+
if plot_func == "contourf":
198+
# Just determine we can't navigate and exit out of the test
199+
assert not cb.ax.get_navigate()
200+
return
201+
202+
assert cb.ax.get_navigate()
203+
204+
# Mouse from 4 to 6 (data coordinates, "d").
205+
vmin, vmax = 4, 6
206+
# The y coordinate doesn't matter, it just needs to be between 0 and 1
207+
# However, we will set d0/d1 to the same y coordinate to test that small
208+
# pixel changes in that coordinate doesn't cancel the zoom like a normal
209+
# axes would.
210+
d0 = (vmin, 0.5)
211+
d1 = (vmax, 0.5)
212+
# Swap them if the orientation is vertical
213+
if orientation == "vertical":
214+
d0 = d0[::-1]
215+
d1 = d1[::-1]
216+
# Convert to screen coordinates ("s"). Events are defined only with pixel
217+
# precision, so round the pixel values, and below, check against the
218+
# corresponding xdata/ydata, which are close but not equal to d0/d1.
219+
s0 = cb.ax.transData.transform(d0).astype(int)
220+
s1 = cb.ax.transData.transform(d1).astype(int)
221+
222+
# Set up the mouse movements
223+
start_event = MouseEvent(
224+
"button_press_event", fig.canvas, *s0, button)
225+
stop_event = MouseEvent(
226+
"button_release_event", fig.canvas, *s1, button)
227+
228+
tb = NavigationToolbar2(fig.canvas)
229+
if tool == "zoom":
230+
tb.zoom()
231+
tb.press_zoom(start_event)
232+
tb.drag_zoom(stop_event)
233+
tb.release_zoom(stop_event)
234+
else:
235+
tb.pan()
236+
tb.press_pan(start_event)
237+
tb.drag_pan(stop_event)
238+
tb.release_pan(stop_event)
239+
240+
# Should be close, but won't be exact due to screen integer resolution
241+
assert (cb.vmin, cb.vmax) == pytest.approx(expected, abs=0.15)
242+
243+
184244
def test_toolbar_zoompan():
185245
expected_warning_regex = (
186246
r"Treat the new Tool classes introduced in "

0 commit comments

Comments
 (0)