From 3c4bff0fa2a66a7155ed841db5705c31d400d84b Mon Sep 17 00:00:00 2001 From: Jay Xiao Date: Fri, 12 Apr 2024 21:46:33 -0400 Subject: [PATCH 1/2] move update_*_limits inside respective Artists, including Collection --- .gitignore | 4 + lib/matplotlib/artist.py | 27 +++++++ lib/matplotlib/artist.pyi | 2 + lib/matplotlib/axes/_base.py | 137 +++++++--------------------------- lib/matplotlib/collections.py | 15 ++++ lib/matplotlib/image.py | 4 + lib/matplotlib/lines.py | 48 ++++++++++++ lib/matplotlib/patches.py | 40 ++++++++++ 8 files changed, 166 insertions(+), 111 deletions(-) diff --git a/.gitignore b/.gitignore index b6f9e1ee74f4..372ca381b300 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,10 @@ # Python files # ################ + +# virtual environment +env/ + # meson-python working directory build .mesonpy* diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py index b79d3cc62338..973eb631e796 100644 --- a/lib/matplotlib/artist.py +++ b/lib/matplotlib/artist.py @@ -212,6 +212,7 @@ def __init__(self): self._path_effects = mpl.rcParams['path.effects'] self._sticky_edges = _XYPair([], []) self._in_layout = True + self._in_autoscale = True def __getstate__(self): d = self.__dict__.copy() @@ -881,6 +882,14 @@ def _fully_clipped_to_axes(self): or isinstance(clip_path, TransformedPatchPath) and clip_path._patch is self.axes.patch)) + def get_in_autoscale(self): + """ + Return bool or tuple[bool] flag, with as many entries as + dimensions in the plot. When True, the artist is included + in autoscale calculations along that axis. + """ + return self._in_autoscale + def get_clip_on(self): """Return whether the artist uses clipping.""" return self._clipon @@ -1086,6 +1095,17 @@ def set_in_layout(self, in_layout): """ self._in_layout = in_layout + def set_in_autoscale(self, in_autoscale): + """ + Set if artist is to be included in autoscale calculations + along certain axes. + + Parameters + ---------- + in_autoscale : bool or tuple[bool] + """ + self._in_autoscale = in_autoscale + def get_label(self): """Return the label used for this artist in the legend.""" return self._label @@ -1220,6 +1240,13 @@ def _internal_update(self, kwargs): kwargs, "{cls.__name__}.set() got an unexpected keyword argument " "{prop_name!r}") + def _update_limits(self, axes_base): + """ + Defaults to failure in base Artist. Will be overridden in + Line2D, Patch, AxesImage, and Collection classes. + """ + raise NotImplementedError('artist child does not have _update_limits function') + def set(self, **kwargs): # docstring and signature are auto-generated via # Artist._update_set_signature_and_docstring() at the end of the diff --git a/lib/matplotlib/artist.pyi b/lib/matplotlib/artist.pyi index 101e97a9a072..777e0ee4cdf4 100644 --- a/lib/matplotlib/artist.pyi +++ b/lib/matplotlib/artist.pyi @@ -97,6 +97,7 @@ class Artist: def get_visible(self) -> bool: ... def get_animated(self) -> bool: ... def get_in_layout(self) -> bool: ... + def get_in_autoscale(self) -> bool | tuple[bool]: ... def get_clip_on(self) -> bool: ... def get_clip_box(self) -> Bbox | None: ... def get_clip_path( @@ -117,6 +118,7 @@ class Artist: def set_visible(self, b: bool) -> None: ... def set_animated(self, b: bool) -> None: ... def set_in_layout(self, in_layout: bool) -> None: ... + def set_in_autoscale(self, in_autoscale: bool | tuple[bool]) -> None: ... def get_label(self) -> object: ... def set_label(self, s: object) -> None: ... def get_zorder(self) -> float: ... diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 36baff85fa66..e49ba0a249cc 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2265,19 +2265,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() - 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]]) - self.update_datalim(points) + collection._update_limits(self) + else: + collection.set_in_autoscale(False) self.stale = True return collection @@ -2295,10 +2285,6 @@ def add_image(self, image): self.stale = True return image - def _update_image_limits(self, image): - xmin, xmax, ymin, ymax = image.get_extent() - self.axes.update_datalim(((xmin, ymin), (xmax, ymax))) - def add_line(self, line): """ Add a `.Line2D` to the Axes; return the line. @@ -2327,54 +2313,6 @@ def _add_text(self, txt): self.stale = True return txt - def _update_line_limits(self, line): - """ - Figures out the data limit of the given line, updating self.dataLim. - """ - path = line.get_path() - if path.vertices.size == 0: - return - - line_trf = line.get_transform() - - if line_trf == self.transData: - data_path = path - elif any(line_trf.contains_branch_seperately(self.transData)): - # Compute the transform from line coordinates to data coordinates. - trf_to_data = line_trf - self.transData - # If transData is affine we can use the cached non-affine component - # of line's path (since the non-affine part of line_trf is - # entirely encapsulated in trf_to_data). - if self.transData.is_affine: - line_trans_path = line._get_transformed_path() - na_path, _ = line_trans_path.get_transformed_path_and_affine() - data_path = trf_to_data.transform_path_affine(na_path) - else: - data_path = trf_to_data.transform_path(path) - else: - # For backwards compatibility we update the dataLim with the - # coordinate range of the given path, even though the coordinate - # systems are completely different. This may occur in situations - # such as when ax.transAxes is passed through for absolute - # positioning. - data_path = path - - if not data_path.vertices.size: - return - - updatex, updatey = line_trf.contains_branch_seperately(self.transData) - if self.name != "rectilinear": - # This block is mostly intended to handle axvline in polar plots, - # for which updatey would otherwise be True. - if updatex and line_trf == self.get_yaxis_transform(): - updatex = False - if updatey and line_trf == self.get_xaxis_transform(): - updatey = False - self.dataLim.update_from_path(data_path, - self.ignore_existing_data_limits, - updatex=updatex, updatey=updatey) - self.ignore_existing_data_limits = False - def add_patch(self, p): """ Add a `.Patch` to the Axes; return the patch. @@ -2388,46 +2326,6 @@ def add_patch(self, p): p._remove_method = self._children.remove return p - def _update_patch_limits(self, patch): - """Update the data limits for the given patch.""" - # hist can add zero height Rectangles, which is useful to keep - # the bins, counts and patches lined up, but it throws off log - # scaling. We'll ignore rects with zero height or width in - # the auto-scaling - - # cannot check for '==0' since unitized data may not compare to zero - # issue #2150 - we update the limits if patch has non zero width - # or height. - if (isinstance(patch, mpatches.Rectangle) and - ((not patch.get_width()) and (not patch.get_height()))): - return - p = patch.get_path() - # Get all vertices on the path - # Loop through each segment to get extrema for Bezier curve sections - vertices = [] - for curve, code in p.iter_bezier(simplify=False): - # Get distance along the curve of any extrema - _, dzeros = curve.axis_aligned_extrema() - # Calculate vertices of start, end and any extrema in between - vertices.append(curve([0, *dzeros, 1])) - - if len(vertices): - vertices = np.vstack(vertices) - - patch_trf = patch.get_transform() - updatex, updatey = patch_trf.contains_branch_seperately(self.transData) - if not (updatex or updatey): - return - if self.name != "rectilinear": - # As in _update_line_limits, but for axvspan. - if updatex and patch_trf == self.get_yaxis_transform(): - updatex = False - if updatey and patch_trf == self.get_xaxis_transform(): - updatey = False - trf_to_data = patch_trf - self.transData - xys = trf_to_data.transform(vertices) - self.update_datalim(xys, updatex=updatex, updatey=updatey) - def add_table(self, tab): """ Add a `.Table` to the Axes; return the table. @@ -2483,12 +2381,29 @@ def relim(self, visible_only=False): for artist in self._children: if not visible_only or artist.get_visible(): - if isinstance(artist, mlines.Line2D): - self._update_line_limits(artist) - elif isinstance(artist, mpatches.Patch): - self._update_patch_limits(artist) - elif isinstance(artist, mimage.AxesImage): - self._update_image_limits(artist) + if artist.get_in_autoscale(): + artist._update_limits(self) + + def _update_line_limits(self, line): + """ + These 3 functions were made to appease the integration tests. + _update_limits() is now inside the respective Artists + """ + line._update_limits(self) + + def _update_patch_limits(self, patch): + """ + These 3 functions were made to appease the integration tests. + _update_limits() is now inside the respective Artists + """ + patch._update_limits(self) + + def _update_image_limits(self, image): + """ + These 3 functions were made to appease the integration tests. + _update_limits() is now inside the respective Artists + """ + image._update_limits(self) def update_datalim(self, xys, updatex=True, updatey=True): """ diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index fd6cc4339d64..87429285d97f 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -957,6 +957,21 @@ def update_from(self, other): self.cmap = other.cmap self.stale = True + def _update_limits(self, axes_base): + # Make sure viewLim is not stale (mostly to match + # pre-lazy-autoscale behavior, which is not really better). + axes_base._unstale_viewLim() + datalim = self.get_datalim(axes_base.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 axes_base.dataLim will update its own minpos. + # This ensures that log scales see the correct minimum. + points = np.concatenate([points, [datalim.minpos]]) + axes_base.update_datalim(points) + class _CollectionWithSizes(Collection): """ diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index 5b0152505397..5fd02dfbf0be 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -1043,6 +1043,10 @@ def get_cursor_data(self, event): else: return arr[i, j] + def _update_limits(self, axes_base): + xmin, xmax, ymin, ymax = self.get_extent() + axes_base.axes.update_datalim(((xmin, ymin), (xmax, ymax))) + class NonUniformImage(AxesImage): diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 72e74f4eb9c5..93a1c493ed96 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -1464,6 +1464,54 @@ def is_dashed(self): """ return self._linestyle in ('--', '-.', ':') + def _update_limits(self, axes_base): + """ + Figures out the data limit of the given line, updating axes_base.dataLim. + """ + path = self.get_path() + if path.vertices.size == 0: + return + + line_trf = self.get_transform() + + if line_trf == axes_base.transData: + data_path = path + elif any(line_trf.contains_branch_seperately(axes_base.transData)): + # Compute the transform from line coordinates to data coordinates. + trf_to_data = line_trf - axes_base.transData + # If transData is affine we can use the cached non-affine component + # of line's path (since the non-affine part of line_trf is + # entirely encapsulated in trf_to_data). + if axes_base.transData.is_affine: + line_trans_path = self._get_transformed_path() + na_path, _ = line_trans_path.get_transformed_path_and_affine() + data_path = trf_to_data.transform_path_affine(na_path) + else: + data_path = trf_to_data.transform_path(path) + else: + # For backwards compatibility we update the dataLim with the + # coordinate range of the given path, even though the coordinate + # systems are completely different. This may occur in situations + # such as when ax.transAxes is passed through for absolute + # positioning. + data_path = path + + if not data_path.vertices.size: + return + + updatex, updatey = line_trf.contains_branch_seperately(axes_base.transData) + if axes_base.name != "rectilinear": + # This block is mostly intended to handle axvline in polar plots, + # for which updatey would otherwise be True. + if updatex and line_trf == axes_base.get_yaxis_transform(): + updatex = False + if updatey and line_trf == axes_base.get_xaxis_transform(): + updatey = False + axes_base.dataLim.update_from_path(data_path, + axes_base.ignore_existing_data_limits, + updatex=updatex, updatey=updatey) + axes_base.ignore_existing_data_limits = False + class AxLine(Line2D): """ diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 2899952634a9..fe541e8298d6 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -650,6 +650,46 @@ def _convert_xy_units(self, xy): y = self.convert_yunits(xy[1]) return x, y + def _update_limits(self, axes_base): + """Update the data limits for the given patch.""" + # hist can add zero height Rectangles, which is useful to keep + # the bins, counts and patches lined up, but it throws off log + # scaling. We'll ignore rects with zero height or width in + # the auto-scaling + + # cannot check for '==0' since unitized data may not compare to zero + # issue #2150 - we update the limits if patch has non zero width + # or height. + if (isinstance(self, Rectangle) and + ((not self.get_width()) and (not self.get_height()))): + return + p = self.get_path() + # Get all vertices on the path + # Loop through each segment to get extrema for Bezier curve sections + vertices = [] + for curve, code in p.iter_bezier(simplify=False): + # Get distance along the curve of any extrema + _, dzeros = curve.axis_aligned_extrema() + # Calculate vertices of start, end and any extrema in between + vertices.append(curve([0, *dzeros, 1])) + + if len(vertices): + vertices = np.vstack(vertices) + + patch_trf = self.get_transform() + updatex, updatey = patch_trf.contains_branch_seperately(axes_base.transData) + if not (updatex or updatey): + return + if axes_base.name != "rectilinear": + # As in _update_line_limits, but for axvspan. + if updatex and patch_trf == axes_base.get_yaxis_transform(): + updatex = False + if updatey and patch_trf == axes_base.get_xaxis_transform(): + updatey = False + trf_to_data = patch_trf - axes_base.transData + xys = trf_to_data.transform(vertices) + axes_base.update_datalim(xys, updatex=updatex, updatey=updatey) + class Shadow(Patch): def __str__(self): From fb7abe500b1095d774b9fba0ea6f230d7ae8e6ef Mon Sep 17 00:00:00 2001 From: Jay Xiao Date: Sat, 13 Apr 2024 03:26:33 -0400 Subject: [PATCH 2/2] test cases and api documentation --- doc/api/artist_api.rst | 2 + .../next_whats_new/artist_in_autoscale.rst | 9 +++++ lib/matplotlib/axes/_base.py | 14 +++---- lib/matplotlib/collections.py | 7 ++++ lib/matplotlib/image.py | 3 ++ lib/matplotlib/lines.py | 3 ++ lib/matplotlib/patches.py | 5 ++- lib/matplotlib/tests/test_artist.py | 40 +++++++++++++++++++ 8 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 doc/users/next_whats_new/artist_in_autoscale.rst diff --git a/doc/api/artist_api.rst b/doc/api/artist_api.rst index 0ca3fb364c41..38c99c90688c 100644 --- a/doc/api/artist_api.rst +++ b/doc/api/artist_api.rst @@ -182,6 +182,8 @@ Miscellaneous :nosignatures: Artist.sticky_edges + Artist.set_in_autoscale + Artist.get_in_autoscale Artist.set_in_layout Artist.get_in_layout Artist.stale diff --git a/doc/users/next_whats_new/artist_in_autoscale.rst b/doc/users/next_whats_new/artist_in_autoscale.rst new file mode 100644 index 000000000000..dbdf83e69463 --- /dev/null +++ b/doc/users/next_whats_new/artist_in_autoscale.rst @@ -0,0 +1,9 @@ +``Artist`` gained setter and getter for new ``_in_autoscale`` flag +------------------------------------------------------------------- + +The ``_in_autoscale`` flag determines whether the instance is used +in the autoscale calculation. The flag can be a bool, or tuple[bool] for 2D/3D. +Expansion to a tuple is done in the setter. + +The purpose is to put auto-limit logic inside respective Artists. +This allows ``Collection`` objects to be used in ``relim`` axis calculations. diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index e49ba0a249cc..0c52a156f2fe 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2379,29 +2379,29 @@ def relim(self, visible_only=False): self.dataLim.set_points(mtransforms.Bbox.null().get_points()) self.ignore_existing_data_limits = True - for artist in self._children: + for artist in self._children: # can be Collection now if not visible_only or artist.get_visible(): if artist.get_in_autoscale(): artist._update_limits(self) def _update_line_limits(self, line): """ - These 3 functions were made to appease the integration tests. - _update_limits() is now inside the respective Artists + These 3 functions keep the interface the same for integration tests. + _update_limits() is now inside the respective Artists. """ line._update_limits(self) def _update_patch_limits(self, patch): """ - These 3 functions were made to appease the integration tests. - _update_limits() is now inside the respective Artists + These 3 functions keep the interface the same for integration tests. + _update_limits() is now inside the respective Artists. """ patch._update_limits(self) def _update_image_limits(self, image): """ - These 3 functions were made to appease the integration tests. - _update_limits() is now inside the respective Artists + These 3 functions keep the interface the same for integration tests. + _update_limits() is now inside the respective Artists. """ image._update_limits(self) diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index 87429285d97f..95739b1314a0 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -958,6 +958,13 @@ def update_from(self, other): self.stale = True def _update_limits(self, axes_base): + """ + Figures out the data limit of a Collection, updating + axes_base.dataLim. + """ + if not self._in_autoscale: + return + # Make sure viewLim is not stale (mostly to match # pre-lazy-autoscale behavior, which is not really better). axes_base._unstale_viewLim() diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index 5fd02dfbf0be..159df6be1a7a 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -1044,6 +1044,9 @@ def get_cursor_data(self, event): return arr[i, j] def _update_limits(self, axes_base): + if not self._in_autoscale: + return + xmin, xmax, ymin, ymax = self.get_extent() axes_base.axes.update_datalim(((xmin, ymin), (xmax, ymax))) diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 93a1c493ed96..20196191b2f9 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -1468,6 +1468,9 @@ def _update_limits(self, axes_base): """ Figures out the data limit of the given line, updating axes_base.dataLim. """ + if not self._in_autoscale: + return + path = self.get_path() if path.vertices.size == 0: return diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index fe541e8298d6..7a53cd1784d8 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -651,7 +651,7 @@ def _convert_xy_units(self, xy): return x, y def _update_limits(self, axes_base): - """Update the data limits for the given patch.""" + """Update the data limits for the patch.""" # hist can add zero height Rectangles, which is useful to keep # the bins, counts and patches lined up, but it throws off log # scaling. We'll ignore rects with zero height or width in @@ -660,6 +660,9 @@ def _update_limits(self, axes_base): # cannot check for '==0' since unitized data may not compare to zero # issue #2150 - we update the limits if patch has non zero width # or height. + if not self._in_autoscale: + return + if (isinstance(self, Rectangle) and ((not self.get_width()) and (not self.get_height()))): return diff --git a/lib/matplotlib/tests/test_artist.py b/lib/matplotlib/tests/test_artist.py index dbb5dd2305e0..0bcbfcf63a64 100644 --- a/lib/matplotlib/tests/test_artist.py +++ b/lib/matplotlib/tests/test_artist.py @@ -562,3 +562,43 @@ def draw(self, renderer, extra): assert 'aardvark' == art.draw(renderer, 'aardvark') assert 'aardvark' == art.draw(renderer, extra='aardvark') + + +def test_line_in_autoscale(): + # test autoscale is performed when in_autoscale=True for a line. + fig, ax = plt.subplots() + ax.plot([0, 1]) + ax.plot([0, 1, 2, 3], in_autoscale=True) + ax.margins(0) + ax.autoscale_view() + assert ax.get_xlim() == (0, 3) + assert ax.get_ylim() == (0, 3) + + # test autoscale is not performed when in_autoscale=False for a line. + fig, ax = plt.subplots() + ax.plot([0, 1]) + ax.plot([0, 1, 2, 3], in_autoscale=False) + ax.margins(0) + ax.autoscale_view() + assert ax.get_xlim() == (0, 1) + assert ax.get_ylim() == (0, 1) + + +def test_patch_in_autoscale(): + # test autoscale is performed when in_autoscale=True for a patch. + fig, ax = plt.subplots() + ax.plot([0, 1]) + img = ax.bar([0, 1, 2, 3], [0, 10, 20, 30], width=0.5, in_autoscale=True) + ax.margins(0) + ax.autoscale_view() + assert ax.get_xlim() == (-.25, 3.25) + assert ax.get_ylim() == (0, 30) + + # test autoscale is not performed when in_autoscale=False for a patch. + fig, ax = plt.subplots() + ax.plot([0, 1]) + img = ax.bar([0, 1, 2, 3], [0, 10, 20, 30], width=0.5, in_autoscale=False) + ax.margins(0) + ax.autoscale_view() + assert ax.get_xlim() == (0, 1) + assert ax.get_ylim() == (0, 1)