From e0981ab14eb4aae38fa4e71ae72a1d68a94fd87b Mon Sep 17 00:00:00 2001 From: sangarshanan Date: Sat, 27 Feb 2021 14:04:34 +0530 Subject: [PATCH] ENH: Add GeoPlot accessor (#1465) Co-authored-by: Martin Fleischmann Co-authored-by: Joris Van den Bossche --- .gitignore | 1 + ci/envs/37-latest-conda-forge.yaml | 1 + ci/envs/38-latest-conda-forge.yaml | 1 + ci/envs/39-latest-conda-forge.yaml | 1 + doc/source/docs/user_guide.rst | 2 +- doc/source/docs/user_guide/mapping.rst | 37 +++++++++++- geopandas/geodataframe.py | 32 +++++----- geopandas/plotting.py | 49 +++++++++++++-- geopandas/tests/test_plotting.py | 82 ++++++++++++++++++++++++++ 9 files changed, 185 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 8eefebd..5e9c00e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ nosetests.xml coverage.xml *.cover .hypothesis/ +result_images # Sphinx documentation doc/_build/ diff --git a/ci/envs/37-latest-conda-forge.yaml b/ci/envs/37-latest-conda-forge.yaml index a0d148a..1ca1293 100644 --- a/ci/envs/37-latest-conda-forge.yaml +++ b/ci/envs/37-latest-conda-forge.yaml @@ -18,6 +18,7 @@ dependencies: - rtree - matplotlib - mapclassify + - scipy - geopy - SQLalchemy - libspatialite diff --git a/ci/envs/38-latest-conda-forge.yaml b/ci/envs/38-latest-conda-forge.yaml index 5ebdc26..a9ecb78 100644 --- a/ci/envs/38-latest-conda-forge.yaml +++ b/ci/envs/38-latest-conda-forge.yaml @@ -18,6 +18,7 @@ dependencies: - rtree - matplotlib - mapclassify + - scipy - geopy # installed in tests.yaml, because not available on windows # - postgis diff --git a/ci/envs/39-latest-conda-forge.yaml b/ci/envs/39-latest-conda-forge.yaml index 1f85652..bb8c8f3 100644 --- a/ci/envs/39-latest-conda-forge.yaml +++ b/ci/envs/39-latest-conda-forge.yaml @@ -19,6 +19,7 @@ dependencies: - matplotlib - descartes - mapclassify + - scipy - geopy # installed in tests.yaml, because not available on windows # - postgis diff --git a/doc/source/docs/user_guide.rst b/doc/source/docs/user_guide.rst index 02b0210..1086f58 100644 --- a/doc/source/docs/user_guide.rst +++ b/doc/source/docs/user_guide.rst @@ -13,7 +13,7 @@ Advanced topics can be found in the :doc:`Advanced Guide ` and f Data Structures Reading and Writing Files Indexing and Selecting Data - Making Maps + Making Maps and plots Managing Projections Geometric Manipulations Set Operations with overlay diff --git a/doc/source/docs/user_guide/mapping.rst b/doc/source/docs/user_guide/mapping.rst index dbad330..f2a96b5 100644 --- a/doc/source/docs/user_guide/mapping.rst +++ b/doc/source/docs/user_guide/mapping.rst @@ -11,7 +11,7 @@ plt.close('all') -Mapping Tools +Mapping and Plotting Tools ========================================= @@ -222,6 +222,41 @@ We can set the ``zorder`` for cities higher than for world to move it of top. @savefig zorder_set.png world.plot(ax=ax, zorder=1); + +Pandas Plots +----------------- + +Plotting methods also allow for different plot styles from pandas +along with the default ``geo`` plot. These methods can be accessed using +the ``kind`` keyword argument in :meth:`~GeoDataFrame.plot`, and include: + +* ``geo`` for mapping +* ``line`` for line plots +* ``bar`` or ``barh`` for bar plots +* ``hist`` for histogram +* ``box`` for boxplot +* ``kde`` or ``density`` for density plots +* ``area`` for area plots +* ``scatter`` for scatter plots +* ``hexbin`` for hexagonal bin plots +* ``pie`` for pie plots + +.. ipython:: python + + gdf = world.head(10) + @savefig pandas_line_plot.png + gdf.plot(kind='scatter', x="pop_est", y="gdp_md_est") + +You can also create these other plots using the ``GeoDataFrame.plot.`` accessor methods instead of providing the ``kind`` keyword argument. + +.. ipython:: python + + @savefig pandas_bar_plot.png + gdf.plot.bar() + +For more information check out the `pandas documentation `_. + + Other Resources ----------------- Links to jupyter Notebooks for different mapping tasks: diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py index 78fc3a3..d3d18d7 100644 --- a/geopandas/geodataframe.py +++ b/geopandas/geodataframe.py @@ -1312,20 +1312,6 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)} return self - def plot(self, *args, **kwargs): - """Generate a plot of the geometries in the ``GeoDataFrame``. - - If the ``column`` parameter is given, colors plot according to values - in that column, otherwise calls ``GeoSeries.plot()`` on the - ``geometry`` column. - - Wraps the ``plot_dataframe()`` function, and documentation is copied - from there. - """ - return plot_dataframe(self, *args, **kwargs) - - plot.__doc__ = plot_dataframe.__doc__ - def dissolve(self, by=None, aggfunc="first", as_index=True): """ Dissolve geometries within `groupby` into single observation. @@ -1617,6 +1603,24 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)} ) return self.geometry.difference(other) + if compat.PANDAS_GE_025: + from pandas.core.accessor import CachedAccessor + + plot = CachedAccessor("plot", geopandas.plotting.GeoplotAccessor) + else: + + def plot(self, *args, **kwargs): + """Generate a plot of the geometries in the ``GeoDataFrame``. + If the ``column`` parameter is given, colors plot according to values + in that column, otherwise calls ``GeoSeries.plot()`` on the + ``geometry`` column. + Wraps the ``plot_dataframe()`` function, and documentation is copied + from there. + """ + return plot_dataframe(self, *args, **kwargs) + + plot.__doc__ = plot_dataframe.__doc__ + def _dataframe_set_geometry(self, col, drop=False, inplace=False, crs=None): if inplace: diff --git a/geopandas/plotting.py b/geopandas/plotting.py index 9c39568..7159aa2 100644 --- a/geopandas/plotting.py +++ b/geopandas/plotting.py @@ -115,7 +115,7 @@ def _PolygonPatch(polygon, **kwargs): path = Path.make_compound_path( Path(np.asarray(polygon.exterior.coords)[:, :2]), - *[Path(np.asarray(ring.coords)[:, :2]) for ring in polygon.interiors] + *[Path(np.asarray(ring.coords)[:, :2]) for ring in polygon.interiors], ) return PathPatch(path, **kwargs) @@ -254,7 +254,7 @@ def _plot_point_collection( vmax=None, marker="o", markersize=None, - **kwargs + **kwargs, ): """ Plots a collection of Point and MultiPoint geometries to `ax` @@ -488,7 +488,7 @@ def plot_dataframe( classification_kwds=None, missing_kwds=None, aspect="auto", - **style_kwds + **style_kwds, ): """ Plot a GeoDataFrame. @@ -508,6 +508,21 @@ def plot_dataframe( If np.array or pd.Series are used then it must have same length as dataframe. Values are used to color the plot. Ignored if `color` is also set. + kind: str + The kind of plots to produce: + - 'geo': Map (default) + Pandas Kinds + - 'line' : line plot + - 'bar' : vertical bar plot + - 'barh' : horizontal bar plot + - 'hist' : histogram + - 'box' : BoxPlot + - 'kde' : Kernel Density Estimation plot + - 'density' : same as 'kde' + - 'area' : area plot + - 'pie' : pie plot + - 'scatter' : scatter plot + - 'hexbin' : hexbin plot. cmap : str (default None) The name of a colormap recognized by matplotlib. color : str (default None) @@ -683,7 +698,7 @@ GON (((-122.84000 49.00000, -120.0000... figsize=figsize, markersize=markersize, aspect=aspect, - **style_kwds + **style_kwds, ) # To accept pd.Series and np.arrays as column @@ -820,7 +835,7 @@ GON (((-122.84000 49.00000, -120.0000... vmax=mx, markersize=markersize, cmap=cmap, - **style_kwds + **style_kwds, ) if missing_kwds is not None and not expl_series[nan_idx].empty: @@ -899,6 +914,30 @@ GON (((-122.84000 49.00000, -120.0000... return ax +if geopandas._compat.PANDAS_GE_025: + from pandas.plotting import PlotAccessor + + class GeoplotAccessor(PlotAccessor): + + __doc__ = plot_dataframe.__doc__ + _pandas_kinds = PlotAccessor._all_kinds + + def __call__(self, *args, **kwargs): + data = self._parent.copy() + kind = kwargs.pop("kind", "geo") + if kind == "geo": + return plot_dataframe(data, *args, **kwargs) + if kind in self._pandas_kinds: + # Access pandas plots + return PlotAccessor(data)(kind=kind, **kwargs) + else: + # raise error + raise ValueError(f"{kind} is not a valid plot kind") + + def geo(self, *args, **kwargs): + return self(kind="geo", *args, **kwargs) + + def _mapclassify_choro(values, scheme, **classification_kwds): """ Wrapper for choropleth schemes from mapclassify for use with plot_dataframe diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py index 2f59958..884d64e 100644 --- a/geopandas/tests/test_plotting.py +++ b/geopandas/tests/test_plotting.py @@ -29,6 +29,13 @@ matplotlib = pytest.importorskip("matplotlib") matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa +try: # skipif and importorskip do not work for decorators + from matplotlib.testing.decorators import check_figures_equal + + MPL_DECORATORS = True +except ImportError: + MPL_DECORATORS = False + @pytest.fixture(autouse=True) def close_figures(request): @@ -42,6 +49,8 @@ try: except KeyError: MPL_DFT_COLOR = matplotlib.rcParams["axes.color_cycle"][0] +plt.rcParams.update({"figure.max_open_warning": 0}) + class TestPointPlotting: def setup_method(self): @@ -1466,6 +1475,79 @@ class TestPlotCollections: ax.cla() +@pytest.mark.skipif(not compat.PANDAS_GE_025, reason="requires pandas > 0.24") +class TestGeoplotAccessor: + def setup_method(self): + geometries = [Polygon([(0, 0), (1, 0), (1, 1)]), Point(1, 3)] + x = [1, 2] + y = [10, 20] + self.gdf = GeoDataFrame({"geometry": geometries, "x": x, "y": y}) + self.df = pd.DataFrame({"x": x, "y": y}) + + def compare_figures(self, kind, fig_test, fig_ref, kwargs): + """Compare Figures.""" + ax_pandas_1 = fig_test.subplots() + self.df.plot(kind=kind, ax=ax_pandas_1, **kwargs) + ax_geopandas_1 = fig_ref.subplots() + self.gdf.plot(kind=kind, ax=ax_geopandas_1, **kwargs) + + ax_pandas_2 = fig_test.subplots() + getattr(self.df.plot, kind)(ax=ax_pandas_2, **kwargs) + ax_geopandas_2 = fig_ref.subplots() + getattr(self.gdf.plot, kind)(ax=ax_geopandas_2, **kwargs) + + _pandas_kinds = [] + if compat.PANDAS_GE_025: + from geopandas.plotting import GeoplotAccessor + + _pandas_kinds = GeoplotAccessor._pandas_kinds + + if MPL_DECORATORS: + + @pytest.mark.parametrize("kind", _pandas_kinds) + @check_figures_equal(extensions=["png", "pdf"]) + def test_pandas_kind(self, kind, fig_test, fig_ref): + """Test Pandas kind.""" + import importlib + + _scipy_dependent_kinds = ["kde", "density"] # Needs scipy + _y_kinds = ["pie"] # Needs y + _xy_kinds = ["scatter", "hexbin"] # Needs x & y + kwargs = {} + if kind in _scipy_dependent_kinds: + if not importlib.util.find_spec("scipy"): + with pytest.raises( + ModuleNotFoundError, match="No module named 'scipy'" + ): + self.gdf.plot(kind=kind) + elif kind in _y_kinds: + kwargs = {"y": "y"} + elif kind in _xy_kinds: + kwargs = {"x": "x", "y": "y"} + + self.compare_figures(kind, fig_test, fig_ref, kwargs) + plt.close("all") + + @check_figures_equal(extensions=["png", "pdf"]) + def test_geo_kind(self, fig_test, fig_ref): + """Test Geo kind.""" + ax1 = fig_test.subplots() + self.gdf.plot(ax=ax1) + ax2 = fig_ref.subplots() + getattr(self.gdf.plot, "geo")(ax=ax2) + plt.close("all") + + def test_invalid_kind(self): + """Test invalid kinds.""" + with pytest.raises(ValueError, match="error is not a valid plot kind"): + self.gdf.plot(kind="error") + with pytest.raises( + AttributeError, + match="'GeoplotAccessor' object has no attribute 'error'", + ): + self.gdf.plot.error() + + def test_column_values(): """ Check that the dataframe plot method returns same values with an