From 7fd427f700724779cb142d90038a3a4c9261aed5 Mon Sep 17 00:00:00 2001 From: David Zaslavsky Date: Mon, 6 Jul 2015 17:47:51 +0800 Subject: [PATCH 1/6] Correctly calculate margins on log scales --- lib/matplotlib/axes/_base.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index b63394713ce5..83e727bf1b05 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2013,9 +2013,15 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): x0, x1 = mtransforms.nonsingular(x0, x1, increasing=False, expander=0.05) if self._xmargin > 0: - delta = (x1 - x0) * self._xmargin - x0 -= delta - x1 += delta + if self.get_xscale() == 'linear': + delta = (x1 - x0) * self._xmargin + x0 -= delta + x1 += delta + else: # log scale + assert(0 < x0 < x1) + factor = pow(x1 / x0, self._xmargin) + x1 *= factor + x0 /= factor if not _tight: x0, x1 = xlocator.view_limits(x0, x1) self.set_xbound(x0, x1) @@ -2037,9 +2043,15 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): y0, y1 = mtransforms.nonsingular(y0, y1, increasing=False, expander=0.05) if self._ymargin > 0: - delta = (y1 - y0) * self._ymargin - y0 -= delta - y1 += delta + if self.get_yscale() == 'linear': + delta = (y1 - y0) * self._ymargin + y0 -= delta + y1 += delta + else: # log scale + assert(0 < y0 < y1) + factor = pow(y1 / y0, self._ymargin) + y1 *= factor + y0 /= factor if not _tight: y0, y1 = ylocator.view_limits(y0, y1) self.set_ybound(y0, y1) From 81f595d275208d0ebbb6f73417ba174e637e169d Mon Sep 17 00:00:00 2001 From: David Zaslavsky Date: Tue, 7 Jul 2015 12:10:04 +0800 Subject: [PATCH 2/6] Add spaces before comments for PEP8 compliance --- lib/matplotlib/axes/_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 83e727bf1b05..d5fe2acd5774 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2017,7 +2017,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): delta = (x1 - x0) * self._xmargin x0 -= delta x1 += delta - else: # log scale + else: # log scale assert(0 < x0 < x1) factor = pow(x1 / x0, self._xmargin) x1 *= factor @@ -2047,7 +2047,7 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): delta = (y1 - y0) * self._ymargin y0 -= delta y1 += delta - else: # log scale + else: # log scale assert(0 < y0 < y1) factor = pow(y1 / y0, self._ymargin) y1 *= factor From 13209d95104c328b0fd570b5e8080c08a3963671 Mon Sep 17 00:00:00 2001 From: David Zaslavsky Date: Tue, 7 Jul 2015 16:11:00 +0800 Subject: [PATCH 3/6] Add test for view limits with margins --- lib/matplotlib/tests/test_axes.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 299e9e0aa21a..5f85bb67aa60 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -3645,6 +3645,28 @@ def test_margins(): assert_equal(ax3.margins(), (1, 0.5)) +@cleanup +def test_view_limits_with_margins(): + '''Tests that autoscale_view correctly calculates the new view limits + with a nonzero margin, for both linear and logarithmic axis scales.''' + MARGIN = 0.5 + LIN_BOUNDS = (-0.35, 1.45) + LOG_BOUNDS = (0.1 / np.sqrt(10), np.sqrt(10)) + data = [0.1, 1] + + fig1, ax1 = plt.subplots(1, 1) + ax1.plot(data, data) + ax1.margins(MARGIN) + assert_equal(ax1.get_xbound(), LIN_BOUNDS) + assert_equal(ax1.get_ybound(), LIN_BOUNDS) + + fig2, ax2 = plt.subplots(1, 1) + ax2.loglog(data, data) + ax2.margins(MARGIN) + assert_equal(ax2.get_xbound(), LOG_BOUNDS) + assert_equal(ax2.get_ybound(), LOG_BOUNDS) + + @cleanup def test_length_one_hist(): fig, ax = plt.subplots() From 7634f5dd10878948412e7d6c7b78898231e305a1 Mon Sep 17 00:00:00 2001 From: David Zaslavsky Date: Thu, 9 Jul 2015 23:08:43 +0800 Subject: [PATCH 4/6] Implement margins with arbitrary separable transformations. This commit changes the implementation of autoscale_view to use the Axes object's transformations to calculate the margins. This properly deals with nonlinear scales, like log and symlog and any others that may be added in the future. The tests for nonzero margins are also updated accordingly. --- lib/matplotlib/axes/_base.py | 76 +++++++++++++++++++++---------- lib/matplotlib/tests/test_axes.py | 38 +++++++++++----- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index d5fe2acd5774..fe025ce5c382 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1994,7 +1994,32 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): else: _tight = self._tight = bool(tight) - if scalex and self._autoscaleXon: + if not self._autoscaleXon: + scalex = False + if not self._autoscaleYon: + scaley = False + + if not scalex and not scaley: + return + + # In order to implement margins in a fully general way that respects + # all possible (separable) coordinate transformations, we need to do + # the autoscaling in three steps: + # 1. Compute the limits required to include the data points in this + # axis and any shared axes, without accounting for margins. + # 2. If there are nonzero margins, update self.viewLim, so that + # self.transLimits will compute the transformation required to map + # the new view limits to (0,1). Use self.transLimits.inverted() + # to calculate the view limits that correspond + # to (-self._xmargin, -self._ymargin) + # and (1 + self._xmargin, 1 + self._ymargin). + # 3. Finally, let the axis locators expand the view limits if + # necessary to align them with tick marks. + # It's marginally more efficient to do each step for both axes, before + # proceeding to the next step. + + # Step 1 + if scalex: xshared = self._shared_x_axes.get_siblings(self) dl = [ax.dataLim for ax in xshared] # ignore non-finite data limits if good limits exist @@ -2012,21 +2037,8 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): # Default nonsingular for, e.g., MaxNLocator x0, x1 = mtransforms.nonsingular(x0, x1, increasing=False, expander=0.05) - if self._xmargin > 0: - if self.get_xscale() == 'linear': - delta = (x1 - x0) * self._xmargin - x0 -= delta - x1 += delta - else: # log scale - assert(0 < x0 < x1) - factor = pow(x1 / x0, self._xmargin) - x1 *= factor - x0 /= factor - if not _tight: - x0, x1 = xlocator.view_limits(x0, x1) - self.set_xbound(x0, x1) - if scaley and self._autoscaleYon: + if scaley: yshared = self._shared_y_axes.get_siblings(self) dl = [ax.dataLim for ax in yshared] # ignore non-finite data limits if good limits exist @@ -2042,16 +2054,30 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): except AttributeError: y0, y1 = mtransforms.nonsingular(y0, y1, increasing=False, expander=0.05) - if self._ymargin > 0: - if self.get_yscale() == 'linear': - delta = (y1 - y0) * self._ymargin - y0 -= delta - y1 += delta - else: # log scale - assert(0 < y0 < y1) - factor = pow(y1 / y0, self._ymargin) - y1 *= factor - y0 /= factor + + # Step 2 + if self._xmargin > 0 or self._ymargin > 0: + if scalex: + self.set_xbound(x0, x1) + if scaley: + self.set_ybound(y0, y1) + # Handling the x and y transformations at the same time allows us + # to save a couple calls to invTransLimits.transform(). + invTrans = (self.transScale + self.transLimits).inverted() + assert(x0 < x1 and y0 < y1) + p0 = invTrans.transform((-self._xmargin, -self._ymargin)) + p1 = invTrans.transform((1 + self._xmargin, 1 + self._ymargin)) + if scalex: + x0, x1 = p0[0], p1[0] + if scaley: + y0, y1 = p0[1], p1[1] + + # Step 3 + if scalex: + if not _tight: + x0, x1 = xlocator.view_limits(x0, x1) + self.set_xbound(x0, x1) + if scaley: if not _tight: y0, y1 = ylocator.view_limits(y0, y1) self.set_ybound(y0, y1) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 5f85bb67aa60..e136f1fcfe44 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -3648,23 +3648,39 @@ def test_margins(): @cleanup def test_view_limits_with_margins(): '''Tests that autoscale_view correctly calculates the new view limits - with a nonzero margin, for both linear and logarithmic axis scales.''' - MARGIN = 0.5 - LIN_BOUNDS = (-0.35, 1.45) - LOG_BOUNDS = (0.1 / np.sqrt(10), np.sqrt(10)) - data = [0.1, 1] + with a nonzero margin, for various kinds of plots. + + The test uses a linear plot, a logarithmic plot, a symlog plot, + and a polar plot as an example of a nonlinear coordinate transformation.''' fig1, ax1 = plt.subplots(1, 1) + data = [0.1, 1] ax1.plot(data, data) - ax1.margins(MARGIN) - assert_equal(ax1.get_xbound(), LIN_BOUNDS) - assert_equal(ax1.get_ybound(), LIN_BOUNDS) + ax1.margins(0.5) + np.testing.assert_array_almost_equal(ax1.get_xbound(), (-0.35, 1.45)) + np.testing.assert_array_almost_equal(ax1.get_ybound(), (-0.35, 1.45)) fig2, ax2 = plt.subplots(1, 1) + data = [0.1, 1] ax2.loglog(data, data) - ax2.margins(MARGIN) - assert_equal(ax2.get_xbound(), LOG_BOUNDS) - assert_equal(ax2.get_ybound(), LOG_BOUNDS) + ax2.margins(0.5) + np.testing.assert_array_almost_equal(ax2.get_xbound(), (0.1 / np.sqrt(10), np.sqrt(10))) + np.testing.assert_array_almost_equal(ax2.get_ybound(), (0.1 / np.sqrt(10), np.sqrt(10))) + + fig3, ax3 = plt.subplots(1, 1) + DATAMAX = 10 + data = np.linspace(-DATAMAX, DATAMAX, 101) + ax3.plot(data, data) + BASE = 10 + ax3.set_xscale('symlog', linthreshx=1, linscalex=1, basex=BASE) + ax3.set_yscale('symlog', linthreshy=1, linscaley=1, basey=BASE) + MARGIN = 0.5 + ax3.margins(MARGIN) + # To understand the following line, see the implementation of + # matplotlib.scale.SymmetricalLogTransform.transform_non_affine() + BOUND = DATAMAX * BASE ** (2 * (1 + BASE / (BASE - 1)) * MARGIN) + np.testing.assert_array_almost_equal(ax3.get_xbound(), (-BOUND, BOUND)) + np.testing.assert_array_almost_equal(ax3.get_ybound(), (-BOUND, BOUND)) @cleanup From 1714eafaffe6bf9e22039b91b9018decd5a053ea Mon Sep 17 00:00:00 2001 From: David Zaslavsky Date: Sat, 11 Jul 2015 00:34:07 +0800 Subject: [PATCH 5/6] Raise an error when adding margins to non-separable axes Non-separable axes make margins difficult because there's no fully general way of calculating the axis coordinates that should correspond to the new view limits. For now, we leave that part of the method unimplemented and raise a NotImplementedError accordingly. --- lib/matplotlib/axes/_base.py | 10 ++++++++-- lib/matplotlib/tests/test_axes.py | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index fe025ce5c382..024e8281e4fc 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2062,8 +2062,14 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): if scaley: self.set_ybound(y0, y1) # Handling the x and y transformations at the same time allows us - # to save a couple calls to invTransLimits.transform(). - invTrans = (self.transScale + self.transLimits).inverted() + # to save a couple calls to invTrans.transform(). + try: + transLimits = self.transLimits + except AttributeError as e: + raise NotImplementedError( + 'margins are not implemented for non-separable axes') + else: + invTrans = (self.transScale + transLimits).inverted() assert(x0 < x1 and y0 < y1) p0 = invTrans.transform((-self._xmargin, -self._ymargin)) p1 = invTrans.transform((1 + self._xmargin, 1 + self._ymargin)) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index e136f1fcfe44..fec23cef24d9 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -3682,6 +3682,10 @@ def test_view_limits_with_margins(): np.testing.assert_array_almost_equal(ax3.get_xbound(), (-BOUND, BOUND)) np.testing.assert_array_almost_equal(ax3.get_ybound(), (-BOUND, BOUND)) + ax4 = plt.subplot(111, polar=True) + data = np.linspace(0, 2*np.pi, 51) + ax4.plot(data, data) + assert_raises(NotImplementedError, ax4.margins, 0.5) @cleanup def test_length_one_hist(): From f685a720a6097afb625c0cd944db951622d0e232 Mon Sep 17 00:00:00 2001 From: David Zaslavsky Date: Mon, 13 Jul 2015 15:14:00 +0800 Subject: [PATCH 6/6] Catch error in scatter() on nonseparable axes --- lib/matplotlib/axes/_axes.py | 6 +++++- lib/matplotlib/axes/_base.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index a41160789fbd..480dc744c9e8 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3774,7 +3774,11 @@ def scatter(self, x, y, s=20, c=None, marker='o', cmap=None, norm=None, self.set_ymargin(0.05) self.add_collection(collection) - self.autoscale_view() + try: + self.autoscale_view() + except NotImplementedError: + # This happens if the axes are not separable. + pass return collection diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 024e8281e4fc..05e5baa02624 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2057,18 +2057,22 @@ def autoscale_view(self, tight=None, scalex=True, scaley=True): # Step 2 if self._xmargin > 0 or self._ymargin > 0: - if scalex: - self.set_xbound(x0, x1) - if scaley: - self.set_ybound(y0, y1) # Handling the x and y transformations at the same time allows us # to save a couple calls to invTrans.transform(). try: transLimits = self.transLimits - except AttributeError as e: + except AttributeError: raise NotImplementedError( 'margins are not implemented for non-separable axes') else: + # Update bounds after checking for transLimits because if the + # axes are not separable, we should allow the caller to catch + # the NotImplementedError without there being any lasting + # changes to the view. + if scalex: + self.set_xbound(x0, x1) + if scaley: + self.set_ybound(y0, y1) invTrans = (self.transScale + transLimits).inverted() assert(x0 < x1 and y0 < y1) p0 = invTrans.transform((-self._xmargin, -self._ymargin))