diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index aa91377c9c5d..32b64ff40b6a 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -16,6 +16,7 @@ import matplotlib.transforms as mtransforms import matplotlib.units as munits import numpy as np +import warnings GRIDLINE_INTERPOLATION_STEPS = 180 @@ -972,11 +973,36 @@ def _update_ticks(self, renderer): tick_tups = [ti for ti in tick_tups if (ti[1] >= ilow) and (ti[1] <= ihigh)] + # so that we don't lose ticks on the end, expand out the interval ever so slightly. The + # "ever so slightly" is defined to be the width of a half of a pixel. We don't want to draw + # a tick that even one pixel outside of the defined axis interval. + if interval[0] <= interval[1]: + interval_expanded = interval + else: + interval_expanded = interval[1], interval[0] + + if hasattr(self, '_get_pixel_distance_along_axis'): + # normally, one does not want to catch all exceptions that could possibly happen, but it + # is not clear exactly what exceptions might arise from a user's projection (their rendition + # of the Axis object). So, we catch all, with the idea that one would rather potentially + # lose a tick from one side of the axis or another, rather than see a stack trace. + try: + ds1 = self._get_pixel_distance_along_axis(interval_expanded[0], -0.5) + except: + warnings.warn("Unable to find pixel distance along axis for interval padding; assuming no interval padding needed.") + ds1 = 0.0 + try: + ds2 = self._get_pixel_distance_along_axis(interval_expanded[1], +0.5) + except: + warnings.warn("Unable to find pixel distance along axis for interval padding; assuming no interval padding needed.") + ds2 = 0.0 + interval_expanded = (interval[0] - ds1, interval[1] + ds2) + ticks_to_draw = [] for tick, loc, label in tick_tups: if tick is None: continue - if not mtransforms.interval_contains(interval, loc): + if not mtransforms.interval_contains(interval_expanded, loc): continue tick.update_position(loc) tick.set_label1(label) @@ -1599,6 +1625,35 @@ def _get_offset_text(self): self.offset_text_position = 'bottom' return offsetText + def _get_pixel_distance_along_axis(self, where, perturb): + """ + Returns the amount, in data coordinates, that a single pixel corresponds to in the + locality given by "where", which is also given in data coordinates, and is an x coordinate. + "perturb" is the amount to perturb the pixel. Usually +0.5 or -0.5. + + Implementing this routine for an axis is optional; if present, it will ensure that no + ticks are lost due to round-off at the extreme ends of an axis. + """ + + # Note that this routine does not work for a polar axis, because of the 1e-10 below. To + # do things correctly, we need to use rmax instead of 1e-10 for a polar axis. But + # since we do not have that kind of information at this point, we just don't try to + # pad anything for the theta axis of a polar plot. + if self.axes.name == 'polar': + return 0.0 + + # + # first figure out the pixel location of the "where" point. We use 1e-10 for the + # y point, so that we remain compatible with log axes. + # + trans = self.axes.transData # transformation from data coords to display coords + transinv = trans.inverted() # transformation from display coords to data coords + pix = trans.transform_point((where, 1e-10)) + ptp = transinv.transform_point((pix[0] + perturb, pix[1])) # perturb the pixel. + dx = abs(ptp[0] - where) + + return dx + def get_label_position(self): """ Return the label position (top or bottom) @@ -1874,6 +1929,27 @@ def _get_offset_text(self): self.offset_text_position = 'left' return offsetText + def _get_pixel_distance_along_axis(self, where, perturb): + """ + Returns the amount, in data coordinates, that a single pixel corresponds to in the + locality given by "where", which is also given in data coordinates, and is an y coordinate. + "perturb" is the amount to perturb the pixel. Usually +0.5 or -0.5. + + Implementing this routine for an axis is optional; if present, it will ensure that no + ticks are lost due to round-off at the extreme ends of an axis. + """ + + # + # first figure out the pixel location of the "where" point. We use 1e-10 for the + # x point, so that we remain compatible with log axes. + # + trans = self.axes.transData # transformation from data coords to display coords + transinv = trans.inverted() # transformation from display coords to data coords + pix = trans.transform_point((1e-10, where)) + ptp = transinv.transform_point((pix[0], pix[1] + perturb)) # perturb the pixel. + dy = abs(ptp[1] - where) + return dy + def get_label_position(self): """ Return the label position (left or right) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 116f900a3ec7..d0af97687b40 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -26,7 +26,7 @@ def test_LinearLocator(): def test_MultipleLocator(): loc = mticker.MultipleLocator(base=3.147) - test_value = np.array([-6.294, -3.147, 0., 3.147, 6.294, 9.441]) + test_value = np.array([-9.441, -6.294, -3.147, 0., 3.147, 6.294, 9.441, 12.588]) assert_almost_equal(loc.tick_values(-7, 10), test_value) @@ -35,12 +35,12 @@ def test_LogLocator(): assert_raises(ValueError, loc.tick_values, 0, 1000) - test_value = np.array([1.00000000e-03, 1.00000000e-01, 1.00000000e+01, - 1.00000000e+03, 1.00000000e+05, 1.00000000e+07]) + test_value = np.array([1.00000000e-05, 1.00000000e-03, 1.00000000e-01, 1.00000000e+01, + 1.00000000e+03, 1.00000000e+05, 1.00000000e+07, 1.000000000e+09]) assert_almost_equal(loc.tick_values(0.001, 1.1e5), test_value) loc = mticker.LogLocator(base=2) - test_value = np.array([1., 2., 4., 8., 16., 32., 64., 128.]) + test_value = np.array([0.5, 1., 2., 4., 8., 16., 32., 64., 128., 256.]) assert_almost_equal(loc.tick_values(1, 100), test_value) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 175508880926..d281acb8f7bd 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1165,7 +1165,7 @@ def tick_values(self, vmin, vmax): vmin = self._base.ge(vmin) base = self._base.get_base() n = (vmax - vmin + 0.001 * base) // base - locs = vmin + np.arange(n + 1) * base + locs = vmin - base + np.arange(n + 3) * base return self.raise_if_exceeds(locs) def view_limits(self, dmin, dmax): @@ -1450,8 +1450,8 @@ def tick_values(self, vmin, vmax): while numdec / stride + 1 > self.numticks: stride += 1 - decades = np.arange(math.floor(vmin), - math.ceil(vmax) + stride, stride) + decades = np.arange(math.floor(vmin) - stride, + math.ceil(vmax) + 2 * stride, stride) if hasattr(self, '_transform'): ticklocs = self._transform.inverted().transform(decades) if len(subs) > 1 or (len(subs == 1) and subs[0] != 1.0):