diff --git a/doc/api/next_api_changes/2019-03-04-AL.rst b/doc/api/next_api_changes/2019-03-04-AL.rst new file mode 100644 index 000000000000..2688774aac59 --- /dev/null +++ b/doc/api/next_api_changes/2019-03-04-AL.rst @@ -0,0 +1,20 @@ +Autoscaling changes +``````````````````` + +Matplotlib used to recompute autoscaled limits after every plotting +(``plot()``, ``bar()``, etc.) call. It now only does so when actually +rendering the canvas, or when the user queries the Axes limits. This is a +major performance improvement for plots with a large number of artists. + +In particular, this means that artists added manually with `Axes.add_line`, +`Axes.add_patch`, etc. will be taken into account by the autoscale, even +without an explicit call to `Axes.autoscale_view`. + +In some cases, this can result in different limits being reported. If this is +an issue, consider triggering a draw with `fig.canvas.draw`. + +LogLocator.nonsingular now maintains the orders of its arguments +```````````````````````````````````````````````````````````````` + +It no longer reorders them in increasing order. The new behavior is consistent +with MaxNLocator. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index affc329f1be0..464b5f007071 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -840,7 +840,7 @@ def axhline(self, y=0, xmin=0, xmax=1, **kwargs): trans = self.get_yaxis_transform(which='grid') l = mlines.Line2D([xmin, xmax], [y, y], transform=trans, **kwargs) self.add_line(l) - self.autoscale_view(scalex=False, scaley=scaley) + self._request_autoscale_view(scalex=False, scaley=scaley) return l @docstring.dedent_interpd @@ -909,7 +909,7 @@ def axvline(self, x=0, ymin=0, ymax=1, **kwargs): trans = self.get_xaxis_transform(which='grid') l = mlines.Line2D([x, x], [ymin, ymax], transform=trans, **kwargs) self.add_line(l) - self.autoscale_view(scalex=scalex, scaley=False) + self._request_autoscale_view(scalex=scalex, scaley=False) return l @docstring.dedent_interpd @@ -965,7 +965,7 @@ def axhspan(self, ymin, ymax, xmin=0, xmax=1, **kwargs): p = mpatches.Polygon(verts, **kwargs) p.set_transform(trans) self.add_patch(p) - self.autoscale_view(scalex=False) + self._request_autoscale_view(scalex=False) return p def axvspan(self, xmin, xmax, ymin=0, ymax=1, **kwargs): @@ -1030,7 +1030,7 @@ def axvspan(self, xmin, xmax, ymin=0, ymax=1, **kwargs): p = mpatches.Polygon(verts, **kwargs) p.set_transform(trans) self.add_patch(p) - self.autoscale_view(scaley=False) + self._request_autoscale_view(scaley=False) return p @_preprocess_data(replace_names=["y", "xmin", "xmax", "colors"], @@ -1105,7 +1105,7 @@ def hlines(self, y, xmin, xmax, colors='k', linestyles='solid', corners = (minx, miny), (maxx, maxy) self.update_datalim(corners) - self.autoscale_view() + self._request_autoscale_view() return lines @@ -1182,7 +1182,7 @@ def vlines(self, x, ymin, ymax, colors='k', linestyles='solid', corners = (minx, miny), (maxx, maxy) self.update_datalim(corners) - self.autoscale_view() + self._request_autoscale_view() return lines @@ -1398,7 +1398,7 @@ def eventplot(self, positions, orientation='horizontal', lineoffsets=1, else: # "horizontal", None or "none" (see EventCollection) corners = (minpos, minline), (maxpos, maxline) self.update_datalim(corners) - self.autoscale_view() + self._request_autoscale_view() return colls @@ -1642,7 +1642,7 @@ def plot(self, *args, scalex=True, scaley=True, data=None, **kwargs): lines = [*self._get_lines(*args, data=data, **kwargs)] for line in lines: self.add_line(line) - self.autoscale_view(scalex=scalex, scaley=scaley) + self._request_autoscale_view(scalex=scalex, scaley=scaley) return lines @_preprocess_data(replace_names=["x", "y"], label_namer="y") @@ -1718,7 +1718,7 @@ def plot_date(self, x, y, fmt='o', tz=None, xdate=True, ydate=False, ret = self.plot(x, y, fmt, **kwargs) - self.autoscale_view() + self._request_autoscale_view() return ret @@ -2422,7 +2422,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", ymin = ymin - np.max(yerr) ymin = max(ymin * 0.9, 1e-100) self.dataLim.intervaly = (ymin, ymax) - self.autoscale_view() + self._request_autoscale_view() bar_container = BarContainer(patches, errorbar, label=label) self.add_container(bar_container) @@ -2623,7 +2623,7 @@ def broken_barh(self, xranges, yrange, **kwargs): col = mcoll.BrokenBarHCollection(xranges_conv, yrange_conv, **kwargs) self.add_collection(col, autolim=True) - self.autoscale_view() + self._request_autoscale_view() return col @@ -3429,7 +3429,7 @@ def extract_err(err, data): for l in caplines: self.add_line(l) - self.autoscale_view() + self._request_autoscale_view() errorbar_container = ErrorbarContainer((data_line, tuple(caplines), tuple(barcols)), has_xerr=(xerr is not None), @@ -4101,7 +4101,7 @@ def dopatch(xs, ys, **kwargs): axis.set_major_formatter(formatter) formatter.seq = [*formatter.seq, *datalabels] - self.autoscale_view( + self._request_autoscale_view( scalex=self._autoscaleXon, scaley=self._autoscaleYon) return dict(whiskers=whiskers, caps=caps, boxes=boxes, @@ -4479,7 +4479,7 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, self.set_ymargin(0.05) self.add_collection(collection) - self.autoscale_view() + self._request_autoscale_view() return collection @@ -4832,9 +4832,7 @@ def hexbin(self, x, y, C=None, gridsize=100, bins=None, corners = ((xmin, ymin), (xmax, ymax)) self.update_datalim(corners) - collection.sticky_edges.x[:] = [xmin, xmax] - collection.sticky_edges.y[:] = [ymin, ymax] - self.autoscale_view(tight=True) + self._request_autoscale_view(tight=True) # add the collection last self.add_collection(collection, autolim=False) @@ -5004,7 +5002,7 @@ def quiver(self, *args, **kw): q = mquiver.Quiver(self, *args, **kw) self.add_collection(q, autolim=True) - self.autoscale_view() + self._request_autoscale_view() return q quiver.__doc__ = mquiver.Quiver.quiver_doc @@ -5020,7 +5018,7 @@ def barbs(self, *args, **kw): b = mquiver.Barbs(self, *args, **kw) self.add_collection(b, autolim=True) - self.autoscale_view() + self._request_autoscale_view() return b # Uses a custom implementation of data-kwarg handling in @@ -5075,7 +5073,7 @@ def fill(self, *args, data=None, **kwargs): for poly in self._get_patches_for_fill(*args, data=data, **kwargs): self.add_patch(poly) patches.append(poly) - self.autoscale_view() + self._request_autoscale_view() return patches @_preprocess_data(replace_names=["x", "y1", "y2", "where"]) @@ -5257,7 +5255,7 @@ def get_interp_point(ind): self.dataLim.update_from_data_xy(XY2, self.ignore_existing_data_limits, updatex=False, updatey=True) self.add_collection(collection, autolim=False) - self.autoscale_view() + self._request_autoscale_view() return collection @_preprocess_data(replace_names=["y", "x1", "x2", "where"]) @@ -5438,7 +5436,7 @@ def get_interp_point(ind): self.dataLim.update_from_data_xy(X2Y, self.ignore_existing_data_limits, updatex=True, updatey=False) self.add_collection(collection, autolim=False) - self.autoscale_view() + self._request_autoscale_view() return collection #### plotting z(x,y): imshow, pcolor and relatives, contour @@ -5937,7 +5935,7 @@ def pcolor(self, *args, alpha=None, norm=None, cmap=None, vmin=None, collection.sticky_edges.y[:] = [miny, maxy] corners = (minx, miny), (maxx, maxy) self.update_datalim(corners) - self.autoscale_view() + self._request_autoscale_view() return collection @_preprocess_data() @@ -6150,7 +6148,7 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, collection.sticky_edges.y[:] = [miny, maxy] corners = (minx, miny), (maxx, maxy) self.update_datalim(corners) - self.autoscale_view() + self._request_autoscale_view() return collection @_preprocess_data() @@ -6320,14 +6318,14 @@ def pcolorfast(self, *args, alpha=None, norm=None, cmap=None, vmin=None, ret.sticky_edges.x[:] = [xl, xr] ret.sticky_edges.y[:] = [yb, yt] self.update_datalim(np.array([[xl, yb], [xr, yt]])) - self.autoscale_view(tight=True) + self._request_autoscale_view(tight=True) return ret @_preprocess_data() def contour(self, *args, **kwargs): kwargs['filled'] = False contours = mcontour.QuadContourSet(self, *args, **kwargs) - self.autoscale_view() + self._request_autoscale_view() return contours contour.__doc__ = mcontour.QuadContourSet._contour_doc @@ -6335,7 +6333,7 @@ def contour(self, *args, **kwargs): def contourf(self, *args, **kwargs): kwargs['filled'] = True contours = mcontour.QuadContourSet(self, *args, **kwargs) - self.autoscale_view() + self._request_autoscale_view() return contours contourf.__doc__ = mcontour.QuadContourSet._contour_doc @@ -6842,7 +6840,7 @@ def hist(self, x, bins=None, range=None, density=None, weights=None, self.set_autoscalex_on(_saved_autoscalex) self.set_autoscaley_on(_saved_autoscaley) - self.autoscale_view() + self._request_autoscale_view() if label is None: labels = [None] diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 5eee450a29f9..893cdf3775e1 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -470,6 +470,8 @@ def __init__(self, fig, rect, self._aspect = 'auto' self._adjustable = 'box' self._anchor = 'C' + self._stale_viewlim_x = False + self._stale_viewlim_y = False self._sharex = sharex self._sharey = sharey if sharex is not None: @@ -615,12 +617,41 @@ def set_figure(self, fig): fig.transFigure) # these will be updated later as data is added self.dataLim = mtransforms.Bbox.null() - self.viewLim = mtransforms.Bbox.unit() + self._viewLim = mtransforms.Bbox.unit() self.transScale = mtransforms.TransformWrapper( mtransforms.IdentityTransform()) self._set_lim_and_transforms() + def _unstale_viewLim(self): + # We should arrange to store this information once per share-group + # instead of on every axis. + scalex = any(ax._stale_viewlim_x + for ax in self._shared_x_axes.get_siblings(self)) + scaley = any(ax._stale_viewlim_y + for ax in self._shared_y_axes.get_siblings(self)) + if scalex or scaley: + for ax in self._shared_x_axes.get_siblings(self): + ax._stale_viewlim_x = False + for ax in self._shared_y_axes.get_siblings(self): + ax._stale_viewlim_y = False + self.autoscale_view(scalex=scalex, scaley=scaley) + + @property + def viewLim(self): + self._unstale_viewLim() + return self._viewLim + + # API could be better, right now this is just to match the old calls to + # autoscale_view() after each plotting method. + def _request_autoscale_view(self, tight=None, scalex=True, scaley=True): + if tight is not None: + self._tight = tight + if scalex: + self._stale_viewlim_x = True # Else keep old state. + if scaley: + self._stale_viewlim_y = True + def _set_lim_and_transforms(self): """ Set the *_xaxis_transform*, *_yaxis_transform*, *transScale*, @@ -645,7 +676,7 @@ def _set_lim_and_transforms(self): # An affine transformation on the data, generally to limit the # range of the axes self.transLimits = mtransforms.BboxTransformFrom( - mtransforms.TransformedBbox(self.viewLim, self.transScale)) + mtransforms.TransformedBbox(self._viewLim, self.transScale)) # The parentheses are important for efficiency here -- they # group the last two (which are usually affines) separately @@ -1862,6 +1893,9 @@ def add_collection(self, collection, autolim=True): collection.set_clip_path(self.patch) if autolim: + # Make sure viewLim is not stale (mostly to match + # pre-lazy-autoscale behavior, which is not really better). + self._unstale_viewLim() self.update_datalim(collection.get_datalim(self.transData)) self.stale = True @@ -2019,7 +2053,7 @@ def _on_units_changed(self, scalex=False, scaley=False): Currently forces updates of data limits and view limits. """ self.relim() - self.autoscale_view(scalex=scalex, scaley=scaley) + self._request_autoscale_view(scalex=scalex, scaley=scaley) def relim(self, visible_only=False): """ @@ -2313,7 +2347,7 @@ def margins(self, *margins, x=None, y=None, tight=True): if y is not None: self.set_ymargin(y) - self.autoscale_view( + self._request_autoscale_view( tight=tight, scalex=(x is not None), scaley=(y is not None) ) @@ -2375,7 +2409,7 @@ def autoscale(self, enable=True, axis='both', tight=None): self._xmargin = 0 if tight and scaley: self._ymargin = 0 - self.autoscale_view(tight=tight, scalex=scalex, scaley=scaley) + self._request_autoscale_view(tight=tight, scalex=scalex, scaley=scaley) def autoscale_view(self, tight=None, scalex=True, scaley=True): """ @@ -2556,11 +2590,12 @@ def draw(self, renderer=None, inframe=False): """Draw everything (plot lines, axes, labels)""" if renderer is None: renderer = self.figure._cachedRenderer - if renderer is None: raise RuntimeError('No renderer defined') if not self.get_visible(): return + self._unstale_viewLim() + renderer.open_group('axes') # prevent triggering call backs during the draw process @@ -2886,7 +2921,7 @@ def locator_params(self, axis='both', tight=None, **kwargs): self.xaxis.get_major_locator().set_params(**kwargs) if _y: self.yaxis.get_major_locator().set_params(**kwargs) - self.autoscale_view(tight=tight, scalex=_x, scaley=_y) + self._request_autoscale_view(tight=tight, scalex=_x, scaley=_y) def tick_params(self, axis='both', **kwargs): """Change the appearance of ticks, tick labels, and gridlines. @@ -3122,7 +3157,6 @@ def _validate_converted_limits(self, limit, convert): Returns ------- The limit value after call to convert(), or None if limit is None. - """ if limit is not None: converted_limit = convert(limit) @@ -3214,11 +3248,14 @@ def set_xlim(self, left=None, right=None, emit=True, auto=False, left = self._validate_converted_limits(left, self.convert_xunits) right = self._validate_converted_limits(right, self.convert_xunits) - old_left, old_right = self.get_xlim() - if left is None: - left = old_left - if right is None: - right = old_right + if left is None or right is None: + # Axes init calls set_xlim(0, 1) before get_xlim() can be called, + # so only grab the limits if we really need them. + old_left, old_right = self.get_xlim() + if left is None: + left = old_left + if right is None: + right = old_right if self.get_xscale() == 'log': if left <= 0: @@ -3240,7 +3277,7 @@ def set_xlim(self, left=None, right=None, emit=True, auto=False, left, right = self.xaxis.get_major_locator().nonsingular(left, right) left, right = self.xaxis.limit_range_for_scale(left, right) - self.viewLim.intervalx = (left, right) + self._viewLim.intervalx = (left, right) if auto is not None: self._autoscaleXon = bool(auto) @@ -3297,8 +3334,7 @@ def set_xscale(self, value, **kwargs): ax.xaxis._set_scale(value, **kwargs) ax._update_transScale() ax.stale = True - - self.autoscale_view(scaley=False) + self._request_autoscale_view(scaley=False) def get_xticks(self, minor=False): """Return the x ticks as a list of locations""" @@ -3592,12 +3628,14 @@ def set_ylim(self, bottom=None, top=None, emit=True, auto=False, bottom = self._validate_converted_limits(bottom, self.convert_yunits) top = self._validate_converted_limits(top, self.convert_yunits) - old_bottom, old_top = self.get_ylim() - - if bottom is None: - bottom = old_bottom - if top is None: - top = old_top + if bottom is None or top is None: + # Axes init calls set_ylim(0, 1) before get_ylim() can be called, + # so only grab the limits if we really need them. + old_bottom, old_top = self.get_ylim() + if bottom is None: + bottom = old_bottom + if top is None: + top = old_top if self.get_yscale() == 'log': if bottom <= 0: @@ -3620,7 +3658,7 @@ def set_ylim(self, bottom=None, top=None, emit=True, auto=False, bottom, top = self.yaxis.get_major_locator().nonsingular(bottom, top) bottom, top = self.yaxis.limit_range_for_scale(bottom, top) - self.viewLim.intervaly = (bottom, top) + self._viewLim.intervaly = (bottom, top) if auto is not None: self._autoscaleYon = bool(auto) @@ -3677,7 +3715,7 @@ def set_yscale(self, value, **kwargs): ax.yaxis._set_scale(value, **kwargs) ax._update_transScale() ax.stale = True - self.autoscale_view(scalex=False) + self._request_autoscale_view(scalex=False) def get_yticks(self, minor=False): """Return the y ticks as a list of locations""" diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index 53df43a41a45..65285bf85f48 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -365,6 +365,9 @@ def contains(self, mouseevent): self._picker is not True # the bool, not just nonzero or 1 else self._pickradius) + if self.axes and self.get_offset_position() == "data": + self.axes._unstale_viewLim() + transform, transOffset, offsets, paths = self._prepare_points() ind = _path.point_in_path_collection( diff --git a/lib/matplotlib/table.py b/lib/matplotlib/table.py index 55eadc51b0c1..0086d88c2b34 100644 --- a/lib/matplotlib/table.py +++ b/lib/matplotlib/table.py @@ -320,6 +320,7 @@ def __init__(self, ax, loc=None, bbox=None, **kwargs): self._bbox = bbox # use axes coords + ax._unstale_viewLim() self.set_transform(ax.transAxes) self._texts = [] diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 8c99c4572f76..c4566e519dec 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -32,6 +32,7 @@ def test_bbox_inches_tight(): yoff = yoff + data[row] cellText.append(['']) plt.xticks([]) + plt.xlim(0, 5) plt.legend([''] * 5, loc=(1.2, 0.2)) # Add a table at the bottom of the axes cellText.reverse() diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 13c5dd1a83eb..d32ada81a73c 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -363,6 +363,9 @@ def test_multi_color_hatch(): rect.set_edgecolor('C{}'.format(i)) rect.set_hatch('/') + ax.autoscale_view() + ax.autoscale(False) + for i in range(5): with mstyle.context({'hatch.color': 'C{}'.format(i)}): r = Rectangle((i - .8 / 2, 5), .8, 1, hatch='//', fc='none') diff --git a/lib/matplotlib/tests/test_simplification.py b/lib/matplotlib/tests/test_simplification.py index 9818c9495e17..e91e91461b35 100644 --- a/lib/matplotlib/tests/test_simplification.py +++ b/lib/matplotlib/tests/test_simplification.py @@ -54,6 +54,8 @@ def test_noise(): fig, ax = plt.subplots() p1 = ax.plot(x, solid_joinstyle='round', linewidth=2.0) + # Ensure that the path's transform takes the new axes limits into account. + fig.canvas.draw() path = p1[0].get_path() transform = p1[0].get_transform() path = transform.transform_path(path) @@ -195,6 +197,8 @@ def test_sine_plus_noise(): fig, ax = plt.subplots() p1 = ax.plot(x, solid_joinstyle='round', linewidth=2.0) + # Ensure that the path's transform takes the new axes limits into account. + fig.canvas.draw() path = p1[0].get_path() transform = p1[0].get_transform() path = transform.transform_path(path) @@ -232,6 +236,8 @@ def test_fft_peaks(): t = np.arange(65536) p1 = ax.plot(abs(np.fft.fft(np.sin(2*np.pi*.01*t)*np.blackman(len(t))))) + # Ensure that the path's transform takes the new axes limits into account. + fig.canvas.draw() path = p1[0].get_path() transform = p1[0].get_transform() path = transform.transform_path(path) diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index b29ddba2e0b6..4f48e5ced850 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1670,6 +1670,15 @@ def __init__(self, numticks=None, presets=None): else: self.presets = presets + @property + def numticks(self): + # Old hard-coded default. + return self._numticks if self._numticks is not None else 11 + + @numticks.setter + def numticks(self, numticks): + self._numticks = numticks + def set_params(self, numticks=None, presets=None): """Set parameters within this locator.""" if presets is not None: @@ -1690,18 +1699,12 @@ def tick_values(self, vmin, vmax): if (vmin, vmax) in self.presets: return self.presets[(vmin, vmax)] - if self.numticks is None: - self._set_numticks() - if self.numticks == 0: return [] ticklocs = np.linspace(vmin, vmax, self.numticks) return self.raise_if_exceeds(ticklocs) - def _set_numticks(self): - self.numticks = 11 # todo; be smart here; this is just for dev - def view_limits(self, vmin, vmax): 'Try to choose the view limits intelligently' @@ -2376,25 +2379,28 @@ def view_limits(self, vmin, vmax): return vmin, vmax def nonsingular(self, vmin, vmax): - if not np.isfinite(vmin) or not np.isfinite(vmax): - return 1, 10 # initial range, no data plotted yet - + swap_vlims = False if vmin > vmax: + swap_vlims = True vmin, vmax = vmax, vmin - if vmax <= 0: + if not np.isfinite(vmin) or not np.isfinite(vmax): + vmin, vmax = 1, 10 # Initial range, no data plotted yet. + elif vmax <= 0: cbook._warn_external( "Data has no positive values, and therefore cannot be " "log-scaled.") - return 1, 10 - - minpos = self.axis.get_minpos() - if not np.isfinite(minpos): - minpos = 1e-300 # This should never take effect. - if vmin <= 0: - vmin = minpos - if vmin == vmax: - vmin = _decade_less(vmin, self._base) - vmax = _decade_greater(vmax, self._base) + vmin, vmax = 1, 10 + else: + minpos = self.axis.get_minpos() + if not np.isfinite(minpos): + minpos = 1e-300 # This should never take effect. + if vmin <= 0: + vmin = minpos + if vmin == vmax: + vmin = _decade_less(vmin, self._base) + vmax = _decade_greater(vmax, self._base) + if swap_vlims: + vmin, vmax = vmax, vmin return vmin, vmax