Skip to content

ENH: Added share_tickers parameter to axes._AxesBase.twinx/y #7528

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 93 additions & 44 deletions lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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':
Expand Down Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused, it looks like this behavior is what I thought it was, but the other behavior is what it used to do.

Copy link
Contributor Author

@madphysicist madphysicist Jan 27, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old way of doing things was:

  1. Copy the Ticker objects to the new axis (lines 973-4)
  2. Save the formatters and locators (lines 979-982)
  3. Trash the formatters and locators with _set_scale (line 985)
  4. Restore the formatters and locators (lines 988-991)

This would restore for both the old and new axis since they always shared the same Ticker object.

I made this process a little more efficient:

  1. Set empty Tickers for the new axis (this one) (lines 993-4)
  2. Let _set_scale mess with the empty Tickers (line 1001)
  3. Reset the Tickers to either a reference (lines 1006-7) or a copy (lines 1009-10) of the old axis's Tickers.

Since _share_tickers defaults to True, the original behavior of having a reference to the original axis' Tickers is preserved unless explicitly asked for by the user. The key here is that _set_scale messes up the formatters and locators for its own purposes and it's effects have to be undone one way or another. I think my way is just simpler.

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')

Expand Down Expand Up @@ -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
-------
Expand All @@ -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')
Expand All @@ -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
-------
Expand All @@ -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())
Expand Down
8 changes: 8 additions & 0 deletions lib/matplotlib/axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
53 changes: 53 additions & 0 deletions lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down