Skip to content

Commit 45accc8

Browse files
committed
Correctly treat pan/zoom events of overlapping axes.
1 parent 3c11861 commit 45accc8

File tree

6 files changed

+340
-12
lines changed

6 files changed

+340
-12
lines changed

doc/api/axes_api.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,9 @@ Interactive
517517
Axes.get_navigate_mode
518518
Axes.set_navigate_mode
519519

520+
Axes.get_forward_navigation_events
521+
Axes.set_forward_navigation_events
522+
520523
Axes.start_pan
521524
Axes.drag_pan
522525
Axes.end_pan
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Correctly treat pan/zoom events of overlapping Axes
2+
---------------------------------------------------
3+
4+
The forwarding of pan/zoom events is now determined by the visibility of the
5+
background-patch (e.g. ``ax.patch.get_visible()``) and by the ``zorder`` of the axes.
6+
7+
- Axes with a visible patch capture the event and do not pass it on to axes below.
8+
Only the Axes with the highest ``zorder`` that contains the event is triggered
9+
(if there are multiple Axes with the same ``zorder``, the last added Axes counts)
10+
- Axes with an invisible patch are also invisible to events and they are passed on to the axes below.
11+
12+
To override the default behavior and explicitly set whether an Axes
13+
should forward navigation events, use `.Axes.set_forward_navigation_events`.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""
2+
===================================
3+
Pan/zoom events of overlapping axes
4+
===================================
5+
6+
Example to illustrate how pan/zoom events of overlapping axes are treated.
7+
8+
9+
The default is the following:
10+
11+
- Axes with a visible patch capture pan/zoom events
12+
- Axes with an invisible patch forward pan/zoom events to axes below
13+
- Shared axes always trigger with their parent axes
14+
(irrespective of the patch visibility)
15+
16+
17+
``ax.set_forward_navigation_events(val)`` can be used to override the
18+
default behaviour:
19+
20+
- ``True``: Forward navigation events to axes below.
21+
- ``False``: Execute navigation events only on this axes.
22+
- ``"auto"``: Use the default behaviour
23+
(``True`` for axes with an invisible patch and ``False`` otherwise).
24+
25+
To disable pan/zoom events completely, use ``ax.set_navigate(False)``
26+
"""
27+
28+
29+
import matplotlib.pyplot as plt
30+
31+
f = plt.figure(figsize=(11, 6))
32+
f.suptitle("Showcase for pan/zoom events on overlapping axes.")
33+
34+
ax = f.add_axes((.05, .05, .9, .9))
35+
ax.patch.set_color(".75")
36+
ax_twin = ax.twinx()
37+
38+
ax1 = f.add_subplot(221)
39+
ax1_twin = ax1.twinx()
40+
ax1.text(.5, .5,
41+
"Visible patch\n\n"
42+
"Pan/zoom events are NOT\n"
43+
"forwarded to axes below",
44+
ha="center", va="center", transform=ax1.transAxes)
45+
46+
ax11 = f.add_subplot(223, sharex=ax1, sharey=ax1)
47+
ax11.set_forward_navigation_events(True)
48+
ax11.text(.5, .5,
49+
"Visible patch\n\n"
50+
"Override capture behavior:\n\n"
51+
"ax.set_forward_navigation_events(True)",
52+
ha="center", va="center", transform=ax11.transAxes)
53+
54+
ax2 = f.add_subplot(222)
55+
ax2_twin = ax2.twinx()
56+
ax2.patch.set_visible(False)
57+
ax2.text(.5, .5,
58+
"Invisible patch\n\n"
59+
"Pan/zoom events are\n"
60+
"forwarded to axes below",
61+
ha="center", va="center", transform=ax2.transAxes)
62+
63+
ax22 = f.add_subplot(224, sharex=ax2, sharey=ax2)
64+
ax22.patch.set_visible(False)
65+
ax22.set_forward_navigation_events(False)
66+
ax22.text(.5, .5,
67+
"Invisible patch\n\n"
68+
"Override capture behavior:\n\n"
69+
"ax.set_forward_navigation_events(False)",
70+
ha="center", va="center", transform=ax22.transAxes)

lib/matplotlib/axes/_base.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ def __init__(self, fig,
577577
xscale=None,
578578
yscale=None,
579579
box_aspect=None,
580+
forward_navigation_events="auto",
580581
**kwargs
581582
):
582583
"""
@@ -611,6 +612,11 @@ def __init__(self, fig,
611612
Set a fixed aspect for the Axes box, i.e. the ratio of height to
612613
width. See `~.axes.Axes.set_box_aspect` for details.
613614
615+
forward_navigation_events : bool or "auto", default: "auto"
616+
Control whether pan/zoom events are passed through to Axes below
617+
this one. "auto" is *True* for axes with an invisible patch and
618+
*False* otherwise.
619+
614620
**kwargs
615621
Other optional keyword arguments:
616622
@@ -646,6 +652,7 @@ def __init__(self, fig,
646652
self._adjustable = 'box'
647653
self._anchor = 'C'
648654
self._stale_viewlims = {name: False for name in self._axis_names}
655+
self._forward_navigation_events = forward_navigation_events
649656
self._sharex = sharex
650657
self._sharey = sharey
651658
self.set_label(label)
@@ -4031,6 +4038,11 @@ def set_navigate(self, b):
40314038
Parameters
40324039
----------
40334040
b : bool
4041+
4042+
See Also
4043+
--------
4044+
matplotlib.axes.Axes.set_forward_navigation_events
4045+
40344046
"""
40354047
self._navigate = b
40364048

@@ -4481,6 +4493,8 @@ def _make_twin_axes(self, *args, **kwargs):
44814493
[0, 0, 1, 1], self.transAxes))
44824494
self.set_adjustable('datalim')
44834495
twin.set_adjustable('datalim')
4496+
twin.set_zorder(self.zorder)
4497+
44844498
self._twinned_axes.join(self, twin)
44854499
return twin
44864500

@@ -4627,6 +4641,31 @@ def _label_outer_yaxis(self, *, skip_non_rectangular_axes,
46274641
if self.yaxis.offsetText.get_position()[0] == 1:
46284642
self.yaxis.offsetText.set_visible(False)
46294643

4644+
def set_forward_navigation_events(self, forward):
4645+
"""
4646+
Set how pan/zoom events are forwarded to Axes below this one.
4647+
4648+
Parameters
4649+
----------
4650+
forward : bool or "auto"
4651+
Possible values:
4652+
4653+
- True: Forward events to other axes with lower or equal zorder.
4654+
- False: Events are only executed on this axes.
4655+
- "auto": Default behaviour (*True* for axes with an invisible
4656+
patch and *False* otherwise)
4657+
4658+
See Also
4659+
--------
4660+
matplotlib.axes.Axes.set_navigate
4661+
4662+
"""
4663+
self._forward_navigation_events = forward
4664+
4665+
def get_forward_navigation_events(self):
4666+
"""Get how pan/zoom events are forwarded to Axes below this one."""
4667+
return self._forward_navigation_events
4668+
46304669

46314670
def _draw_rasterized(figure, artists, renderer):
46324671
"""

lib/matplotlib/backend_bases.py

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2995,6 +2995,63 @@ def _zoom_pan_handler(self, event):
29952995
elif event.name == "button_release_event":
29962996
self.release_zoom(event)
29972997

2998+
def _start_event_axes_pan(self, event, method="zoom"):
2999+
# call "ax.start_pan(..)" on all relevant axes of an event
3000+
3001+
def _ax_filter(ax):
3002+
return (ax.in_axes(event) and
3003+
ax.get_navigate() and
3004+
getattr(ax, f"can_{method}")()
3005+
)
3006+
3007+
def _capture_events(ax):
3008+
f = ax.get_forward_navigation_events()
3009+
if f == "auto": # (capture = patch visibility)
3010+
f = not ax.patch.get_visible()
3011+
return not f
3012+
3013+
# get all relevant axes for the event
3014+
axes = list(filter(_ax_filter, self.canvas.figure.get_axes()))
3015+
3016+
if len(axes) == 0:
3017+
return
3018+
3019+
if self._nav_stack() is None:
3020+
self.push_current() # Set the home button to this view.
3021+
3022+
# group axes by zorder (reverse to trigger later axes first)
3023+
grps = dict()
3024+
for ax in reversed(axes):
3025+
grps.setdefault(ax.get_zorder(), []).append(ax)
3026+
3027+
pan_axes = []
3028+
# go through zorders in reverse until we hit a capturing axes
3029+
for zorder in sorted(grps, reverse=True):
3030+
for ax in grps[zorder]:
3031+
pan_axes.append(ax)
3032+
# NOTE: shared axes are automatically triggered, but twin-axes not!
3033+
pan_axes.extend(ax._twinned_axes.get_siblings(ax))
3034+
3035+
if _capture_events(ax):
3036+
break # break if we hit a capturing axes
3037+
else:
3038+
# If the inner loop finished without an explicit break,
3039+
# (e.g. no capturing axes was found) continue the
3040+
# outer loop to the next zorder.
3041+
continue
3042+
3043+
# If the inner loop was terminated with an explicit break,
3044+
# terminate the outer loop as well.
3045+
break
3046+
3047+
# avoid duplicated triggers (but keep order of list)
3048+
pan_axes = list(dict.fromkeys(pan_axes))
3049+
3050+
for ax in pan_axes:
3051+
ax.start_pan(event.x, event.y, event.button)
3052+
3053+
return pan_axes
3054+
29983055
def pan(self, *args):
29993056
"""
30003057
Toggle the pan/zoom tool.
@@ -3020,16 +3077,14 @@ def press_pan(self, event):
30203077
if (event.button not in [MouseButton.LEFT, MouseButton.RIGHT]
30213078
or event.x is None or event.y is None):
30223079
return
3023-
axes = [a for a in self.canvas.figure.get_axes()
3024-
if a.in_axes(event) and a.get_navigate() and a.can_pan()]
3080+
3081+
axes = self._start_event_axes_pan(event, method="pan")
30253082
if not axes:
30263083
return
3027-
if self._nav_stack() is None:
3028-
self.push_current() # set the home button to this view
3029-
for ax in axes:
3030-
ax.start_pan(event.x, event.y, event.button)
3084+
30313085
self.canvas.mpl_disconnect(self._id_drag)
30323086
id_drag = self.canvas.mpl_connect("motion_notify_event", self.drag_pan)
3087+
30333088
self._pan_info = self._PanInfo(
30343089
button=event.button, axes=axes, cid=id_drag)
30353090

@@ -3075,21 +3130,23 @@ def press_zoom(self, event):
30753130
if (event.button not in [MouseButton.LEFT, MouseButton.RIGHT]
30763131
or event.x is None or event.y is None):
30773132
return
3078-
axes = [a for a in self.canvas.figure.get_axes()
3079-
if a.in_axes(event) and a.get_navigate() and a.can_zoom()]
3133+
3134+
axes = self._start_event_axes_pan(event, method="zoom")
30803135
if not axes:
30813136
return
3082-
if self._nav_stack() is None:
3083-
self.push_current() # set the home button to this view
3137+
30843138
id_zoom = self.canvas.mpl_connect(
30853139
"motion_notify_event", self.drag_zoom)
3140+
30863141
# A colorbar is one-dimensional, so we extend the zoom rectangle out
30873142
# to the edge of the Axes bbox in the other dimension. To do that we
30883143
# store the orientation of the colorbar for later.
3089-
if hasattr(axes[0], "_colorbar"):
3090-
cbar = axes[0]._colorbar.orientation
3144+
parent_ax = axes[0]
3145+
if hasattr(parent_ax, "_colorbar"):
3146+
cbar = parent_ax._colorbar.orientation
30913147
else:
30923148
cbar = None
3149+
30933150
self._zoom_info = self._ZoomInfo(
30943151
direction="in" if event.button == 1 else "out",
30953152
start_xy=(event.x, event.y), axes=axes, cid=id_zoom, cbar=cbar)

0 commit comments

Comments
 (0)