From 546713e49ce65f4b27c1a4e71282ab1512a65dae Mon Sep 17 00:00:00 2001 From: Joseph Fox-Rabinovitz Date: Mon, 28 Nov 2016 17:49:56 -0500 Subject: [PATCH] ENH: Added share_tickers parameter to axes._AxesBase.twinx/y Added copy constructor to axis.Ticker --- lib/matplotlib/axes/_base.py | 137 ++++++++++++++++++++---------- lib/matplotlib/axis.py | 8 ++ lib/matplotlib/tests/test_axes.py | 53 ++++++++++++ 3 files changed, 154 insertions(+), 44 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index d4d3578b6e51..5bb36e672964 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -462,6 +462,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* [ *True* | *False* ] whether the axes is visible @@ -476,6 +483,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) @@ -493,6 +508,10 @@ def __init__(self, fig, rect, self.set_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 sharex._adjustable == 'box': @@ -968,50 +987,52 @@ def cla(self): self.callbacks = cbook.CallbackRegistry() if self._sharex is not None: - # major and minor are 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=None) - # Save the current formatter/locator so we don't lose it - majf = self._sharex.xaxis.get_major_formatter() - minf = self._sharex.xaxis.get_minor_formatter() - majl = self._sharex.xaxis.get_major_locator() - minl = self._sharex.xaxis.get_minor_locator() - # This overwrites the current formatter/locator self.xaxis._set_scale(self._sharex.xaxis.get_scale()) - # Reset the formatter/locator - self.xaxis.set_major_formatter(majf) - self.xaxis.set_minor_formatter(minf) - self.xaxis.set_major_locator(majl) - self.xaxis.set_minor_locator(minl) + # 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') 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=None) - # Save the current formatter/locator so we don't lose it - majf = self._sharey.yaxis.get_major_formatter() - minf = self._sharey.yaxis.get_minor_formatter() - majl = self._sharey.yaxis.get_major_locator() - minl = self._sharey.yaxis.get_minor_locator() - # This overwrites the current formatter/locator self.yaxis._set_scale(self._sharey.yaxis.get_scale()) - # Reset the formatter/locator - self.yaxis.set_major_formatter(majf) - self.yaxis.set_minor_formatter(minf) - self.yaxis.set_major_locator(majl) - self.yaxis.set_minor_locator(minl) + # 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') @@ -3898,15 +3919,29 @@ def _make_twin_axes(self, *kl, **kwargs): ax2 = self.figure.add_axes(self.get_position(True), *kl, **kwargs) 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 ------- @@ -3918,7 +3953,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') @@ -3928,15 +3963,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` + + `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:: - 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` + 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 ------- @@ -3948,7 +3997,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 870c5e8f6f72..90cfaa3da995 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -604,6 +604,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 Axis(artist.Artist): """ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index b909dc5608d3..1cf1d11fae6f 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -25,6 +25,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 import matplotlib.colors as mcolors @@ -143,6 +144,58 @@ def test_twinx_cla(): assert ax.patch.get_visible() assert ax.yaxis.get_visible() +@cleanup +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() + + 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 + + # Verify that for share_tickers=True, the formatters and locators + # are identical no matter what + 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() + + # Make some more twinned axes to play with (with share_tickers=False) + 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 + 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() + @image_comparison(baseline_images=['twin_autoscale'], extensions=['png']) def test_twinx_axis_scales():