Repository: matplotlib/matplotlib

Issue:
[ENH]: add_collection(..., autolim=True) should update view limits as well
### Problem

`Axes.add_collection()` has the parameter `autolim`, which updates `Axes.dataLim`, however it does not update `Axes.viewLim`. This requires an explicit `ax.autoscale_view()` - or when using it internally, we use the lazy `ax._request_autoscale_view`.

### Proposed solution

`Axes.add_collection(..., autolim=True)` should itself call `ax._request_autoscale_view()`. If we opt into limits handling, this should possibly include the view limits as well. Requireing an extra call from users is cumbersome. Also since  `ax._request_autoscale_view()` is private, users can only call the eager `ax.autoscale_view()`.

Todo: Investigate how to make this work correctly for 3D, which seems to use a dedicated `auto_scale_xyz()`.


Discussion:
Is this the same as #29219?
Basically yes, though I haven't read through #29219 completely. I've started #29958 to give it a try. Let's see where that's still failing and needs extra work.



Patch:
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/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),


Test patch:
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/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