From 5144212d6966c64f1e40aea30c7ae954f3bb0b83 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 22 Apr 2025 11:59:00 +0200 Subject: [PATCH] ENH: ax.add_collection(..., autolim=True) updates view limits This makes explicit calls to `autoscale_view()` or `_request_autoscale_view()` unnecessary. 3D Axes have a special `auto_scale_xyz()`, also there's a mixture of `add_collection()` and `add_collection3d()`. This needs separate sorting . I've added a private value `autolim="_datalim_only"` to keep the behavior for 3D Axes unchanged for now. That will be resolved by a follow-up PR. I believe it's getting too complicated if we fold this into the 2D change. --- .../next_api_changes/behavior/29958-TH.rst | 8 ++++++++ .../multicolored_line.py | 1 - .../shapes_and_collections/collections.py | 16 +++++---------- .../ellipse_collection.py | 2 -- galleries/users_explain/axes/autoscale.py | 20 ------------------- lib/matplotlib/axes/_axes.py | 5 ----- lib/matplotlib/axes/_base.py | 19 ++++++++++++++++++ lib/matplotlib/axes/_base.pyi | 2 +- lib/matplotlib/colorbar.py | 4 ++-- lib/matplotlib/tests/test_backend_ps.py | 6 +++++- lib/matplotlib/tests/test_collections.py | 2 -- lib/matplotlib/tests/test_patches.py | 4 +++- lib/matplotlib/tri/_tripcolor.py | 4 +++- lib/mpl_toolkits/mplot3d/axes3d.py | 20 +++++++++---------- lib/mpl_toolkits/mplot3d/tests/test_art3d.py | 2 +- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 2 +- .../mplot3d/tests/test_legend3d.py | 6 +++--- 17 files changed, 61 insertions(+), 62 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/29958-TH.rst diff --git a/doc/api/next_api_changes/behavior/29958-TH.rst b/doc/api/next_api_changes/behavior/29958-TH.rst new file mode 100644 index 000000000000..cacaf2bac612 --- /dev/null +++ b/doc/api/next_api_changes/behavior/29958-TH.rst @@ -0,0 +1,8 @@ +``Axes.add_collection(..., autolim=True)`` updates view limits +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``Axes.add_collection(..., autolim=True)`` has so far only updated the data limits. +Users needed to additionally call `.Axes.autoscale_view` to update the view limits. +View limits are now updated as well if ``autolim=True``, using a lazy internal +update mechanism, so that the costs only apply once also if you add multiple +collections. diff --git a/galleries/examples/lines_bars_and_markers/multicolored_line.py b/galleries/examples/lines_bars_and_markers/multicolored_line.py index 3a71225d0112..a643b2de160c 100644 --- a/galleries/examples/lines_bars_and_markers/multicolored_line.py +++ b/galleries/examples/lines_bars_and_markers/multicolored_line.py @@ -72,7 +72,6 @@ def colored_line(x, y, c, ax=None, **lc_kwargs): # Plot the line collection to the axes ax = ax or plt.gca() ax.add_collection(lc) - ax.autoscale_view() return lc diff --git a/galleries/examples/shapes_and_collections/collections.py b/galleries/examples/shapes_and_collections/collections.py index 1f60afda1c5f..032be40317c0 100644 --- a/galleries/examples/shapes_and_collections/collections.py +++ b/galleries/examples/shapes_and_collections/collections.py @@ -1,7 +1,7 @@ """ -========================================================= -Line, Poly and RegularPoly Collection with autoscaling -========================================================= +===================================== +Line, Poly and RegularPoly Collection +===================================== For the first two subplots, we will use spirals. Their size will be set in plot units, not data units. Their positions will be set in data units by using @@ -38,7 +38,7 @@ # Make some offsets xyo = rs.randn(npts, 2) -# Make a list of colors cycling through the default series. +# Make a list of colors from the default color cycle. colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) @@ -59,14 +59,12 @@ # but it is good enough to generate a plot that you can use # as a starting point. If you know beforehand the range of # x and y that you want to show, it is better to set them -# explicitly, leave out the *autolim* keyword argument (or set it to False), -# and omit the 'ax1.autoscale_view()' call below. +# explicitly, set the *autolim* keyword argument to False. # Make a transform for the line segments such that their size is # given in points: col.set_color(colors) -ax1.autoscale_view() # See comment above, after ax1.add_collection. ax1.set_title('LineCollection using offsets') @@ -79,7 +77,6 @@ col.set_color(colors) -ax2.autoscale_view() ax2.set_title('PolyCollection using offsets') # 7-sided regular polygons @@ -90,7 +87,6 @@ col.set_transform(trans) # the points to pixels transform ax3.add_collection(col, autolim=True) col.set_color(colors) -ax3.autoscale_view() ax3.set_title('RegularPolyCollection using offsets') @@ -114,7 +110,6 @@ col = collections.LineCollection(segs, offsets=offs) ax4.add_collection(col, autolim=True) col.set_color(colors) -ax4.autoscale_view() ax4.set_title('Successive data offsets') ax4.set_xlabel('Zonal velocity component (m/s)') ax4.set_ylabel('Depth (m)') @@ -136,6 +131,5 @@ # - `matplotlib.collections.LineCollection` # - `matplotlib.collections.RegularPolyCollection` # - `matplotlib.axes.Axes.add_collection` -# - `matplotlib.axes.Axes.autoscale_view` # - `matplotlib.transforms.Affine2D` # - `matplotlib.transforms.Affine2D.scale` diff --git a/galleries/examples/shapes_and_collections/ellipse_collection.py b/galleries/examples/shapes_and_collections/ellipse_collection.py index 7118e5f7abf2..39f0cb7dcb6a 100644 --- a/galleries/examples/shapes_and_collections/ellipse_collection.py +++ b/galleries/examples/shapes_and_collections/ellipse_collection.py @@ -30,7 +30,6 @@ offset_transform=ax.transData) ec.set_array((X + Y).ravel()) ax.add_collection(ec) -ax.autoscale_view() ax.set_xlabel('X') ax.set_ylabel('y') cbar = plt.colorbar(ec) @@ -47,5 +46,4 @@ # - `matplotlib.collections` # - `matplotlib.collections.EllipseCollection` # - `matplotlib.axes.Axes.add_collection` -# - `matplotlib.axes.Axes.autoscale_view` # - `matplotlib.cm.ScalarMappable.set_array` diff --git a/galleries/users_explain/axes/autoscale.py b/galleries/users_explain/axes/autoscale.py index df1fbbc8aea8..337960302c38 100644 --- a/galleries/users_explain/axes/autoscale.py +++ b/galleries/users_explain/axes/autoscale.py @@ -18,7 +18,6 @@ import matplotlib.pyplot as plt import numpy as np -import matplotlib as mpl x = np.linspace(-2 * np.pi, 2 * np.pi, 100) y = np.sinc(x) @@ -159,22 +158,3 @@ ax.autoscale(enable=None, axis="x", tight=True) print(ax.margins()) - -# %% -# Working with collections -# ------------------------ -# -# Autoscale works out of the box for all lines, patches, and images added to -# the Axes. One of the artists that it won't work with is a `.Collection`. -# After adding a collection to the Axes, one has to manually trigger the -# `~matplotlib.axes.Axes.autoscale_view()` to recalculate -# axes limits. - -fig, ax = plt.subplots() -collection = mpl.collections.StarPolygonCollection( - 5, rotation=0, sizes=(250,), # five point star, zero angle, size 250px - offsets=np.column_stack([x, y]), # Set the positions - offset_transform=ax.transData, # Propagate transformations of the Axes -) -ax.add_collection(collection) -ax.autoscale_view() diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 3e39bbd4acdc..857ca698de9c 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3028,7 +3028,6 @@ def broken_barh(self, xranges, yrange, **kwargs): col = mcoll.PolyCollection(np.array(vertices), **kwargs) self.add_collection(col, autolim=True) - self._request_autoscale_view() return col @@ -5337,7 +5336,6 @@ 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._request_autoscale_view() return collection @@ -5808,7 +5806,6 @@ def quiver(self, *args, **kwargs): args = self._quiver_units(args, kwargs) q = mquiver.Quiver(self, *args, **kwargs) self.add_collection(q, autolim=True) - self._request_autoscale_view() return q # args can be some combination of X, Y, U, V, C and all should be replaced @@ -5820,7 +5817,6 @@ def barbs(self, *args, **kwargs): args = self._quiver_units(args, kwargs) b = mquiver.Barbs(self, *args, **kwargs) self.add_collection(b, autolim=True) - self._request_autoscale_view() return b # Uses a custom implementation of data-kwarg handling in @@ -5980,7 +5976,6 @@ def _fill_between_x_or_y( where=where, interpolate=interpolate, step=step, **kwargs) self.add_collection(collection) - self._request_autoscale_view() return collection def _fill_between_process_units(self, ind_dir, dep_dir, ind, dep1, dep2, **kwargs): diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index c3f80abe491e..fa628b3f34e0 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2336,6 +2336,23 @@ def add_child_axes(self, ax): def add_collection(self, collection, autolim=True): """ Add a `.Collection` to the Axes; return the collection. + + Parameters + ---------- + collection : `.Collection` + The collection to add. + autolim : bool + Whether to update data and view limits. + + .. versionchanged:: 3.11 + + This now also updates the view limits, making explicit + calls to `~.Axes.autoscale_view` unnecessary. + + As an implementation detail, the value "_datalim_only" is + supported to smooth the internal transition from pre-3.11 + behavior. This is not a public interface and will be removed + again in the future. """ _api.check_isinstance(mcoll.Collection, collection=collection) if not collection.get_label(): @@ -2371,6 +2388,8 @@ def add_collection(self, collection, autolim=True): updatex=x_is_data or ox_is_data, updatey=y_is_data or oy_is_data, ) + if autolim != "_datalim_only": + self._request_autoscale_view() self.stale = True return collection diff --git a/lib/matplotlib/axes/_base.pyi b/lib/matplotlib/axes/_base.pyi index cb538a49172a..5a5225eb8ba1 100644 --- a/lib/matplotlib/axes/_base.pyi +++ b/lib/matplotlib/axes/_base.pyi @@ -234,7 +234,7 @@ class _AxesBase(martist.Artist): def add_artist(self, a: Artist) -> Artist: ... def add_child_axes(self, ax: _AxesBase) -> _AxesBase: ... def add_collection( - self, collection: Collection, autolim: bool = ... + self, collection: Collection, autolim: bool | Literal["_datalim_only"] = ... ) -> Collection: ... def add_image(self, image: AxesImage) -> AxesImage: ... def add_line(self, line: Line2D) -> Line2D: ... diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py index 4348f02cfc34..2a11477ed1c2 100644 --- a/lib/matplotlib/colorbar.py +++ b/lib/matplotlib/colorbar.py @@ -373,7 +373,7 @@ def __init__( colors=[mpl.rcParams['axes.edgecolor']], linewidths=[0.5 * mpl.rcParams['axes.linewidth']], clip_on=False) - self.ax.add_collection(self.dividers) + self.ax.add_collection(self.dividers, autolim=False) self._locator = None self._minorlocator = None @@ -807,7 +807,7 @@ def add_lines(self, *args, **kwargs): xy = self.ax.transAxes.inverted().transform(inches.transform(xy)) col.set_clip_path(mpath.Path(xy, closed=True), self.ax.transAxes) - self.ax.add_collection(col) + self.ax.add_collection(col, autolim=False) self.stale = True def update_ticks(self): diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index f5ec85005079..9859a286e5fd 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -354,7 +354,11 @@ def test_path_collection(): sizes = [0.02, 0.04] pc = mcollections.PathCollection(paths, sizes, zorder=-1, facecolors='yellow', offsets=offsets) - ax.add_collection(pc) + # Note: autolim=False is used to keep the view limits as is for now, + # given the updated behavior of autolim=True to also update the view + # limits. It may be reasonable to test the limits handling in the future + # as well. This will require regenerating the reference image. + ax.add_collection(pc, autolim=False) ax.set_xlim(0, 1) diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 53a6f45668fa..e6e36da29aa8 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -408,7 +408,6 @@ def test_EllipseCollection(): ww, hh, aa, units='x', offsets=XY, offset_transform=ax.transData, facecolors='none') ax.add_collection(ec) - ax.autoscale_view() def test_EllipseCollection_setter_getter(): @@ -526,7 +525,6 @@ def test_regularpolycollection_rotate(): 4, sizes=(100,), rotation=alpha, offsets=[xy], offset_transform=ax.transData) ax.add_collection(col, autolim=True) - ax.autoscale_view() @image_comparison(['regularpolycollection_scale.png'], remove_text=True) diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index d69a9dad4337..ed608eebb6a7 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -941,7 +941,9 @@ def test_arc_in_collection(fig_test, fig_ref): arc2 = Arc([.5, .5], .5, 1, theta1=0, theta2=60, angle=20) col = mcollections.PatchCollection(patches=[arc2], facecolors='none', edgecolors='k') - fig_ref.subplots().add_patch(arc1) + ax_ref = fig_ref.subplots() + ax_ref.add_patch(arc1) + ax_ref.autoscale_view() fig_test.subplots().add_collection(col) diff --git a/lib/matplotlib/tri/_tripcolor.py b/lib/matplotlib/tri/_tripcolor.py index f3c26b0b25ff..5a5b24522d17 100644 --- a/lib/matplotlib/tri/_tripcolor.py +++ b/lib/matplotlib/tri/_tripcolor.py @@ -163,5 +163,7 @@ def tripcolor(ax, *args, alpha=1.0, norm=None, cmap=None, vmin=None, corners = (minx, miny), (maxx, maxy) ax.update_datalim(corners) ax.autoscale_view() - ax.add_collection(collection) + # TODO: check whether the above explicit limit handling can be + # replaced by autolim=True + ax.add_collection(collection, autolim=False) return collection diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 55b204022fb9..9576b299ab72 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -2133,7 +2133,7 @@ def fill_between(self, x1, y1, z1, x2, y2, z2, *, polyc = art3d.Poly3DCollection(polys, facecolors=facecolors, shade=shade, axlim_clip=axlim_clip, **kwargs) - self.add_collection(polyc) + self.add_collection(polyc, autolim="_datalim_only") self.auto_scale_xyz([x1, x2], [y1, y2], [z1, z2], had_data) return polyc @@ -2332,7 +2332,7 @@ def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, polys, facecolors=color, shade=shade, lightsource=lightsource, axlim_clip=axlim_clip, **kwargs) - self.add_collection(polyc) + self.add_collection(polyc, autolim="_datalim_only") self.auto_scale_xyz(X, Y, Z, had_data) return polyc @@ -2458,7 +2458,7 @@ def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs): lines = list(row_lines) + list(col_lines) linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs) - self.add_collection(linec) + self.add_collection(linec, autolim="_datalim_only") return linec @@ -2559,7 +2559,7 @@ def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, verts, *args, shade=shade, lightsource=lightsource, facecolors=color, axlim_clip=axlim_clip, **kwargs) - self.add_collection(polyc) + self.add_collection(polyc, autolim="_datalim_only") self.auto_scale_xyz(tri.x, tri.y, z, had_data) return polyc @@ -2901,7 +2901,7 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *, # Currently unable to do so due to issues with Patch3DCollection # See https://github.com/matplotlib/matplotlib/issues/14298 for details - collection = super().add_collection(col) + collection = super().add_collection(col, autolim="_datalim_only") return collection @_preprocess_data(replace_names=["xs", "ys", "zs", "s", @@ -3231,7 +3231,7 @@ def bar3d(self, x, y, z, dx, dy, dz, color=None, lightsource=lightsource, axlim_clip=axlim_clip, *args, **kwargs) - self.add_collection(col) + self.add_collection(col, autolim="_datalim_only") self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data) @@ -3328,7 +3328,7 @@ def calc_arrows(UVW): if any(len(v) == 0 for v in input_args): # No quivers, so just make an empty collection and return early linec = art3d.Line3DCollection([], **kwargs) - self.add_collection(linec) + self.add_collection(linec, autolim="_datalim_only") return linec shaft_dt = np.array([0., length], dtype=float) @@ -3366,7 +3366,7 @@ def calc_arrows(UVW): lines = [] linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs) - self.add_collection(linec) + self.add_collection(linec, autolim="_datalim_only") self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data) @@ -3897,7 +3897,7 @@ def _extract_errs(err, data, lomask, himask): errline = art3d.Line3DCollection(np.array(coorderr).T, axlim_clip=axlim_clip, **eb_lines_style) - self.add_collection(errline) + self.add_collection(errline, autolim="_datalim_only") errlines.append(errline) coorderrs.append(coorderr) @@ -4047,7 +4047,7 @@ def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', stemlines = art3d.Line3DCollection( lines, linestyles=linestyle, colors=linecolor, label='_nolegend_', axlim_clip=axlim_clip) - self.add_collection(stemlines) + self.add_collection(stemlines, autolim="_datalim_only") markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_') stem_container = StemContainer((markerline, stemlines, baseline), diff --git a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py index 174c12608ae9..8ff6050443ab 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_art3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_art3d.py @@ -55,7 +55,7 @@ def test_zordered_error(): fig = plt.figure() ax = fig.add_subplot(projection="3d") - ax.add_collection(Line3DCollection(lc)) + ax.add_collection(Line3DCollection(lc), autolim="_datalim_only") ax.scatter(*pc, visible=False) plt.draw() diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index cd45c8e33a6f..3545762e01f8 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -1218,7 +1218,7 @@ def _test_proj_draw_axes(M, s=1, *args, **kwargs): fig, ax = plt.subplots(*args, **kwargs) linec = LineCollection(lines) - ax.add_collection(linec) + ax.add_collection(linec, autolim="_datalim_only") for x, y, t in zip(txs, tys, ['o', 'x', 'y', 'z']): ax.text(x, y, t) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py index 7fd676df1e31..091ae2c3e12f 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py @@ -47,9 +47,9 @@ def test_linecollection_scaled_dashes(): lc3 = art3d.Line3DCollection(lines3, linestyles=":", lw=.5) fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) - ax.add_collection(lc1) - ax.add_collection(lc2) - ax.add_collection(lc3) + ax.add_collection(lc1, autolim="_datalim_only") + ax.add_collection(lc2, autolim="_datalim_only") + ax.add_collection(lc3, autolim="_datalim_only") leg = ax.legend([lc1, lc2, lc3], ['line1', 'line2', 'line 3']) h1, h2, h3 = leg.legend_handles