From cf5c6d51723519e13856d912f47e0a000ebf3501 Mon Sep 17 00:00:00 2001 From: Sanchit Rishi Date: Fri, 20 Mar 2026 18:42:18 +0530 Subject: [PATCH 1/3] ENH: Give control whether twinx() or twiny() overlays the main axis (#31122) --- .../next_whats_new/twin_axes_zorder.rst | 12 ++++ .../twin_axes_zorder.py | 44 ++++++++++++++ lib/matplotlib/axes/_base.py | 57 ++++++++++++++++--- lib/matplotlib/axes/_base.pyi | 10 +++- lib/matplotlib/tests/test_axes.py | 44 ++++++++++++++ 5 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 doc/release/next_whats_new/twin_axes_zorder.rst create mode 100644 galleries/examples/subplots_axes_and_figures/twin_axes_zorder.py diff --git a/doc/release/next_whats_new/twin_axes_zorder.rst b/doc/release/next_whats_new/twin_axes_zorder.rst new file mode 100644 index 000000000000..77ce9419fca0 --- /dev/null +++ b/doc/release/next_whats_new/twin_axes_zorder.rst @@ -0,0 +1,12 @@ +Twin Axes ``delta_zorder`` +-------------------------- + +`~matplotlib.axes.Axes.twinx` and `~matplotlib.axes.Axes.twiny` now accept a +*delta_zorder* keyword argument, a relative offset added to the original Axes' +zorder, to control whether the twin Axes is drawn in front of, or behind, the +original Axes. For example, pass ``delta_zorder=-1`` to easily draw a twin Axes +behind the main Axes. + +In addition, Matplotlib now automatically manages background patch visibility +for each group of twinned Axes so that only the bottom-most Axes in the group +has a visible background patch (respecting ``frameon``). diff --git a/galleries/examples/subplots_axes_and_figures/twin_axes_zorder.py b/galleries/examples/subplots_axes_and_figures/twin_axes_zorder.py new file mode 100644 index 000000000000..a0be6ce79389 --- /dev/null +++ b/galleries/examples/subplots_axes_and_figures/twin_axes_zorder.py @@ -0,0 +1,44 @@ +""" +=========================== +Twin Axes with delta_zorder +=========================== + +`~matplotlib.axes.Axes.twinx` and `~matplotlib.axes.Axes.twiny` accept a +*delta_zorder* keyword argument (a relative offset added to the original Axes' +zorder) that controls whether the twin Axes is drawn in front of or behind the +original Axes. + +Matplotlib also automatically manages background patch visibility for twinned +Axes groups so that only the bottom-most Axes has a visible background patch +(respecting ``frameon``). This avoids the background of a higher-zorder twin +Axes covering artists drawn on the underlying Axes. +""" + +import matplotlib.pyplot as plt +import numpy as np + +x = np.linspace(0, 10, 400) +y_main = np.sin(x) +y_twin = 0.4 * np.cos(x) + 0.6 + +fig, ax = plt.subplots() + +# Put the twin Axes behind the original Axes (relative to the original zorder). +ax2 = ax.twinx(delta_zorder=-1) + +# Draw something broad on the twin Axes so that the stacking is obvious. +ax2.fill_between(x, 0, y_twin, color="C1", alpha=0.35, label="twin fill") +ax2.plot(x, y_twin, color="C1", lw=6, alpha=0.8) + +# Draw overlapping artists on the main Axes; they appear on top. +ax.scatter(x[::8], y_main[::8], s=35, color="C0", edgecolor="k", linewidth=0.5, + zorder=3, label="main scatter") +ax.plot(x, y_main, color="C0", lw=4) + +ax.set_xlabel("x") +ax.set_ylabel("main y") +ax2.set_ylabel("twin y") +ax.set_title("Twin Axes drawn behind the main Axes using delta_zorder") + +fig.tight_layout() +plt.show() diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index db85c1eea7fe..8ce1cc001fc6 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -614,6 +614,10 @@ def __str__(self): return "{0}({1[0]:g},{1[1]:g};{1[2]:g}x{1[3]:g})".format( type(self).__name__, self._position.bounds) + def set_zorder(self, level): + super().set_zorder(level) + self._update_twinned_axes_patch_visibility() + def __init__(self, fig, *args, facecolor=None, # defaults to rc axes.facecolor @@ -3324,6 +3328,7 @@ def set_frame_on(self, b): b : bool """ self._frameon = b + self._update_twinned_axes_patch_visibility() self.stale = True def get_axisbelow(self): @@ -4705,7 +4710,32 @@ def get_tightbbox(self, renderer=None, *, call_axes_locator=True, return mtransforms.Bbox.union( [b for b in bb if b.width != 0 or b.height != 0]) - def _make_twin_axes(self, *args, **kwargs): + def _update_twinned_axes_patch_visibility(self): + """ + Update patch visibility for a group of twinned Axes. + + Only the bottom-most Axes in the group (lowest zorder, breaking ties by + creation/insertion order) has a visible background patch. + """ + if self not in self._twinned_axes: + return + twinned = list(self._twinned_axes.get_siblings(self)) + if not twinned: + return + fig = self.get_figure(root=False) + fig_axes = fig.axes if fig is not None else [] + insertion_order = {ax: idx for idx, ax in enumerate(fig_axes)} + + twinned.sort( + key=lambda ax: (ax.get_zorder(), insertion_order.get(ax, len(fig_axes))) + ) + bottom = twinned[0] + for ax in twinned: + patch = getattr(ax, "patch", None) + if patch is not None: + patch.set_visible((ax is bottom) and ax.get_frame_on()) + + def _make_twin_axes(self, *args, delta_zorder=0.0, **kwargs): """Make a twinx Axes of self. This is used for twinx and twiny.""" if 'sharex' in kwargs and 'sharey' in kwargs: # The following line is added in v2.2 to avoid breaking Seaborn, @@ -4722,12 +4752,13 @@ def _make_twin_axes(self, *args, **kwargs): [0, 0, 1, 1], self.transAxes)) self.set_adjustable('datalim') twin.set_adjustable('datalim') - twin.set_zorder(self.zorder) + twin.set_zorder(self.get_zorder() + delta_zorder) self._twinned_axes.join(self, twin) + self._update_twinned_axes_patch_visibility() return twin - def twinx(self, axes_class=None, **kwargs): + def twinx(self, axes_class=None, *, delta_zorder=0.0, **kwargs): """ Create a twin Axes sharing the xaxis. @@ -4748,6 +4779,12 @@ def twinx(self, axes_class=None, **kwargs): .. versionadded:: 3.11 + delta_zorder : float, default: 0 + A zorder offset for the twin Axes, relative to the original Axes. + The twin's zorder is set to ``self.get_zorder() + delta_zorder``. + By default (*delta_zorder* is 0), the twin has the same zorder as + the original Axes. + kwargs : dict The keyword arguments passed to `.Figure.add_subplot` or `.Figure.add_axes`. @@ -4765,18 +4802,17 @@ def twinx(self, axes_class=None, **kwargs): """ if axes_class: kwargs["axes_class"] = axes_class - ax2 = self._make_twin_axes(sharex=self, **kwargs) + ax2 = self._make_twin_axes(sharex=self, delta_zorder=delta_zorder, **kwargs) ax2.yaxis.tick_right() ax2.yaxis.set_label_position('right') ax2.yaxis.set_offset_position('right') ax2.set_autoscalex_on(self.get_autoscalex_on()) self.yaxis.tick_left() ax2.xaxis.set_visible(False) - ax2.patch.set_visible(False) ax2.xaxis.units = self.xaxis.units return ax2 - def twiny(self, axes_class=None, **kwargs): + def twiny(self, axes_class=None, *, delta_zorder=0.0, **kwargs): """ Create a twin Axes sharing the yaxis. @@ -4797,6 +4833,12 @@ def twiny(self, axes_class=None, **kwargs): .. versionadded:: 3.11 + delta_zorder : float, default: 0 + A zorder offset for the twin Axes, relative to the original Axes. + The twin's zorder is set to ``self.get_zorder() + delta_zorder``. + By default (*delta_zorder* is 0), the twin has the same zorder as + the original Axes. + kwargs : dict The keyword arguments passed to `.Figure.add_subplot` or `.Figure.add_axes`. @@ -4814,13 +4856,12 @@ def twiny(self, axes_class=None, **kwargs): """ if axes_class: kwargs["axes_class"] = axes_class - ax2 = self._make_twin_axes(sharey=self, **kwargs) + ax2 = self._make_twin_axes(sharey=self, delta_zorder=delta_zorder, **kwargs) ax2.xaxis.tick_top() ax2.xaxis.set_label_position('top') ax2.set_autoscaley_on(self.get_autoscaley_on()) self.xaxis.tick_bottom() ax2.yaxis.set_visible(False) - ax2.patch.set_visible(False) ax2.yaxis.units = self.yaxis.units return ax2 diff --git a/lib/matplotlib/axes/_base.pyi b/lib/matplotlib/axes/_base.pyi index 835dcfd60124..4a70405346a5 100644 --- a/lib/matplotlib/axes/_base.pyi +++ b/lib/matplotlib/axes/_base.pyi @@ -384,10 +384,14 @@ class _AxesBase(martist.Artist): *, call_axes_locator: bool = ..., bbox_extra_artists: Sequence[Artist] | None = ..., - for_layout_only: bool = ... + for_layout_only: bool = ..., ) -> Bbox | None: ... - def twinx(self, axes_class: Axes | None = ..., **kwargs) -> Axes: ... - def twiny(self, axes_class: Axes | None = ..., **kwargs) -> Axes: ... + def twinx( + self, axes_class: Axes | None = ..., *, delta_zorder: float = ..., **kwargs + ) -> Axes: ... + def twiny( + self, axes_class: Axes | None = ..., *, delta_zorder: float = ..., **kwargs + ) -> Axes: ... @classmethod def get_shared_x_axes(cls) -> cbook.GrouperView: ... @classmethod diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 57a295d418a6..fc4b61ff5854 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -8081,6 +8081,50 @@ def test_twinning_default_axes_class(): assert type(twiny) is Axes +def test_twinning_patch_visibility_default(): + _, ax = plt.subplots() + ax2 = ax.twinx() + assert ax.patch.get_visible() + assert not ax2.patch.get_visible() + + +def test_twinning_patch_visibility_respects_delta_zorder(): + _, ax = plt.subplots() + ax2 = ax.twinx(delta_zorder=-1) + assert ax2.get_zorder() == ax.get_zorder() - 1 + assert ax2.patch.get_visible() + assert not ax.patch.get_visible() + + +def test_twinning_patch_visibility_multiple_twins_same_zorder(): + _, ax = plt.subplots() + ax2 = ax.twinx() + ax3 = ax.twinx() + assert ax.patch.get_visible() + assert not ax2.patch.get_visible() + assert not ax3.patch.get_visible() + + +def test_twinning_patch_visibility_updates_for_new_bottom(): + _, ax = plt.subplots() + ax2 = ax.twinx() + ax3 = ax.twinx(delta_zorder=-1) + assert ax3.patch.get_visible() + assert not ax2.patch.get_visible() + assert not ax.patch.get_visible() + + +def test_twinning_patch_visibility_updates_after_set_zorder(): + _, ax = plt.subplots() + ax2 = ax.twinx() + assert ax.patch.get_visible() + assert not ax2.patch.get_visible() + + ax2.set_zorder(ax.get_zorder() - 1) + assert ax2.patch.get_visible() + assert not ax.patch.get_visible() + + @mpl.style.context('mpl20') @check_figures_equal() def test_stairs_fill_zero_linewidth(fig_test, fig_ref): From df68eb4392a2fe992c3af45b351c7cc73d8537f0 Mon Sep 17 00:00:00 2001 From: Sanchit Rishi Date: Tue, 31 Mar 2026 01:40:34 +0530 Subject: [PATCH 2/3] Update doc/release/next_whats_new/twin_axes_zorder.rst Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- doc/release/next_whats_new/twin_axes_zorder.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release/next_whats_new/twin_axes_zorder.rst b/doc/release/next_whats_new/twin_axes_zorder.rst index 77ce9419fca0..ea3ca7ee5569 100644 --- a/doc/release/next_whats_new/twin_axes_zorder.rst +++ b/doc/release/next_whats_new/twin_axes_zorder.rst @@ -4,7 +4,7 @@ Twin Axes ``delta_zorder`` `~matplotlib.axes.Axes.twinx` and `~matplotlib.axes.Axes.twiny` now accept a *delta_zorder* keyword argument, a relative offset added to the original Axes' zorder, to control whether the twin Axes is drawn in front of, or behind, the -original Axes. For example, pass ``delta_zorder=-1`` to easily draw a twin Axes +original Axes. For example, pass ``delta_zorder=-1`` to draw a twin Axes behind the main Axes. In addition, Matplotlib now automatically manages background patch visibility From 499db29d3e2c2001ec887102bdcc40462203ccdd Mon Sep 17 00:00:00 2001 From: Sanchit Rishi Date: Tue, 31 Mar 2026 01:50:32 +0530 Subject: [PATCH 3/3] Update lib/matplotlib/axes/_base.py Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/axes/_base.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 8ce1cc001fc6..e8b06080e3dc 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -4731,9 +4731,8 @@ def _update_twinned_axes_patch_visibility(self): ) bottom = twinned[0] for ax in twinned: - patch = getattr(ax, "patch", None) - if patch is not None: - patch.set_visible((ax is bottom) and ax.get_frame_on()) + if ax.patch is not None: + ax.patch.set_visible((ax is bottom) and ax.get_frame_on()) def _make_twin_axes(self, *args, delta_zorder=0.0, **kwargs): """Make a twinx Axes of self. This is used for twinx and twiny."""