Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 6b596a0

Browse files
authored
Merge pull request matplotlib#19515 from greglucas/colorbar-axes-zoom
Colorbar axis zoom and pan
2 parents 899b82c + 21fc347 commit 6b596a0

File tree

5 files changed

+225
-57
lines changed

5 files changed

+225
-57
lines changed
Lines changed: 10 additions & 0 deletions
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

Lines changed: 74 additions & 49 deletions
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

Lines changed: 28 additions & 7 deletions
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

Lines changed: 53 additions & 1 deletion
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

Lines changed: 60 additions & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /