Repository: geopandas/geopandas

Issue:
ENH: Allow autolim keyword argument in plots
#### Is your feature request related to a problem?

I use geopandas to reproject global (e.g. Natural Earth) data and plot it for small regions. This causes axes limits to update to encompass the global (and often badly distorted) data

```python
import geopandas

# open Natural Earth data for the world and Greenland
world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres'))
greenland = world[world.name == 'Greenland']

# suppose we make a map of Greenland
ax = greenland.plot()

# check axes limits
print(ax.get_ylim())  # ca. 60 to 85 degrees North

# add other country borders for context
# (I want to add autolim=False)
ax = world.plot(ax=ax, color='0.75', zorder=-1)

# axes limits have been updated though
print(ax.get_ylim())  # ca. -90 to 90 degrees
```

I wish I could prevent geopandas from updating axes limits when plotting a dataframe.

#### Describe the solution you'd like

`GeoDataFrame.plot` should get a new parameter `autolim`. This is forwarded to matplotlib's `ax.add_collection`. Suggested docstring:

```python
    autolim : bool (default True)
        Update axes data limits to contain the new geometries.
```

#### API breaking implications

None. If `autolim` defaults to `True`, I think this will be fully backward-compatible:

https://github.com/geopandas/geopandas/blob/d5541f8817aac769d628a3108d95f7e1295053c7/geopandas/plotting.py#L182-L186

https://github.com/geopandas/geopandas/blob/d5541f8817aac769d628a3108d95f7e1295053c7/geopandas/plotting.py#L240-L244

#### Describe alternatives you've considered

A workaround is to lock axes limits with `ax.set_autoscale_on(False)`. This works if you do things in this order: plot data of interest, lock axes, plot contextual data. I understand that the `autolim` parameter is superior though, because it allows auto-scaling, but tells matplotlib which layers' limits should be ignored.

#### Additional context

This change will benefit a small library I am developing, which combines plots from xarray and geopandas: https://hyoga.readthedocs.io/en/latest/foreword/startup.html

The current code quietly uses `ax.set_autoscale_on(False)`, which is not ideal:

https://github.com/juseg/hyoga/blob/79d91002fb0c8f0f5df3e773492cd7977b7c9a42/hyoga/plot/datasets.py#L378-L382

Happy to open a PR if this makes sense.

Discussion:
juseg: This implements `autolim` for linestrings and polygons. Do we want to wait for the matplotlib PR or ignore scatter plots for now? And maybe add a note about it in the dostring?

My branch pointed to a very old commit, sorry...
martinfleis: I'd give them a few days over at https://github.com/matplotlib/matplotlib/pull/15595 to respond to your comment. Right now we can only ignore point case for now or wait for that PR to be merged. Ideally we would prepare this PR knowing how it will be done in matplotlib but that doesn't seem to be clear yet.
juseg: Sorry for again leaving this branch stale for much longer than "a few days" (see previous comment). As far as I understand matplotlib still does not support auto-scaling scatter plots. Would it be possible to merge this PR to support `autolim` on lines and polygons, ignoring points for now?
juseg: Thanks! I opened a follow-up issue for the point geometry case at #3331.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7316809e..b184818b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,7 @@ files based on a bounding box, and write out a bounding box column to parquet fi
   will silence the warning about mismachted indices. (#3212)
 - `GeoSeries.set_crs` can now be used to remove CRS information by passing
   `crs=None, allow_override=True`. (#3316)
+- Added ``autolim`` keyword argument to ``GeoSeries.plot()`` and ``GeoDataFrame.plot()`` (#2817).
 
 Backwards incompatible API changes:
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 272855c8..d5a6ee4e 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1849,7 +1849,12 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
             return _geodataframe_constructor_with_fallback(
                 pd.DataFrame._from_mgr(mgr, axes)
             )
-        return GeoDataFrame._from_mgr(mgr, axes)
+        gdf = GeoDataFrame._from_mgr(mgr, axes)
+        # _from_mgr doesn't preserve metadata (expect __finalize__ to be called)
+        # still need to mimic __init__ behaviour with geometry=None
+        if (gdf.columns == "geometry").sum() == 1:  # only if "geometry" is single col
+            gdf._geometry_column_name = "geometry"
+        return gdf
 
     @property
     def _constructor_sliced(self):
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
index 90495833..5c2f416e 100644
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -112,7 +112,15 @@ def _PolygonPatch(polygon, **kwargs):
 
 
 def _plot_polygon_collection(
-    ax, geoms, values=None, color=None, cmap=None, vmin=None, vmax=None, **kwargs
+    ax,
+    geoms,
+    values=None,
+    color=None,
+    cmap=None,
+    vmin=None,
+    vmax=None,
+    autolim=True,
+    **kwargs,
 ):
     """
     Plots a collection of Polygon and MultiPolygon geometries to `ax`
@@ -133,6 +141,8 @@ def _plot_polygon_collection(
         Color to fill the polygons. Cannot be used together with `values`.
     color : single color or sequence of `N` colors
         Sets both `edgecolor` and `facecolor`
+    autolim : bool (default True)
+        Update axes data limits to contain the new geometries.
     **kwargs
         Additional keyword arguments passed to the collection
 
@@ -167,13 +177,21 @@ def _plot_polygon_collection(
         if "norm" not in kwargs:
             collection.set_clim(vmin, vmax)
 
-    ax.add_collection(collection, autolim=True)
+    ax.add_collection(collection, autolim=autolim)
     ax.autoscale_view()
     return collection
 
 
 def _plot_linestring_collection(
-    ax, geoms, values=None, color=None, cmap=None, vmin=None, vmax=None, **kwargs
+    ax,
+    geoms,
+    values=None,
+    color=None,
+    cmap=None,
+    vmin=None,
+    vmax=None,
+    autolim=True,
+    **kwargs,
 ):
     """
     Plots a collection of LineString and MultiLineString geometries to `ax`
@@ -189,6 +207,8 @@ def _plot_linestring_collection(
         have 1:1 correspondence with the geometries (not their components).
     color : single color or sequence of `N` colors
         Cannot be used together with `values`.
+    autolim : bool (default True)
+        Update axes data limits to contain the new geometries.
 
     Returns
     -------
@@ -222,7 +242,7 @@ def _plot_linestring_collection(
         if "norm" not in kwargs:
             collection.set_clim(vmin, vmax)
 
-    ax.add_collection(collection, autolim=True)
+    ax.add_collection(collection, autolim=autolim)
     ax.autoscale_view()
     return collection
 
@@ -291,7 +311,14 @@ def _plot_point_collection(
 
 
 def plot_series(
-    s, cmap=None, color=None, ax=None, figsize=None, aspect="auto", **style_kwds
+    s,
+    cmap=None,
+    color=None,
+    ax=None,
+    figsize=None,
+    aspect="auto",
+    autolim=True,
+    **style_kwds,
 ):
     """
     Plot a GeoSeries.
@@ -327,6 +354,8 @@ def plot_series(
         square appears square in the middle of the plot. This implies an
         Equirectangular projection. If None, the aspect of `ax` won't be changed. It can
         also be set manually (float) as the ratio of y-unit to x-unit.
+    autolim : bool (default True)
+        Update axes data limits to contain the new geometries.
     **style_kwds : dict
         Color options to be passed on to the actual plot function, such
         as ``edgecolor``, ``facecolor``, ``linewidth``, ``markersize``,
@@ -421,7 +450,13 @@ def plot_series(
 
         values_ = values[poly_idx] if cmap else None
         _plot_polygon_collection(
-            ax, polys, values_, facecolor=facecolor, cmap=cmap, **style_kwds
+            ax,
+            polys,
+            values_,
+            facecolor=facecolor,
+            cmap=cmap,
+            autolim=autolim,
+            **style_kwds,
         )
 
     # plot all LineStrings and MultiLineString components in same collection
@@ -431,7 +466,7 @@ def plot_series(
         color_ = expl_color[line_idx] if color_given else color
 
         _plot_linestring_collection(
-            ax, lines, values_, color=color_, cmap=cmap, **style_kwds
+            ax, lines, values_, color=color_, cmap=cmap, autolim=autolim, **style_kwds
         )
 
     # plot all Points in the same collection
@@ -468,6 +503,7 @@ def plot_dataframe(
     classification_kwds=None,
     missing_kwds=None,
     aspect="auto",
+    autolim=True,
     **style_kwds,
 ):
     """
@@ -571,7 +607,8 @@ def plot_dataframe(
         square appears square in the middle of the plot. This implies an
         Equirectangular projection. If None, the aspect of `ax` won't be changed. It can
         also be set manually (float) as the ratio of y-unit to x-unit.
-
+    autolim : bool (default True)
+        Update axes data limits to contain the new geometries.
     **style_kwds : dict
         Style options to be passed on to the actual plot function, such
         as ``edgecolor``, ``facecolor``, ``linewidth``, ``markersize``,
@@ -658,6 +695,7 @@ def plot_dataframe(
             figsize=figsize,
             markersize=markersize,
             aspect=aspect,
+            autolim=autolim,
             **style_kwds,
         )
 
@@ -797,7 +835,14 @@ def plot_dataframe(
     subset = values[poly_idx & np.invert(nan_idx)]
     if not polys.empty:
         _plot_polygon_collection(
-            ax, polys, subset, vmin=mn, vmax=mx, cmap=cmap, **style_kwds
+            ax,
+            polys,
+            subset,
+            vmin=mn,
+            vmax=mx,
+            cmap=cmap,
+            autolim=autolim,
+            **style_kwds,
         )
 
     # plot all LineStrings and MultiLineString components in same collection
@@ -805,7 +850,14 @@ def plot_dataframe(
     subset = values[line_idx & np.invert(nan_idx)]
     if not lines.empty:
         _plot_linestring_collection(
-            ax, lines, subset, vmin=mn, vmax=mx, cmap=cmap, **style_kwds
+            ax,
+            lines,
+            subset,
+            vmin=mn,
+            vmax=mx,
+            cmap=cmap,
+            autolim=autolim,
+            **style_kwds,
         )
 
     # plot all Points in the same collection


Test patch:
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 4a9601ad..7230c3ea 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -689,14 +689,16 @@ def test_groupby_metadata(crs, geometry_name):
     df.groupby("value2").apply(func, **kwargs)
     # selecting the non-group columns -> no need to pass the keyword
     if (
-        compat.PANDAS_GE_21 if geometry_name == "geometry" else compat.PANDAS_GE_20
-    ) and not compat.PANDAS_GE_22:
+        compat.PANDAS_GE_22
+        or (compat.PANDAS_GE_20 and geometry_name == "geometry")
+        or not compat.PANDAS_GE_20
+    ):
+        df.groupby("value2")[[geometry_name, "value1"]].apply(func)
+    else:
         # https://github.com/geopandas/geopandas/pull/2966#issuecomment-1878816712
-        # with pandas 2.0 and 2.1 this is failing
+        # with pandas 2.0 and 2.1 with geom col != geometry this is failing
         with pytest.raises(AttributeError):
             df.groupby("value2")[[geometry_name, "value1"]].apply(func)
-    else:
-        df.groupby("value2")[[geometry_name, "value1"]].apply(func)
 
     # actual test with functionality
     res = df.groupby("value2").apply(
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
index d47b8987..9df655eb 100644
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -494,6 +494,28 @@ class TestLineStringPlotting:
         )
         self.df3 = GeoDataFrame({"geometry": self.linearrings, "values": values})
 
+    def test_autolim_false(self):
+        """Test linestring plot preserving axes limits."""
+        ax = self.lines[: self.N // 2].plot()
+        ylim = ax.get_ylim()
+        self.lines.plot(ax=ax, autolim=False)
+        assert ax.get_ylim() == ylim
+        ax = self.df[: self.N // 2].plot()
+        ylim = ax.get_ylim()
+        self.df.plot(ax=ax, autolim=False)
+        assert ax.get_ylim() == ylim
+
+    def test_autolim_true(self):
+        """Test linestring plot autoscaling axes limits."""
+        ax = self.lines[: self.N // 2].plot()
+        ylim = ax.get_ylim()
+        self.lines.plot(ax=ax, autolim=True)
+        assert ax.get_ylim() != ylim
+        ax = self.df[: self.N // 2].plot()
+        ylim = ax.get_ylim()
+        self.df.plot(ax=ax, autolim=True)
+        assert ax.get_ylim() != ylim
+
     def test_single_color(self):
         ax = self.lines.plot(color="green")
         _check_colors(self.N, ax.collections[0].get_colors(), ["green"] * self.N)
@@ -637,6 +659,28 @@ class TestPolygonPlotting:
         df_nan = GeoDataFrame({"geometry": t3, "values": [np.nan]})
         self.df3 = pd.concat([self.df, df_nan])
 
+    def test_autolim_false(self):
+        """Test polygon plot preserving axes limits."""
+        ax = self.polys[:1].plot()
+        xlim = ax.get_xlim()
+        self.polys.plot(ax=ax, autolim=False)
+        assert ax.get_xlim() == xlim
+        ax = self.df[:1].plot()
+        xlim = ax.get_xlim()
+        self.df.plot(ax=ax, autolim=False)
+        assert ax.get_xlim() == xlim
+
+    def test_autolim_true(self):
+        """Test polygon plot autoscaling axes limits."""
+        ax = self.polys[:1].plot()
+        xlim = ax.get_xlim()
+        self.polys.plot(ax=ax, autolim=True)
+        assert ax.get_xlim() != xlim
+        ax = self.df[:1].plot()
+        xlim = ax.get_xlim()
+        self.df.plot(ax=ax, autolim=True)
+        assert ax.get_xlim() != xlim
+
     def test_single_color(self):
         ax = self.polys.plot(color="green")
         _check_colors(2, ax.collections[0].get_facecolors(), ["green"] * 2)