Repository: SciTools/cartopy
Pull request #2252 (2023-09-28 12:05:55)

Gridliner: don't destroy and recreate artists
<!--

Thanks for contributing to cartopy!
Please use this template as a guide to streamline the pull request you are about to make.

Remember: it is significantly easier to merge small pull requests. Consider whether this pull
request could be broken into smaller parts before submitting.

-->



## Rationale

<!-- Please provide detail as to *why* you are making this change. -->
@greglucas has asked for this at least twice!

When re-drawing the `GridLiner`, update its existing `LineCollection` and `Text` objects rather than creating new ones.  This was trivial for the `LineCollection`s but a bit more complicated for the labels since we might have different numbers of them e.g. after zooming.  Clearly Matplotlib's `Axis` has already solved that problem so I got my inspiration from [there](https://github.com/matplotlib/matplotlib/blob/e3a5cee92bf10f07608f9e638f8d586ba81f16c7/lib/matplotlib/axis.py#L1620-L1626).  We now keep two lists of labels: `_all_labels` is the list of labels that have been drawn at some point.  `_labels` is the list that were used at the most recent draw (and still gets cleared out and repopulated each time).

Goes on top of #2249.  ~Only the third commit is new.~

## Implications

<!-- If applicable, to the best of your knowledge, what are the implications of this change? -->


<!--
## Checklist

 * If you have not already done so, ensure you've read and signed the Contributor Licence Agreement (CLA).
   (See the [governance page](http://scitools.org.uk/governance.html) for the CLA and what to do with it).

 * If this is a new feature, please provide an example of its use in the description. We may want to make a
   follow-on pull request to put the example in the gallery!

 * Ensure there is a suitable item in the cartopy test suite for the change you are proposing.

-->


rcomer: Putting this one in draft as it will need rebasing once #2249 is merged.

Patch:
diff --git a/INSTALL b/INSTALL
index 49e93adf..0fed352a 100644
--- a/INSTALL
+++ b/INSTALL
@@ -62,7 +62,7 @@ Further information about the required dependencies can be found here:
 **Python** 3.9 or later (https://www.python.org/)
     Python 2 support was removed in v0.19.
 
-**Matplotlib** 3.5 or later (https://matplotlib.org/)
+**Matplotlib** 3.4 or later (https://matplotlib.org/)
     Python package for 2D plotting. Python package required for any
     graphical capabilities.
 
diff --git a/lib/cartopy/mpl/__init__.py b/lib/cartopy/mpl/__init__.py
index 1f8a0e7c..75bcce3a 100644
--- a/lib/cartopy/mpl/__init__.py
+++ b/lib/cartopy/mpl/__init__.py
@@ -9,8 +9,8 @@ import packaging.version
 
 
 _MPL_VERSION = packaging.version.parse(matplotlib.__version__)
+_MPL_34 = _MPL_VERSION.release[:2] >= (3, 4)
 _MPL_35 = _MPL_VERSION.release[:2] >= (3, 5)
-_MPL_36 = _MPL_VERSION.release[:2] >= (3, 6)
 _MPL_38 = _MPL_VERSION.release[:2] >= (3, 8)
 
-assert _MPL_35, 'Cartopy is only supported with Matplotlib 3.5 or greater.'
+assert _MPL_34, 'Cartopy is only supported with Matplotlib 3.4 or greater.'
diff --git a/lib/cartopy/mpl/gridliner.py b/lib/cartopy/mpl/gridliner.py
index 2469e251..627e95ae 100644
--- a/lib/cartopy/mpl/gridliner.py
+++ b/lib/cartopy/mpl/gridliner.py
@@ -434,6 +434,9 @@ class Gridliner(matplotlib.artist.Artist):
         self.yline_artists = []
 
         # List of all labels (Label objects)
+        self._all_labels = []
+
+        # List of active labels (used in current draw)
         self._labels = []
 
         # Draw status
@@ -609,6 +612,25 @@ class Gridliner(matplotlib.artist.Artist):
 
         return True
 
+    def _generate_labels(self):
+        """
+        A generator to yield as many labels as needed, re-using existing ones
+        where possible.
+        """
+        for label in self._all_labels:
+            yield label
+
+        while True:
+            # Ran out of existing labels.  Create some empty ones.
+            new_artist = matplotlib.text.Text()
+            new_artist.set_figure(self.axes.figure)
+            new_artist.axes = self.axes
+
+            new_label = Label(new_artist, None, None, None)
+            self._all_labels.append(new_label)
+
+            yield new_label
+
     def _draw_gridliner(self, nx=None, ny=None, renderer=None):
         """Create Artists for all visible elements and add to our Axes.
 
@@ -626,11 +648,6 @@ class Gridliner(matplotlib.artist.Artist):
             return
         self._drawn = True
 
-        # Clear lists of child artists
-        self.xline_artists.clear()
-        self.yline_artists.clear()
-        self._labels.clear()
-
         # Inits
         lon_lim, lat_lim = self._axes_domain(nx=nx, ny=ny)
         transform = self._crs_transform()
@@ -687,9 +704,16 @@ class Gridliner(matplotlib.artist.Artist):
                     isinstance(crs, _RectangularProjection) and
                     abs(np.diff(lon_lim)) == abs(np.diff(crs.x_limits))):
                 nx -= 1
-            lon_lc = mcollections.LineCollection(lon_lines,
-                                                 **collection_kwargs)
-            self.xline_artists.append(lon_lc)
+
+            if self.xline_artists:
+                # Update existing collection.
+                lon_lc, = self.xline_artists
+                lon_lc.set(segments=lon_lines, **collection_kwargs)
+            else:
+                # Create new collection.
+                lon_lc = mcollections.LineCollection(lon_lines,
+                                                     **collection_kwargs)
+                self.xline_artists.append(lon_lc)
 
         # Parallels
         lon_min, lon_max = lon_lim
@@ -701,14 +725,22 @@ class Gridliner(matplotlib.artist.Artist):
                                          n_steps)[np.newaxis, :]
         lat_lines[:, :, 1] = np.array(lat_ticks)[:, np.newaxis]
         if self.ylines:
-            lat_lc = mcollections.LineCollection(lat_lines,
-                                                 **collection_kwargs)
-            self.yline_artists.append(lat_lc)
+            if self.yline_artists:
+                # Update existing collection.
+                lat_lc, = self.yline_artists
+                lat_lc.set(segments=lat_lines, **collection_kwargs)
+            else:
+                lat_lc = mcollections.LineCollection(lat_lines,
+                                                     **collection_kwargs)
+                self.yline_artists.append(lat_lc)
 
         #################
         # Label drawing #
         #################
 
+        # Clear drawn labels
+        self._labels.clear()
+
         if not any((self.left_labels, self.right_labels, self.bottom_labels,
                     self.top_labels, self.inline_labels, self.geo_labels)):
             return
@@ -770,6 +802,9 @@ class Gridliner(matplotlib.artist.Artist):
         crs_transform = self._crs_transform().transform
         inverse_data_transform = self.axes.transData.inverted().transform_point
 
+        # Create a generator for the Label objects.
+        generate_labels = self._generate_labels()
+
         for xylabel, lines, line_ticks, formatter, label_style in (
                 ('x', lon_lines, lon_ticks,
                  self.xformatter, self.xlabel_style.copy()),
@@ -915,11 +950,11 @@ class Gridliner(matplotlib.artist.Artist):
                     elif not y_set:
                         y = pt0[1]
 
-                    # Add text to the plot
+                    # Update generated label.
+                    label = next(generate_labels)
                     text = formatter(tick_value)
-                    artist = matplotlib.text.Text(x, y, text, **kw)
-                    artist.set_figure(self.axes.figure)
-                    artist.axes = self.axes
+                    artist = label.artist
+                    artist.set(x=x, y=y, text=text, **kw)
 
                     # Update loc from spine overlapping now that we have a bbox
                     # of the label.
@@ -975,8 +1010,10 @@ class Gridliner(matplotlib.artist.Artist):
                                 break
 
                     # Updates
-                    label = Label(artist, this_path, xylabel, loc)
                     label.set_visible(visible)
+                    label.path = this_path
+                    label.xy = xylabel
+                    label.loc = loc
                     self._labels.append(label)
 
         # Now check overlapping of ordered visible labels
@@ -1263,7 +1300,10 @@ class Label:
         self.loc = loc
         self.path = path
         self.xy = xy
-        self.priority = loc in ["left", "right", "top", "bottom"]
+
+    @property
+    def priority(self):
+        return self.loc in ["left", "right", "top", "bottom"]
 
     def set_visible(self, value):
         self.artist.set_visible(value)


Test patch:
diff --git a/.github/workflows/ci-testing.yml b/.github/workflows/ci-testing.yml
index 82140eae..f9993de9 100644
--- a/.github/workflows/ci-testing.yml
+++ b/.github/workflows/ci-testing.yml
@@ -33,7 +33,7 @@ jobs:
         if: matrix.python-version == '3.9' && matrix.os == 'ubuntu-latest'
         id: minimum-packages
         run: |
-          pip install cython==0.29.24 matplotlib==3.5.3 numpy==1.21 owslib==0.24.1 pyproj==3.1 scipy==1.6.3 shapely==1.7.1
+          pip install cython==0.29.24 matplotlib==3.4.3 numpy==1.21 owslib==0.24.1 pyproj==3.1 scipy==1.6.3 shapely==1.7.1
 
       - name: Coverage packages
         id: coverage
diff --git a/lib/cartopy/tests/mpl/test_crs.py b/lib/cartopy/tests/mpl/test_crs.py
index d7a4baba..c3703666 100644
--- a/lib/cartopy/tests/mpl/test_crs.py
+++ b/lib/cartopy/tests/mpl/test_crs.py
@@ -8,12 +8,12 @@ import matplotlib.pyplot as plt
 import pytest
 
 import cartopy.crs as ccrs
-from cartopy.mpl import _MPL_36
+from cartopy.mpl import _MPL_35
 
 
 @pytest.mark.natural_earth
 @pytest.mark.mpl_image_compare(
-    filename="igh_land.png", tolerance=0.5 if _MPL_36 else 3.6)
+    filename="igh_land.png", tolerance=0.5 if _MPL_35 else 3.6)
 def test_igh_land():
     crs = ccrs.InterruptedGoodeHomolosine(emphasis="land")
     ax = plt.axes(projection=crs)
@@ -24,7 +24,7 @@ def test_igh_land():
 
 @pytest.mark.natural_earth
 @pytest.mark.mpl_image_compare(filename="igh_ocean.png",
-                               tolerance=0.5 if _MPL_36 else 4.5)
+                               tolerance=0.5 if _MPL_35 else 4.5)
 def test_igh_ocean():
     crs = ccrs.InterruptedGoodeHomolosine(
         central_longitude=-160, emphasis="ocean"
diff --git a/lib/cartopy/tests/mpl/test_gridliner.py b/lib/cartopy/tests/mpl/test_gridliner.py
index 474820b5..c3903b00 100644
--- a/lib/cartopy/tests/mpl/test_gridliner.py
+++ b/lib/cartopy/tests/mpl/test_gridliner.py
@@ -14,7 +14,6 @@ import pytest
 from shapely.geos import geos_version
 
 import cartopy.crs as ccrs
-from cartopy.mpl import _MPL_36
 from cartopy.mpl.geoaxes import GeoAxes
 from cartopy.mpl.gridliner import (LATITUDE_FORMATTER, LONGITUDE_FORMATTER,
                                    Gridliner, classic_formatter,
@@ -498,10 +497,7 @@ def test_gridliner_title_adjust():
     plt.rcParams['axes.titley'] = None
 
     fig = plt.figure(layout='constrained')
-    if _MPL_36:
-        fig.get_layout_engine().set(h_pad=1/8)
-    else:
-        fig.set_constrained_layout_pads(h_pad=1/8)
+    fig.get_layout_engine().set(h_pad=1/8)
     for n, proj in enumerate(projs, 1):
         ax = fig.add_subplot(2, 2, n, projection=proj)
         ax.coastlines()
@@ -524,3 +520,29 @@ def test_gridliner_title_noadjust():
     ax.set_extent([-180, 180, -60, 60])
     fig.draw_without_rendering()
     assert ax.title.get_position() == pos
+
+
+def test_gridliner_labels_zoom():
+    fig = plt.figure()
+    ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())
+
+    # Start with a global map.
+    ax.set_global()
+    gl = ax.gridlines(draw_labels=True, auto_update=True)
+
+    fig.draw_without_rendering()  # Generate child artists
+    labels = [a.get_text() for a in gl.bottom_label_artists if a.get_visible()]
+    assert labels == ['180°', '120°W', '60°W', '0°', '60°E', '120°E', '180°']
+    # For first draw, active labels should be all of the labels.
+    assert len(gl._all_labels) == 24
+    assert gl._labels == gl._all_labels
+
+    # Zoom in.
+    ax.set_extent([-20, 10.0, 45.0, 70.0])
+
+    fig.draw_without_rendering()  # Update child artists
+    labels = [a.get_text() for a in gl.bottom_label_artists if a.get_visible()]
+    assert labels == ['15°W', '10°W', '5°W', '0°', '5°E']
+    # After zoom, we may not be using all the available labels.
+    assert len(gl._all_labels) == 24
+    assert gl._labels == gl._all_labels[:20]
diff --git a/lib/cartopy/tests/mpl/test_mpl_integration.py b/lib/cartopy/tests/mpl/test_mpl_integration.py
index cc4e1ca5..deba9cf4 100644
--- a/lib/cartopy/tests/mpl/test_mpl_integration.py
+++ b/lib/cartopy/tests/mpl/test_mpl_integration.py
@@ -12,7 +12,7 @@ import numpy as np
 import pytest
 
 import cartopy.crs as ccrs
-from cartopy.mpl import _MPL_38
+from cartopy.mpl import _MPL_35, _MPL_38
 
 
 @pytest.mark.natural_earth
@@ -196,7 +196,8 @@ def test_simple_global():
         id='ObliqueMercator_rotated',
     ),
 ])
-@pytest.mark.mpl_image_compare(style='mpl20')
+@pytest.mark.mpl_image_compare(
+    tolerance=0.5 if _MPL_35 else 0.97, style='mpl20')
 def test_global_map(proj):
     if isinstance(proj, tuple):
         proj, kwargs = proj
@@ -956,7 +957,9 @@ def test_barbs_1d_transformed():
 
 
 @pytest.mark.natural_earth
-@pytest.mark.mpl_image_compare(filename='streamplot.png', style='mpl20')
+@pytest.mark.mpl_image_compare(
+    filename='streamplot.png', style='mpl20',
+    tolerance=0.5 if _MPL_35 else 9.77)
 def test_streamplot():
     x = np.arange(-60, 42.5, 2.5)
     y = np.arange(30, 72.5, 2.5)