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 b63394713ce5..05e5baa02624 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,15 +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: - delta = (x1 - x0) * self._xmargin - x0 -= delta - x1 += delta - 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 @@ -2036,10 +2054,40 @@ 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: - delta = (y1 - y0) * self._ymargin - y0 -= delta - y1 += delta + + # Step 2 + if self._xmargin > 0 or self._ymargin > 0: + # 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: + 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)) + 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 299e9e0aa21a..fec23cef24d9 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -3645,6 +3645,48 @@ 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 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(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(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)) + + 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(): fig, ax = plt.subplots()