Skip to content

Commit ae73ddb

Browse files
authored
Merge pull request matplotlib#27148 from raphaelquast/nested_ax_zoom
Correctly treat pan/zoom events of overlapping axes.
2 parents 3e47cc2 + 5394aff commit ae73ddb

File tree

7 files changed

+332
-10
lines changed

7 files changed

+332
-10
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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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+
Note: The visibility of the patch hereby refers to the value of
17+
``ax.patch.get_visible()``. The color and transparency of a
18+
patch have no effect on the treatment of pan/zoom events!
19+
20+
21+
``ax.set_forward_navigation_events(val)`` can be used to override the
22+
default behaviour:
23+
24+
- ``True``: Forward navigation events to axes below.
25+
- ``False``: Execute navigation events only on this axes.
26+
- ``"auto"``: Use the default behaviour.
27+
28+
To disable pan/zoom events completely, use ``ax.set_navigate(False)``
29+
30+
"""
31+
32+
33+
import matplotlib.pyplot as plt
34+
35+
fig = plt.figure(figsize=(11, 6))
36+
fig.suptitle("Showcase for pan/zoom events on overlapping axes.")
37+
38+
ax = fig.add_axes((.05, .05, .9, .9))
39+
ax.patch.set_color(".75")
40+
ax_twin = ax.twinx()
41+
42+
ax1 = fig.add_subplot(221)
43+
ax1_twin = ax1.twinx()
44+
ax1.text(.5, .5,
45+
"Visible patch\n\n"
46+
"Pan/zoom events are NOT\n"
47+
"forwarded to axes below",
48+
ha="center", va="center", transform=ax1.transAxes)
49+
50+
ax11 = fig.add_subplot(223, sharex=ax1, sharey=ax1)
51+
ax11.set_forward_navigation_events(True)
52+
ax11.text(.5, .5,
53+
"Visible patch\n\n"
54+
"Override capture behavior:\n\n"
55+
"ax.set_forward_navigation_events(True)",
56+
ha="center", va="center", transform=ax11.transAxes)
57+
58+
ax2 = fig.add_subplot(222)
59+
ax2_twin = ax2.twinx()
60+
ax2.patch.set_visible(False)
61+
ax2.text(.5, .5,
62+
"Invisible patch\n\n"
63+
"Pan/zoom events are\n"
64+
"forwarded to axes below",
65+
ha="center", va="center", transform=ax2.transAxes)
66+
67+
ax22 = fig.add_subplot(224, sharex=ax2, sharey=ax2)
68+
ax22.patch.set_visible(False)
69+
ax22.set_forward_navigation_events(False)
70+
ax22.text(.5, .5,
71+
"Invisible patch\n\n"
72+
"Override capture behavior:\n\n"
73+
"ax.set_forward_navigation_events(False)",
74+
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
@@ -571,6 +571,7 @@ def __init__(self, fig,
571571
xscale=None,
572572
yscale=None,
573573
box_aspect=None,
574+
forward_navigation_events="auto",
574575
**kwargs
575576
):
576577
"""
@@ -605,6 +606,11 @@ def __init__(self, fig,
605606
Set a fixed aspect for the Axes box, i.e. the ratio of height to
606607
width. See `~.axes.Axes.set_box_aspect` for details.
607608
609+
forward_navigation_events : bool or "auto", default: "auto"
610+
Control whether pan/zoom events are passed through to Axes below
611+
this one. "auto" is *True* for axes with an invisible patch and
612+
*False* otherwise.
613+
608614
**kwargs
609615
Other optional keyword arguments:
610616
@@ -640,6 +646,7 @@ def __init__(self, fig,
640646
self._adjustable = 'box'
641647
self._anchor = 'C'
642648
self._stale_viewlims = {name: False for name in self._axis_names}
649+
self._forward_navigation_events = forward_navigation_events
643650
self._sharex = sharex
644651
self._sharey = sharey
645652
self.set_label(label)
@@ -4016,6 +4023,11 @@ def set_navigate(self, b):
40164023
Parameters
40174024
----------
40184025
b : bool
4026+
4027+
See Also
4028+
--------
4029+
matplotlib.axes.Axes.set_forward_navigation_events
4030+
40194031
"""
40204032
self._navigate = b
40214033

@@ -4466,6 +4478,8 @@ def _make_twin_axes(self, *args, **kwargs):
44664478
[0, 0, 1, 1], self.transAxes))
44674479
self.set_adjustable('datalim')
44684480
twin.set_adjustable('datalim')
4481+
twin.set_zorder(self.zorder)
4482+
44694483
self._twinned_axes.join(self, twin)
44704484
return twin
44714485

@@ -4612,6 +4626,31 @@ def _label_outer_yaxis(self, *, skip_non_rectangular_axes,
46124626
if self.yaxis.offsetText.get_position()[0] == 1:
46134627
self.yaxis.offsetText.set_visible(False)
46144628

4629+
def set_forward_navigation_events(self, forward):
4630+
"""
4631+
Set how pan/zoom events are forwarded to Axes below this one.
4632+
4633+
Parameters
4634+
----------
4635+
forward : bool or "auto"
4636+
Possible values:
4637+
4638+
- True: Forward events to other axes with lower or equal zorder.
4639+
- False: Events are only executed on this axes.
4640+
- "auto": Default behaviour (*True* for axes with an invisible
4641+
patch and *False* otherwise)
4642+
4643+
See Also
4644+
--------
4645+
matplotlib.axes.Axes.set_navigate
4646+
4647+
"""
4648+
self._forward_navigation_events = forward
4649+
4650+
def get_forward_navigation_events(self):
4651+
"""Get how pan/zoom events are forwarded to Axes below this one."""
4652+
return self._forward_navigation_events
4653+
46154654

46164655
def _draw_rasterized(figure, artists, renderer):
46174656
"""

lib/matplotlib/axes/_base.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class _AxesBase(martist.Artist):
7575
xscale: str | ScaleBase | None = ...,
7676
yscale: str | ScaleBase | None = ...,
7777
box_aspect: float | None = ...,
78+
forward_navigation_events: bool | Literal["auto"] = ...,
7879
**kwargs
7980
) -> None: ...
8081
def get_subplotspec(self) -> SubplotSpec | None: ...
@@ -363,6 +364,8 @@ class _AxesBase(martist.Artist):
363364
def can_pan(self) -> bool: ...
364365
def get_navigate(self) -> bool: ...
365366
def set_navigate(self, b: bool) -> None: ...
367+
def get_forward_navigation_events(self) -> bool | Literal["auto"]: ...
368+
def set_forward_navigation_events(self, forward: bool | Literal["auto"]) -> None: ...
366369
def get_navigate_mode(self) -> Literal["PAN", "ZOOM"] | None: ...
367370
def set_navigate_mode(self, b: Literal["PAN", "ZOOM"] | None) -> None: ...
368371
def start_pan(self, x: float, y: float, button: MouseButton) -> None: ...

lib/matplotlib/backend_bases.py

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2996,6 +2996,59 @@ def _zoom_pan_handler(self, event):
29962996
elif event.name == "button_release_event":
29972997
self.release_zoom(event)
29982998

2999+
def _start_event_axes_interaction(self, event, *, method):
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+
axes_to_trigger = []
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+
axes_to_trigger.append(ax)
3032+
# NOTE: shared axes are automatically triggered, but twin-axes not!
3033+
axes_to_trigger.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+
axes_to_trigger = list(dict.fromkeys(axes_to_trigger))
3049+
3050+
return axes_to_trigger
3051+
29993052
def pan(self, *args):
30003053
"""
30013054
Toggle the pan/zoom tool.
@@ -3021,16 +3074,18 @@ def press_pan(self, event):
30213074
if (event.button not in [MouseButton.LEFT, MouseButton.RIGHT]
30223075
or event.x is None or event.y is None):
30233076
return
3024-
axes = [a for a in self.canvas.figure.get_axes()
3025-
if a.in_axes(event) and a.get_navigate() and a.can_pan()]
3077+
3078+
axes = self._start_event_axes_interaction(event, method="pan")
30263079
if not axes:
30273080
return
3028-
if self._nav_stack() is None:
3029-
self.push_current() # set the home button to this view
3081+
3082+
# call "ax.start_pan(..)" on all relevant axes of an event
30303083
for ax in axes:
30313084
ax.start_pan(event.x, event.y, event.button)
3085+
30323086
self.canvas.mpl_disconnect(self._id_drag)
30333087
id_drag = self.canvas.mpl_connect("motion_notify_event", self.drag_pan)
3088+
30343089
self._pan_info = self._PanInfo(
30353090
button=event.button, axes=axes, cid=id_drag)
30363091

@@ -3076,21 +3131,23 @@ def press_zoom(self, event):
30763131
if (event.button not in [MouseButton.LEFT, MouseButton.RIGHT]
30773132
or event.x is None or event.y is None):
30783133
return
3079-
axes = [a for a in self.canvas.figure.get_axes()
3080-
if a.in_axes(event) and a.get_navigate() and a.can_zoom()]
3134+
3135+
axes = self._start_event_axes_interaction(event, method="zoom")
30813136
if not axes:
30823137
return
3083-
if self._nav_stack() is None:
3084-
self.push_current() # set the home button to this view
3138+
30853139
id_zoom = self.canvas.mpl_connect(
30863140
"motion_notify_event", self.drag_zoom)
3141+
30873142
# A colorbar is one-dimensional, so we extend the zoom rectangle out
30883143
# to the edge of the Axes bbox in the other dimension. To do that we
30893144
# store the orientation of the colorbar for later.
3090-
if hasattr(axes[0], "_colorbar"):
3091-
cbar = axes[0]._colorbar.orientation
3145+
parent_ax = axes[0]
3146+
if hasattr(parent_ax, "_colorbar"):
3147+
cbar = parent_ax._colorbar.orientation
30923148
else:
30933149
cbar = None
3150+
30943151
self._zoom_info = self._ZoomInfo(
30953152
direction="in" if event.button == 1 else "out",
30963153
start_xy=(event.x, event.y), axes=axes, cid=id_zoom, cbar=cbar)

0 commit comments

Comments
 (0)