Repository: geopandas/geopandas

Issue:
Consider scaling y-axis for unprojected map plots
Following the R&Py workshop https://github.com/rsbivand/ectqg19-workshop, we (me and @darribas) saw that in R, **sf** scales the y-axis for plotting as a function of the distance between the equator and the middle of the y range of the map https://github.com/r-spatial/sf/blob/8150edb7fbc1e7096526303a6d2876a7b5455260/R/plot.R#L529

> #' The default aspect for map plots is 1; if however data are not
#' projected (coordinates are long/lat), the aspect is by default set to
#' 1/cos(My * pi/180) with My the y coordinate of the middle of the map
#' (the mean of \code{ylim}, which defaults to the y range of bounding box). This
#' implies an \href{https://en.wikipedia.org/wiki/Equirectangular_projection}{Equirectangular projection}.

Unless I'm missing something, https://github.com/geopandas/geopandas/blob/29add0a735b00dc20c79e0fccc8e6a775c4997b0/geopandas/plotting.py#L414 sets `'equal'` in all cases. I haven't found which function you use (through pyproj?) to determin whether the map object is known to be in geographical rather than projected coordinates. Also I don't know whether a real valued aspect can be passed.





Discussion:
Hi @rsbivand,

sorry for slow response. This is quite interesting! You are right that Geopandas currently set aspect to equal in all cases. In 0.6.1 we do not have an option to determine whether gdf is in geographical or projected coordinates. This will change in 0.7 with newly adopted pyproj.CRS (#1101). I think that once we have this change merged, we should definitely consider using the formula above.

I don't think we support passing aspect either, but that could be done in the same PR.

Thanks!
Thanks, and the reference to pyproj helped me see alternatives for handling the PROJ migration; using **reticulate** I'll be able to cross-check solutions (not map aspect, but sensible choices of coordinate operation pipelines).
@jorisvandenbossche @snowman2 what do you think about this? Since we can now easily distinguish between geographic and projected CRS, we might use the formula above to reflect true shapes better in `plot`.
This is something that could potentially have unintended side effects by making it the default behavior. I would like to see some exploration of potential side effects.

Alternative options I have thought of could be to add an optional kwarg to enable this vehavior or to add a section in the plotting documentation about projecting to Plate Carree using `to_crs` before plotting. https://proj.org/operations/projections/eqc.html

I think starting the exploration phase and working out the details of the implementation is a good direction to head in.
@snowman2 are there certain kinds of unintended side effects that are you thinking of?

> to add a section in the plotting documentation about projecting to Plate Carree using to_crs before plotting. https://proj.org/operations/projections/eqc.html

Can you explain this a bit more? (I thought that Plate Carree gives basically the same as just plotting naively lon/lat values as x/y (as geopandas is doing), except that your x and y axis shows different values)
In any case, `set_aspect` allows to specify a ratio ("num"): https://matplotlib.org/api/_as_gen/matplotlib.axes.Axes.set_aspect.html
Feel free to ignore my other post - after reading what @jorisvandenbossche wrote and re-reading the thread I understand better what is proposed. No issues from my end. 👍

Resolving patch:
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -274,7 +274,9 @@ def _plot_point_collection(
 plot_point_collection = deprecated(_plot_point_collection)
 
 
-def plot_series(s, cmap=None, color=None, ax=None, figsize=None, **style_kwds):
+def plot_series(
+    s, cmap=None, color=None, ax=None, figsize=None, aspect="auto", **style_kwds
+):
     """
     Plot a GeoSeries.
 
@@ -301,6 +303,14 @@ def plot_series(s, cmap=None, color=None, ax=None, figsize=None, **style_kwds):
     figsize : pair of floats (default None)
         Size of the resulting matplotlib.figure.Figure. If the argument
         ax is given explicitly, figsize is ignored.
+    aspect : 'auto', 'equal' or float (default 'auto')
+        Set aspect of axis. If 'auto', the default aspect for map plots is 'equal'; if
+        however data are not projected (coordinates are long/lat), the aspect is by
+        default set to 1/cos(s_y * pi/180) with s_y the y coordinate of the middle of
+        the GeoSeries (the mean of the y range of bounding box) so that a long/lat
+        square appears square in the middle of the plot. This implies an
+        Equirectangular projection. It can also be set manually (float) as the ratio
+        of y-unit to x-unit.
     **style_kwds : dict
         Color options to be passed on to the actual plot function, such
         as ``edgecolor``, ``facecolor``, ``linewidth``, ``markersize``,
@@ -336,7 +346,18 @@ def plot_series(s, cmap=None, color=None, ax=None, figsize=None, **style_kwds):
 
     if ax is None:
         fig, ax = plt.subplots(figsize=figsize)
-    ax.set_aspect("equal")
+
+    if aspect == "auto":
+        if s.crs and s.crs.is_geographic:
+            bounds = s.total_bounds
+            y_coord = np.mean([bounds[1], bounds[3]])
+            ax.set_aspect(1 / np.cos(y_coord * np.pi / 180))
+            # formula ported from R package sp
+            # https://github.com/edzer/sp/blob/master/R/mapasp.R
+        else:
+            ax.set_aspect("equal")
+    else:
+        ax.set_aspect(aspect)
 
     if s.empty:
         warnings.warn(
@@ -421,6 +442,7 @@ def plot_dataframe(
     legend_kwds=None,
     classification_kwds=None,
     missing_kwds=None,
+    aspect="auto",
     **style_kwds
 ):
     """
@@ -491,6 +513,14 @@ def plot_dataframe(
         to be passed on to geometries with missing values in addition to
         or overwriting other style kwds. If None, geometries with missing
         values are not plotted.
+    aspect : 'auto', 'equal' or float (default 'auto')
+        Set aspect of axis. If 'auto', the default aspect for map plots is 'equal'; if
+        however data are not projected (coordinates are long/lat), the aspect is by
+        default set to 1/cos(df_y * pi/180) with df_y the y coordinate of the middle of
+        the GeoDataFrame (the mean of the y range of bounding box) so that a long/lat
+        square appears square in the middle of the plot. This implies an
+        Equirectangular projection. It can also be set manually (float) as the ratio
+        of y-unit to x-unit.
 
     **style_kwds : dict
         Color options to be passed on to the actual plot function, such
@@ -535,7 +565,18 @@ def plot_dataframe(
         if cax is not None:
             raise ValueError("'ax' can not be None if 'cax' is not.")
         fig, ax = plt.subplots(figsize=figsize)
-    ax.set_aspect("equal")
+
+    if aspect == "auto":
+        if df.crs and df.crs.is_geographic:
+            bounds = df.total_bounds
+            y_coord = np.mean([bounds[1], bounds[3]])
+            ax.set_aspect(1 / np.cos(y_coord * np.pi / 180))
+            # formula ported from R package sp
+            # https://github.com/edzer/sp/blob/master/R/mapasp.R
+        else:
+            ax.set_aspect("equal")
+    else:
+        ax.set_aspect(aspect)
 
     if df.empty:
         warnings.warn(
@@ -556,6 +597,7 @@ def plot_dataframe(
             ax=ax,
             figsize=figsize,
             markersize=markersize,
+            aspect=aspect,
             **style_kwds
         )
 


Test patch:
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -629,6 +629,51 @@ def test_style_kwargs(self):
         assert ax.collections[2].get_sizes() == [10]
 
 
+class TestGeographicAspect:
+    def setup_class(self):
+        pth = get_path("naturalearth_lowres")
+        df = read_file(pth)
+        self.north = df.loc[df.continent == "North America"]
+        self.north_proj = self.north.to_crs("ESRI:102008")
+        bounds = self.north.total_bounds
+        y_coord = np.mean([bounds[1], bounds[3]])
+        self.exp = 1 / np.cos(y_coord * np.pi / 180)
+
+    def test_auto(self):
+        ax = self.north.geometry.plot()
+        assert ax.get_aspect() == self.exp
+        ax2 = self.north_proj.geometry.plot()
+        assert ax2.get_aspect() in ["equal", 1.0]
+        ax = self.north.plot()
+        assert ax.get_aspect() == self.exp
+        ax2 = self.north_proj.plot()
+        assert ax2.get_aspect() in ["equal", 1.0]
+        ax3 = self.north.plot("pop_est")
+        assert ax3.get_aspect() == self.exp
+        ax4 = self.north_proj.plot("pop_est")
+        assert ax4.get_aspect() in ["equal", 1.0]
+
+    def test_manual(self):
+        ax = self.north.geometry.plot(aspect="equal")
+        assert ax.get_aspect() in ["equal", 1.0]
+        ax2 = self.north.geometry.plot(aspect=0.5)
+        assert ax2.get_aspect() == 0.5
+        ax3 = self.north_proj.geometry.plot(aspect=0.5)
+        assert ax3.get_aspect() == 0.5
+        ax = self.north.plot(aspect="equal")
+        assert ax.get_aspect() in ["equal", 1.0]
+        ax2 = self.north.plot(aspect=0.5)
+        assert ax2.get_aspect() == 0.5
+        ax3 = self.north_proj.plot(aspect=0.5)
+        assert ax3.get_aspect() == 0.5
+        ax = self.north.plot("pop_est", aspect="equal")
+        assert ax.get_aspect() in ["equal", 1.0]
+        ax2 = self.north.plot("pop_est", aspect=0.5)
+        assert ax2.get_aspect() == 0.5
+        ax3 = self.north_proj.plot("pop_est", aspect=0.5)
+        assert ax3.get_aspect() == 0.5
+
+
 class TestMapclassifyPlotting:
     @classmethod
     def setup_class(cls):