diff --git a/doc/api/next_api_changes/behavior/31530-TZ.rst b/doc/api/next_api_changes/behavior/31530-TZ.rst new file mode 100644 index 000000000000..470104817a59 --- /dev/null +++ b/doc/api/next_api_changes/behavior/31530-TZ.rst @@ -0,0 +1,6 @@ +``relim()`` now accounts for Collection artists +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Previously, `~.axes.Axes.relim` did not recalculate data limits for +`.Collection` artists (e.g. those created by `~.axes.Axes.scatter`). +Calling ``ax.relim()`` followed by ``ax.autoscale_view()`` now correctly +includes scatter plots and other collections in the axes limits. diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index 1030a6809ceb..88e38634b5b1 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -240,9 +240,6 @@ def remove(self): with `.FigureCanvasBase.draw_idle`. Call `~.axes.Axes.relim` to update the Axes limits if desired. - Note: `~.axes.Axes.relim` will not see collections even if the - collection was added to the Axes with *autolim* = True. - Note: there is no support for removing the artist's legend entry. """ @@ -271,11 +268,6 @@ def remove(self): else: raise NotImplementedError('cannot remove artist') - # TODO: the fix for the collections relim problem is to move the - # limits calculation into the artist itself, including the property of - # whether or not the artist should affect the limits. Then there will - # be no distinction between axes.add_line, axes.add_patch, etc. - # TODO: add legend support def have_units(self): """Return whether units are set on any axis.""" diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 1a32af922342..ee933ea138ad 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2386,10 +2386,21 @@ def add_collection(self, collection, autolim=True): autolim : bool Whether to update data and view limits. + If *False*, the collection does not take part in any limit + operations. + .. versionchanged:: 3.11 - This now also updates the view limits, making explicit - calls to `~.Axes.autoscale_view` unnecessary. + Since 3.11 ``autolim=True`` matches the standard behavior + of other ``add_[artist]`` methods: Axes data and view limits + are both updated in the method, and the collection will + be considered in future data limit updates through + `.relim`. + + Prior to matplotlib 3.11 this was only a one-time update + of the data limits. Updating view limits required an + explicit call to `~.Axes.autoscale_view`, and collections + did not take part in `.relim`. As an implementation detail, the value "_datalim_only" is supported to smooth the internal transition from pre-3.11 @@ -2407,29 +2418,12 @@ def add_collection(self, collection, autolim=True): collection.set_clip_path(self.patch) if autolim: + if autolim != "_datalim_only": + collection._set_in_autoscale(True) # Make sure viewLim is not stale (mostly to match # pre-lazy-autoscale behavior, which is not really better). self._unstale_viewLim() - datalim = collection.get_datalim(self.transData) - points = datalim.get_points() - if not np.isinf(datalim.minpos).all(): - # By definition, if minpos (minimum positive value) is set - # (i.e., non-inf), then min(points) <= minpos <= max(points), - # and minpos would be superfluous. However, we add minpos to - # the call so that self.dataLim will update its own minpos. - # This ensures that log scales see the correct minimum. - points = np.concatenate([points, [datalim.minpos]]) - # only update the dataLim for x/y if the collection uses transData - # in this direction. - x_is_data, y_is_data = (collection.get_transform() - .contains_branch_separately(self.transData)) - ox_is_data, oy_is_data = (collection.get_offset_transform() - .contains_branch_separately(self.transData)) - self.update_datalim( - points, - updatex=x_is_data or ox_is_data, - updatey=y_is_data or oy_is_data, - ) + self._update_collection_limits(collection) if autolim != "_datalim_only": self._request_autoscale_view() @@ -2598,6 +2592,29 @@ def _update_patch_limits(self, patch): xys = trf_to_data.transform(vertices) self.update_datalim(xys, updatex=updatex, updatey=updatey) + def _update_collection_limits(self, collection): + """Update the data limits for the given collection.""" + datalim = collection.get_datalim(self.transData) + points = datalim.get_points() + if not np.isinf(datalim.minpos).all(): + # By definition, if minpos (minimum positive value) is set + # (i.e., non-inf), then min(points) <= minpos <= max(points), + # and minpos would be superfluous. However, we add minpos to + # the call so that self.dataLim will update its own minpos. + # This ensures that log scales see the correct minimum. + points = np.concatenate([points, [datalim.minpos]]) + # only update the dataLim for x/y if the collection uses transData + # in this direction. + x_is_data, y_is_data = (collection.get_transform() + .contains_branch_separately(self.transData)) + ox_is_data, oy_is_data = (collection.get_offset_transform() + .contains_branch_separately(self.transData)) + self.update_datalim( + points, + updatex=x_is_data or ox_is_data, + updatey=y_is_data or oy_is_data, + ) + def add_table(self, tab): """ Add a `.Table` to the Axes; return the table. @@ -2638,15 +2655,11 @@ def relim(self, visible_only=False): """ Recompute the data limits based on current artists. - At present, `.Collection` instances are not supported. - Parameters ---------- visible_only : bool, default: False Whether to exclude invisible artists. """ - # Collections are deliberately not supported (yet); see - # the TODO note in artists.py. self.dataLim.ignore(True) self.dataLim.set_points(mtransforms.Bbox.null().get_points()) self.ignore_existing_data_limits = True @@ -2661,6 +2674,8 @@ def relim(self, visible_only=False): self._update_patch_limits(artist) elif isinstance(artist, mimage.AxesImage): self._update_image_limits(artist) + elif isinstance(artist, mcoll.Collection): + self._update_collection_limits(artist) def update_datalim(self, xys, updatex=True, updatey=True): """ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index f006c624f8d7..0f58da4200cc 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -6511,6 +6511,79 @@ def test_relim_visible_only(): assert ax.get_ylim() == y1 +def test_relim_collection(): + fig, ax = plt.subplots() + sc = ax.scatter([1, 2, 3], [4, 5, 6]) + ax.relim() + expected = sc.get_datalim(ax.transData) + assert_allclose(ax.dataLim.get_points(), expected.get_points()) + assert_allclose(ax.dataLim.minpos, expected.minpos) + + # After updating offsets, relim should track the new data. + sc.set_offsets([[10, 20], [30, 40]]) + ax.relim() + expected = sc.get_datalim(ax.transData) + assert_allclose(ax.dataLim.get_points(), expected.get_points()) + assert_allclose(ax.dataLim.minpos, expected.minpos) + + # visible_only=True should ignore hidden collections. + line, = ax.plot([0, 1], [0, 1]) + sc.set_visible(False) + ax.relim(visible_only=True) + # With scatter hidden, limits should be driven by the line only. + assert_allclose(ax.dataLim.get_points(), [[0, 0], [1, 1]]) + # minpos is the minimum *positive* value; line data [0, 1] gives 1.0. + assert_array_equal(ax.dataLim.minpos, [1., 1.]) + + +def test_relim_collection_autolim_false(): + # GH#30859 - Collection added with autolim=False must not participate + # in relim() later. + import matplotlib.collections as mcollections + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.relim() + expected = ax.dataLim.frozen() + # Build a collection far outside current limits and add it with autolim=False. + sc = mcollections.PathCollection([]) + sc.set_offsets([[100, 200], [300, 400]]) + ax.add_collection(sc, autolim=False) + ax.relim() + # dataLim must remain unchanged because autolim=False was requested. + assert_allclose(ax.dataLim.get_points(), expected.get_points()) + assert_allclose(ax.dataLim.minpos, expected.minpos) + + +def test_relim_collection_log_scale(): + # GH#30859 - relim() for Collection on a log-scaled axis should + # correctly propagate minpos into dataLim. + fig, ax = plt.subplots() + ax.set_xscale('log') + ax.set_yscale('log') + sc = ax.scatter([1e-3, 1e-2, 1e-1], [1e1, 1e2, 1e3]) + sc.set_offsets([[1e1, 1e4], [1e2, 1e5]]) + ax.relim() + expected = sc.get_datalim(ax.transData) + assert_allclose(ax.dataLim.get_points(), expected.get_points()) + assert_allclose(ax.dataLim.minpos, expected.minpos) + + +def test_relim_collection_autoscale_view(): + # GH#30859 - end-to-end: after set_offsets(), relim() + autoscale_view() + # must update the visible axis limits, not just dataLim. + fig, ax = plt.subplots() + sc = ax.scatter([], []) + xs = np.linspace(0, 10, 50) + sc.set_offsets(np.column_stack((xs, np.sin(xs)))) + ax.relim() + ax.autoscale_view() + xlim = ax.get_xlim() + ylim = ax.get_ylim() + # autoscale_view adds a margin, so limits should comfortably contain data + assert xlim[0] <= 0 and xlim[1] >= 10, f"xlim should contain [0, 10], got {xlim}" + assert ylim[0] <= -1 and ylim[1] >= 1, f"ylim should contain [-1, 1], got {ylim}" + + def test_text_labelsize(): """ tests for issue #1172