diff --git a/doc/users/next_whats_new/shared_tickers.rst b/doc/users/next_whats_new/shared_tickers.rst new file mode 100644 index 000000000000..816c2974d5dd --- /dev/null +++ b/doc/users/next_whats_new/shared_tickers.rst @@ -0,0 +1,7 @@ +Two shared axis can have different tick formatters and locators +--------------------------------------------------------------- + +Previously two shared axis were forced to have the same tick formatter and +tick locator. It is now possible to set shared axis to have different tickers +and formatters using the *share_tickers* keyword argument to `twinx()` and +`twiny()`. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index b771474c585b..d08e6d52da3b 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -448,6 +448,13 @@ def __init__(self, fig, rect, to share the x-axis with *sharey* an class:`~matplotlib.axes.Axes` instance to share the y-axis with + *share_tickers* [ *True* | *False* ] whether the major and + minor `Formatter` and `Locator` instances + are always shared (if `True`) or can be set + independently (if `False`) between this set + of axes and `sharex` and `sharey`. This + argument has no meaning if neither `sharex` + nor `sharey` are set. Defaults to `True`. *title* the title string *visible* bool, whether the axes is visible *xlabel* the xlabel @@ -461,6 +468,14 @@ def __init__(self, fig, rect, *yticklabels* sequence of strings *yticks* sequence of floats ================ ========================================= + + .. warning:: + + Setting `share_tickers` to `False` and changing the + `Locator`s of a shared axis may not play with autoscaling. + Autoscaling may need to access the `Locator` object of the + base axis. Normally, with `share_tickers=True`, the axes + are guaranteed to share a `Locator` instance. """ % {'scale': ' | '.join( [repr(x) for x in mscale.get_scale_names()])} martist.Artist.__init__(self) @@ -478,6 +493,10 @@ def __init__(self, fig, rect, self._anchor = 'C' self._sharex = sharex self._sharey = sharey + # share_tickers is only used as a modifier for sharex/y. It + # should not remain in kwargs by the time kwargs updates the + # instance dictionary. + self._share_tickers = kwargs.pop('share_tickers', True) if sharex is not None: self._shared_x_axes.join(self, sharex) if sharey is not None: @@ -986,15 +1005,27 @@ def cla(self): self.callbacks = cbook.CallbackRegistry() if self._sharex is not None: - # major and minor are axis.Ticker class instances with - # locator and formatter attributes - self.xaxis.major = self._sharex.xaxis.major - self.xaxis.minor = self._sharex.xaxis.minor + # The tickers need to exist but can be empty until after the + # call to Axis._set_scale since they will be overwritten + # anyway + self.xaxis.major = maxis.Ticker() + self.xaxis.minor = maxis.Ticker() + + # Copy the axis limits x0, x1 = self._sharex.get_xlim() self.set_xlim(x0, x1, emit=False, auto=self._sharex.get_autoscalex_on()) self.xaxis._scale = mscale.scale_factory( self._sharex.xaxis.get_scale(), self.xaxis) + + # Reset the formatter/locator. Axis handle gets marked as + # stale in previous line, no need to repeat. + if self._share_tickers: + self.xaxis.major = self._sharex.xaxis.major + self.xaxis.minor = self._sharex.xaxis.minor + else: + self.xaxis.major.update_from(self._sharex.xaxis.major) + self.xaxis.minor.update_from(self._sharex.xaxis.minor) else: self.xaxis._set_scale('linear') try: @@ -1003,13 +1034,27 @@ def cla(self): pass if self._sharey is not None: - self.yaxis.major = self._sharey.yaxis.major - self.yaxis.minor = self._sharey.yaxis.minor + # The tickers need to exist but can be empty until after the + # call to Axis._set_scale since they will be overwritten + # anyway + self.yaxis.major = maxis.Ticker() + self.yaxis.minor = maxis.Ticker() + + # Copy the axis limits y0, y1 = self._sharey.get_ylim() self.set_ylim(y0, y1, emit=False, auto=self._sharey.get_autoscaley_on()) self.yaxis._scale = mscale.scale_factory( self._sharey.yaxis.get_scale(), self.yaxis) + + # Reset the formatter/locator. Axis handle gets marked as + # stale in previous line, no need to repeat. + if self._share_tickers: + self.yaxis.major = self._sharey.yaxis.major + self.yaxis.minor = self._sharey.yaxis.minor + else: + self.yaxis.major.update_from(self._sharey.yaxis.major) + self.yaxis.minor.update_from(self._sharey.yaxis.minor) else: self.yaxis._set_scale('linear') try: @@ -4093,15 +4138,29 @@ def _make_twin_axes(self, *kl, **kwargs): self._twinned_axes.join(self, ax2) return ax2 - def twinx(self): + def twinx(self, share_tickers=True): """ - Create a twin Axes sharing the xaxis + Create a twin Axes sharing the xaxis. - Create a new Axes instance with an invisible x-axis and an independent - y-axis positioned opposite to the original one (i.e. at right). The - x-axis autoscale setting will be inherited from the original Axes. - To ensure that the tick marks of both y-axes align, see - `~matplotlib.ticker.LinearLocator` + Create a new Axes instance with an invisible x-axis and an + independent y-axis positioned opposite to the original one (i.e. + at right). The x-axis autoscale setting will be inherited from + the original Axes. To ensure that the tick marks of both y-axes + align, see :class:`matplotlib.ticker.LinearLocator`. + + `share_tickers` determines if the shared axis will always have + the same major and minor `Formatter` and `Locator` objects as + this one. This is usually desirable since the axes overlap. + However, if one of the axes is shifted so that they are both + visible, it may be useful to set this parameter to ``False``. + + .. warning:: + + Setting `share_tickers` to `False` and modifying the + `Locator` of either axis may cause problems with + autoscaling. Autoscaling may require access to the + `Locator`, so the behavior will be undefined if the base and + twinned axis do not share a `Locator` instance. Returns ------- @@ -4113,7 +4172,7 @@ def twinx(self): For those who are 'picking' artists while using twinx, pick events are only called for the artists in the top-most axes. """ - ax2 = self._make_twin_axes(sharex=self) + ax2 = self._make_twin_axes(sharex=self, share_tickers=share_tickers) ax2.yaxis.tick_right() ax2.yaxis.set_label_position('right') ax2.yaxis.set_offset_position('right') @@ -4123,15 +4182,29 @@ def twinx(self): ax2.patch.set_visible(False) return ax2 - def twiny(self): + def twiny(self, share_tickers=True): """ - Create a twin Axes sharing the yaxis + Create a twin Axes sharing the yaxis. + + Create a new Axes instance with an invisible y-axis and an + independent x-axis positioned opposite to the original one (i.e. + at top). The y-axis autoscale setting will be inherited from the + original Axes. To ensure that the tick marks of both x-axes + align, see :class:`matplotlib.ticker.LinearLocator` - Create a new Axes instance with an invisible y-axis and an independent - x-axis positioned opposite to the original one (i.e. at top). The - y-axis autoscale setting will be inherited from the original Axes. - To ensure that the tick marks of both x-axes align, see - `~matplotlib.ticker.LinearLocator` + `share_tickers` determines if the shared axis will always have + the same major and minor `Formatter` and `Locator` objects as + this one. This is usually desirable since the axes overlap. + However, if one of the axes is shifted so that they are both + visible, it may be useful to set this parameter to ``False``. + + .. warning:: + + Setting `share_tickers` to `False` and modifying the + `Locator` of either axis may cause problems with + autoscaling. Autoscaling may require access to the + `Locator`, so the behavior will be undefined if the base and + twinned axis do not share a `Locator` instance. Returns ------- @@ -4143,8 +4216,7 @@ def twiny(self): For those who are 'picking' artists while using twiny, pick events are only called for the artists in the top-most axes. """ - - ax2 = self._make_twin_axes(sharey=self) + ax2 = self._make_twin_axes(sharey=self, share_tickers=share_tickers) ax2.xaxis.tick_top() ax2.xaxis.set_label_position('top') ax2.set_autoscaley_on(self.get_autoscaley_on()) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index b3a7ffcc3ca3..d9ec72ec744b 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -647,6 +647,14 @@ class Ticker(object): locator = None formatter = None + def update_from(self, ticker): + """ + Copies the formatter and locator of another ticker into this + one. + """ + self.locator = ticker.locator + self.formatter = ticker.formatter + class _LazyTickList(object): """ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 5a3a3e5d4253..c9bf8d9f1927 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -22,6 +22,7 @@ import matplotlib.markers as mmarkers import matplotlib.patches as mpatches import matplotlib.colors as mcolors +import matplotlib.ticker as mticker from numpy.testing import assert_allclose, assert_array_equal from matplotlib.cbook import ( IgnoredKeywordWarning, MatplotlibDeprecationWarning) @@ -182,6 +183,63 @@ def test_twinx_cla(): assert ax.yaxis.get_visible() +def test_twin_xy_sharing(): + fig, ax = plt.subplots() + + # Make some twinned axes to play with (with share_tickers=True) + ax2 = ax.twinx() + ax3 = ax2.twiny() + plt.draw() + + # Check that major and minor tickers are the same by default + assert ax.xaxis.major is ax2.xaxis.major + assert ax.xaxis.minor is ax2.xaxis.minor + assert ax2.yaxis.major is ax3.yaxis.major + assert ax2.yaxis.minor is ax3.yaxis.minor + + # Check that the tickers remain identical after setting new + # locators and formatters + ax2.xaxis.set_major_formatter(mticker.PercentFormatter()) + ax3.yaxis.set_major_locator(mticker.MaxNLocator()) + assert ax.xaxis.get_major_formatter() is ax2.xaxis.get_major_formatter() + assert ax2.yaxis.get_major_locator() is ax3.yaxis.get_major_locator() + + # Now check that twinned axes with share_tickers=False don't share tickers + ax4 = ax.twinx(share_tickers=False) + ax5 = ax2.twiny(share_tickers=False) + plt.draw() + + assert ax4 is not ax2 + assert ax5 is not ax3 + + assert ax.xaxis.major is not ax4.xaxis.major + assert ax.xaxis.minor is not ax4.xaxis.minor + assert ax.xaxis.get_major_formatter() is ax4.xaxis.get_major_formatter() + assert ax.xaxis.get_minor_formatter() is ax4.xaxis.get_minor_formatter() + assert ax.xaxis.get_major_locator() is ax4.xaxis.get_major_locator() + assert ax.xaxis.get_minor_locator() is ax4.xaxis.get_minor_locator() + + assert ax2.yaxis.major is not ax5.yaxis.major + assert ax2.yaxis.minor is not ax5.yaxis.minor + assert ax2.yaxis.get_major_formatter() is ax5.yaxis.get_major_formatter() + assert ax2.yaxis.get_minor_formatter() is ax5.yaxis.get_minor_formatter() + assert ax2.yaxis.get_major_locator() is ax5.yaxis.get_major_locator() + assert ax2.yaxis.get_minor_locator() is ax5.yaxis.get_minor_locator() + + # Verify that for share_tickers=False, the formatters and locators + # can be changed independently + old_formatter = ax.xaxis.get_minor_formatter() + old_locator = ax2.yaxis.get_minor_locator() + ax4.xaxis.set_minor_formatter(mticker.PercentFormatter()) + ax5.yaxis.set_minor_locator(mticker.MaxNLocator()) + + assert (ax.xaxis.get_minor_formatter() is not + ax4.xaxis.get_minor_formatter()) + assert ax2.yaxis.get_minor_locator() is not ax4.yaxis.get_minor_locator() + assert ax.xaxis.get_minor_formatter() is old_formatter + assert ax2.yaxis.get_minor_locator() is old_locator + + @image_comparison(baseline_images=['twin_autoscale'], extensions=['png']) def test_twinx_axis_scales(): x = np.array([0, 0.5, 1])